first commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
authenticated: true,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-new' })),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
describe('api specific - asset hub appearances route', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
appearances: [
|
||||
{ id: 'appearance-1', appearanceIndex: 0, artStyle: 'realistic' },
|
||||
],
|
||||
})
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
description: 'old description',
|
||||
descriptions: JSON.stringify(['old description', 'variant description']),
|
||||
})
|
||||
})
|
||||
|
||||
it('PATCH preserves description array length instead of rewriting fixed triple entries', async () => {
|
||||
const mod = await import('@/app/api/asset-hub/appearances/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/appearances',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
description: 'updated description',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
description: 'updated description',
|
||||
descriptions: JSON.stringify(['updated description', 'variant description']),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('POST initializes new appearance with a single description entry', async () => {
|
||||
const mod = await import('@/app/api/asset-hub/appearances/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/appearances',
|
||||
method: 'POST',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
changeReason: '新造型',
|
||||
description: 'new description',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
description: 'new description',
|
||||
descriptions: JSON.stringify(['new description']),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{
|
||||
success: boolean
|
||||
async: boolean
|
||||
taskId: string
|
||||
status: string
|
||||
deduped: boolean
|
||||
}>>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: null,
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: null,
|
||||
editModel: null,
|
||||
videoModel: null,
|
||||
capabilityDefaults: {},
|
||||
})),
|
||||
buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasGlobalCharacterOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacterAppearance: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
findMany: vi.fn(async () => []),
|
||||
createMany: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => billingMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - asset hub generate image art style', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses persisted appearance artStyle when request payload does not provide one', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.findFirst).toHaveBeenCalled()
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('realistic')
|
||||
})
|
||||
|
||||
it('uses persisted location artStyle when request payload does not provide one', async () => {
|
||||
prismaMock.globalLocation.findFirst
|
||||
.mockResolvedValueOnce({ artStyle: 'japanese-anime' })
|
||||
.mockResolvedValueOnce({ name: 'Location 1', summary: 'Summary 1' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'location',
|
||||
id: 'location-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalLocation.findFirst).toHaveBeenCalled()
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('japanese-anime')
|
||||
expect(submitArg?.payload?.count).toBe(3)
|
||||
})
|
||||
|
||||
it('fails with invalid params when persisted artStyle is missing', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: null })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards requested count into asset hub image task payload', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as {
|
||||
payload?: Record<string, unknown>
|
||||
dedupeKey?: string
|
||||
} | undefined
|
||||
expect(submitArg?.payload?.count).toBe(5)
|
||||
expect(submitArg?.dedupeKey).toContain(':5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
globalLocation: {
|
||||
create: vi.fn(async () => ({ id: 'location-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),
|
||||
},
|
||||
globalLocationImage: {
|
||||
createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(
|
||||
async () => ({ count: 0 }),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - asset hub location create', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images after creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/asset-hub/locations/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/locations',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
summary: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
const createManyArg = prismaMock.globalLocationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,440 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const readAssetsMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetRenderLabelMock = vi.hoisted(() => vi.fn())
|
||||
const submitAssetGenerateTaskMock = vi.hoisted(() => vi.fn())
|
||||
const copyAssetFromGlobalMock = vi.hoisted(() => vi.fn())
|
||||
const createAssetMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetMock = vi.hoisted(() => vi.fn())
|
||||
const removeAssetMock = vi.hoisted(() => vi.fn())
|
||||
const updateAssetVariantMock = vi.hoisted(() => vi.fn())
|
||||
const selectAssetRenderMock = vi.hoisted(() => vi.fn())
|
||||
const revertAssetRenderMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/assets/services/read-assets', () => ({
|
||||
readAssets: readAssetsMock,
|
||||
}))
|
||||
vi.mock('@/lib/assets/services/asset-label', () => ({
|
||||
updateAssetRenderLabel: updateAssetRenderLabelMock,
|
||||
}))
|
||||
vi.mock('@/lib/assets/services/asset-actions', () => ({
|
||||
createAsset: createAssetMock,
|
||||
submitAssetGenerateTask: submitAssetGenerateTaskMock,
|
||||
copyAssetFromGlobal: copyAssetFromGlobalMock,
|
||||
updateAsset: updateAssetMock,
|
||||
removeAsset: removeAssetMock,
|
||||
updateAssetVariant: updateAssetVariantMock,
|
||||
submitAssetModifyTask: vi.fn(),
|
||||
selectAssetRender: selectAssetRenderMock,
|
||||
revertAssetRender: revertAssetRenderMock,
|
||||
}))
|
||||
|
||||
describe('api specific - unified assets routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readAssetsMock.mockResolvedValue([{ id: 'asset-1', kind: 'character' }])
|
||||
updateAssetRenderLabelMock.mockResolvedValue(undefined)
|
||||
submitAssetGenerateTaskMock.mockResolvedValue({ success: true, taskId: 'task-1' })
|
||||
copyAssetFromGlobalMock.mockResolvedValue({ success: true })
|
||||
createAssetMock.mockResolvedValue({ success: true, assetId: 'prop-1' })
|
||||
updateAssetMock.mockResolvedValue({ success: true })
|
||||
removeAssetMock.mockResolvedValue({ success: true })
|
||||
updateAssetVariantMock.mockResolvedValue({ success: true })
|
||||
selectAssetRenderMock.mockResolvedValue({ success: true })
|
||||
revertAssetRenderMock.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('GET /api/assets reads global assets with the authenticated user scope', async () => {
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets?scope=global&kind=character',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireUserAuth).toHaveBeenCalled()
|
||||
expect(readAssetsMock).toHaveBeenCalledWith({
|
||||
scope: 'global',
|
||||
projectId: null,
|
||||
folderId: null,
|
||||
kind: 'character',
|
||||
}, {
|
||||
userId: 'user-1',
|
||||
})
|
||||
expect(body).toEqual({ assets: [{ id: 'asset-1', kind: 'character' }] })
|
||||
})
|
||||
|
||||
it('GET /api/assets reads prop assets through the unified filter contract', async () => {
|
||||
readAssetsMock.mockResolvedValue([{ id: 'prop-1', kind: 'prop' }])
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets?scope=project&projectId=project-1&kind=prop',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
|
||||
expect(readAssetsMock).toHaveBeenCalledWith({
|
||||
scope: 'project',
|
||||
projectId: 'project-1',
|
||||
folderId: null,
|
||||
kind: 'prop',
|
||||
})
|
||||
expect(body).toEqual({ assets: [{ id: 'prop-1', kind: 'prop' }] })
|
||||
})
|
||||
|
||||
it('POST /api/assets creates a project prop through the centralized asset action service', async () => {
|
||||
const mod = await import('@/app/api/assets/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
description: '一把短小青铜匕首,雕纹手柄,刃面磨损发暗',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(createAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
name: '青铜匕首',
|
||||
summary: '古旧短刃,雕纹手柄',
|
||||
description: '一把短小青铜匕首,雕纹手柄,刃面磨损发暗',
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true, assetId: 'prop-1' })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/update-label forwards to the centralized label service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/update-label/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/asset-1/update-label',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
newName: '林夏',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'asset-1' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuth).toHaveBeenCalledWith('project-1')
|
||||
expect(updateAssetRenderLabelMock).toHaveBeenCalledWith({
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
assetId: 'asset-1',
|
||||
projectId: 'project-1',
|
||||
newName: '林夏',
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /api/asset-hub/update-asset-label explicitly rejects global image label updates', async () => {
|
||||
const mod = await import('@/app/api/asset-hub/update-asset-label/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/update-asset-label',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'asset-1',
|
||||
newName: '林夏',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(authMock.requireUserAuth).toHaveBeenCalled()
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(body.error.message).toBe('Global asset images no longer support label updates')
|
||||
expect(updateAssetRenderLabelMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('PATCH /api/assets/[assetId] updates a global prop through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'prop',
|
||||
name: '青铜短刃',
|
||||
summary: '更锋利的版本',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireUserAuth).toHaveBeenCalled()
|
||||
expect(updateAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
body: {
|
||||
scope: 'global',
|
||||
kind: 'prop',
|
||||
name: '青铜短刃',
|
||||
summary: '更锋利的版本',
|
||||
},
|
||||
access: {
|
||||
scope: 'global',
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('DELETE /api/assets/[assetId] removes a project prop through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1',
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.DELETE(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(removeAssetMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/generate forwards project asset generation to the unified service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/asset-1/generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'asset-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
|
||||
expect(submitAssetGenerateTaskMock).toHaveBeenCalledWith({
|
||||
request: req,
|
||||
kind: 'character',
|
||||
assetId: 'asset-1',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
projectId: 'project-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 2,
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true, taskId: 'task-1' })
|
||||
})
|
||||
|
||||
it('PATCH /api/assets/[assetId]/variants/[variantId] updates a prop variant through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/variants/[variantId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1/variants/prop-image-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1', variantId: 'prop-image-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(updateAssetVariantMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
variantId: 'prop-image-1',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
description: '古旧短刃,雕纹手柄',
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/select-render confirms a project prop through the unified route', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/select-render/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-1/select-render',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
confirm: true,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
|
||||
expect(selectAssetRenderMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
assetId: 'prop-1',
|
||||
body: {
|
||||
scope: 'project',
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
confirm: true,
|
||||
},
|
||||
access: {
|
||||
scope: 'project',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/novel-promotion/[projectId]/copy-from-global delegates to the centralized copy service', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/copy-from-global/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/copy-from-global',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'voice',
|
||||
targetId: 'character-1',
|
||||
globalAssetId: 'voice-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ projectId: 'project-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
|
||||
kind: 'voice',
|
||||
targetId: 'character-1',
|
||||
globalAssetId: 'voice-1',
|
||||
access: {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('POST /api/assets/[assetId]/copy delegates prop copy to the centralized copy service', async () => {
|
||||
const mod = await import('@/app/api/assets/[assetId]/copy/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/assets/prop-target-1/copy',
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'prop',
|
||||
projectId: 'project-1',
|
||||
globalAssetId: 'prop-global-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, {
|
||||
params: Promise.resolve({ assetId: 'prop-target-1' }),
|
||||
})
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
|
||||
kind: 'prop',
|
||||
targetId: 'prop-target-1',
|
||||
globalAssetId: 'prop-global-1',
|
||||
access: {
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
},
|
||||
})
|
||||
expect(body).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn<() => Promise<{ session: { user: { id: string } } } | Response>>(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
globalCharacter: {
|
||||
create: vi.fn(async () => ({ id: 'character-1', userId: 'user-1' })),
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
name: 'Hero',
|
||||
appearances: [],
|
||||
})),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const mediaAttachMock = vi.hoisted(() => ({
|
||||
attachMediaFieldsToGlobalCharacter: vi.fn(async (value: unknown) => value),
|
||||
}))
|
||||
|
||||
const mediaServiceMock = vi.hoisted(() => ({
|
||||
resolveMediaRefFromLegacyValue: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/attach', () => mediaAttachMock)
|
||||
vi.mock('@/lib/media/service', () => mediaServiceMock)
|
||||
vi.mock('@/lib/env', () => envMock)
|
||||
|
||||
describe('api specific - characters POST forwarding to reference task', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.globalAssetFolder.findUnique.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('forwards locale and accept-language into background reference task payload', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Hero',
|
||||
artStyle: 'realistic',
|
||||
generateFromReference: true,
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
customDescription: '冷静,黑发',
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const calledUrl = fetchMock.mock.calls[0]?.[0]
|
||||
const calledInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
expect(String(calledUrl)).toContain('/api/asset-hub/reference-to-character')
|
||||
expect((calledInit?.headers as Record<string, string>)['Accept-Language']).toBe('zh-CN,zh;q=0.9')
|
||||
|
||||
const rawBody = calledInit?.body
|
||||
expect(typeof rawBody).toBe('string')
|
||||
const forwarded = JSON.parse(String(rawBody)) as {
|
||||
locale?: string
|
||||
meta?: { locale?: string }
|
||||
customDescription?: string
|
||||
artStyle?: string
|
||||
referenceImageUrls?: string[]
|
||||
appearanceId?: string
|
||||
characterId?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
expect(forwarded.locale).toBe('zh')
|
||||
expect(forwarded.meta?.locale).toBe('zh')
|
||||
expect(forwarded.customDescription).toBe('冷静,黑发')
|
||||
expect(forwarded.artStyle).toBe('realistic')
|
||||
expect(forwarded.referenceImageUrls).toEqual(['https://example.com/ref.png'])
|
||||
expect(forwarded.characterId).toBe('character-1')
|
||||
expect(forwarded.appearanceId).toBe('appearance-1')
|
||||
expect(forwarded.count).toBe(5)
|
||||
})
|
||||
|
||||
it('returns unauthorized when auth fails', async () => {
|
||||
authMock.requireUserAuth.mockResolvedValueOnce(
|
||||
NextResponse.json({ error: { code: 'UNAUTHORIZED' } }, { status: 401 }),
|
||||
)
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'Hero' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
mockUnauthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
describe('api specific - characters POST', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('returns unauthorized when user is not authenticated', async () => {
|
||||
installAuthMocks()
|
||||
mockUnauthenticated()
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'A' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns invalid params when name is missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
|
||||
it('returns invalid params when artStyle is missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'Hero' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
novelData: { id: 'novel-data-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionCharacter: {
|
||||
create: vi.fn(async () => ({ id: 'character-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'character-1', appearances: [] })),
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/env', () => envMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion character style forwarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images when creating by text prompt', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/character',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Hero',
|
||||
description: '主角设定',
|
||||
artStyle: 'realistic',
|
||||
count: 4,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle before creating character', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/character',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Hero',
|
||||
description: '主角设定',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionCharacter.create).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{
|
||||
success: boolean
|
||||
async: boolean
|
||||
taskId: string
|
||||
status: string
|
||||
deduped: boolean
|
||||
}>>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
analysisModel: null,
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: null,
|
||||
editModel: null,
|
||||
videoModel: null,
|
||||
videoRatio: '16:9',
|
||||
artStyle: 'american-comic',
|
||||
capabilityDefaults: {},
|
||||
capabilityOverrides: {},
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasLocationImageOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => billingMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion generate image art style', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and forwards it into task payload', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('realistic')
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards requested count into task payload and dedupe key', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as {
|
||||
payload?: Record<string, unknown>
|
||||
dedupeKey?: string
|
||||
} | undefined
|
||||
expect(submitArg?.payload?.count).toBe(6)
|
||||
expect(submitArg?.dedupeKey).toBe('image_character:appearance-1:6')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
novelData: { id: 'novel-data-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionLocation: {
|
||||
create: vi.fn(async () => ({ id: 'location-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),
|
||||
},
|
||||
locationImage: {
|
||||
createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(
|
||||
async () => ({ count: 0 }),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion location style forwarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images when creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle before creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionLocation.create).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates requested number of slots and forwards count', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0, 1, 2, 3, 4])
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1', name: 'User 1' } },
|
||||
project: { id: 'project-1', userId: 'user-1', name: 'Project 1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: 'img::storyboard',
|
||||
editModel: 'img::edit',
|
||||
videoModel: 'video::model',
|
||||
audioModel: 'audio::model',
|
||||
})),
|
||||
update: vi.fn(async () => ({
|
||||
id: 'np-1',
|
||||
artStyle: 'realistic',
|
||||
})),
|
||||
},
|
||||
userPreference: {
|
||||
upsert: vi.fn(async () => ({ userId: 'user-1', artStyle: 'realistic' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const mediaAttachMock = vi.hoisted(() => ({
|
||||
attachMediaFieldsToProject: vi.fn(async (value: unknown) => value),
|
||||
}))
|
||||
|
||||
const logMock = vi.hoisted(() => ({
|
||||
logProjectAction: vi.fn(),
|
||||
}))
|
||||
|
||||
const modelConfigContractMock = vi.hoisted(() => ({
|
||||
parseModelKeyStrict: vi.fn(() => ({ provider: 'mock', modelId: 'mock-model' })),
|
||||
}))
|
||||
|
||||
const capabilityLookupMock = vi.hoisted(() => ({
|
||||
resolveBuiltinModelContext: vi.fn(() => null),
|
||||
getCapabilityOptionFields: vi.fn(() => ({})),
|
||||
validateCapabilitySelectionsPayload: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/attach', () => mediaAttachMock)
|
||||
vi.mock('@/lib/logging/semantic', () => logMock)
|
||||
vi.mock('@/lib/model-config-contract', () => modelConfigContractMock)
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => capabilityLookupMock)
|
||||
|
||||
describe('api specific - novel promotion project art style validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and keeps user preference unchanged', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
artStyle: ' realistic ',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ artStyle: 'realistic' }),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionProject.update).not.toHaveBeenCalled()
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts audioModel and keeps user preference unchanged', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type PanelRecord = {
|
||||
id: string
|
||||
storyboardId: string
|
||||
panelIndex: number
|
||||
shotType: string
|
||||
cameraMove: string
|
||||
description: string
|
||||
videoPrompt: string
|
||||
location: string
|
||||
characters: string
|
||||
srtSegment: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
type StoryboardRecord = {
|
||||
id: string
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1', userId: 'user-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-panel-variant',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
storyboardModel: 'img::storyboard',
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
}))
|
||||
|
||||
const rollbackSpy = vi.hoisted(() => ({
|
||||
delete: vi.fn(async () => ({})),
|
||||
findFirst: vi.fn(async () => ({ panelIndex: 4 })),
|
||||
updateMany: vi.fn(async () => ({ count: 2 })),
|
||||
count: vi.fn(async () => 3),
|
||||
storyboardUpdate: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
const createTxSpy = vi.hoisted(() => ({
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 'panel-after-1', panelIndex: 2 },
|
||||
{ id: 'panel-after-2', panelIndex: 3 },
|
||||
]),
|
||||
update: vi.fn(async () => ({})),
|
||||
create: vi.fn(async (args: { data: PanelRecord }) => ({
|
||||
id: args.data.id,
|
||||
panelIndex: args.data.panelIndex,
|
||||
})),
|
||||
count: vi.fn(async () => 4),
|
||||
storyboardUpdate: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
storyboard: {
|
||||
id: 'storyboard-1',
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: 'project-1',
|
||||
},
|
||||
},
|
||||
} satisfies StoryboardRecord,
|
||||
panels: new Map<string, PanelRecord>(),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionStoryboard: {
|
||||
findUnique: vi.fn(async () => routeState.storyboard),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),
|
||||
},
|
||||
$transaction: vi.fn(async (
|
||||
fn: (tx: {
|
||||
novelPromotionPanel: {
|
||||
findMany: typeof createTxSpy.findMany
|
||||
update: typeof createTxSpy.update
|
||||
create: typeof createTxSpy.create
|
||||
delete: typeof rollbackSpy.delete
|
||||
findFirst: typeof rollbackSpy.findFirst
|
||||
updateMany: typeof rollbackSpy.updateMany
|
||||
count: typeof rollbackSpy.count
|
||||
}
|
||||
novelPromotionStoryboard: {
|
||||
update: typeof createTxSpy.storyboardUpdate
|
||||
}
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
const invocation = prismaMock.$transaction.mock.calls.length
|
||||
if (invocation > 1) {
|
||||
return await fn({
|
||||
novelPromotionPanel: {
|
||||
findMany: createTxSpy.findMany,
|
||||
update: createTxSpy.update,
|
||||
create: createTxSpy.create,
|
||||
delete: rollbackSpy.delete,
|
||||
findFirst: rollbackSpy.findFirst,
|
||||
updateMany: rollbackSpy.updateMany,
|
||||
count: rollbackSpy.count,
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
update: rollbackSpy.storyboardUpdate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return await fn({
|
||||
novelPromotionPanel: {
|
||||
findMany: createTxSpy.findMany,
|
||||
update: createTxSpy.update,
|
||||
create: createTxSpy.create,
|
||||
delete: rollbackSpy.delete,
|
||||
findFirst: rollbackSpy.findFirst,
|
||||
updateMany: rollbackSpy.updateMany,
|
||||
count: rollbackSpy.count,
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
update: createTxSpy.storyboardUpdate,
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {
|
||||
return {
|
||||
id,
|
||||
storyboardId,
|
||||
panelIndex,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
description: `description-${id}`,
|
||||
videoPrompt: `prompt-${id}`,
|
||||
location: 'Old Town',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeRoute(body: Record<string, unknown>): Promise<Response> {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/panel-variant',
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
}
|
||||
|
||||
describe('api specific - panel variant route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
routeState.storyboard = {
|
||||
id: 'storyboard-1',
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: 'project-1',
|
||||
},
|
||||
},
|
||||
}
|
||||
routeState.panels = new Map<string, PanelRecord>([
|
||||
['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],
|
||||
['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],
|
||||
])
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {
|
||||
routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {
|
||||
routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create panel when image billing payload validation fails', async () => {
|
||||
configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string; message: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(json.error.message).toBe('missing capability')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the created panel when submitTask fails after insertion', async () => {
|
||||
submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(502)
|
||||
expect(json.error.code).toBe('EXTERNAL_ERROR')
|
||||
|
||||
expect(createTxSpy.create).toHaveBeenCalledTimes(1)
|
||||
const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id
|
||||
expect(createdPanelId).toEqual(expect.any(String))
|
||||
expect(rollbackSpy.delete).toHaveBeenCalledWith({
|
||||
where: { id: createdPanelId },
|
||||
})
|
||||
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {
|
||||
where: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: { gt: 3 },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { increment: 1004 },
|
||||
panelNumber: { increment: 1004 },
|
||||
},
|
||||
})
|
||||
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: { gt: 1007 },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { decrement: 1005 },
|
||||
panelNumber: { decrement: 1005 },
|
||||
},
|
||||
})
|
||||
expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'storyboard-1' },
|
||||
data: { panelCount: 3 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: 'img::storyboard',
|
||||
editModel: 'img::edit',
|
||||
videoModel: 'video::model',
|
||||
audioModel: 'audio::tts',
|
||||
videoRatio: '9:16',
|
||||
artStyle: 'realistic',
|
||||
ttsRate: '+0%',
|
||||
})),
|
||||
},
|
||||
project: {
|
||||
create: vi.fn(async () => ({
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
description: null,
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
create: vi.fn(async () => ({ id: 'np-1', projectId: 'project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - project create default audio model', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('copies user preference audioModel into the new novel promotion project', async () => {
|
||||
const mod = await import('@/app/api/projects/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/projects',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Test Project',
|
||||
description: '',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, routeContext)
|
||||
expect(res.status).toBe(201)
|
||||
expect(prismaMock.project.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: 'Test Project',
|
||||
description: null,
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
expect(prismaMock.novelPromotionProject.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
audioModel: 'audio::tts',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an explicit validation error when description exceeds the max length', async () => {
|
||||
const mod = await import('@/app/api/projects/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/projects',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN',
|
||||
},
|
||||
body: {
|
||||
name: 'Test Project',
|
||||
description: 'a'.repeat(501),
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, routeContext)
|
||||
const body = await res.json() as {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
details?: {
|
||||
field?: string
|
||||
limit?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error?.code).toBe('INVALID_PARAMS')
|
||||
expect(body.error?.message).toBe('项目描述不能超过 500 个字符。')
|
||||
expect(body.error?.details?.field).toBe('description')
|
||||
expect(body.error?.details?.limit).toBe(500)
|
||||
expect(prismaMock.project.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
mockUnauthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
describe('api specific - reference to character route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('returns unauthorized when user is not authenticated', async () => {
|
||||
installAuthMocks()
|
||||
mockUnauthenticated()
|
||||
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/reference-to-character',
|
||||
method: 'POST',
|
||||
body: {
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns invalid params when references are missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/reference-to-character',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1', userId: 'user-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({ id: 'np-1' })),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
update: vi.fn<(args: { data?: { speakerVoices?: string } }) => Promise<{ id: string }>>(async () => ({ id: 'episode-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn(async (input: string) => {
|
||||
if (input.includes('fal')) return 'voice/storage/fal.wav'
|
||||
if (input.includes('preview')) return 'voice/storage/preview.wav'
|
||||
return null
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/service', () => ({
|
||||
resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,
|
||||
}))
|
||||
|
||||
describe('api specific - speaker voice provider contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when provider is missing in PATCH payload', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: '/m/fal-reference',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionEpisode.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores fal speaker voice with explicit provider and normalized audio storage key', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
provider: 'fal',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: '/m/fal-reference',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as
|
||||
| [{ data?: { speakerVoices?: string } }]
|
||||
| undefined
|
||||
expect(updateCall).toBeTruthy()
|
||||
if (!updateCall) throw new Error('expected update call')
|
||||
const updateArg = updateCall[0]
|
||||
const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>
|
||||
|
||||
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/fal-reference')
|
||||
expect(saved.Narrator).toEqual({
|
||||
provider: 'fal',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: 'voice/storage/fal.wav',
|
||||
})
|
||||
})
|
||||
|
||||
it('stores bailian speaker voice with explicit provider and voiceId', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
provider: 'bailian',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'qwen-tts-vd-001',
|
||||
previewAudioUrl: '/m/preview-audio',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as
|
||||
| [{ data?: { speakerVoices?: string } }]
|
||||
| undefined
|
||||
expect(updateCall).toBeTruthy()
|
||||
if (!updateCall) throw new Error('expected update call')
|
||||
const updateArg = updateCall[0]
|
||||
const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>
|
||||
|
||||
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/preview-audio')
|
||||
expect(saved.Narrator).toEqual({
|
||||
provider: 'bailian',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'qwen-tts-vd-001',
|
||||
previewAudioUrl: 'voice/storage/preview.wav',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
const probeModelLlmProtocolMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
success: true,
|
||||
protocol: 'responses' as const,
|
||||
checkedAt: '2026-03-05T00:00:00.000Z',
|
||||
traces: [],
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/user-api/model-llm-protocol-probe', () => ({
|
||||
probeModelLlmProtocol: probeModelLlmProtocolMock,
|
||||
}))
|
||||
|
||||
describe('api specific - user api-config probe model llm protocol', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('probes protocol for openai-compatible provider/model', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { success: boolean; protocol?: string }
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.protocol).toBe('responses')
|
||||
expect(probeModelLlmProtocolMock).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-openai-compatible provider ids', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'gemini-compatible:node-1',
|
||||
modelId: 'gemini-3-pro-preview',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid body payload', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: '',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
const createAssistantChatResponseMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new Response('event: done\ndata: ok\n\n', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/assistant-platform', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/assistant-platform')>('@/lib/assistant-platform')
|
||||
return {
|
||||
...actual,
|
||||
createAssistantChatResponse: createAssistantChatResponseMock,
|
||||
}
|
||||
})
|
||||
|
||||
describe('api specific - user assistant chat', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('accepts api-config-template assistant request and forwards payload', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: '请配置文生视频模板' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createAssistantChatResponseMock).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: '请配置文生视频模板' }],
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid assistantId', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'unknown-assistant',
|
||||
messages: [],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createAssistantChatResponseMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps assistant platform missing-config error to 400 response', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const { AssistantPlatformError } = await import('@/lib/assistant-platform')
|
||||
createAssistantChatResponseMock.mockRejectedValueOnce(
|
||||
new AssistantPlatformError('ASSISTANT_MODEL_NOT_CONFIGURED', 'analysisModel is required'),
|
||||
)
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
const payload = await res.json() as { code?: string; error?: { code?: string; details?: { code?: string } } }
|
||||
expect(payload.error?.code).toBe('MISSING_CONFIG')
|
||||
expect(payload.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')
|
||||
expect(payload.error?.details?.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
customModels: JSON.stringify([
|
||||
{
|
||||
modelId: 'qwen3-tts-vd-2026-01-26',
|
||||
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
name: 'Qwen3 TTS',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
},
|
||||
{
|
||||
modelId: 'qwen-voice-design',
|
||||
modelKey: 'bailian::qwen-voice-design',
|
||||
name: 'Qwen Voice Design',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
},
|
||||
]),
|
||||
customProviders: JSON.stringify([
|
||||
{
|
||||
id: 'bailian',
|
||||
name: 'Alibaba Bailian',
|
||||
apiKey: 'k-bailian',
|
||||
},
|
||||
]),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/model-capabilities/catalog', () => ({
|
||||
findBuiltinCapabilities: vi.fn(() => undefined),
|
||||
}))
|
||||
vi.mock('@/lib/model-pricing/catalog', () => ({
|
||||
findBuiltinPricingCatalogEntry: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
describe('api specific - user models audio filter', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('excludes voice design models from the audio model list', async () => {
|
||||
const mod = await import('@/app/api/user/models/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/models',
|
||||
method: 'GET',
|
||||
})
|
||||
const res = await mod.GET(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { audio: Array<{ value: string }> }
|
||||
expect(body.audio.map((item) => item.value)).toEqual([
|
||||
'bailian::qwen3-tts-vd-2026-01-26',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
upsert: vi.fn(async () => ({
|
||||
userId: 'user-1',
|
||||
artStyle: 'realistic',
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - user preference art style validation', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and persists normalized value', async () => {
|
||||
const mod = await import('@/app/api/user-preference/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user-preference',
|
||||
method: 'PATCH',
|
||||
body: { artStyle: ' realistic ' },
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.userPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ artStyle: 'realistic' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/user-preference/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user-preference',
|
||||
method: 'PATCH',
|
||||
body: { artStyle: 'anime' },
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1', userId: 'user-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({ audioModel: 'fal::fal-ai/index-tts-2/text-to-speech' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn<() => Promise<{
|
||||
id: string
|
||||
audioModel: string | null
|
||||
characters: Array<{ name: string; customVoiceUrl: string; voiceId: string | null }>
|
||||
} | null>>(async () => ({
|
||||
id: 'np-1',
|
||||
audioModel: 'fal::project-tts-model',
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },
|
||||
],
|
||||
})),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'line-1',
|
||||
speaker: 'Narrator',
|
||||
content: 'hello world',
|
||||
})),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
resolveModelSelectionOrSingle: vi.fn(async (_userId: string, model: string | null | undefined) => ({
|
||||
provider: 'fal',
|
||||
modelId: 'fal-ai/index-tts-2/text-to-speech',
|
||||
modelKey: model || 'fal::fal-ai/index-tts-2/text-to-speech',
|
||||
mediaType: 'audio',
|
||||
})),
|
||||
getProviderKey: vi.fn((providerId: string) => providerId),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/task/has-output', () => ({
|
||||
hasVoiceLineAudioOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
describe('api specific - voice generate default audio model', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses project audioModel when request does not provide one', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::project-tts-model',
|
||||
'audio',
|
||||
)
|
||||
|
||||
const submitCall = submitTaskMock.mock.calls[0] as [{ payload?: Record<string, unknown> }] | undefined
|
||||
const submitArg = submitCall?.[0]
|
||||
expect(submitArg?.payload?.audioModel).toBe('fal::project-tts-model')
|
||||
})
|
||||
|
||||
it('request audioModel overrides user preference audioModel', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
audioModel: 'fal::custom-tts',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::custom-tts',
|
||||
'audio',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to user preference audioModel when project audioModel is empty', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-1',
|
||||
audioModel: null,
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::fal-ai/index-tts-2/text-to-speech',
|
||||
'audio',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an explicit qwen voiceId error when only uploaded reference audio is available', async () => {
|
||||
apiConfigMock.resolveModelSelectionOrSingle.mockResolvedValueOnce({
|
||||
provider: 'bailian',
|
||||
modelId: 'qwen3-tts-vd-2026-01-26',
|
||||
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
mediaType: 'audio',
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const json = await res.json()
|
||||
expect(json.error?.message).toBe('无音色ID,QwenTTS 必须使用 AI 设计音色')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user