feat: initial release v0.3.0
This commit is contained in:
182
tests/integration/chain/image.chain.test.ts
Normal file
182
tests/integration/chain/image.chain.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getUserModels: vi.fn(async () => ({
|
||||
characterModel: 'model-character-1',
|
||||
locationModel: 'model-location-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),
|
||||
parseJsonStringArray: vi.fn(() => [] as string[]),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - image queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('image tasks are enqueued into image queue with jobId=taskId', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('modify asset image task also routes to image queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-2',
|
||||
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
})
|
||||
|
||||
it('queued image job payload can be consumed by worker handler and persist image fields', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: '黑发,风衣',
|
||||
descriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-chain-worker-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)
|
||||
|
||||
const result = await handleAssetHubImageTask(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
type: 'character',
|
||||
appearanceId: 'appearance-1',
|
||||
imageCount: 3,
|
||||
})
|
||||
|
||||
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify(['cos/global-character-generated.png', 'cos/global-character-generated.png', 'cos/global-character-generated.png']),
|
||||
imageUrl: 'cos/global-character-generated.png',
|
||||
selectedIndex: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
185
tests/integration/chain/text.chain.test.ts
Normal file
185
tests/integration/chain/text.chain.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => JSON.stringify({
|
||||
episodes: [
|
||||
{
|
||||
number: 1,
|
||||
title: '第一集',
|
||||
summary: '开端',
|
||||
startMarker: 'START_MARKER',
|
||||
endMarker: 'END_MARKER',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
|
||||
buildPrompt: vi.fn(() => 'episode-split-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createTextMarkerMatcher: (content: string) => ({
|
||||
matchMarker: (marker: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(marker, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
return { startIndex, endIndex: startIndex + marker.length }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - text queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('text tasks are enqueued into text queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-1',
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
options: expect.objectContaining({ jobId: 'task-text-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('explicit priority is preserved for text queue jobs', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-2',
|
||||
type: TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
userId: 'user-1',
|
||||
}, { priority: 7 })
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))
|
||||
})
|
||||
|
||||
it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')
|
||||
|
||||
const content = [
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'START_MARKER',
|
||||
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于链路测试验证。',
|
||||
'END_MARKER',
|
||||
'后置内容用于确保边界外还有文本,并继续补足长度。',
|
||||
].join('')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-chain-worker-1',
|
||||
type: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { content },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)
|
||||
|
||||
const result = await handleEpisodeSplitTask(toJob(queued!))
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.episodes).toHaveLength(1)
|
||||
expect(result.episodes[0]?.title).toBe('第一集')
|
||||
expect(result.episodes[0]?.content).toContain('START_MARKER')
|
||||
expect(result.episodes[0]?.content).toContain('END_MARKER')
|
||||
})
|
||||
})
|
||||
203
tests/integration/chain/video.chain.test.ts
Normal file
203
tests/integration/chain/video.chain.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
|
||||
resolveVideoSourceFromGeneration: vi.fn(async () => 'https://provider.example/video.mp4'),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
|
||||
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
|
||||
}))
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 5,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
const concurrencyGateMock = vi.hoisted(() => ({
|
||||
withUserConcurrencyGate: vi.fn(async <T>(input: {
|
||||
run: () => Promise<T>
|
||||
}) => await input.run()),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => undefined),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
withTaskLifecycle: workerMock.withTaskLifecycle,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
|
||||
}))
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => ({
|
||||
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
|
||||
}))
|
||||
vi.mock('@/lib/model-config-contract', () => ({
|
||||
parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),
|
||||
}))
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/workers/user-concurrency-gate', () => concurrencyGateMock)
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - video queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
videoUrl: 'cos/base-video.mp4',
|
||||
})
|
||||
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
|
||||
id: 'line-1',
|
||||
audioUrl: 'cos/line-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-1',
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { videoModel: 'fal::video-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VIDEO_PANEL,
|
||||
options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('LIP_SYNC is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-2',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
})
|
||||
|
||||
it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVideoWorker } = await import('@/lib/workers/video.worker')
|
||||
createVideoWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-chain-worker-1',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
|
||||
const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
})
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
lipSyncTaskId: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
172
tests/integration/chain/voice.chain.test.ts
Normal file
172
tests/integration/chain/voice.chain.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
|
||||
}))
|
||||
|
||||
const voiceMock = vi.hoisted(() => ({
|
||||
generateVoiceLine: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
|
||||
}))
|
||||
|
||||
const voiceDesignMock = vi.hoisted(() => ({
|
||||
handleVoiceDesignTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/voice/generate-voice-line', () => ({
|
||||
generateVoiceLine: voiceMock.generateVoiceLine,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
withTaskLifecycle: workerMock.withTaskLifecycle,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/voice-design', () => ({
|
||||
handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - voice queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
voiceMock.generateVoiceLine.mockResolvedValue({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({
|
||||
presetId: 'voice-design-1',
|
||||
previewAudioUrl: 'cos/preview-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VOICE_LINE is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: { lineId: 'line-1', episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VOICE_LINE,
|
||||
options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-2',
|
||||
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalAssetHubVoiceDesign',
|
||||
targetId: 'voice-design-1',
|
||||
payload: { voicePrompt: 'female calm narrator' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)
|
||||
})
|
||||
|
||||
it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVoiceWorker } = await import('@/lib/workers/voice.worker')
|
||||
createVoiceWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-chain-worker-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: {
|
||||
lineId: 'line-1',
|
||||
episodeId: 'episode-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
},
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)
|
||||
|
||||
const result = await processor!(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
userId: 'user-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user