import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AssetsService } from './assets.service'; import type { PrismaService } from '../prisma/prisma.service'; import type { ProjectsService } from '../projects/projects.service'; import type { StorageService } from './storage.service'; import type { AuthRequestUser } from '../auth/auth.types'; const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' }; function createFile(overrides: Partial = {}): Express.Multer.File { return { fieldname: 'file', originalname: 'novel.txt', encoding: '7bit', mimetype: 'text/plain', size: 12, buffer: Buffer.from('hello novel'), destination: '', filename: '', path: '', stream: undefined as never, ...overrides }; } describe('AssetsService', () => { let prisma: { project: { findUnique: ReturnType }; asset: { create: ReturnType; findUnique: ReturnType }; }; let storage: Pick; let projectsService: Pick; let service: AssetsService; beforeEach(() => { prisma = { project: { findUnique: vi.fn() }, asset: { create: vi.fn(), findUnique: vi.fn() } }; storage = { storePrivateFile: vi.fn().mockResolvedValue({ file_path: 'local://novels/test.txt', size: 12n, hash: 'hash', backend: 'local' }), readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('video bytes')) }; projectsService = { assertProjectOwner: vi.fn().mockResolvedValue(100n) }; service = new AssetsService( prisma as unknown as PrismaService, storage as StorageService, projectsService as ProjectsService ); }); it('stores novel uploads as private assets', async () => { prisma.asset.create.mockResolvedValue({ id: 200n, user_id: 1n, project_id: 100n, asset_type: 'novel_text', file_path: 'local://novels/test.txt', file_url: null, mime_type: 'text/plain', width: null, height: null, duration: null, size: 12n, hash: 'hash', visibility: 'private', status: 'active', created_at: new Date('2026-05-31T00:00:00.000Z') }); const result = await service.uploadNovelFile(user, '100', createFile()); expect(storage.storePrivateFile).toHaveBeenCalledWith( expect.objectContaining({ originalname: 'novel.txt' }), 'novels' ); expect(prisma.asset.create).toHaveBeenCalledWith({ data: expect.objectContaining({ asset_type: 'novel_text', visibility: 'private', file_url: null }) }); expect(result.asset.visibility).toBe('private'); expect(result.next_step).toBe('copyright_confirm'); }); it('rejects unsupported novel file types', async () => { await expect( service.uploadNovelFile( user, '100', createFile({ originalname: 'novel.exe', mimetype: 'application/octet-stream' }) ) ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects uploads to projects owned by others', async () => { vi.mocked(projectsService.assertProjectOwner).mockRejectedValue( new ForbiddenException('Project is private') ); await expect(service.uploadNovelFile(user, '100', createFile())).rejects.toBeInstanceOf( ForbiddenException ); }); it('returns a private file buffer for owned assets', async () => { prisma.asset.findUnique.mockResolvedValue({ id: 300n, user_id: 1n, project_id: 100n, asset_type: 'video', file_path: 'local://videos/final.mp4', file_url: null, mime_type: 'video/mp4', width: 1080, height: 1920, duration: 4, size: 11n, hash: 'video-hash', visibility: 'private', status: 'active', created_at: new Date('2026-05-31T00:00:00.000Z') }); const result = await service.downloadAssetForUser(user, '300'); expect(storage.readPrivateFile).toHaveBeenCalledWith('local://videos/final.mp4'); expect(result.asset.id).toBe('300'); expect(result.filename).toBe('video-300.mp4'); expect(result.buffer.toString()).toBe('video bytes'); }); });