Redesign
This commit is contained in:
@@ -2,7 +2,6 @@ import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { DocumentsService } from '../domain/documents.service';
|
||||
import type { CreateBody, IdParams, ListQuery, UpdateBody } from './documents.validation';
|
||||
import { getStorageService } from '../../../core/storage/storage.service';
|
||||
import { appConfig } from '../../../core/config/config-loader';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import path from 'path';
|
||||
import { Transform, TransformCallback } from 'stream';
|
||||
@@ -238,7 +237,7 @@ export class DocumentsController {
|
||||
mp.file.pipe(counter);
|
||||
|
||||
const storage = getStorageService();
|
||||
const bucket = (doc.storage_bucket || appConfig.getMinioConfig().bucket);
|
||||
const bucket = 'documents'; // Filesystem storage ignores bucket, but keep for interface compatibility
|
||||
const version = 'v1';
|
||||
const unique = cryptoRandom();
|
||||
const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* @ai-summary Fastify routes for documents API
|
||||
*/
|
||||
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
// Note: Validation uses TypeScript types at handler level; follow existing repo pattern (no JSON schema registration)
|
||||
|
||||
@@ -14,17 +13,17 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
||||
const requireAuth = fastify.authenticate.bind(fastify);
|
||||
|
||||
fastify.get('/documents', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.list.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.get<{ Params: any }>('/documents/:id', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.get.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: async (req, reply) => {
|
||||
const userId = (req as any).user?.sub as string;
|
||||
const query = { vehicleId: (req.params as any).vehicleId };
|
||||
@@ -34,27 +33,27 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
||||
});
|
||||
|
||||
fastify.post<{ Body: any }>('/documents', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.create.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.put<{ Params: any; Body: any }>('/documents/:id', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.update.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.delete<{ Params: any }>('/documents/:id', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.remove.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.post<{ Params: any }>('/documents/:id/upload', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.upload.bind(ctrl)
|
||||
});
|
||||
|
||||
fastify.get<{ Params: any }>('/documents/:id/download', {
|
||||
preHandler: [requireAuth, tenantMiddleware as any],
|
||||
preHandler: [requireAuth],
|
||||
handler: ctrl.download.bind(ctrl)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for MinIO storage adapter
|
||||
* @ai-context Tests storage layer with mocked MinIO client
|
||||
*/
|
||||
|
||||
import { createMinioAdapter } from '../../../../core/storage/adapters/minio.adapter';
|
||||
import { Client as MinioClient } from 'minio';
|
||||
import { appConfig } from '../../../../core/config/config-loader';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('minio');
|
||||
jest.mock('../../../../core/config/config-loader');
|
||||
|
||||
const mockMinioClient = jest.mocked(MinioClient);
|
||||
const mockAppConfig = jest.mocked(appConfig);
|
||||
|
||||
describe('MinIO Storage Adapter', () => {
|
||||
let clientInstance: jest.Mocked<MinioClient>;
|
||||
let adapter: ReturnType<typeof createMinioAdapter>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
clientInstance = {
|
||||
putObject: jest.fn(),
|
||||
getObject: jest.fn(),
|
||||
removeObject: jest.fn(),
|
||||
statObject: jest.fn(),
|
||||
presignedGetObject: jest.fn(),
|
||||
presignedPutObject: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockMinioClient.mockImplementation(() => clientInstance);
|
||||
|
||||
mockAppConfig.getMinioConfig.mockReturnValue({
|
||||
endpoint: 'localhost',
|
||||
port: 9000,
|
||||
accessKey: 'testkey',
|
||||
secretKey: 'testsecret',
|
||||
bucket: 'test-bucket',
|
||||
});
|
||||
|
||||
adapter = createMinioAdapter();
|
||||
});
|
||||
|
||||
describe('putObject', () => {
|
||||
it('should upload Buffer with correct parameters', async () => {
|
||||
const buffer = Buffer.from('test content');
|
||||
clientInstance.putObject.mockResolvedValue('etag-123');
|
||||
|
||||
await adapter.putObject('test-bucket', 'test-key', buffer, 'text/plain', { 'x-custom': 'value' });
|
||||
|
||||
expect(clientInstance.putObject).toHaveBeenCalledWith(
|
||||
'test-bucket',
|
||||
'test-key',
|
||||
buffer,
|
||||
buffer.length,
|
||||
{
|
||||
'Content-Type': 'text/plain',
|
||||
'x-custom': 'value',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should upload string with correct parameters', async () => {
|
||||
const content = 'test content';
|
||||
clientInstance.putObject.mockResolvedValue('etag-123');
|
||||
|
||||
await adapter.putObject('test-bucket', 'test-key', content, 'text/plain');
|
||||
|
||||
expect(clientInstance.putObject).toHaveBeenCalledWith(
|
||||
'test-bucket',
|
||||
'test-key',
|
||||
content,
|
||||
content.length,
|
||||
{ 'Content-Type': 'text/plain' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should upload stream without size', async () => {
|
||||
const stream = new Readable();
|
||||
clientInstance.putObject.mockResolvedValue('etag-123');
|
||||
|
||||
await adapter.putObject('test-bucket', 'test-key', stream, 'application/octet-stream');
|
||||
|
||||
expect(clientInstance.putObject).toHaveBeenCalledWith(
|
||||
'test-bucket',
|
||||
'test-key',
|
||||
stream,
|
||||
undefined,
|
||||
{ 'Content-Type': 'application/octet-stream' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle upload without content type', async () => {
|
||||
const buffer = Buffer.from('test');
|
||||
clientInstance.putObject.mockResolvedValue('etag-123');
|
||||
|
||||
await adapter.putObject('test-bucket', 'test-key', buffer);
|
||||
|
||||
expect(clientInstance.putObject).toHaveBeenCalledWith(
|
||||
'test-bucket',
|
||||
'test-key',
|
||||
buffer,
|
||||
buffer.length,
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObjectStream', () => {
|
||||
it('should return object stream', async () => {
|
||||
const mockStream = new Readable();
|
||||
clientInstance.getObject.mockResolvedValue(mockStream);
|
||||
|
||||
const result = await adapter.getObjectStream('test-bucket', 'test-key');
|
||||
|
||||
expect(clientInstance.getObject).toHaveBeenCalledWith('test-bucket', 'test-key');
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteObject', () => {
|
||||
it('should remove object', async () => {
|
||||
clientInstance.removeObject.mockResolvedValue(undefined);
|
||||
|
||||
await adapter.deleteObject('test-bucket', 'test-key');
|
||||
|
||||
expect(clientInstance.removeObject).toHaveBeenCalledWith('test-bucket', 'test-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headObject', () => {
|
||||
it('should return object metadata', async () => {
|
||||
const mockStat = {
|
||||
size: 1024,
|
||||
etag: 'test-etag',
|
||||
lastModified: '2024-01-01T00:00:00Z',
|
||||
metaData: {
|
||||
'content-type': 'application/pdf',
|
||||
'x-custom-header': 'custom-value',
|
||||
},
|
||||
};
|
||||
clientInstance.statObject.mockResolvedValue(mockStat);
|
||||
|
||||
const result = await adapter.headObject('test-bucket', 'test-key');
|
||||
|
||||
expect(clientInstance.statObject).toHaveBeenCalledWith('test-bucket', 'test-key');
|
||||
expect(result).toEqual({
|
||||
size: 1024,
|
||||
etag: 'test-etag',
|
||||
lastModified: new Date('2024-01-01T00:00:00Z'),
|
||||
contentType: 'application/pdf',
|
||||
metadata: mockStat.metaData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle metadata with Content-Type header', async () => {
|
||||
const mockStat = {
|
||||
size: 1024,
|
||||
etag: 'test-etag',
|
||||
lastModified: '2024-01-01T00:00:00Z',
|
||||
metaData: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
},
|
||||
};
|
||||
clientInstance.statObject.mockResolvedValue(mockStat);
|
||||
|
||||
const result = await adapter.headObject('test-bucket', 'test-key');
|
||||
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', async () => {
|
||||
const mockStat = {
|
||||
size: 1024,
|
||||
etag: 'test-etag',
|
||||
};
|
||||
clientInstance.statObject.mockResolvedValue(mockStat);
|
||||
|
||||
const result = await adapter.headObject('test-bucket', 'test-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
size: 1024,
|
||||
etag: 'test-etag',
|
||||
lastModified: undefined,
|
||||
contentType: undefined,
|
||||
metadata: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSignedUrl', () => {
|
||||
it('should generate GET signed URL with default expiry', async () => {
|
||||
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
|
||||
|
||||
const result = await adapter.getSignedUrl('test-bucket', 'test-key');
|
||||
|
||||
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
|
||||
expect(result).toBe('https://example.com/signed-url');
|
||||
});
|
||||
|
||||
it('should generate GET signed URL with custom expiry', async () => {
|
||||
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
|
||||
|
||||
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
|
||||
method: 'GET',
|
||||
expiresSeconds: 600,
|
||||
});
|
||||
|
||||
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 600);
|
||||
expect(result).toBe('https://example.com/signed-url');
|
||||
});
|
||||
|
||||
it('should generate PUT signed URL', async () => {
|
||||
clientInstance.presignedPutObject.mockResolvedValue('https://example.com/put-url');
|
||||
|
||||
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
|
||||
method: 'PUT',
|
||||
expiresSeconds: 300,
|
||||
});
|
||||
|
||||
expect(clientInstance.presignedPutObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
|
||||
expect(result).toBe('https://example.com/put-url');
|
||||
});
|
||||
|
||||
it('should enforce minimum expiry time', async () => {
|
||||
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
|
||||
|
||||
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 0 });
|
||||
|
||||
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 1);
|
||||
});
|
||||
|
||||
it('should enforce maximum expiry time', async () => {
|
||||
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
|
||||
|
||||
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 10000000 });
|
||||
|
||||
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 604800); // 7 days max
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioClient instantiation', () => {
|
||||
it('should create client with correct configuration', () => {
|
||||
expect(mockMinioClient).toHaveBeenCalledWith({
|
||||
endPoint: 'localhost',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
accessKey: 'testkey',
|
||||
secretKey: 'testsecret',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user