release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

View File

@@ -0,0 +1,396 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
import { buildMockRequest } from '../../../helpers/request'
type RouteMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
type AuthState = {
authenticated: boolean
}
type RouteContext = {
params: Promise<Record<string, string>>
}
const authState = vi.hoisted<AuthState>(() => ({
authenticated: false,
}))
const prismaMock = vi.hoisted(() => ({
globalCharacter: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
globalAssetFolder: {
findUnique: vi.fn(),
},
characterAppearance: {
findUnique: vi.fn(),
update: vi.fn(),
},
novelPromotionLocation: {
findUnique: vi.fn(),
update: vi.fn(),
},
locationImage: {
updateMany: vi.fn(),
update: vi.fn(),
},
novelPromotionClip: {
update: vi.fn(),
},
}))
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: 'novel-promotion' },
}
},
requireProjectAuthLight: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
}
},
}
})
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/cos', () => ({
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
}))
function toModuleImportPath(routeFile: string): string {
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
}
function resolveParamValue(paramName: string): string {
const key = paramName.toLowerCase()
if (key.includes('project')) return 'project-1'
if (key.includes('character')) return 'character-1'
if (key.includes('location')) return 'location-1'
if (key.includes('appearance')) return '0'
if (key.includes('episode')) return 'episode-1'
if (key.includes('storyboard')) return 'storyboard-1'
if (key.includes('panel')) return 'panel-1'
if (key.includes('clip')) return 'clip-1'
if (key.includes('folder')) return 'folder-1'
if (key === 'id') return 'id-1'
return `${paramName}-1`
}
function toApiPath(routeFile: string): { path: string; params: Record<string, string> } {
const withoutPrefix = routeFile
.replace(/^src\/app/, '')
.replace(/\/route\.ts$/, '')
const params: Record<string, string> = {}
const path = withoutPrefix.replace(/\[([^\]]+)\]/g, (_full, paramName: string) => {
const value = resolveParamValue(paramName)
params[paramName] = value
return value
})
return { path, params }
}
function buildGenericBody() {
return {
id: 'id-1',
name: 'Name',
type: 'character',
userInstruction: 'instruction',
characterId: 'character-1',
locationId: 'location-1',
appearanceId: 'appearance-1',
modifyPrompt: 'modify prompt',
storyboardId: 'storyboard-1',
panelId: 'panel-1',
panelIndex: 0,
episodeId: 'episode-1',
content: 'x'.repeat(140),
voicePrompt: 'voice prompt',
previewText: 'preview text',
referenceImageUrl: 'https://example.com/ref.png',
referenceImageUrls: ['https://example.com/ref.png'],
lineId: 'line-1',
audioModel: 'fal::audio-model',
videoModel: 'fal::video-model',
insertAfterPanelId: 'panel-1',
sourcePanelId: 'panel-2',
variant: { video_prompt: 'variant prompt' },
currentDescription: 'description',
modifyInstruction: 'instruction',
currentPrompt: 'prompt',
all: false,
}
}
async function invokeRouteMethod(
routeFile: string,
method: RouteMethod,
): Promise<Response> {
const { path, params } = toApiPath(routeFile)
const modulePath = toModuleImportPath(routeFile)
const mod = await import(modulePath)
const handler = mod[method] as ((req: Request, ctx?: RouteContext) => Promise<Response>) | undefined
if (!handler) {
throw new Error(`Route ${routeFile} missing method ${method}`)
}
const req = buildMockRequest({
path,
method,
...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),
})
return await handler(req, { params: Promise.resolve(params) })
}
describe('api contract - crud routes (behavior)', () => {
const routes = ROUTE_CATALOG.filter(
(entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',
)
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = false
prismaMock.globalCharacter.findUnique.mockResolvedValue({
id: 'character-1',
userId: 'user-1',
})
prismaMock.globalAssetFolder.findUnique.mockResolvedValue({
id: 'folder-1',
userId: 'user-1',
})
prismaMock.globalCharacter.update.mockResolvedValue({
id: 'character-1',
name: 'Alice',
userId: 'user-1',
appearances: [],
})
prismaMock.globalCharacter.delete.mockResolvedValue({ id: 'character-1' })
prismaMock.characterAppearance.findUnique.mockResolvedValue({
id: 'appearance-1',
characterId: 'character-1',
imageUrls: JSON.stringify(['cos/char-0.png', 'cos/char-1.png']),
imageUrl: null,
selectedIndex: null,
character: { id: 'character-1', name: 'Alice' },
})
prismaMock.characterAppearance.update.mockResolvedValue({
id: 'appearance-1',
selectedIndex: 1,
imageUrl: 'cos/char-1.png',
})
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
id: 'location-1',
name: 'Old Town',
images: [
{ id: 'img-0', imageIndex: 0, imageUrl: 'cos/loc-0.png' },
{ id: 'img-1', imageIndex: 1, imageUrl: 'cos/loc-1.png' },
],
})
prismaMock.locationImage.updateMany.mockResolvedValue({ count: 2 })
prismaMock.locationImage.update.mockResolvedValue({
id: 'img-1',
imageIndex: 1,
imageUrl: 'cos/loc-1.png',
isSelected: true,
})
prismaMock.novelPromotionLocation.update.mockResolvedValue({
id: 'location-1',
selectedImageId: 'img-1',
})
prismaMock.novelPromotionClip.update.mockResolvedValue({
id: 'clip-1',
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
})
})
it('crud route group exists', () => {
expect(routes.length).toBeGreaterThan(0)
})
it('all crud route methods reject unauthenticated requests (no 2xx pass-through)', async () => {
const methods: ReadonlyArray<RouteMethod> = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']
let checkedMethodCount = 0
for (const entry of routes) {
const modulePath = toModuleImportPath(entry.routeFile)
const mod = await import(modulePath)
for (const method of methods) {
if (typeof mod[method] !== 'function') continue
checkedMethodCount += 1
const res = await invokeRouteMethod(entry.routeFile, method)
expect(res.status, `${entry.routeFile}#${method} should reject unauthenticated`).toBeGreaterThanOrEqual(400)
expect(res.status, `${entry.routeFile}#${method} should not be server-error on auth gate`).toBeLessThan(500)
}
}
expect(checkedMethodCount).toBeGreaterThan(0)
})
it('PATCH /asset-hub/characters/[characterId] writes normalized fields to prisma.globalCharacter.update', async () => {
authState.authenticated = true
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'PATCH',
body: {
name: ' Alice ',
aliases: ['A'],
profileConfirmed: true,
folderId: 'folder-1',
},
})
const res = await mod.PATCH(req, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.globalCharacter.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'character-1' },
data: expect.objectContaining({
name: 'Alice',
aliases: ['A'],
profileConfirmed: true,
folderId: 'folder-1',
}),
}))
})
it('DELETE /asset-hub/characters/[characterId] deletes owned character and blocks non-owner', async () => {
authState.authenticated = true
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
id: 'character-1',
userId: 'user-1',
})
const okReq = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'DELETE',
})
const okRes = await mod.DELETE(okReq, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(okRes.status).toBe(200)
expect(prismaMock.globalCharacter.delete).toHaveBeenCalledWith({ where: { id: 'character-1' } })
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
id: 'character-1',
userId: 'other-user',
})
const forbiddenReq = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'DELETE',
})
const forbiddenRes = await mod.DELETE(forbiddenReq, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(forbiddenRes.status).toBe(403)
})
it('POST /novel-promotion/[projectId]/select-character-image writes selectedIndex and imageUrl key', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/select-character-image/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/select-character-image',
method: 'POST',
body: {
characterId: 'character-1',
appearanceId: 'appearance-1',
selectedIndex: 1,
},
})
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
where: { id: 'appearance-1' },
data: {
selectedIndex: 1,
imageUrl: 'cos/char-1.png',
},
})
const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }
expect(payload).toEqual({
success: true,
selectedIndex: 1,
imageUrl: 'https://signed.example/cos/char-1.png',
})
})
it('POST /novel-promotion/[projectId]/select-location-image toggles selected state and selectedImageId', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/select-location-image/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/select-location-image',
method: 'POST',
body: {
locationId: 'location-1',
selectedIndex: 1,
},
})
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.locationImage.updateMany).toHaveBeenCalledWith({
where: { locationId: 'location-1' },
data: { isSelected: false },
})
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
where: { locationId_imageIndex: { locationId: 'location-1', imageIndex: 1 } },
data: { isSelected: true },
})
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
where: { id: 'location-1' },
data: { selectedImageId: 'img-1' },
})
})
it('PATCH /novel-promotion/[projectId]/clips/[clipId] writes provided editable fields', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/clips/[clipId]/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/clips/clip-1',
method: 'PATCH',
body: {
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
},
})
const res = await mod.PATCH(req, {
params: Promise.resolve({ projectId: 'project-1', clipId: 'clip-1' }),
})
expect(res.status).toBe(200)
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-1' },
data: {
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
},
})
})
})

