feat:Strengthen the testing framework
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
type RouteParams = Record<string, string>
|
||||
type RouteParamValue = string | string[] | undefined
|
||||
type RouteParams = Record<string, RouteParamValue>
|
||||
type HeaderMap = Record<string, string>
|
||||
|
||||
type RouteHandler = (
|
||||
type RouteHandler<TParams extends RouteParams = RouteParams> = (
|
||||
req: NextRequest,
|
||||
ctx?: { params: Promise<RouteParams> },
|
||||
ctx: { params: Promise<TParams> },
|
||||
) => Promise<Response>
|
||||
|
||||
export async function callRoute(
|
||||
handler: RouteHandler,
|
||||
export async function callRoute<TParams extends RouteParams>(
|
||||
handler: RouteHandler<TParams>,
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
options?: { headers?: HeaderMap; params?: RouteParams; query?: Record<string, string> },
|
||||
options?: { headers?: HeaderMap; params?: TParams; query?: Record<string, string> },
|
||||
) {
|
||||
const url = new URL('http://localhost:3000/api/test')
|
||||
if (options?.query) {
|
||||
@@ -30,6 +31,6 @@ export async function callRoute(
|
||||
},
|
||||
...(payload ? { body: payload } : {}),
|
||||
})
|
||||
const context = { params: Promise.resolve(options?.params || {}) }
|
||||
const context = { params: Promise.resolve((options?.params || {}) as TParams) }
|
||||
return await handler(req, context)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,18 @@ import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
mode: 'success' as 'success' | 'fail',
|
||||
errorMessage: 'queue add failed',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/queues', () => ({
|
||||
addTaskJob: vi.fn(async () => ({ id: 'mock-job' })),
|
||||
addTaskJob: vi.fn(async () => {
|
||||
if (queueState.mode === 'fail') {
|
||||
throw new Error(queueState.errorMessage)
|
||||
}
|
||||
return { id: 'mock-job' }
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
@@ -19,6 +29,8 @@ describe('billing/submitter integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
queueState.mode = 'success'
|
||||
queueState.errorMessage = 'queue add failed'
|
||||
})
|
||||
|
||||
it('builds billing info server-side for billable task submission', async () => {
|
||||
@@ -127,4 +139,46 @@ describe('billing/submitter integration', () => {
|
||||
expect(task?.errorCode).toBe('INVALID_PARAMS')
|
||||
expect(task?.errorMessage).toContain('missing server-generated billingInfo')
|
||||
})
|
||||
|
||||
it('rolls back billing freeze and marks task failed when queue enqueue fails', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
queueState.mode = 'fail'
|
||||
queueState.errorMessage = 'queue unavailable'
|
||||
|
||||
await expect(
|
||||
submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-e',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-e',
|
||||
payload: { maxSeconds: 6 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' } satisfies Pick<ApiError, 'code'>)
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
|
||||
expect(task).toBeTruthy()
|
||||
expect(task?.status).toBe('failed')
|
||||
expect(task?.errorCode).toBe('ENQUEUE_FAILED')
|
||||
expect(task?.errorMessage).toContain('queue unavailable')
|
||||
expect(task?.billingInfo).toMatchObject({
|
||||
billable: true,
|
||||
status: 'rolled_back',
|
||||
})
|
||||
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
|
||||
expect(freeze?.status).toBe('rolled_back')
|
||||
})
|
||||
})
|
||||
|
||||
154
tests/integration/provider/fal-provider.contract.test.ts
Normal file
154
tests/integration/provider/fal-provider.contract.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { queryFalStatus, submitFalTask } from '@/lib/async-submit'
|
||||
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
|
||||
|
||||
describe('provider contract - fal queue', () => {
|
||||
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startScenarioServer()
|
||||
process.env.FAL_QUEUE_BASE_URL = `${server.baseUrl}/fal`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.FAL_QUEUE_BASE_URL
|
||||
await server?.close()
|
||||
server = null
|
||||
})
|
||||
|
||||
it('submits the expected auth header and json payload', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'POST',
|
||||
path: '/fal/fal-ai/nano-banana-pro',
|
||||
mode: 'success',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { request_id: 'req_image_1' },
|
||||
},
|
||||
})
|
||||
|
||||
const requestId = await submitFalTask(
|
||||
'fal-ai/nano-banana-pro',
|
||||
{
|
||||
prompt: 'generate image',
|
||||
image_urls: ['data:image/png;base64,AAAA'],
|
||||
},
|
||||
'fal-key-1',
|
||||
)
|
||||
|
||||
expect(requestId).toBe('req_image_1')
|
||||
const requests = server!.getRequests('POST', '/fal/fal-ai/nano-banana-pro')
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.headers.authorization).toBe('Key fal-key-1')
|
||||
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
|
||||
prompt: 'generate image',
|
||||
image_urls: ['data:image/png;base64,AAAA'],
|
||||
})
|
||||
})
|
||||
|
||||
it('treats transient status failure as pending and completes after retry', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/fal/fal-ai/veo3.1/requests/req_video_1/status',
|
||||
mode: 'retryable_error_then_success',
|
||||
pollSequence: [
|
||||
{ status: 503, body: { error: 'upstream unavailable' } },
|
||||
{ status: 200, body: { status: 'COMPLETED' } },
|
||||
],
|
||||
})
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/fal/fal-ai/veo3.1/fast/image-to-video/requests/req_video_1',
|
||||
mode: 'success',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: {
|
||||
video: { url: 'https://cdn.local/video.mp4' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const first = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
||||
const second = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
||||
|
||||
expect(first).toEqual({
|
||||
status: 'IN_PROGRESS',
|
||||
completed: false,
|
||||
failed: false,
|
||||
})
|
||||
expect(second).toEqual({
|
||||
status: 'COMPLETED',
|
||||
completed: true,
|
||||
failed: false,
|
||||
resultUrl: 'https://cdn.local/video.mp4',
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a failed status response as failed with explicit provider error', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/fal/fal-ai/veo3.1/requests/req_failed/status',
|
||||
mode: 'fatal_error',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: {
|
||||
status: 'FAILED',
|
||||
error: 'content moderation failed',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_failed', 'fal-key-3')
|
||||
expect(result).toEqual({
|
||||
status: 'FAILED',
|
||||
completed: false,
|
||||
failed: true,
|
||||
error: 'content moderation failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails explicitly when submit response is malformed', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'POST',
|
||||
path: '/fal/fal-ai/nano-banana-pro',
|
||||
mode: 'malformed_response',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { ok: true },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
submitFalTask('fal-ai/nano-banana-pro', { prompt: 'bad response' }, 'fal-key-4'),
|
||||
).rejects.toThrow('FAL未返回request_id')
|
||||
})
|
||||
|
||||
it('treats completed result without media url as failed', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media/status',
|
||||
mode: 'queued_then_success',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { status: 'COMPLETED' },
|
||||
},
|
||||
})
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media',
|
||||
mode: 'malformed_response',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { images: [] },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await queryFalStatus('fal-ai/nano-banana-pro', 'req_no_media', 'fal-key-5')
|
||||
expect(result).toEqual({
|
||||
status: 'COMPLETED',
|
||||
completed: true,
|
||||
failed: false,
|
||||
resultUrl: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'
|
||||
import { pollAsyncTask } from '@/lib/async-poll'
|
||||
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
|
||||
|
||||
const getProviderConfigMock = vi.hoisted(() => vi.fn())
|
||||
const getUserModelsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
getUserModels: getUserModelsMock,
|
||||
}))
|
||||
|
||||
function encode(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
describe('provider contract - openai compatible media template', () => {
|
||||
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startScenarioServer()
|
||||
vi.clearAllMocks()
|
||||
getProviderConfigMock.mockResolvedValue({
|
||||
id: 'openai-compatible:provider-local',
|
||||
apiKey: 'sk-local',
|
||||
baseUrl: `${server.baseUrl}/compat/v1`,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close()
|
||||
server = null
|
||||
})
|
||||
|
||||
it('renders create request against provider baseUrl and returns OCOMPAT externalId', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'POST',
|
||||
path: '/compat/v1/video/create',
|
||||
mode: 'success',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { status: 'queued', task_id: 'task_local_1' },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateVideoViaOpenAICompatTemplate({
|
||||
userId: 'user-local',
|
||||
providerId: 'openai-compatible:provider-local',
|
||||
modelId: 'veo-local',
|
||||
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||
imageUrl: 'data:image/png;base64,AAAA',
|
||||
prompt: 'animate this frame',
|
||||
options: {
|
||||
duration: 5,
|
||||
aspectRatio: '9:16',
|
||||
},
|
||||
profile: 'openai-compatible',
|
||||
template: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/video/create',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
image: '{{image}}',
|
||||
duration: '{{duration}}',
|
||||
},
|
||||
},
|
||||
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||
response: {
|
||||
taskIdPath: '$.task_id',
|
||||
statusPath: '$.status',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 1000,
|
||||
timeoutMs: 30_000,
|
||||
doneStates: ['done'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
async: true,
|
||||
requestId: 'task_local_1',
|
||||
externalId: `OCOMPAT:VIDEO:b64_${encode('openai-compatible:provider-local')}:${encode('veo-local')}:task_local_1`,
|
||||
})
|
||||
|
||||
const requests = server!.getRequests('POST', '/compat/v1/video/create')
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.headers.authorization).toBe('Bearer sk-local')
|
||||
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
|
||||
model: 'veo-local',
|
||||
prompt: 'animate this frame',
|
||||
image: 'data:image/png;base64,AAAA',
|
||||
duration: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('polls localhost provider status and falls back to content endpoint when output url is missing', async () => {
|
||||
getUserModelsMock.mockResolvedValue([
|
||||
{
|
||||
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||
modelId: 'veo-local',
|
||||
name: 'Local Veo',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:provider-local',
|
||||
price: 0,
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/video/create' },
|
||||
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||
content: { method: 'GET', path: '/video/content/{{task_id}}' },
|
||||
response: {
|
||||
statusPath: '$.status',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 1000,
|
||||
timeoutMs: 30_000,
|
||||
doneStates: ['done'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
server!.defineScenario({
|
||||
method: 'GET',
|
||||
path: '/compat/v1/video/status/task_local_2',
|
||||
mode: 'queued_then_success',
|
||||
pollSequence: [
|
||||
{ status: 200, body: { status: 'running' } },
|
||||
{ status: 200, body: { status: 'done' } },
|
||||
],
|
||||
})
|
||||
|
||||
const first = await pollAsyncTask(
|
||||
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
|
||||
'user-local',
|
||||
)
|
||||
const second = await pollAsyncTask(
|
||||
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
|
||||
'user-local',
|
||||
)
|
||||
|
||||
expect(first).toEqual({ status: 'pending' })
|
||||
expect(second).toEqual({
|
||||
status: 'completed',
|
||||
resultUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
|
||||
videoUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
|
||||
downloadHeaders: {
|
||||
Authorization: 'Bearer sk-local',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails explicitly when async create response omits task id', async () => {
|
||||
server!.defineScenario({
|
||||
method: 'POST',
|
||||
path: '/compat/v1/video/create',
|
||||
mode: 'malformed_response',
|
||||
submitResponse: {
|
||||
status: 200,
|
||||
body: { status: 'queued' },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
generateVideoViaOpenAICompatTemplate({
|
||||
userId: 'user-local',
|
||||
providerId: 'openai-compatible:provider-local',
|
||||
modelId: 'veo-local',
|
||||
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||
imageUrl: 'data:image/png;base64,AAAA',
|
||||
prompt: 'bad create payload',
|
||||
profile: 'openai-compatible',
|
||||
template: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/video/create',
|
||||
bodyTemplate: { prompt: '{{prompt}}' },
|
||||
},
|
||||
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||
response: {
|
||||
taskIdPath: '$.task_id',
|
||||
statusPath: '$.status',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 1000,
|
||||
timeoutMs: 30_000,
|
||||
doneStates: ['done'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
150
tests/integration/task/create-task-dedupe.integration.test.ts
Normal file
150
tests/integration/task/create-task-dedupe.integration.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
|
||||
import { createTask } from '@/lib/task/service'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser } from '../../helpers/billing-fixtures'
|
||||
|
||||
const reconcileMock = vi.hoisted(() => ({
|
||||
isJobAlive: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/reconcile', () => reconcileMock)
|
||||
|
||||
describe('task service dedupe + orphan recovery', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
vi.clearAllMocks()
|
||||
reconcileMock.isJobAlive.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('dedupes to an active task when dedupeKey matches and queue job is alive', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
const existing = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
status: TASK_STATUS.QUEUED,
|
||||
payload: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
dedupeKey: 'voice_line:line-1',
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await createTask({
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
dedupeKey: 'voice_line:line-1',
|
||||
})
|
||||
|
||||
expect(result.deduped).toBe(true)
|
||||
expect(result.task.id).toBe(existing.id)
|
||||
expect(reconcileMock.isJobAlive).toHaveBeenCalledWith(existing.id)
|
||||
})
|
||||
|
||||
it('fails orphaned active task and creates a replacement when queue job is missing', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
const existing = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
status: TASK_STATUS.QUEUED,
|
||||
payload: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
dedupeKey: 'video_panel:panel-1',
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
})
|
||||
reconcileMock.isJobAlive.mockResolvedValue(false)
|
||||
|
||||
const result = await createTask({
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
dedupeKey: 'video_panel:panel-1',
|
||||
})
|
||||
|
||||
expect(result.deduped).toBe(false)
|
||||
expect(result.task.id).not.toBe(existing.id)
|
||||
|
||||
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
|
||||
expect(failedExisting).toMatchObject({
|
||||
status: TASK_STATUS.FAILED,
|
||||
errorCode: 'RECONCILE_ORPHAN',
|
||||
dedupeKey: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails locale-less active task and replaces it instead of deduping forever', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
const existing = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
status: TASK_STATUS.QUEUED,
|
||||
payload: {
|
||||
episodeId: 'episode-1',
|
||||
},
|
||||
dedupeKey: 'script_to_storyboard_run:episode-1',
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await createTask({
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: {
|
||||
episodeId: 'episode-1',
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
dedupeKey: 'script_to_storyboard_run:episode-1',
|
||||
})
|
||||
|
||||
expect(result.deduped).toBe(false)
|
||||
expect(result.task.id).not.toBe(existing.id)
|
||||
|
||||
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
|
||||
expect(failedExisting).toMatchObject({
|
||||
status: TASK_STATUS.FAILED,
|
||||
errorCode: 'TASK_LOCALE_REQUIRED',
|
||||
dedupeKey: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user