Files
waoowaoo/tests/integration/api/contract/llm-observe-routes.test.ts

363 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
import { buildMockRequest } from '../../../helpers/request'
type AuthState = {
authenticated: boolean
projectMode: 'novel-promotion' | 'other'
}
type 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')
})
}
})