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<{ taskId: string }> } type EmptyRouteContext = { params: Promise> } type ReplayEvent = Awaited>[number] type TaskLifecycleReplayEvent = Awaited>[number] 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(() => ({ 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 (fn: () => Promise) => await fn())) const listEventsAfterMock = vi.hoisted(() => vi.fn(async () => []), ) const listTaskLifecycleEventsMock = vi.hoisted(() => vi.fn(async () => []), ) const addChannelListenerMock = vi.hoisted(() => vi.fn<(channel: string, listener: (message: string) => void) => Promise<() => Promise>>( 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)', () => { const emptyRouteContext: EmptyRouteContext = { params: Promise.resolve({}) } 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, emptyRouteContext) 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, emptyRouteContext) 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, emptyRouteContext) 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, emptyRouteContext) 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, emptyRouteContext) 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, emptyRouteContext) expect(res.status).toBe(200) const payload = await res.json() as { states: Array> } 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: TaskLifecycleReplayEvent[] = [ { 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: 'task.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> } 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, emptyRouteContext) 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, emptyRouteContext) 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: 'task.created' }, } satisfies ReplayEvent, ]) const req = buildMockRequest({ path: '/api/sse', method: 'GET', query: { projectId: 'project-1' }, headers: { 'last-event-id': '3' }, }) const res = await GET(req, emptyRouteContext) 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, emptyRouteContext) 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() }) })