View File

@@ -0,0 +1,469 @@
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')
}
})
}
})

View File

@@ -0,0 +1,362 @@
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 LLMRouteCase = {
routeFile: string
body: Record<string, unknown>
params?: Record<string, string>
expectedTaskType: TaskType
expectedTargetType: string
expectedProjectId: string
}
type RouteContext = {
params: Promise<Record<string, string>>
}
const authState = vi.hoisted<AuthState>(() => ({
authenticated: true,
projectMode: 'novel-promotion',
}))
const maybeSubmitLLMTaskMock = vi.hoisted(() =>
vi.fn(async () => new Response(
JSON.stringify({ taskId: 'task-1', async: true }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)),
)
const configServiceMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(async () => ({
analysisModel: 'llm::analysis',
})),
getProjectModelConfig: vi.fn(async () => ({
analysisModel: 'llm::analysis',
})),
}))
const prismaMock = vi.hoisted(() => ({
globalCharacter: {
findUnique: vi.fn(async () => ({
id: 'global-character-1',
userId: 'user-1',
})),
},
globalLocation: {
findUnique: vi.fn(async () => ({
id: 'global-location-1',
userId: 'user-1',
})),
},
}))
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/llm-observe/route-task', () => ({
maybeSubmitLLMTask: maybeSubmitLLMTaskMock,
}))
vi.mock('@/lib/config-service', () => configServiceMock)
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 ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [
{
routeFile: 'src/app/api/asset-hub/ai-design-character/route.ts',
body: { userInstruction: 'design a heroic character' },
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,
expectedTargetType: 'GlobalAssetHubCharacterDesign',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/asset-hub/ai-design-location/route.ts',
body: { userInstruction: 'design a noir city location' },
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,
expectedTargetType: 'GlobalAssetHubLocationDesign',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/asset-hub/ai-modify-character/route.ts',
body: {
characterId: 'global-character-1',
appearanceIndex: 0,
currentDescription: 'old desc',
modifyInstruction: 'make the outfit darker',
},
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,
expectedTargetType: 'GlobalCharacter',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/asset-hub/ai-modify-location/route.ts',
body: {
locationId: 'global-location-1',
imageIndex: 0,
currentDescription: 'old location desc',
modifyInstruction: 'add more fog',
},
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,
expectedTargetType: 'GlobalLocation',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/asset-hub/reference-to-character/route.ts',
body: { referenceImageUrl: 'https://example.com/ref.png' },
expectedTaskType: TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
expectedTargetType: 'GlobalCharacter',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',
body: { userInstruction: 'create a rebel hero' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_CREATE_CHARACTER,
expectedTargetType: 'NovelPromotionCharacterDesign',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',
body: { userInstruction: 'create a mountain temple' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_CREATE_LOCATION,
expectedTargetType: 'NovelPromotionLocationDesign',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
body: {
characterId: 'character-1',
appearanceId: 'appearance-1',
currentDescription: 'old appearance',
modifyInstruction: 'add armor',
},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_MODIFY_APPEARANCE,
expectedTargetType: 'CharacterAppearance',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',
body: {
locationId: 'location-1',
currentDescription: 'old location',
modifyInstruction: 'add rain',
},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_MODIFY_LOCATION,
expectedTargetType: 'NovelPromotionLocation',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',
body: {
panelId: 'panel-1',
currentPrompt: 'old prompt',
modifyInstruction: 'more dramatic angle',
},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
expectedTargetType: 'NovelPromotionPanel',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',
body: {},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.ANALYZE_GLOBAL,
expectedTargetType: 'NovelPromotionProject',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',
body: { panelId: 'panel-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
expectedTargetType: 'NovelPromotionPanel',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze/route.ts',
body: { episodeId: 'episode-1', content: 'Analyze this chapter' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.ANALYZE_NOVEL,
expectedTargetType: 'NovelPromotionProject',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',
body: { items: ['character-1', 'character-2'] },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,
expectedTargetType: 'NovelPromotionProject',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',
body: { characterId: 'character-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_CONFIRM,
expectedTargetType: 'NovelPromotionCharacter',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/clips/route.ts',
body: { episodeId: 'episode-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.CLIPS_BUILD,
expectedTargetType: 'NovelPromotionEpisode',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',
body: { content: 'x'.repeat(120) },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.EPISODE_SPLIT_LLM,
expectedTargetType: 'NovelPromotionProject',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',
body: { referenceImageUrl: 'https://example.com/ref.png' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.REFERENCE_TO_CHARACTER,
expectedTargetType: 'NovelPromotionProject',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',
body: { episodeId: 'episode-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.SCREENPLAY_CONVERT,
expectedTargetType: 'NovelPromotionEpisode',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',
body: { episodeId: 'episode-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
expectedTargetType: 'NovelPromotionEpisode',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',
body: { episodeId: 'episode-1', content: 'story text' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
expectedTargetType: 'NovelPromotionEpisode',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',
body: { episodeId: 'episode-1' },
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.VOICE_ANALYZE,
expectedTargetType: 'NovelPromotionEpisode',
expectedProjectId: 'project-1',
},
]
async function invokePostRoute(routeCase: LLMRouteCase): 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 - llm observe routes (behavior)', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = true
authState.projectMode = 'novel-promotion'
maybeSubmitLLMTaskMock.mockResolvedValue(
new Response(JSON.stringify({ taskId: 'task-1', async: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
})
it('keeps expected coverage size', () => {
expect(ROUTE_CASES.length).toBe(22)
})
for (const routeCase of ROUTE_CASES) {
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
authState.authenticated = false
const res = await invokePostRoute(routeCase)
expect(res.status).toBe(401)
expect(maybeSubmitLLMTaskMock).not.toHaveBeenCalled()
})
it(`${routeCase.routeFile} -> submits llm task with expected contract when authenticated`, async () => {
const res = await invokePostRoute(routeCase)
expect(res.status).toBe(200)
expect(maybeSubmitLLMTaskMock).toHaveBeenCalledWith(expect.objectContaining({
type: routeCase.expectedTaskType,
targetType: routeCase.expectedTargetType,
projectId: routeCase.expectedProjectId,
userId: 'user-1',
}))
const callArg = maybeSubmitLLMTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
expect(callArg?.type).toBe(routeCase.expectedTaskType)
expect(callArg?.targetType).toBe(routeCase.expectedTargetType)
expect(callArg?.projectId).toBe(routeCase.expectedProjectId)
expect(callArg?.userId).toBe('user-1')
const json = await res.json() as Record<string, unknown>
expect(json.async).toBe(true)
expect(typeof json.taskId).toBe('string')
})
}
})

View File

@@ -0,0 +1,446 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_STATUS } from '@/lib/task/types'
import { buildMockRequest } from '../../../helpers/request'
type AuthState = {
authenticated: boolean
}
type RouteContext = {
params: Promise<Record<string, string>>
}
type TaskRecord = {
id: string
userId: string
projectId: string
type: string
targetType: string
targetId: string
status: string
errorCode: string | null
errorMessage: string | null
}
const authState = vi.hoisted<AuthState>(() => ({
authenticated: true,
}))
const queryTasksMock = vi.hoisted(() => vi.fn())
const dismissFailedTasksMock = vi.hoisted(() => vi.fn())
const getTaskByIdMock = vi.hoisted(() => vi.fn())
const cancelTaskMock = vi.hoisted(() => vi.fn())
const removeTaskJobMock = vi.hoisted(() => vi.fn(async () => true))
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => undefined))
const queryTaskTargetStatesMock = vi.hoisted(() => vi.fn())
const withPrismaRetryMock = vi.hoisted(() => vi.fn(async <T>(fn: () => Promise<T>) => await fn()))
const listEventsAfterMock = vi.hoisted(() => vi.fn(async () => []))
const listTaskLifecycleEventsMock = vi.hoisted(() => vi.fn(async () => []))
const addChannelListenerMock = vi.hoisted(() => vi.fn(async () => async () => undefined))
const subscriberState = vi.hoisted(() => ({
listener: null as ((message: string) => void) | null,
}))
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' } } }
},
requireProjectAuthLight: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1' },
}
},
}
})
vi.mock('@/lib/task/service', () => ({
queryTasks: queryTasksMock,
dismissFailedTasks: dismissFailedTasksMock,
getTaskById: getTaskByIdMock,
cancelTask: cancelTaskMock,
}))
vi.mock('@/lib/task/queues', () => ({
removeTaskJob: removeTaskJobMock,
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: publishTaskEventMock,
getProjectChannel: vi.fn((projectId: string) => `project:${projectId}`),
listEventsAfter: listEventsAfterMock,
listTaskLifecycleEvents: listTaskLifecycleEventsMock,
}))
vi.mock('@/lib/task/state-service', () => ({
queryTaskTargetStates: queryTaskTargetStatesMock,
}))
vi.mock('@/lib/prisma-retry', () => ({
withPrismaRetry: withPrismaRetryMock,
}))
vi.mock('@/lib/sse/shared-subscriber', () => ({
getSharedSubscriber: vi.fn(() => ({
addChannelListener: addChannelListenerMock,
})),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: {
findMany: vi.fn(async () => []),
},
},
}))
const baseTask: TaskRecord = {
id: 'task-1',
userId: 'user-1',
projectId: 'project-1',
type: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
status: TASK_STATUS.FAILED,
errorCode: null,
errorMessage: null,
}
describe('api contract - task infra routes (behavior)', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = true
subscriberState.listener = null
queryTasksMock.mockResolvedValue([baseTask])
dismissFailedTasksMock.mockResolvedValue(1)
getTaskByIdMock.mockResolvedValue(baseTask)
cancelTaskMock.mockResolvedValue({
task: {
...baseTask,
status: TASK_STATUS.FAILED,
errorCode: 'TASK_CANCELLED',
errorMessage: 'Task cancelled by user',
},
cancelled: true,
})
queryTaskTargetStatesMock.mockResolvedValue([
{
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
active: true,
status: TASK_STATUS.PROCESSING,
taskId: 'task-1',
updatedAt: new Date().toISOString(),
},
])
addChannelListenerMock.mockImplementation(async (_channel: string, listener: (message: string) => void) => {
subscriberState.listener = listener
return async () => undefined
})
listTaskLifecycleEventsMock.mockResolvedValue([])
})
it('GET /api/tasks: unauthenticated -> 401; authenticated -> 200 with caller-owned tasks', async () => {
const { GET } = await import('@/app/api/tasks/route')
authState.authenticated = false
const unauthorizedReq = buildMockRequest({
path: '/api/tasks',
method: 'GET',
query: { projectId: 'project-1', limit: 20 },
})
const unauthorizedRes = await GET(unauthorizedReq)
expect(unauthorizedRes.status).toBe(401)
authState.authenticated = true
const req = buildMockRequest({
path: '/api/tasks',
method: 'GET',
query: { projectId: 'project-1', limit: 20, targetId: 'appearance-1' },
})
const res = await GET(req)
expect(res.status).toBe(200)
const payload = await res.json() as { tasks: TaskRecord[] }
expect(payload.tasks).toHaveLength(1)
expect(payload.tasks[0]?.id).toBe('task-1')
expect(queryTasksMock).toHaveBeenCalledWith(expect.objectContaining({
projectId: 'project-1',
targetId: 'appearance-1',
limit: 20,
}))
})
it('POST /api/tasks/dismiss: invalid params -> 400; success -> dismissed count', async () => {
const { POST } = await import('@/app/api/tasks/dismiss/route')
const invalidReq = buildMockRequest({
path: '/api/tasks/dismiss',
method: 'POST',
body: { taskIds: [] },
})
const invalidRes = await POST(invalidReq)
expect(invalidRes.status).toBe(400)
const req = buildMockRequest({
path: '/api/tasks/dismiss',
method: 'POST',
body: { taskIds: ['task-1', 'task-2'] },
})
const res = await POST(req)
expect(res.status).toBe(200)
const payload = await res.json() as { success: boolean; dismissed: number }
expect(payload.success).toBe(true)
expect(payload.dismissed).toBe(1)
expect(dismissFailedTasksMock).toHaveBeenCalledWith(['task-1', 'task-2'], 'user-1')
})
it('POST /api/task-target-states: validates payload and returns queried states', async () => {
const { POST } = await import('@/app/api/task-target-states/route')
const invalidReq = buildMockRequest({
path: '/api/task-target-states',
method: 'POST',
body: { projectId: 'project-1' },
})
const invalidRes = await POST(invalidReq)
expect(invalidRes.status).toBe(400)
const req = buildMockRequest({
path: '/api/task-target-states',
method: 'POST',
body: {
projectId: 'project-1',
targets: [
{
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
types: ['IMAGE_CHARACTER'],
},
],
},
})
const res = await POST(req)
expect(res.status).toBe(200)
const payload = await res.json() as { states: Array<Record<string, unknown>> }
expect(payload.states).toHaveLength(1)
expect(withPrismaRetryMock).toHaveBeenCalledTimes(1)
expect(queryTaskTargetStatesMock).toHaveBeenCalledWith({
projectId: 'project-1',
userId: 'user-1',
targets: [
{
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
types: ['IMAGE_CHARACTER'],
},
],
})
})
it('GET /api/tasks/[taskId]: enforces ownership and returns task detail', async () => {
const route = await import('@/app/api/tasks/[taskId]/route')
authState.authenticated = false
const unauthorizedReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
const unauthorizedRes = await route.GET(unauthorizedReq, { params: Promise.resolve({ taskId: 'task-1' }) })
expect(unauthorizedRes.status).toBe(401)
authState.authenticated = true
getTaskByIdMock.mockResolvedValueOnce({ ...baseTask, userId: 'other-user' })
const notFoundReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
const notFoundRes = await route.GET(notFoundReq, { params: Promise.resolve({ taskId: 'task-1' }) })
expect(notFoundRes.status).toBe(404)
const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })
expect(res.status).toBe(200)
const payload = await res.json() as { task: TaskRecord }
expect(payload.task.id).toBe('task-1')
})
it('GET /api/tasks/[taskId]?includeEvents=1: returns lifecycle events for refresh replay', async () => {
const route = await import('@/app/api/tasks/[taskId]/route')
const replayEvents = [
{
id: '11',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: null,
payload: {
lifecycleType: 'processing',
stepId: 'clip_1_phase1',
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 3,
message: 'running',
},
},
]
listTaskLifecycleEventsMock.mockResolvedValueOnce(replayEvents)
const req = buildMockRequest({
path: '/api/tasks/task-1',
method: 'GET',
query: { includeEvents: '1', eventsLimit: '1200' },
})
const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })
expect(res.status).toBe(200)
const payload = await res.json() as { task: TaskRecord; events: Array<Record<string, unknown>> }
expect(payload.task.id).toBe('task-1')
expect(payload.events).toHaveLength(1)
expect(payload.events[0]?.id).toBe('11')
expect(listTaskLifecycleEventsMock).toHaveBeenCalledWith('task-1', 1200)
})
it('DELETE /api/tasks/[taskId]: cancellation publishes cancelled event payload', async () => {
const { DELETE } = await import('@/app/api/tasks/[taskId]/route')
const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'DELETE' })
const res = await DELETE(req, { params: Promise.resolve({ taskId: 'task-1' }) } as RouteContext)
expect(res.status).toBe(200)
expect(removeTaskJobMock).toHaveBeenCalledWith('task-1')
expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({
taskId: 'task-1',
projectId: 'project-1',
payload: expect.objectContaining({
cancelled: true,
stage: 'cancelled',
}),
}))
})
it('GET /api/sse: missing projectId -> 400; unauthenticated with projectId -> 401', async () => {
const { GET } = await import('@/app/api/sse/route')
const invalidReq = buildMockRequest({ path: '/api/sse', method: 'GET' })
const invalidRes = await GET(invalidReq)
expect(invalidRes.status).toBe(400)
authState.authenticated = false
const unauthorizedReq = buildMockRequest({
path: '/api/sse',
method: 'GET',
query: { projectId: 'project-1' },
})
const unauthorizedRes = await GET(unauthorizedReq)
expect(unauthorizedRes.status).toBe(401)
})
it('GET /api/sse: authenticated replay request returns SSE stream and replays missed events', async () => {
const { GET } = await import('@/app/api/sse/route')
listEventsAfterMock.mockResolvedValueOnce([
{
id: '4',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: null,
payload: { lifecycleType: 'created' },
},
])
const req = buildMockRequest({
path: '/api/sse',
method: 'GET',
query: { projectId: 'project-1' },
headers: { 'last-event-id': '3' },
})
const res = await GET(req)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/event-stream')
expect(listEventsAfterMock).toHaveBeenCalledWith('project-1', 3, 5000)
expect(addChannelListenerMock).toHaveBeenCalledWith('project:project-1', expect.any(Function))
const reader = res.body?.getReader()
expect(reader).toBeTruthy()
const firstChunk = await reader!.read()
expect(firstChunk.done).toBe(false)
const decoded = new TextDecoder().decode(firstChunk.value)
expect(decoded).toContain('event:')
await reader!.cancel()
})
it('GET /api/sse: channel lifecycle stream includes terminal completed event', async () => {
const { GET } = await import('@/app/api/sse/route')
listEventsAfterMock.mockResolvedValueOnce([])
const req = buildMockRequest({
path: '/api/sse',
method: 'GET',
query: { projectId: 'project-1' },
headers: { 'last-event-id': '10' },
})
const res = await GET(req)
expect(res.status).toBe(200)
const listener = subscriberState.listener
expect(listener).toBeTruthy()
listener!(JSON.stringify({
id: '11',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: null,
payload: { lifecycleType: 'processing', progress: 60 },
}))
listener!(JSON.stringify({
id: '12',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: null,
payload: { lifecycleType: 'completed', progress: 100 },
}))
const reader = res.body?.getReader()
expect(reader).toBeTruthy()
const chunk1 = await reader!.read()
const chunk2 = await reader!.read()
const merged = `${new TextDecoder().decode(chunk1.value)}${new TextDecoder().decode(chunk2.value)}`
expect(merged).toContain('"lifecycleType":"processing"')
expect(merged).toContain('"lifecycleType":"completed"')
expect(merged).toContain('"taskId":"task-1"')
await reader!.cancel()
})
})

