release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
396
tests/integration/api/contract/crud-routes.test.ts
Normal file
396
tests/integration/api/contract/crud-routes.test.ts
Normal 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 }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
469
tests/integration/api/contract/direct-submit-routes.test.ts
Normal file
469
tests/integration/api/contract/direct-submit-routes.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
362
tests/integration/api/contract/llm-observe-routes.test.ts
Normal file
362
tests/integration/api/contract/llm-observe-routes.test.ts
Normal 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')
|
||||
})
|
||||
}
|
||||
})
|
||||
446
tests/integration/api/contract/task-infra-routes.test.ts
Normal file
446
tests/integration/api/contract/task-infra-routes.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
35
tests/integration/api/helpers/call-route.ts
Normal file
35
tests/integration/api/helpers/call-route.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
45
tests/integration/api/specific/characters-post.test.ts
Normal file
45
tests/integration/api/specific/characters-post.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
86
tests/integration/billing/api-contract.integration.test.ts
Normal file
86
tests/integration/billing/api-contract.integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
183
tests/integration/billing/ledger.integration.test.ts
Normal file
183
tests/integration/billing/ledger.integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
137
tests/integration/billing/service.integration.test.ts
Normal file
137
tests/integration/billing/service.integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
78
tests/integration/billing/submitter.integration.test.ts
Normal file
78
tests/integration/billing/submitter.integration.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal file
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
182
tests/integration/chain/image.chain.test.ts
Normal file
182
tests/integration/chain/image.chain.test.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
185
tests/integration/chain/text.chain.test.ts
Normal file
185
tests/integration/chain/text.chain.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
189
tests/integration/chain/video.chain.test.ts
Normal file
189
tests/integration/chain/video.chain.test.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
172
tests/integration/chain/voice.chain.test.ts
Normal file
172
tests/integration/chain/voice.chain.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user