Add file module for uploads and storage

- 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>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:17:55 +03:00
1 parent 5cfc8986d7
commit f32c6e348d
49 files changed
+1992 -2

No files matched your search

@@ -0,0 +1,75 @@
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);
});
});