View File

@@ -0,0 +1,35 @@
import { NextRequest } from 'next/server'
type RouteParams = Record<string, string>
type HeaderMap = Record<string, string>
type RouteHandler = (
req: NextRequest,
ctx?: { params: Promise<RouteParams> },
) => Promise<Response>
export async function callRoute(
handler: RouteHandler,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
options?: { headers?: HeaderMap; params?: RouteParams; query?: Record<string, string> },
) {
const url = new URL('http://localhost:3000/api/test')
if (options?.query) {
for (const [key, value] of Object.entries(options.query)) {
url.searchParams.set(key, value)
}
}
const payload = body === undefined ? undefined : JSON.stringify(body)
const req = new NextRequest(url, {
method,
headers: {
...(payload ? { 'content-type': 'application/json' } : {}),
...(options?.headers || {}),
},
...(payload ? { body: payload } : {}),
})
const context = { params: Promise.resolve(options?.params || {}) }
return await handler(req, context)
}

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { buildMockRequest } from '../../../helpers/request'
const authMock = vi.hoisted(() => ({
requireUserAuth: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const prismaMock = vi.hoisted(() => ({
globalAssetFolder: {
findUnique: vi.fn(),
},
globalCharacter: {
create: vi.fn(async () => ({ id: 'character-1', userId: 'user-1' })),
findUnique: vi.fn(async () => ({
id: 'character-1',
userId: 'user-1',
name: 'Hero',
appearances: [],
})),
},
globalCharacterAppearance: {
create: vi.fn(async () => ({ id: 'appearance-1' })),
},
}))
const mediaAttachMock = vi.hoisted(() => ({
attachMediaFieldsToGlobalCharacter: vi.fn(async (value: unknown) => value),
}))
const mediaServiceMock = vi.hoisted(() => ({
resolveMediaRefFromLegacyValue: vi.fn(async () => null),
}))
const envMock = vi.hoisted(() => ({
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
}))
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/media/attach', () => mediaAttachMock)
vi.mock('@/lib/media/service', () => mediaServiceMock)
vi.mock('@/lib/env', () => envMock)
describe('api specific - characters POST forwarding to reference task', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.globalAssetFolder.findUnique.mockResolvedValue(null)
})
it('forwards locale and accept-language into background reference task payload', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const mod = await import('@/app/api/asset-hub/characters/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters',
method: 'POST',
headers: {
'accept-language': 'zh-CN,zh;q=0.9',
},
body: {
name: 'Hero',
generateFromReference: true,
referenceImageUrl: 'https://example.com/ref.png',
customDescription: '冷静,黑发',
},
})
const res = await mod.POST(req)
expect(res.status).toBe(200)
const calledUrl = fetchMock.mock.calls[0]?.[0]
const calledInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
expect(String(calledUrl)).toContain('/api/asset-hub/reference-to-character')
expect((calledInit?.headers as Record<string, string>)['Accept-Language']).toBe('zh-CN,zh;q=0.9')
const rawBody = calledInit?.body
expect(typeof rawBody).toBe('string')
const forwarded = JSON.parse(String(rawBody)) as {
locale?: string
meta?: { locale?: string }
customDescription?: string
referenceImageUrls?: string[]
appearanceId?: string
characterId?: string
}
expect(forwarded.locale).toBe('zh')
expect(forwarded.meta?.locale).toBe('zh')
expect(forwarded.customDescription).toBe('冷静,黑发')
expect(forwarded.referenceImageUrls).toEqual(['https://example.com/ref.png'])
expect(forwarded.characterId).toBe('character-1')
expect(forwarded.appearanceId).toBe('appearance-1')
})
it('returns unauthorized when auth fails', async () => {
authMock.requireUserAuth.mockResolvedValueOnce(
NextResponse.json({ error: { code: 'UNAUTHORIZED' } }, { status: 401 }),
)
const mod = await import('@/app/api/asset-hub/characters/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters',
method: 'POST',
body: { name: 'Hero' },
})
const res = await mod.POST(req)
expect(res.status).toBe(401)
})
})

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
import {
installAuthMocks,
mockAuthenticated,
mockUnauthenticated,
resetAuthMockState,
} from '../../../helpers/auth'
describe('api specific - characters POST', () => {
beforeEach(() => {
vi.resetModules()
resetAuthMockState()
})
it('returns unauthorized when user is not authenticated', async () => {
installAuthMocks()
mockUnauthenticated()
const mod = await import('@/app/api/asset-hub/characters/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters',
method: 'POST',
body: { name: 'A' },
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(401)
})
it('returns invalid params when name is missing', async () => {
installAuthMocks()
mockAuthenticated('user-a')
const mod = await import('@/app/api/asset-hub/characters/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters',
method: 'POST',
body: {},
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error.code).toBe('INVALID_PARAMS')
})
})

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
import {
installAuthMocks,
mockAuthenticated,
mockUnauthenticated,
resetAuthMockState,
} from '../../../helpers/auth'
describe('api specific - reference to character route', () => {
beforeEach(() => {
vi.resetModules()
resetAuthMockState()
})
it('returns unauthorized when user is not authenticated', async () => {
installAuthMocks()
mockUnauthenticated()
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
const req = buildMockRequest({
path: '/api/asset-hub/reference-to-character',
method: 'POST',
body: {
referenceImageUrl: 'https://example.com/ref.png',
},
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
expect(res.status).toBe(401)
})
it('returns invalid params when references are missing', async () => {
installAuthMocks()
mockAuthenticated('user-a')
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
const req = buildMockRequest({
path: '/api/asset-hub/reference-to-character',
method: 'POST',
body: {},
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error.code).toBe('INVALID_PARAMS')
})
})

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'
import { apiHandler } from '@/lib/api-errors'
import { calcText } from '@/lib/billing/cost'
import { withTextBilling } from '@/lib/billing/service'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
describe('billing/api contract integration', () => {
beforeEach(async () => {
await resetBillingState()
process.env.BILLING_MODE = 'ENFORCE'
})
it('returns 402 payload when balance is insufficient', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 0)
const route = apiHandler(async () => {
await withTextBilling(
user.id,
'anthropic/claude-sonnet-4',
1000,
500,
{ projectId: project.id, action: 'api_contract_insufficient' },
async () => ({ ok: true }),
)
return NextResponse.json({ ok: true })
})
const req = new NextRequest('http://localhost/api/test', {
method: 'POST',
headers: { 'x-request-id': 'req_insufficient' },
})
const response = await route(req, { params: Promise.resolve({}) })
const body = await response.json()
expect(response.status).toBe(402)
expect(body?.error?.code).toBe('INSUFFICIENT_BALANCE')
expect(typeof body?.required).toBe('number')
expect(typeof body?.available).toBe('number')
})
it('rejects duplicate retry with same request id and prevents duplicate charge', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 5)
const route = apiHandler(async () => {
await withTextBilling(
user.id,
'anthropic/claude-sonnet-4',
1000,
500,
{ projectId: project.id, action: 'api_contract_dedupe' },
async () => ({ ok: true }),
)
return NextResponse.json({ ok: true })
})
const req1 = new NextRequest('http://localhost/api/test', {
method: 'POST',
headers: { 'x-request-id': 'same_request_id' },
})
const req2 = new NextRequest('http://localhost/api/test', {
method: 'POST',
headers: { 'x-request-id': 'same_request_id' },
})
const resp1 = await route(req1, { params: Promise.resolve({}) })
const resp2 = await route(req2, { params: Promise.resolve({}) })
const body2 = await resp2.json()
expect(resp1.status).toBe(200)
expect(resp2.status).toBe(409)
expect(body2?.error?.code).toBe('CONFLICT')
expect(String(body2?.error?.message || '')).toContain('duplicate billing request already confirmed')
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
const expectedCharge = calcText('anthropic/claude-sonnet-4', 1000, 500)
expect(balance?.totalSpent).toBeCloseTo(expectedCharge, 8)
expect(await prisma.balanceFreeze.count()).toBe(1)
})
})

