- File model + migration: file metadata, content lives in object storage - IFileStorage port with local disk and S3-compatible (RustFS/MinIO) adapters - POST /files, GET /files/:id/download, DELETE /files/:id - MIME sniffed from content; only safe images served inline, nosniff header - FileOwnerRegistry: owner modules register to grant visibility and keep their files from GC; FileGcService removes orphaned files hourly - TransformResponseInterceptor passes StreamableFile through Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
|
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
|
import type { IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
|
|
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
|
|
import { UploadFileUseCase } from './upload-file.use-case.js';
|
|
import { UploadFileCommand } from './upload-file.command.js';
|
|
import { mockFileRepository, mockFileStorage } from '../test-helpers.js';
|
|
|
|
vi.mock('node:fs', () => ({ createReadStream: vi.fn(() => 'stream') }));
|
|
|
|
describe('UploadFileUseCase', () => {
|
|
let fileRepository: IFileRepository;
|
|
let fileStorage: IFileStorage;
|
|
let detector: IFileTypeDetector;
|
|
let useCase: UploadFileUseCase;
|
|
|
|
beforeEach(() => {
|
|
fileRepository = mockFileRepository();
|
|
fileStorage = mockFileStorage();
|
|
detector = { detect: vi.fn() };
|
|
useCase = new UploadFileUseCase(fileRepository, fileStorage, detector);
|
|
|
|
vi.mocked(fileRepository.create).mockImplementation(async (file: StoredFile) =>
|
|
StoredFile.reconstitute({
|
|
id: 'file-1',
|
|
storageKey: file.storageKey,
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
size: file.size,
|
|
uploaderId: file.uploaderId,
|
|
createdAt: file.createdAt,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('stores the file under a generated key with the sniffed MIME type', async () => {
|
|
vi.mocked(detector.detect).mockResolvedValue('image/png');
|
|
|
|
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10));
|
|
|
|
const [key, , meta] = vi.mocked(fileStorage.put).mock.calls[0]!;
|
|
expect(key).toMatch(/^files\/[0-9a-f-]{36}$/);
|
|
expect(meta).toEqual({ contentType: 'image/png', size: 10 });
|
|
expect(dto).toEqual({
|
|
id: 'file-1',
|
|
fileName: 'photo.png',
|
|
mimeType: 'image/png',
|
|
size: 10,
|
|
uploaderId: 'user-1',
|
|
createdAt: expect.any(Date),
|
|
});
|
|
expect(dto).not.toHaveProperty('storageKey');
|
|
});
|
|
|
|
it('falls back to octet-stream for unrecognized content, ignoring the name', async () => {
|
|
vi.mocked(detector.detect).mockResolvedValue(null);
|
|
|
|
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'evil.html', 10));
|
|
|
|
expect(dto.mimeType).toBe('application/octet-stream');
|
|
});
|
|
|
|
it('removes the stored object when saving the row fails', async () => {
|
|
vi.mocked(detector.detect).mockResolvedValue('image/png');
|
|
vi.mocked(fileRepository.create).mockRejectedValue(new Error('db down'));
|
|
|
|
await expect(useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10))).rejects.toThrow(
|
|
'db down',
|
|
);
|
|
|
|
const [key] = vi.mocked(fileStorage.put).mock.calls[0]!;
|
|
expect(fileStorage.delete).toHaveBeenCalledWith(key);
|
|
});
|
|
});
|