feat: initial release v0.3.0

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

View File

@@ -0,0 +1,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']),
}),
}))
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -0,0 +1,132 @@
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', mode: 'novel-promotion', 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 syncs to user preference', 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).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ artStyle: 'realistic' }),
}),
)
})
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 syncs it to user preference', 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).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
}),
}),
)
})
})

View File

@@ -0,0 +1,70 @@
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,
mode: 'novel-promotion',
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.novelPromotionProject.create).toHaveBeenCalledWith({
data: expect.objectContaining({
projectId: 'project-1',
audioModel: 'audio::tts',
}),
})
})
})

View File

@@ -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')
})
})

View File

@@ -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', mode: 'novel-promotion' },
})),
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',
})
})
})

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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',
])
})
})

View File

@@ -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()
})
})

View File

@@ -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', mode: 'novel-promotion' },
})),
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('无音色IDQwenTTS 必须使用 AI 设计音色')
expect(submitTaskMock).not.toHaveBeenCalled()
})
})