View File

@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
confirmChargeWithRecord,
freezeBalance,
getBalance,
recordShadowUsage,
rollbackFreeze,
} from '@/lib/billing/ledger'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
describe('billing/ledger integration', () => {
beforeEach(async () => {
await resetBillingState()
process.env.BILLING_MODE = 'ENFORCE'
})
it('freezes balance when enough funds exist', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_ok' })
expect(freezeId).toBeTruthy()
const balance = await getBalance(user.id)
expect(balance.balance).toBeCloseTo(7, 8)
expect(balance.frozenAmount).toBeCloseTo(3, 8)
})
it('returns null freeze id when balance is insufficient', async () => {
const user = await createTestUser()
await seedBalance(user.id, 1)
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_no_money' })
expect(freezeId).toBeNull()
})
it('reuses same freeze record with the same idempotency key', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
const first = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
const second = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
expect(first).toBeTruthy()
expect(second).toBe(first)
const balance = await getBalance(user.id)
expect(balance.balance).toBeCloseTo(8, 8)
expect(balance.frozenAmount).toBeCloseTo(2, 8)
expect(await prisma.balanceFreeze.count()).toBe(1)
})
it('supports partial confirmation and refunds difference', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'confirm_partial' })
expect(freezeId).toBeTruthy()
const confirmed = await confirmChargeWithRecord(
freezeId!,
{
projectId: project.id,
action: 'integration_confirm',
apiType: 'voice',
model: 'index-tts2',
quantity: 2,
unit: 'second',
},
{ chargedAmount: 2 },
)
expect(confirmed).toBe(true)
const balance = await getBalance(user.id)
expect(balance.balance).toBeCloseTo(8, 8)
expect(balance.frozenAmount).toBeCloseTo(0, 8)
expect(balance.totalSpent).toBeCloseTo(2, 8)
expect(await prisma.usageCost.count()).toBe(1)
})
it('is idempotent when confirm is called repeatedly', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'confirm_idem' })
expect(freezeId).toBeTruthy()
const first = await confirmChargeWithRecord(
freezeId!,
{
projectId: project.id,
action: 'integration_confirm',
apiType: 'image',
model: 'seedream',
quantity: 1,
unit: 'image',
},
{ chargedAmount: 1 },
)
const second = await confirmChargeWithRecord(
freezeId!,
{
projectId: project.id,
action: 'integration_confirm',
apiType: 'image',
model: 'seedream',
quantity: 1,
unit: 'image',
},
{ chargedAmount: 1 },
)
expect(first).toBe(true)
expect(second).toBe(true)
expect(await prisma.balanceTransaction.count({ where: { freezeId: freezeId! } })).toBe(1)
})
it('rolls back pending freeze and restores funds', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
const freezeId = await freezeBalance(user.id, 4, { idempotencyKey: 'rollback_ok' })
expect(freezeId).toBeTruthy()
const rolled = await rollbackFreeze(freezeId!)
expect(rolled).toBe(true)
const balance = await getBalance(user.id)
expect(balance.balance).toBeCloseTo(10, 8)
expect(balance.frozenAmount).toBeCloseTo(0, 8)
})
it('returns false when trying to rollback a non-pending freeze', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'rollback_after_confirm' })
expect(freezeId).toBeTruthy()
await confirmChargeWithRecord(
freezeId!,
{
projectId: project.id,
action: 'integration_confirm',
apiType: 'voice',
model: 'index-tts2',
quantity: 5,
unit: 'second',
},
{ chargedAmount: 1 },
)
const rolled = await rollbackFreeze(freezeId!)
expect(rolled).toBe(false)
})
it('records shadow usage as audit transaction without balance change', async () => {
const user = await createTestUser()
await seedBalance(user.id, 5)
const ok = await recordShadowUsage(user.id, {
projectId: 'asset-hub',
action: 'shadow_test',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1200,
unit: 'token',
cost: 0.25,
metadata: { source: 'test' },
})
expect(ok).toBe(true)
const balance = await getBalance(user.id)
expect(balance.balance).toBeCloseTo(5, 8)
expect(balance.totalSpent).toBeCloseTo(0, 8)
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
})
})

