feat: initial release v0.3.0
This commit is contained in:
121
tests/unit/task/async-poll-bailian.test.ts
Normal file
121
tests/unit/task/async-poll-bailian.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
53
tests/unit/task/async-poll-external-id.test.ts
Normal file
53
tests/unit/task/async-poll-external-id.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
85
tests/unit/task/async-poll-openai.test.ts
Normal file
85
tests/unit/task/async-poll-openai.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
83
tests/unit/task/error-message.test.ts
Normal file
83
tests/unit/task/error-message.test.ts
Normal 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('模型返回空响应')
|
||||
})
|
||||
})
|
||||
23
tests/unit/task/intent.test.ts
Normal file
23
tests/unit/task/intent.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
65
tests/unit/task/llm-observe-contract.test.ts
Normal file
65
tests/unit/task/llm-observe-contract.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
65
tests/unit/task/normalize-error.test.ts
Normal file
65
tests/unit/task/normalize-error.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
38
tests/unit/task/presentation.test.ts
Normal file
38
tests/unit/task/presentation.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
231
tests/unit/task/publisher.replay.test.ts
Normal file
231
tests/unit/task/publisher.replay.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user