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: any; let storage: Pick; let projectsService: Pick; let service: AssetsService; beforeEach(() => { prisma = { project: { findUnique: vi.fn() }, asset: { create: vi.fn(), findUnique: vi.fn() }, renderTask: { findMany: vi.fn().mockResolvedValue([]) }, providerLog: { findMany: vi.fn().mockResolvedValue([]) }, providerConfig: { findUnique: vi.fn().mockResolvedValue(null) }, videoClip: { findMany: vi.fn().mockResolvedValue([]) } }; 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'); }); it('returns generation provider and model for direct asset detail', 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') }); prisma.renderTask.findMany.mockResolvedValue([ { id: 20n, project_id: 100n, episode_id: 10n, shot_id: 8n, task_type: 'live_action_video_clip_generate', provider_id: 13n, status: 'success', input_json: {}, input_hash: null, idempotency_key: null, output_asset_id: 300n, provider_request_id: 'task-external-1', retry_count: 0, max_retry: 0, cost_estimate: null, cost_actual: { toString: () => '0.35' }, error_code: null, error_message: null, created_at: new Date('2026-05-31T00:00:00.000Z'), started_at: null, finished_at: new Date('2026-05-31T00:00:00.000Z') } ]); prisma.providerLog.findMany.mockResolvedValue([ { id: 40n, provider_id: 13n, task_id: 20n, project_id: 100n, provider_type: 'VideoProvider', provider_code: 'kling-image-to-video', model_name: 'kling-v2-1', request_json: {}, response_json: {}, input_size: null, output_size: null, cost_estimate: null, cost_actual: null, status: 'success', error_code: null, error_message: null, started_at: null, finished_at: null, created_at: new Date('2026-05-31T00:00:00.000Z') } ]); prisma.providerConfig.findUnique.mockResolvedValue({ id: 13n, provider_type: 'VideoProvider', provider_code: 'kling-image-to-video', display_name: '可灵图生视频', mode: 'real', model_name: 'kling-v2-1', config_json: {}, fallback_provider_id: null, is_enabled: true, priority: 10, rate_limit_json: {}, cost_rule_json: {}, created_at: new Date('2026-05-31T00:00:00.000Z'), updated_at: new Date('2026-05-31T00:00:00.000Z') }); const result = await service.getAssetForUser(user, '300'); expect(result.generation).toEqual( expect.objectContaining({ provider_name: '可灵图生视频', provider_code: 'kling-image-to-video', model_name: 'kling-v2-1', task_id: '20', provider_request_id: 'task-external-1' }) ); }); });