View File

@@ -0,0 +1,137 @@
import { randomUUID } from 'node:crypto'
import { beforeEach, describe, expect, it } from 'vitest'
import { calcVoice } from '@/lib/billing/cost'
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
import { prepareTaskBilling, rollbackTaskBilling, settleTaskBilling } from '@/lib/billing/service'
import { TASK_TYPE, type TaskBillingInfo } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
function expectBillableInfo(info: TaskBillingInfo | null | undefined): Extract<TaskBillingInfo, { billable: true }> {
expect(info?.billable).toBe(true)
if (!info || !info.billable) {
throw new Error('Expected billable task billing info')
}
return info
}
describe('billing/service integration', () => {
beforeEach(async () => {
await resetBillingState()
})
it('marks task billing as skipped in OFF mode', async () => {
process.env.BILLING_MODE = 'OFF'
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
const result = await prepareTaskBilling({
id: randomUUID(),
userId: user.id,
projectId: project.id,
billingInfo: info,
})
expect(result?.billable).toBe(true)
expect((result as TaskBillingInfo & { status: string }).status).toBe('skipped')
})
it('records shadow audit in SHADOW mode and does not consume balance', async () => {
process.env.BILLING_MODE = 'SHADOW'
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
const taskId = randomUUID()
const prepared = expectBillableInfo(await prepareTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: info,
}))
expect(prepared.status).toBe('quoted')
const settled = expectBillableInfo(await settleTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: prepared,
}, {
result: { actualDurationSeconds: 2 },
}))
expect(settled.status).toBe('settled')
expect(settled.chargedCost).toBe(0)
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
expect(balance?.balance).toBeCloseTo(10, 8)
expect(balance?.totalSpent).toBeCloseTo(0, 8)
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
})
it('freezes and settles in ENFORCE mode with actual usage', async () => {
process.env.BILLING_MODE = 'ENFORCE'
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
const taskId = randomUUID()
const prepared = expectBillableInfo(await prepareTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: info,
}))
expect(prepared.status).toBe('frozen')
expect(prepared.freezeId).toBeTruthy()
const settled = expectBillableInfo(await settleTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: prepared,
}, {
result: { actualDurationSeconds: 2 },
}))
expect(settled.status).toBe('settled')
expect(settled.chargedCost).toBeCloseTo(calcVoice(2), 8)
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
expect(balance?.totalSpent).toBeCloseTo(calcVoice(2), 8)
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
})
it('rolls back frozen billing in ENFORCE mode', async () => {
process.env.BILLING_MODE = 'ENFORCE'
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
const taskId = randomUUID()
const prepared = expectBillableInfo(await prepareTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: info,
}))
const rolled = expectBillableInfo(await rollbackTaskBilling({
id: taskId,
billingInfo: prepared,
}))
expect(rolled.status).toBe('rolled_back')
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
expect(balance?.balance).toBeCloseTo(10, 8)
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
})
})

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ApiError } from '@/lib/api-errors'
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
import { submitTask } from '@/lib/task/submitter'
import { TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
vi.mock('@/lib/task/queues', () => ({
addTaskJob: vi.fn(async () => ({ id: 'mock-job' })),
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: vi.fn(async () => ({})),
}))
describe('billing/submitter integration', () => {
beforeEach(async () => {
await resetBillingState()
process.env.BILLING_MODE = 'ENFORCE'
})
it('builds billing info server-side for billable task submission', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
const result = await submitTask({
userId: user.id,
locale: 'en',
projectId: 'project-a',
type: TASK_TYPE.VOICE_LINE,
targetType: 'VoiceLine',
targetId: 'line-a',
payload: { maxSeconds: 5 },
})
expect(result.success).toBe(true)
const task = await prisma.task.findUnique({ where: { id: result.taskId } })
expect(task).toBeTruthy()
const billing = task?.billingInfo as { billable?: boolean; source?: string } | null
expect(billing?.billable).toBe(true)
expect(billing?.source).toBe('task')
})
it('marks task as failed when balance is insufficient', async () => {
const user = await createTestUser()
await seedBalance(user.id, 0)
const billingInfo = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 10 })
expect(billingInfo?.billable).toBe(true)
await expect(
submitTask({
userId: user.id,
locale: 'en',
projectId: 'project-b',
type: TASK_TYPE.VOICE_LINE,
targetType: 'VoiceLine',
targetId: 'line-b',
payload: { maxSeconds: 10 },
billingInfo,
}),
).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' } satisfies Pick<ApiError, 'code'>)
const task = await prisma.task.findFirst({
where: {
userId: user.id,
type: TASK_TYPE.VOICE_LINE,
},
orderBy: { createdAt: 'desc' },
})
expect(task).toBeTruthy()
expect(task?.status).toBe('failed')
expect(task?.errorCode).toBe('INSUFFICIENT_BALANCE')
})
})

