Files
waoowaoo/tests/integration/api/contract/task-infra-routes.test.ts

447 lines
14 KiB
TypeScript

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()
})
})