470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
|
import { buildMockRequest } from '../../../helpers/request'
|
|
|
|
type AuthState = {
|
|
authenticated: boolean
|
|
projectMode: 'novel-promotion' | 'other'
|
|
}
|
|
|
|
type SubmitResult = {
|
|
taskId: string
|
|
async: true
|
|
}
|
|
|
|
type RouteContext = {
|
|
params: Promise<Record<string, string>>
|
|
}
|
|
|
|
type DirectRouteCase = {
|
|
routeFile: string
|
|
body: Record<string, unknown>
|
|
params?: Record<string, string>
|
|
expectedTaskType: TaskType
|
|
expectedTargetType: string
|
|
expectedProjectId: string
|
|
}
|
|
|
|
const authState = vi.hoisted<AuthState>(() => ({
|
|
authenticated: true,
|
|
projectMode: 'novel-promotion',
|
|
}))
|
|
|
|
const submitTaskMock = vi.hoisted(() => vi.fn<[], Promise<SubmitResult>>())
|
|
|
|
const configServiceMock = vi.hoisted(() => ({
|
|
getUserModelConfig: vi.fn(async () => ({
|
|
characterModel: 'img::character',
|
|
locationModel: 'img::location',
|
|
editModel: 'img::edit',
|
|
})),
|
|
buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({
|
|
...input.basePayload,
|
|
generationOptions: { resolution: '1024x1024' },
|
|
})),
|
|
getProjectModelConfig: vi.fn(async () => ({
|
|
characterModel: 'img::character',
|
|
locationModel: 'img::location',
|
|
editModel: 'img::edit',
|
|
storyboardModel: 'img::storyboard',
|
|
analysisModel: 'llm::analysis',
|
|
})),
|
|
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
|
...input.basePayload,
|
|
generationOptions: { resolution: '1024x1024' },
|
|
})),
|
|
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({
|
|
resolution: '1024x1024',
|
|
})),
|
|
}))
|
|
|
|
const hasOutputMock = vi.hoisted(() => ({
|
|
hasGlobalCharacterOutput: vi.fn(async () => false),
|
|
hasGlobalLocationOutput: vi.fn(async () => false),
|
|
hasGlobalCharacterAppearanceOutput: vi.fn(async () => false),
|
|
hasGlobalLocationImageOutput: vi.fn(async () => false),
|
|
hasCharacterAppearanceOutput: vi.fn(async () => false),
|
|
hasLocationImageOutput: vi.fn(async () => false),
|
|
hasPanelLipSyncOutput: vi.fn(async () => false),
|
|
hasPanelImageOutput: vi.fn(async () => false),
|
|
hasPanelVideoOutput: vi.fn(async () => false),
|
|
hasVoiceLineAudioOutput: vi.fn(async () => false),
|
|
}))
|
|
|
|
const prismaMock = vi.hoisted(() => ({
|
|
userPreference: {
|
|
findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),
|
|
},
|
|
novelPromotionPanel: {
|
|
findFirst: vi.fn(async () => ({ id: 'panel-1' })),
|
|
findMany: vi.fn(async () => []),
|
|
findUnique: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
|
const id = where?.id || 'panel-1'
|
|
if (id === 'panel-src') {
|
|
return {
|
|
id,
|
|
panelIndex: 1,
|
|
shotType: 'wide',
|
|
cameraMove: 'static',
|
|
description: 'source description',
|
|
videoPrompt: 'source video prompt',
|
|
location: 'source location',
|
|
characters: '[]',
|
|
srtSegment: '',
|
|
duration: 3,
|
|
}
|
|
}
|
|
if (id === 'panel-ins') {
|
|
return {
|
|
id,
|
|
panelIndex: 2,
|
|
shotType: 'medium',
|
|
cameraMove: 'push',
|
|
description: 'insert description',
|
|
videoPrompt: 'insert video prompt',
|
|
location: 'insert location',
|
|
characters: '[]',
|
|
srtSegment: '',
|
|
duration: 3,
|
|
}
|
|
}
|
|
return {
|
|
id,
|
|
panelIndex: 0,
|
|
shotType: 'medium',
|
|
cameraMove: 'static',
|
|
description: 'panel description',
|
|
videoPrompt: 'panel prompt',
|
|
location: 'panel location',
|
|
characters: '[]',
|
|
srtSegment: '',
|
|
duration: 3,
|
|
}
|
|
}),
|
|
update: vi.fn(async () => ({})),
|
|
create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),
|
|
},
|
|
novelPromotionProject: {
|
|
findUnique: vi.fn(async () => ({
|
|
id: 'project-data-1',
|
|
characters: [
|
|
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.mp3' },
|
|
],
|
|
})),
|
|
},
|
|
novelPromotionEpisode: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: 'episode-1',
|
|
speakerVoices: '{}',
|
|
})),
|
|
},
|
|
novelPromotionVoiceLine: {
|
|
findMany: vi.fn(async () => [
|
|
{ id: 'line-1', speaker: 'Narrator', content: 'hello world voice line' },
|
|
]),
|
|
findFirst: vi.fn(async () => ({
|
|
id: 'line-1',
|
|
speaker: 'Narrator',
|
|
content: 'hello world voice line',
|
|
})),
|
|
},
|
|
$transaction: vi.fn(async (fn: (tx: {
|
|
novelPromotionPanel: {
|
|
findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>
|
|
update: (args: unknown) => Promise<unknown>
|
|
create: (args: unknown) => Promise<{ id: string; panelIndex: number }>
|
|
}
|
|
}) => Promise<unknown>) => {
|
|
const tx = {
|
|
novelPromotionPanel: {
|
|
findMany: async () => [],
|
|
update: async () => ({}),
|
|
create: async () => ({ id: 'panel-created', panelIndex: 3 }),
|
|
},
|
|
}
|
|
return await fn(tx)
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/api-auth', () => {
|
|
const unauthorized = () => new Response(
|
|
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
|
{ status: 401, headers: { 'content-type': 'application/json' } },
|
|
)
|
|
|
|
return {
|
|
isErrorResponse: (value: unknown) => value instanceof Response,
|
|
requireUserAuth: async () => {
|
|
if (!authState.authenticated) return unauthorized()
|
|
return { session: { user: { id: 'user-1' } } }
|
|
},
|
|
requireProjectAuth: async (projectId: string) => {
|
|
if (!authState.authenticated) return unauthorized()
|
|
return {
|
|
session: { user: { id: 'user-1' } },
|
|
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
|
}
|
|
},
|
|
requireProjectAuthLight: async (projectId: string) => {
|
|
if (!authState.authenticated) return unauthorized()
|
|
return {
|
|
session: { user: { id: 'user-1' } },
|
|
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/task/submitter', () => ({
|
|
submitTask: submitTaskMock,
|
|
}))
|
|
vi.mock('@/lib/task/resolve-locale', () => ({
|
|
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
|
}))
|
|
vi.mock('@/lib/config-service', () => configServiceMock)
|
|
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
|
vi.mock('@/lib/billing', () => ({
|
|
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
|
}))
|
|
vi.mock('@/lib/qwen-voice-design', () => ({
|
|
validateVoicePrompt: vi.fn(() => ({ valid: true })),
|
|
validatePreviewText: vi.fn(() => ({ valid: true })),
|
|
}))
|
|
vi.mock('@/lib/media/outbound-image', () => ({
|
|
sanitizeImageInputsForTaskPayload: vi.fn((inputs: unknown[]) => ({
|
|
normalized: inputs
|
|
.filter((item): item is string => typeof item === 'string')
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0),
|
|
issues: [] as Array<{ reason: string }>,
|
|
})),
|
|
}))
|
|
vi.mock('@/lib/model-capabilities/lookup', () => ({
|
|
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
|
|
}))
|
|
vi.mock('@/lib/model-pricing/lookup', () => ({
|
|
resolveBuiltinPricing: vi.fn(() => ({ status: 'ok' })),
|
|
}))
|
|
vi.mock('@/lib/api-config', () => ({
|
|
resolveModelSelection: vi.fn(async () => ({
|
|
model: 'img::storyboard',
|
|
})),
|
|
}))
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: prismaMock,
|
|
}))
|
|
|
|
function toApiPath(routeFile: string): string {
|
|
return routeFile
|
|
.replace(/^src\/app/, '')
|
|
.replace(/\/route\.ts$/, '')
|
|
.replace('[projectId]', 'project-1')
|
|
}
|
|
|
|
function toModuleImportPath(routeFile: string): string {
|
|
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
|
}
|
|
|
|
const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
|
{
|
|
routeFile: 'src/app/api/asset-hub/generate-image/route.ts',
|
|
body: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
|
|
expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,
|
|
expectedTargetType: 'GlobalCharacter',
|
|
expectedProjectId: 'global-asset-hub',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/asset-hub/modify-image/route.ts',
|
|
body: {
|
|
type: 'character',
|
|
id: 'global-character-1',
|
|
modifyPrompt: 'sharpen details',
|
|
appearanceIndex: 0,
|
|
imageIndex: 0,
|
|
extraImageUrls: ['https://example.com/ref-a.png'],
|
|
},
|
|
expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,
|
|
expectedTargetType: 'GlobalCharacterAppearance',
|
|
expectedProjectId: 'global-asset-hub',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/asset-hub/voice-design/route.ts',
|
|
body: { voicePrompt: 'female calm narrator', previewText: '你好世界' },
|
|
expectedTaskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
|
expectedTargetType: 'GlobalAssetHubVoiceDesign',
|
|
expectedProjectId: 'global-asset-hub',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
|
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
|
expectedTargetType: 'CharacterAppearance',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
|
body: { videoModel: 'fal::video-model', storyboardId: 'storyboard-1', panelIndex: 0 },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
|
|
expectedTargetType: 'NovelPromotionPanel',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',
|
|
body: { storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.INSERT_PANEL,
|
|
expectedTargetType: 'NovelPromotionStoryboard',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
|
body: {
|
|
storyboardId: 'storyboard-1',
|
|
panelIndex: 0,
|
|
voiceLineId: 'line-1',
|
|
lipSyncModel: 'fal::lip-model',
|
|
},
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.LIP_SYNC,
|
|
expectedTargetType: 'NovelPromotionPanel',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',
|
|
body: {
|
|
type: 'character',
|
|
characterId: 'character-1',
|
|
appearanceId: 'appearance-1',
|
|
modifyPrompt: 'enhance texture',
|
|
extraImageUrls: ['https://example.com/ref-b.png'],
|
|
},
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
|
expectedTargetType: 'CharacterAppearance',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',
|
|
body: {
|
|
storyboardId: 'storyboard-1',
|
|
panelIndex: 0,
|
|
modifyPrompt: 'increase contrast',
|
|
extraImageUrls: ['https://example.com/ref-c.png'],
|
|
selectedAssets: [{ imageUrl: 'https://example.com/ref-d.png' }],
|
|
},
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
|
expectedTargetType: 'NovelPromotionPanel',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',
|
|
body: {
|
|
storyboardId: 'storyboard-1',
|
|
insertAfterPanelId: 'panel-ins',
|
|
sourcePanelId: 'panel-src',
|
|
variant: { video_prompt: 'new prompt', description: 'variant desc' },
|
|
},
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.PANEL_VARIANT,
|
|
expectedTargetType: 'NovelPromotionPanel',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',
|
|
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.REGENERATE_GROUP,
|
|
expectedTargetType: 'CharacterAppearance',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',
|
|
body: { panelId: 'panel-1', count: 1 },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.IMAGE_PANEL,
|
|
expectedTargetType: 'NovelPromotionPanel',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',
|
|
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1', imageIndex: 0 },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
|
expectedTargetType: 'CharacterAppearance',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',
|
|
body: { storyboardId: 'storyboard-1' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.REGENERATE_STORYBOARD_TEXT,
|
|
expectedTargetType: 'NovelPromotionStoryboard',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',
|
|
body: { voicePrompt: 'warm female voice', previewText: 'This is preview text' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.VOICE_DESIGN,
|
|
expectedTargetType: 'NovelPromotionProject',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
{
|
|
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',
|
|
body: { episodeId: 'episode-1', lineId: 'line-1', audioModel: 'fal::audio-model' },
|
|
params: { projectId: 'project-1' },
|
|
expectedTaskType: TASK_TYPE.VOICE_LINE,
|
|
expectedTargetType: 'NovelPromotionVoiceLine',
|
|
expectedProjectId: 'project-1',
|
|
},
|
|
]
|
|
|
|
async function invokePostRoute(routeCase: DirectRouteCase): Promise<Response> {
|
|
const modulePath = toModuleImportPath(routeCase.routeFile)
|
|
const mod = await import(modulePath)
|
|
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
|
const req = buildMockRequest({
|
|
path: toApiPath(routeCase.routeFile),
|
|
method: 'POST',
|
|
body: routeCase.body,
|
|
})
|
|
return await post(req, { params: Promise.resolve(routeCase.params || {}) })
|
|
}
|
|
|
|
describe('api contract - direct submit routes (behavior)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
authState.authenticated = true
|
|
authState.projectMode = 'novel-promotion'
|
|
let seq = 0
|
|
submitTaskMock.mockImplementation(async () => ({
|
|
taskId: `task-${++seq}`,
|
|
async: true,
|
|
}))
|
|
})
|
|
|
|
it('keeps expected coverage size', () => {
|
|
expect(DIRECT_CASES.length).toBe(16)
|
|
})
|
|
|
|
for (const routeCase of DIRECT_CASES) {
|
|
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
|
|
authState.authenticated = false
|
|
const res = await invokePostRoute(routeCase)
|
|
expect(res.status).toBe(401)
|
|
expect(submitTaskMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it(`${routeCase.routeFile} -> submits task with expected contract when authenticated`, async () => {
|
|
const res = await invokePostRoute(routeCase)
|
|
expect(res.status).toBe(200)
|
|
expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: routeCase.expectedTaskType,
|
|
targetType: routeCase.expectedTargetType,
|
|
projectId: routeCase.expectedProjectId,
|
|
userId: 'user-1',
|
|
}))
|
|
|
|
const submitArg = submitTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
|
|
expect(submitArg?.type).toBe(routeCase.expectedTaskType)
|
|
expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)
|
|
expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)
|
|
expect(submitArg?.userId).toBe('user-1')
|
|
|
|
const json = await res.json() as Record<string, unknown>
|
|
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
|
|
if (isVoiceGenerateRoute) {
|
|
expect(json.success).toBe(true)
|
|
expect(json.async).toBe(true)
|
|
expect(typeof json.taskId).toBe('string')
|
|
} else {
|
|
expect(json.async).toBe(true)
|
|
expect(typeof json.taskId).toBe('string')
|
|
}
|
|
})
|
|
}
|
|
})
|