View File

@@ -0,0 +1,136 @@
import { randomUUID } from 'node:crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Job } from 'bullmq'
import { UnrecoverableError } from 'bullmq'
import { prepareTaskBilling } from '@/lib/billing/service'
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
import { TaskTerminatedError } from '@/lib/task/errors'
import { withTaskLifecycle } from '@/lib/workers/shared'
import { TASK_TYPE, type TaskBillingInfo, type TaskJobData } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createQueuedTask, createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: vi.fn(async () => ({})),
}))
async function createPreparedVoiceTask() {
process.env.BILLING_MODE = 'ENFORCE'
const user = await createTestUser()
const project = await createTestProject(user.id)
await seedBalance(user.id, 10)
const taskId = randomUUID()
const raw = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })
if (!raw || !raw.billable) {
throw new Error('failed to build billing info fixture')
}
const prepared = await prepareTaskBilling({
id: taskId,
userId: user.id,
projectId: project.id,
billingInfo: raw,
})
const billingInfo = prepared as TaskBillingInfo
await createQueuedTask({
id: taskId,
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VOICE_LINE,
targetType: 'VoiceLine',
targetId: 'line-1',
billingInfo,
})
const jobData: TaskJobData = {
taskId,
type: TASK_TYPE.VOICE_LINE,
locale: 'en',
projectId: project.id,
targetType: 'VoiceLine',
targetId: 'line-1',
billingInfo,
userId: user.id,
payload: {},
}
const job = {
data: jobData,
queueName: 'voice',
opts: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2_000,
},
},
attemptsMade: 0,
} as unknown as Job<TaskJobData>
return { taskId, user, project, job }
}
describe('billing/worker lifecycle integration', () => {
beforeEach(async () => {
await resetBillingState()
})
it('settles billing and marks task completed on success', async () => {
const fixture = await createPreparedVoiceTask()
await withTaskLifecycle(fixture.job, async () => ({ actualDurationSeconds: 2 }))
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
expect(task?.status).toBe('completed')
const billing = task?.billingInfo as TaskBillingInfo
expect(billing?.billable).toBe(true)
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
})
it('rolls back billing and marks task failed on error', async () => {
const fixture = await createPreparedVoiceTask()
await expect(
withTaskLifecycle(fixture.job, async () => {
throw new Error('worker failed')
}),
).rejects.toBeInstanceOf(UnrecoverableError)
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
expect(task?.status).toBe('failed')
const billing = task?.billingInfo as TaskBillingInfo
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
})
it('keeps task active for queue retry on retryable worker error', async () => {
const fixture = await createPreparedVoiceTask()
await expect(
withTaskLifecycle(fixture.job, async () => {
throw new TypeError('terminated')
}),
).rejects.toBeInstanceOf(TypeError)
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
expect(task?.status).toBe('processing')
const billing = task?.billingInfo as TaskBillingInfo
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('frozen')
})
it('rolls back billing on cancellation path', async () => {
const fixture = await createPreparedVoiceTask()
await expect(
withTaskLifecycle(fixture.job, async () => {
throw new TaskTerminatedError(fixture.taskId)
}),
).rejects.toBeInstanceOf(UnrecoverableError)
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
const billing = task?.billingInfo as TaskBillingInfo
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
expect(task?.status).not.toBe('failed')
})
})

