feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
apiKey: 'bl-key',
})),
)
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/async-submit', () => ({
queryFalStatus: vi.fn(),
}))
vi.mock('@/lib/async-task-utils', () => ({
queryGeminiBatchStatus: vi.fn(),
queryGoogleVideoStatus: vi.fn(),
querySeedanceVideoStatus: vi.fn(),
}))
import { pollAsyncTask } from '@/lib/async-poll'
describe('async poll bailian task', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns pending when task is running', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'RUNNING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-running', 'user-1')
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(fetchMock).toHaveBeenCalledWith(
'https://dashscope.aliyuncs.com/api/v1/tasks/task-running',
{
headers: {
Authorization: 'Bearer bl-key',
},
},
)
expect(result).toEqual({ status: 'pending' })
})
it('returns completed with video url when task succeeded', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'SUCCEEDED',
video_url: 'https://video.example/result.mp4',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-success', 'user-1')
expect(result).toEqual({
status: 'completed',
resultUrl: 'https://video.example/result.mp4',
videoUrl: 'https://video.example/result.mp4',
imageUrl: undefined,
})
})
it('returns failed when task succeeded but no media url', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'SUCCEEDED',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-no-url', 'user-1')
expect(result).toEqual({
status: 'failed',
error: 'Bailian: 任务完成但未返回结果URL',
})
})
it('returns output code/message when task failed', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'FAILED',
code: 'InternalError.DownloadException',
message: 'Unknown error occurred while downloading the file.',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-failed', 'user-1')
expect(result).toEqual({
status: 'failed',
error: 'Bailian: InternalError.DownloadException',
})
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { formatExternalId, parseExternalId } from '@/lib/async-poll'
describe('async poll externalId contract', () => {
it('parses standard FAL externalId with endpoint', () => {
const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')
expect(parsed.provider).toBe('FAL')
expect(parsed.type).toBe('VIDEO')
expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')
expect(parsed.requestId).toBe('req_123')
})
it('rejects legacy non-standard externalId formats', () => {
expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)
expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)
})
it('requires endpoint when formatting FAL externalId', () => {
expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)
})
it('parses OPENAI video externalId with provider token', () => {
const parsed = parseExternalId('OPENAI:VIDEO:b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ:vid_123')
expect(parsed.provider).toBe('OPENAI')
expect(parsed.type).toBe('VIDEO')
expect(parsed.providerToken).toBe('b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ')
expect(parsed.requestId).toBe('vid_123')
})
it('requires provider token when formatting OPENAI externalId', () => {
expect(() => formatExternalId('OPENAI', 'VIDEO', 'vid_123')).toThrow(/providerToken/)
})
it('parses and formats BAILIAN externalId', () => {
const externalId = formatExternalId('BAILIAN', 'VIDEO', 'task_123')
expect(externalId).toBe('BAILIAN:VIDEO:task_123')
const parsed = parseExternalId(externalId)
expect(parsed.provider).toBe('BAILIAN')
expect(parsed.type).toBe('VIDEO')
expect(parsed.requestId).toBe('task_123')
})
it('parses and formats SILICONFLOW externalId', () => {
const externalId = formatExternalId('SILICONFLOW', 'IMAGE', 'task_456')
expect(externalId).toBe('SILICONFLOW:IMAGE:task_456')
const parsed = parseExternalId(externalId)
expect(parsed.provider).toBe('SILICONFLOW')
expect(parsed.type).toBe('IMAGE')
expect(parsed.requestId).toBe('task_456')
})
})

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
import { pollAsyncTask } from '@/lib/async-poll'
const PROVIDER_TOKEN = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')
/**
* pollOpenAIVideoTask now uses raw fetch (not OpenAI SDK),
* so we mock fetch instead of the SDK.
*/
describe('async poll OPENAI video status mapping', () => {
let fetchSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})
fetchSpy = vi.fn()
globalThis.fetch = fetchSpy as unknown as typeof fetch
})
it('maps queued/in_progress to pending', async () => {
fetchSpy
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'queued' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'in_progress' }),
})
const queued = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_queued`, 'user-1')
const progress = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_running`, 'user-1')
expect(queued).toEqual({ status: 'pending' })
expect(progress).toEqual({ status: 'pending' })
})
it('maps completed to downloadable url and auth headers', async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'vid_done',
status: 'completed',
}),
})
const result = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_done`, 'user-1')
expect(result.status).toBe('completed')
expect(result.resultUrl).toBe('https://oa.test/v1/videos/vid_done/content')
expect(result.videoUrl).toBe('https://oa.test/v1/videos/vid_done/content')
expect(result.downloadHeaders).toEqual({
Authorization: 'Bearer oa-key',
})
})
it('maps failed to failed with provider error message', async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'vid_failed',
status: 'failed',
error: { message: 'generation failed' },
}),
})
const result = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_failed`, 'user-1')
expect(result).toEqual({ status: 'failed', error: 'generation failed' })
})
})

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'
describe('task error message normalization', () => {
it('maps TASK_CANCELLED to unified cancelled message', () => {
const summary = resolveTaskErrorSummary({
errorCode: 'TASK_CANCELLED',
errorMessage: 'whatever',
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('keeps cancelled semantics from normalized task error details', () => {
const summary = resolveTaskErrorSummary({
error: {
code: 'CONFLICT',
message: 'Task cancelled by user',
details: { cancelled: true, originalCode: 'TASK_CANCELLED' },
},
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('extracts nested error message from payload', () => {
const message = resolveTaskErrorMessage({
error: {
details: {
message: 'provider failed',
},
},
}, 'fallback')
expect(message).toBe('provider failed')
})
it('supports flat error/details string payload', () => {
expect(resolveTaskErrorMessage({
error: 'provider failed',
}, 'fallback')).toBe('provider failed')
expect(resolveTaskErrorMessage({
details: 'provider failed',
}, 'fallback')).toBe('provider failed')
})
it('uses fallback when payload has no structured error', () => {
expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')
})
it('recognizes cancelled semantics from message-only payload', () => {
const summary = resolveTaskErrorSummary({
message: 'Task cancelled by user',
})
expect(summary.cancelled).toBe(true)
expect(summary.message).toBe('Task cancelled by user')
})
it('prefers user-friendly message for MODEL_NOT_OPEN', () => {
const summary = resolveTaskErrorSummary({
error: {
code: 'MODEL_NOT_OPEN',
message: 'raw provider message should not be shown',
},
})
expect(summary.code).toBe('MODEL_NOT_OPEN')
expect(summary.message).toContain('模型权限未开通')
expect(summary.message).toContain('https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model')
})
it('prefers user-friendly message for EMPTY_RESPONSE', () => {
const summary = resolveTaskErrorSummary({
error: {
code: 'EMPTY_RESPONSE',
message: 'raw provider empty response',
},
})
expect(summary.code).toBe('EMPTY_RESPONSE')
expect(summary.message).toContain('模型返回空响应')
})
})

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { resolveTaskIntent } from '@/lib/task/intent'
describe('resolveTaskIntent', () => {
it('maps generate task types', () => {
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
})
it('maps regenerate and modify task types', () => {
expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')
})
it('falls back to process for unknown types', () => {
expect(resolveTaskIntent('unknown_type')).toBe('process')
expect(resolveTaskIntent(null)).toBe('process')
expect(resolveTaskIntent(undefined)).toBe('process')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
import { TASK_TYPE } from '@/lib/task/types'
describe('llm observe task contract', () => {
it('maps AI_CREATE tasks to standard llm policy', () => {
const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)
const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterPolicy.consoleEnabled).toBe(true)
expect(characterPolicy.displayMode).toBe('loading')
expect(characterPolicy.captureReasoning).toBe(true)
expect(locationPolicy.consoleEnabled).toBe(true)
expect(locationPolicy.displayMode).toBe('loading')
expect(locationPolicy.captureReasoning).toBe(true)
})
it('maps story/script run tasks to long-flow stage metadata', () => {
const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)
const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
expect(storyMeta.flowId).toBe('novel_promotion_generation')
expect(storyMeta.flowStageIndex).toBe(1)
expect(storyMeta.flowStageTotal).toBe(2)
expect(scriptMeta.flowId).toBe('novel_promotion_generation')
expect(scriptMeta.flowStageIndex).toBe(2)
expect(scriptMeta.flowStageTotal).toBe(2)
})
it('maps AI_CREATE tasks to dedicated single-stage flows', () => {
const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)
const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')
expect(characterMeta.flowStageIndex).toBe(1)
expect(characterMeta.flowStageTotal).toBe(1)
expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')
expect(locationMeta.flowStageIndex).toBe(1)
expect(locationMeta.flowStageTotal).toBe(1)
})
it('returns a stable two-stage pipeline for story/script flow', () => {
const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)
expect(stageTaskTypes).toEqual([
TASK_TYPE.STORY_TO_SCRIPT_RUN,
TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
])
})
it('falls back to single-stage metadata for unknown task type', () => {
const meta = getTaskFlowMeta('unknown_task_type')
const pipeline = getTaskPipeline('unknown_task_type')
expect(meta.flowId).toBe('single:unknown_task_type')
expect(meta.flowStageIndex).toBe(1)
expect(meta.flowStageTotal).toBe(1)
expect(pipeline.stages).toHaveLength(1)
expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { normalizeAnyError } from '@/lib/errors/normalize'
describe('normalizeAnyError network termination mapping', () => {
it('maps undici terminated TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps socket hang up TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('socket hang up'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps wrapped terminated message to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
})
describe('normalizeAnyError provider-specific mapping', () => {
it('maps Ark ModelNotOpen payload to MODEL_NOT_OPEN', () => {
const normalized = normalizeAnyError({
status: 404,
code: 'ModelNotOpen',
message: 'Your account has not activated the model doubao-seedream. Please activate the model service in the Ark Console.',
})
expect(normalized.code).toBe('MODEL_NOT_OPEN')
expect(normalized.retryable).toBe(false)
})
it('maps Gemini empty response payload to EMPTY_RESPONSE even when status is 429', () => {
const normalized = normalizeAnyError({
status: 429,
message: 'received empty response from Gemini: no meaningful content in candidates (code: channel:empty_response)',
})
expect(normalized.code).toBe('EMPTY_RESPONSE')
expect(normalized.retryable).toBe(true)
})
it('maps template status 500 message to EXTERNAL_ERROR instead of INTERNAL_ERROR', () => {
const normalized = normalizeAnyError(new Error('Template request failed with status 500: upstream overloaded'))
expect(normalized.code).toBe('EXTERNAL_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps openai-compatible video template mismatch to VIDEO_API_FORMAT_UNSUPPORTED', () => {
const normalized = normalizeAnyError(
new Error('VIDEO_API_FORMAT_UNSUPPORTED: OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND'),
)
expect(normalized.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')
expect(normalized.retryable).toBe(false)
})
it('maps template status 415 message to VIDEO_API_FORMAT_UNSUPPORTED', () => {
const normalized = normalizeAnyError(
new Error('Template request failed with status 415: unsupported media type'),
)
expect(normalized.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')
expect(normalized.retryable).toBe(false)
})
})

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
describe('resolveTaskPresentationState', () => {
it('uses overlay mode when running and has output', () => {
const state = resolveTaskPresentationState({
phase: 'processing',
intent: 'regenerate',
resource: 'image',
hasOutput: true,
})
expect(state.isRunning).toBe(true)
expect(state.mode).toBe('overlay')
expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')
})
it('uses placeholder mode when running and no output', () => {
const state = resolveTaskPresentationState({
phase: 'queued',
intent: 'generate',
resource: 'image',
hasOutput: false,
})
expect(state.mode).toBe('placeholder')
expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')
})
it('maps failed state to failed label', () => {
const state = resolveTaskPresentationState({
phase: 'failed',
intent: 'modify',
resource: 'video',
hasOutput: true,
})
expect(state.isError).toBe(true)
expect(state.labelKey).toBe('taskStatus.failed.video')
})
})

View File

@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type TaskEventRow = {
id: number
taskId: string
projectId: string
userId: string
eventType: string
payload: Record<string, unknown> | null
createdAt: Date
}
type TaskMeta = {
id: string
type: string
targetType: string
targetId: string
episodeId: string | null
}
const taskEventFindManyMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<TaskEventRow[]>>(async () => []),
)
const taskEventCreateMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<TaskEventRow | null>>(async () => null),
)
const taskFindManyMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<TaskMeta[]>>(async () => []),
)
const redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))
vi.mock('@/lib/prisma', () => ({
prisma: {
taskEvent: {
findMany: taskEventFindManyMock,
create: taskEventCreateMock,
},
task: {
findMany: taskFindManyMock,
},
},
}))
vi.mock('@/lib/redis', () => ({
redis: {
publish: redisPublishMock,
},
}))
import { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'
describe('task publisher replay', () => {
beforeEach(() => {
taskEventFindManyMock.mockReset()
taskEventCreateMock.mockReset()
taskFindManyMock.mockReset()
redisPublishMock.mockReset()
})
it('replays persisted lifecycle + stream rows in chronological order', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 12,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 2,
lane: 'main',
delta: 'world',
},
},
createdAt: new Date('2026-02-27T00:00:02.000Z'),
},
{
id: 11,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
stepTitle: '阶段1',
},
createdAt: new Date('2026-02-27T00:00:01.000Z'),
},
{
id: 10,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.ignored',
payload: {},
createdAt: new Date('2026-02-27T00:00:00.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listTaskLifecycleEvents('task-1', 50)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: { taskId: 'task-1' },
orderBy: { id: 'desc' },
take: 50,
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['11', '12'])
expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])
expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')
})
it('persists stream rows when persist=true', async () => {
taskEventCreateMock.mockResolvedValueOnce({
id: 99,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
})
redisPublishMock.mockResolvedValueOnce(1)
const message = await publishTaskStreamEvent({
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
taskType: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
persist: true,
})
expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
taskId: 'task-1',
eventType: 'task.stream',
}),
}))
expect(redisPublishMock).toHaveBeenCalledTimes(1)
expect(message?.id).toBe('99')
expect(message?.type).toBe('task.stream')
})
it('replays lifecycle + stream rows in listEventsAfter', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 101,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 3,
lane: 'main',
delta: 'chunk',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
},
{
id: 102,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
},
createdAt: new Date('2026-02-27T00:00:04.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listEventsAfter('project-1', 100, 20)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: {
projectId: 'project-1',
id: { gt: 100 },
},
orderBy: { id: 'asc' },
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['101', '102'])
expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])
expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')
})
})