import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildMockRequest } from '../../../helpers/request' type PanelRecord = { id: string storyboardId: string panelIndex: number shotType: string cameraMove: string description: string videoPrompt: string location: string characters: string srtSegment: string duration: number } type StoryboardRecord = { id: string episode: { novelPromotionProject: { projectId: string } } } const authMock = vi.hoisted(() => ({ requireProjectAuthLight: vi.fn(async () => ({ session: { user: { id: 'user-1' } }, project: { id: 'project-1', userId: 'user-1' }, })), isErrorResponse: vi.fn((value: unknown) => value instanceof Response), })) const submitTaskMock = vi.hoisted(() => vi.fn(async () => ({ success: true, async: true, taskId: 'task-panel-variant', runId: null, status: 'queued', deduped: false, }))) const configServiceMock = vi.hoisted(() => ({ getProjectModelConfig: vi.fn(async () => ({ storyboardModel: 'img::storyboard', })), buildImageBillingPayload: vi.fn(async (input: { basePayload: Record }) => ({ ...input.basePayload, generationOptions: { resolution: '1024x1024' }, })), })) const rollbackSpy = vi.hoisted(() => ({ delete: vi.fn(async () => ({})), findFirst: vi.fn(async () => ({ panelIndex: 4 })), updateMany: vi.fn(async () => ({ count: 2 })), count: vi.fn(async () => 3), storyboardUpdate: vi.fn(async () => ({})), })) const createTxSpy = vi.hoisted(() => ({ findMany: vi.fn(async () => [ { id: 'panel-after-1', panelIndex: 2 }, { id: 'panel-after-2', panelIndex: 3 }, ]), update: vi.fn(async () => ({})), create: vi.fn(async (args: { data: PanelRecord }) => ({ id: args.data.id, panelIndex: args.data.panelIndex, })), count: vi.fn(async () => 4), storyboardUpdate: vi.fn(async () => ({})), })) const routeState = vi.hoisted(() => ({ storyboard: { id: 'storyboard-1', episode: { novelPromotionProject: { projectId: 'project-1', }, }, } satisfies StoryboardRecord, panels: new Map(), })) const prismaMock = vi.hoisted(() => ({ novelPromotionStoryboard: { findUnique: vi.fn(async () => routeState.storyboard), }, novelPromotionPanel: { findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null), }, $transaction: vi.fn(async ( fn: (tx: { novelPromotionPanel: { findMany: typeof createTxSpy.findMany update: typeof createTxSpy.update create: typeof createTxSpy.create delete: typeof rollbackSpy.delete findFirst: typeof rollbackSpy.findFirst updateMany: typeof rollbackSpy.updateMany count: typeof rollbackSpy.count } novelPromotionStoryboard: { update: typeof createTxSpy.storyboardUpdate } }) => Promise, ) => { const invocation = prismaMock.$transaction.mock.calls.length if (invocation > 1) { return await fn({ novelPromotionPanel: { findMany: createTxSpy.findMany, update: createTxSpy.update, create: createTxSpy.create, delete: rollbackSpy.delete, findFirst: rollbackSpy.findFirst, updateMany: rollbackSpy.updateMany, count: rollbackSpy.count, }, novelPromotionStoryboard: { update: rollbackSpy.storyboardUpdate, }, }) } return await fn({ novelPromotionPanel: { findMany: createTxSpy.findMany, update: createTxSpy.update, create: createTxSpy.create, delete: rollbackSpy.delete, findFirst: rollbackSpy.findFirst, updateMany: rollbackSpy.updateMany, count: rollbackSpy.count, }, novelPromotionStoryboard: { update: createTxSpy.storyboardUpdate, }, }) }), })) vi.mock('@/lib/api-auth', () => authMock) vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock })) vi.mock('@/lib/config-service', () => configServiceMock) vi.mock('@/lib/prisma', () => ({ prisma: prismaMock })) vi.mock('@/lib/billing', () => ({ buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })), })) vi.mock('@/lib/task/resolve-locale', () => ({ resolveRequiredTaskLocale: vi.fn(() => 'zh'), })) function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord { return { id, storyboardId, panelIndex, shotType: 'medium', cameraMove: 'static', description: `description-${id}`, videoPrompt: `prompt-${id}`, location: 'Old Town', characters: '[]', srtSegment: '', duration: 3, } } async function invokeRoute(body: Record): Promise { const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route') const req = buildMockRequest({ path: '/api/novel-promotion/project-1/panel-variant', method: 'POST', body, }) return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) }) } describe('api specific - panel variant route', () => { beforeEach(() => { vi.clearAllMocks() routeState.storyboard = { id: 'storyboard-1', episode: { novelPromotionProject: { projectId: 'project-1', }, }, } routeState.panels = new Map([ ['panel-src', buildPanel('panel-src', 'storyboard-1', 1)], ['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)], ]) }) it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => { routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1)) const res = await invokeRoute({ storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins', sourcePanelId: 'panel-src', variant: { video_prompt: 'variant prompt', description: 'variant desc' }, }) const json = await res.json() as { error: { code: string } } expect(res.status).toBe(400) expect(json.error.code).toBe('INVALID_PARAMS') expect(createTxSpy.create).not.toHaveBeenCalled() expect(submitTaskMock).not.toHaveBeenCalled() }) it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => { routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2)) const res = await invokeRoute({ storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins', sourcePanelId: 'panel-src', variant: { video_prompt: 'variant prompt', description: 'variant desc' }, }) const json = await res.json() as { error: { code: string } } expect(res.status).toBe(400) expect(json.error.code).toBe('INVALID_PARAMS') expect(createTxSpy.create).not.toHaveBeenCalled() expect(submitTaskMock).not.toHaveBeenCalled() }) it('does not create panel when image billing payload validation fails', async () => { configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability')) const res = await invokeRoute({ storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins', sourcePanelId: 'panel-src', variant: { video_prompt: 'variant prompt', description: 'variant desc' }, }) const json = await res.json() as { error: { code: string; message: string } } expect(res.status).toBe(400) expect(json.error.code).toBe('INVALID_PARAMS') expect(json.error.message).toBe('missing capability') expect(createTxSpy.create).not.toHaveBeenCalled() expect(submitTaskMock).not.toHaveBeenCalled() }) it('rolls back the created panel when submitTask fails after insertion', async () => { submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable')) const res = await invokeRoute({ storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins', sourcePanelId: 'panel-src', variant: { video_prompt: 'variant prompt', description: 'variant desc' }, }) const json = await res.json() as { error: { code: string } } expect(res.status).toBe(502) expect(json.error.code).toBe('EXTERNAL_ERROR') expect(createTxSpy.create).toHaveBeenCalledTimes(1) const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id expect(createdPanelId).toEqual(expect.any(String)) expect(rollbackSpy.delete).toHaveBeenCalledWith({ where: { id: createdPanelId }, }) expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, { where: { storyboardId: 'storyboard-1', panelIndex: { gt: 3 }, }, data: { panelIndex: { increment: 1004 }, panelNumber: { increment: 1004 }, }, }) expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, { where: { storyboardId: 'storyboard-1', panelIndex: { gt: 1007 }, }, data: { panelIndex: { decrement: 1005 }, panelNumber: { decrement: 1005 }, }, }) expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({ where: { id: 'storyboard-1' }, data: { panelCount: 3 }, }) }) })