View File

@@ -0,0 +1,182 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getUserModels: vi.fn(async () => ({
characterModel: 'model-character-1',
locationModel: 'model-location-1',
})),
}))
const prismaMock = vi.hoisted(() => ({
globalCharacter: {
findFirst: vi.fn(),
},
globalCharacterAppearance: {
update: vi.fn(async () => ({})),
},
globalLocation: {
findFirst: vi.fn(),
},
globalLocationImage: {
update: vi.fn(async () => ({})),
},
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),
parseJsonStringArray: vi.fn(() => [] as string[]),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
parseJsonStringArray: sharedMock.parseJsonStringArray,
}
})
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - image queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
})
it('image tasks are enqueued into image queue with jobId=taskId', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-image-1',
type: TASK_TYPE.ASSET_HUB_IMAGE,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'global-character-1',
payload: { type: 'character', id: 'global-character-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.ASSET_HUB_IMAGE,
options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),
}))
})
it('modify asset image task also routes to image queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-image-2',
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
})
it('queued image job payload can be consumed by worker handler and persist image fields', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')
prismaMock.globalCharacter.findFirst.mockResolvedValue({
id: 'global-character-1',
name: 'Hero',
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'base',
description: '黑发,风衣',
descriptions: null,
},
],
})
await addTaskJob({
taskId: 'task-image-chain-worker-1',
type: TASK_TYPE.ASSET_HUB_IMAGE,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'global-character-1',
payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)
const result = await handleAssetHubImageTask(toJob(queued!))
expect(result).toEqual({
type: 'character',
appearanceId: 'appearance-1',
imageCount: 1,
})
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
where: { id: 'appearance-1' },
data: {
imageUrls: JSON.stringify(['cos/global-character-generated.png']),
imageUrl: 'cos/global-character-generated.png',
selectedIndex: null,
},
})
})
})

View File

@@ -0,0 +1,185 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const prismaMock = vi.hoisted(() => ({
project: {
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
},
novelPromotionProject: {
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
},
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(() => JSON.stringify({
episodes: [
{
number: 1,
title: '第一集',
summary: '开端',
startMarker: 'START_MARKER',
endMarker: 'END_MARKER',
},
],
})),
}))
const configMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/config-service', () => configMock)
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
buildPrompt: vi.fn(() => 'episode-split-prompt'),
}))
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
createTextMarkerMatcher: (content: string) => ({
matchMarker: (marker: string, fromIndex = 0) => {
const startIndex = content.indexOf(marker, fromIndex)
if (startIndex === -1) return null
return { startIndex, endIndex: startIndex + marker.length }
},
}),
}))
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - text queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
})
it('text tasks are enqueued into text queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-text-1',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: { episodeId: 'episode-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
options: expect.objectContaining({ jobId: 'task-text-1', priority: 0 }),
}))
})
it('explicit priority is preserved for text queue jobs', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-text-2',
type: TASK_TYPE.REFERENCE_TO_CHARACTER,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionProject',
targetId: 'project-1',
payload: { referenceImageUrl: 'https://example.com/ref.png' },
userId: 'user-1',
}, { priority: 7 })
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))
})
it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')
const content = [
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'START_MARKER',
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于链路测试验证。',
'END_MARKER',
'后置内容用于确保边界外还有文本,并继续补足长度。',
].join('')
await addTaskJob({
taskId: 'task-text-chain-worker-1',
type: TASK_TYPE.EPISODE_SPLIT_LLM,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionProject',
targetId: 'project-1',
payload: { content },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)
const result = await handleEpisodeSplitTask(toJob(queued!))
expect(result.success).toBe(true)
expect(result.episodes).toHaveLength(1)
expect(result.episodes[0]?.title).toBe('第一集')
expect(result.episodes[0]?.content).toContain('START_MARKER')
expect(result.episodes[0]?.content).toContain('END_MARKER')
})
})

View File

@@ -0,0 +1,189 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const workerState = vi.hoisted(() => ({
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
resolveVideoSourceFromGeneration: vi.fn(async () => 'https://provider.example/video.mp4'),
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => undefined),
},
novelPromotionVoiceLine: {
findUnique: vi.fn(),
},
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
withTaskLifecycle: workerMock.withTaskLifecycle,
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
}))
vi.mock('@/lib/model-capabilities/lookup', () => ({
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
}))
vi.mock('@/lib/model-config-contract', () => ({
parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),
}))
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - video queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
workerState.processor = null
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
videoUrl: 'cos/base-video.mp4',
})
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
id: 'line-1',
audioUrl: 'cos/line-1.mp3',
})
})
it('VIDEO_PANEL is enqueued into video queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-video-1',
type: TASK_TYPE.VIDEO_PANEL,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { videoModel: 'fal::video-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.VIDEO_PANEL,
options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),
}))
})
it('LIP_SYNC is enqueued into video queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-video-2',
type: TASK_TYPE.LIP_SYNC,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)
})
it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { createVideoWorker } = await import('@/lib/workers/video.worker')
createVideoWorker()
const processor = workerState.processor
expect(processor).toBeTruthy()
await addTaskJob({
taskId: 'task-video-chain-worker-1',
type: TASK_TYPE.LIP_SYNC,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)
const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
expect(result).toEqual({
panelId: 'panel-1',
voiceLineId: 'line-1',
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
})
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
lipSyncTaskId: null,
},
})
})
})

View File

@@ -0,0 +1,172 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type AddCall = {
jobName: string
data: TaskJobData
options: Record<string, unknown>
}
const queueState = vi.hoisted(() => ({
addCallsByQueue: new Map<string, AddCall[]>(),
}))
const workerState = vi.hoisted(() => ({
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
}))
const voiceMock = vi.hoisted(() => ({
generateVoiceLine: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
}))
const voiceDesignMock = vi.hoisted(() => ({
handleVoiceDesignTask: vi.fn(),
}))
vi.mock('bullmq', () => ({
Queue: class {
private readonly queueName: string
constructor(queueName: string) {
this.queueName = queueName
}
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
const list = queueState.addCallsByQueue.get(this.queueName) || []
list.push({ jobName, data, options })
queueState.addCallsByQueue.set(this.queueName, list)
return { id: data.taskId }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/voice/generate-voice-line', () => ({
generateVoiceLine: voiceMock.generateVoiceLine,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
withTaskLifecycle: workerMock.withTaskLifecycle,
}))
vi.mock('@/lib/workers/handlers/voice-design', () => ({
handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,
}))
function toJob(data: TaskJobData): Job<TaskJobData> {
return { data } as unknown as Job<TaskJobData>
}
describe('chain contract - voice queue behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
queueState.addCallsByQueue.clear()
workerState.processor = null
voiceMock.generateVoiceLine.mockResolvedValue({
lineId: 'line-1',
audioUrl: 'cos/voice-line-1.mp3',
})
voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({
presetId: 'voice-design-1',
previewAudioUrl: 'cos/preview-1.mp3',
})
})
it('VOICE_LINE is enqueued into voice queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-voice-1',
type: TASK_TYPE.VOICE_LINE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
payload: { lineId: 'line-1', episodeId: 'episode-1' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual(expect.objectContaining({
jobName: TASK_TYPE.VOICE_LINE,
options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),
}))
})
it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-voice-2',
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalAssetHubVoiceDesign',
targetId: 'voice-design-1',
payload: { voicePrompt: 'female calm narrator' },
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)
})
it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
const { createVoiceWorker } = await import('@/lib/workers/voice.worker')
createVoiceWorker()
const processor = workerState.processor
expect(processor).toBeTruthy()
await addTaskJob({
taskId: 'task-voice-chain-worker-1',
type: TASK_TYPE.VOICE_LINE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
payload: {
lineId: 'line-1',
episodeId: 'episode-1',
audioModel: 'fal::voice-model',
},
userId: 'user-1',
})
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
const queued = calls[0]?.data
expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)
const result = await processor!(toJob(queued!))
expect(result).toEqual({
lineId: 'line-1',
audioUrl: 'cos/voice-line-1.mp3',
})
expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({
projectId: 'project-1',
episodeId: 'episode-1',
lineId: 'line-1',
userId: 'user-1',
audioModel: 'fal::voice-model',
})
})
})