first commit

This commit is contained in:
wsq
2026-05-13 21:58:19 +08:00
commit 0167c66cb7
1475 changed files with 233414 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { expect } from 'vitest'
import { prisma } from './prisma'
import { toMoneyNumber } from '@/lib/billing/money'
export async function expectBalance(userId: string, params: {
balance: number
frozenAmount: number
totalSpent: number
}) {
const row = await prisma.userBalance.findUnique({ where: { userId } })
expect(row).toBeTruthy()
expect(toMoneyNumber(row!.balance)).toBeCloseTo(params.balance, 8)
expect(toMoneyNumber(row!.frozenAmount)).toBeCloseTo(params.frozenAmount, 8)
expect(toMoneyNumber(row!.totalSpent)).toBeCloseTo(params.totalSpent, 8)
}
export async function expectNoNegativeLedger(userId: string) {
const row = await prisma.userBalance.findUnique({ where: { userId } })
expect(row).toBeTruthy()
expect(toMoneyNumber(row!.balance)).toBeGreaterThanOrEqual(0)
expect(toMoneyNumber(row!.frozenAmount)).toBeGreaterThanOrEqual(0)
expect(toMoneyNumber(row!.totalSpent)).toBeGreaterThanOrEqual(0)
}
+132
View File
@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { vi } from 'vitest'
type SessionUser = {
id: string
name?: string | null
email?: string | null
}
type SessionPayload = {
user: SessionUser
}
type MockAuthState = {
session: SessionPayload | null
projectAuthMode: 'allow' | 'forbidden' | 'not_found'
}
const defaultSession: SessionPayload = {
user: {
id: 'test-user-id',
name: 'test-user',
email: 'test@example.com',
},
}
let state: MockAuthState = {
session: defaultSession,
projectAuthMode: 'allow',
}
function unauthorizedResponse() {
return NextResponse.json(
{
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Unauthorized',
},
},
{ status: 401 },
)
}
function forbiddenResponse() {
return NextResponse.json(
{
success: false,
error: {
code: 'FORBIDDEN',
message: 'Forbidden',
},
},
{ status: 403 },
)
}
function notFoundResponse() {
return NextResponse.json(
{
success: false,
error: {
code: 'NOT_FOUND',
message: 'Project not found',
},
},
{ status: 404 },
)
}
export function installAuthMocks() {
vi.doMock('@/lib/api-auth', () => ({
isErrorResponse: (value: unknown) => value instanceof NextResponse,
requireUserAuth: async () => {
if (!state.session) return unauthorizedResponse()
return { session: state.session }
},
requireProjectAuth: async (projectId: string) => {
if (!state.session) return unauthorizedResponse()
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
if (state.projectAuthMode === 'not_found') return notFoundResponse()
return {
session: state.session,
project: { id: projectId, userId: state.session.user.id, name: 'project' },
novelData: { id: 'novel-data-id' },
}
},
requireProjectAuthLight: async (projectId: string) => {
if (!state.session) return unauthorizedResponse()
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
if (state.projectAuthMode === 'not_found') return notFoundResponse()
return {
session: state.session,
project: { id: projectId, userId: state.session.user.id, name: 'project' },
}
},
}))
}
export function mockAuthenticated(userId: string) {
state = {
...state,
session: {
user: {
...defaultSession.user,
id: userId,
},
},
}
}
export function mockUnauthenticated() {
state = {
...state,
session: null,
}
}
export function mockProjectAuth(mode: 'allow' | 'forbidden' | 'not_found') {
state = {
...state,
projectAuthMode: mode,
}
}
export function resetAuthMockState() {
state = {
session: defaultSession,
projectAuthMode: 'allow',
}
vi.doUnmock('@/lib/api-auth')
}
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto'
import type { TaskBillingInfo, TaskType } from '@/lib/task/types'
import { TASK_STATUS } from '@/lib/task/types'
import { Prisma } from '@prisma/client'
import { prisma } from './prisma'
export async function createTestUser() {
const suffix = randomUUID().slice(0, 8)
return await prisma.user.create({
data: {
name: `billing_user_${suffix}`,
email: `billing_${suffix}@example.com`,
},
})
}
export async function createTestProject(userId: string) {
const suffix = randomUUID().slice(0, 8)
return await prisma.project.create({
data: {
name: `Billing Project ${suffix}`,
userId,
},
})
}
export async function seedBalance(userId: string, balance: number) {
return await prisma.userBalance.upsert({
where: { userId },
create: {
userId,
balance,
frozenAmount: 0,
totalSpent: 0,
},
update: {
balance,
frozenAmount: 0,
totalSpent: 0,
},
})
}
export async function createQueuedTask(params: {
id: string
userId: string
projectId: string
type: TaskType
targetType: string
targetId: string
billingInfo?: TaskBillingInfo | null
payload?: Record<string, unknown> | null
}) {
return await prisma.task.create({
data: {
id: params.id,
userId: params.userId,
projectId: params.projectId,
type: params.type,
targetType: params.targetType,
targetId: params.targetId,
status: TASK_STATUS.QUEUED,
billingInfo: (params.billingInfo ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
payload: (params.payload ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
queuedAt: new Date(),
},
})
}
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from './prisma'
export async function resetBillingState() {
await prisma.balanceTransaction.deleteMany()
await prisma.balanceFreeze.deleteMany()
await prisma.usageCost.deleteMany()
await prisma.taskEvent.deleteMany()
await prisma.task.deleteMany()
await prisma.userBalance.deleteMany()
await prisma.project.deleteMany()
await prisma.session.deleteMany()
await prisma.account.deleteMany()
await prisma.userPreference.deleteMany()
await prisma.user.deleteMany()
}
export async function resetTaskState() {
await prisma.taskEvent.deleteMany()
await prisma.task.deleteMany()
}
export async function resetAssetHubState() {
await prisma.globalCharacterAppearance.deleteMany()
await prisma.globalCharacter.deleteMany()
await prisma.globalLocationImage.deleteMany()
await prisma.globalLocation.deleteMany()
await prisma.globalVoice.deleteMany()
await prisma.globalAssetFolder.deleteMany()
}
export async function resetNovelPromotionState() {
await prisma.novelPromotionVoiceLine.deleteMany()
await prisma.novelPromotionPanel.deleteMany()
await prisma.supplementaryPanel.deleteMany()
await prisma.novelPromotionStoryboard.deleteMany()
await prisma.novelPromotionShot.deleteMany()
await prisma.novelPromotionClip.deleteMany()
await prisma.characterAppearance.deleteMany()
await prisma.locationImage.deleteMany()
await prisma.novelPromotionCharacter.deleteMany()
await prisma.novelPromotionLocation.deleteMany()
await prisma.videoEditorProject.deleteMany()
await prisma.novelPromotionEpisode.deleteMany()
await prisma.novelPromotionProject.deleteMany()
}
export async function resetSystemState() {
await resetTaskState()
await resetAssetHubState()
await resetNovelPromotionState()
await prisma.usageCost.deleteMany()
await prisma.project.deleteMany()
await prisma.userPreference.deleteMany()
await prisma.account.deleteMany()
await prisma.session.deleteMany()
await prisma.userBalance.deleteMany()
await prisma.balanceFreeze.deleteMany()
await prisma.balanceTransaction.deleteMany()
await prisma.user.deleteMany()
}
+26
View File
@@ -0,0 +1,26 @@
type CompletionResult = {
text: string
reasoning?: string
}
const state: { nextText: string; nextReasoning: string } = {
nextText: '{"ok":true}',
nextReasoning: '',
}
export function configureFakeLLM(result: CompletionResult) {
state.nextText = result.text
state.nextReasoning = result.reasoning || ''
}
export function resetFakeLLM() {
state.nextText = '{"ok":true}'
state.nextReasoning = ''
}
export async function fakeChatCompletion() {
return {
output_text: state.nextText,
reasoning: state.nextReasoning,
}
}
+37
View File
@@ -0,0 +1,37 @@
const state: {
nextImageUrl: string
nextVideoUrl: string
nextAudioUrl: string
} = {
nextImageUrl: 'images/fake-image.jpg',
nextVideoUrl: 'video/fake-video.mp4',
nextAudioUrl: 'voice/fake-audio.mp3',
}
export function configureFakeMedia(params: {
imageUrl?: string
videoUrl?: string
audioUrl?: string
}) {
if (params.imageUrl) state.nextImageUrl = params.imageUrl
if (params.videoUrl) state.nextVideoUrl = params.videoUrl
if (params.audioUrl) state.nextAudioUrl = params.audioUrl
}
export function resetFakeMedia() {
state.nextImageUrl = 'images/fake-image.jpg'
state.nextVideoUrl = 'video/fake-video.mp4'
state.nextAudioUrl = 'voice/fake-audio.mp3'
}
export async function fakeGenerateImage() {
return { success: true, imageUrl: state.nextImageUrl }
}
export async function fakeGenerateVideo() {
return { success: true, videoUrl: state.nextVideoUrl }
}
export async function fakeGenerateAudio() {
return { success: true, audioUrl: state.nextAudioUrl }
}
+35
View File
@@ -0,0 +1,35 @@
const providerState: {
falApiKey: string
googleApiKey: string
openrouterApiKey: string
} = {
falApiKey: 'fake-fal-key',
googleApiKey: 'fake-google-key',
openrouterApiKey: 'fake-openrouter-key',
}
export function configureFakeProviders(params: {
falApiKey?: string
googleApiKey?: string
openrouterApiKey?: string
}) {
if (params.falApiKey) providerState.falApiKey = params.falApiKey
if (params.googleApiKey) providerState.googleApiKey = params.googleApiKey
if (params.openrouterApiKey) providerState.openrouterApiKey = params.openrouterApiKey
}
export function resetFakeProviders() {
providerState.falApiKey = 'fake-fal-key'
providerState.googleApiKey = 'fake-google-key'
providerState.openrouterApiKey = 'fake-openrouter-key'
}
export function getFakeProviderConfig(provider: 'fal' | 'google' | 'openrouter') {
if (provider === 'fal') {
return { apiKey: providerState.falApiKey }
}
if (provider === 'google') {
return { apiKey: providerState.googleApiKey }
}
return { apiKey: providerState.openrouterApiKey }
}
+193
View File
@@ -0,0 +1,193 @@
import http, { type IncomingMessage, type ServerResponse } from 'node:http'
export type FakeScenarioMode =
| 'success'
| 'queued_then_success'
| 'retryable_error_then_success'
| 'fatal_error'
| 'malformed_response'
| 'timeout'
export type FakeResponseSpec = {
status: number
headers?: Record<string, string>
body?: string | Buffer | Record<string, unknown> | unknown[] | null
delayMs?: number
}
export type FakeRequestRecord = {
method: string
path: string
query: string
bodyText: string
headers: Record<string, string | string[] | undefined>
}
type RouteKey = `${Uppercase<string>} ${string}`
type RouteScenario = {
mode: FakeScenarioMode
submitResponse?: FakeResponseSpec
pollSequence?: FakeResponseSpec[]
errorCode?: string
delayMs?: number
}
function routeKey(method: string, path: string): RouteKey {
return `${method.toUpperCase()} ${path}` as RouteKey
}
function normalizeHeaders(headers: IncomingMessage['headers']): Record<string, string | string[] | undefined> {
return Object.fromEntries(Object.entries(headers))
}
function toBodyText(chunks: Buffer[]): string {
if (chunks.length === 0) return ''
return Buffer.concat(chunks).toString('utf8')
}
function isJsonBody(body: FakeResponseSpec['body']): body is Record<string, unknown> | unknown[] | null {
return body === null || Array.isArray(body) || (!!body && typeof body === 'object' && !Buffer.isBuffer(body))
}
async function writeResponse(
res: ServerResponse,
spec: FakeResponseSpec,
inheritedDelayMs: number | undefined,
) {
const delayMs = spec.delayMs ?? inheritedDelayMs ?? 0
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
const headers = { ...(spec.headers || {}) }
if (isJsonBody(spec.body) && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
res.writeHead(spec.status, headers)
if (spec.body === undefined) {
res.end()
return
}
if (Buffer.isBuffer(spec.body)) {
res.end(spec.body)
return
}
if (isJsonBody(spec.body)) {
res.end(JSON.stringify(spec.body))
return
}
res.end(spec.body)
}
export async function startScenarioServer() {
const requests = new Map<RouteKey, FakeRequestRecord[]>()
const routes = new Map<RouteKey, { queue: FakeResponseSpec[]; mode: FakeScenarioMode; delayMs?: number }>()
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1')
const key = routeKey(req.method || 'GET', url.pathname)
const entry = routes.get(key)
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const bodyText = toBodyText(chunks)
const history = requests.get(key) || []
history.push({
method: (req.method || 'GET').toUpperCase(),
path: url.pathname,
query: url.search,
bodyText,
headers: normalizeHeaders(req.headers),
})
requests.set(key, history)
if (!entry) {
await writeResponse(res, {
status: 404,
body: { error: 'SCENARIO_ROUTE_NOT_FOUND', path: url.pathname },
}, 0)
return
}
const next = entry.queue.length > 1 ? entry.queue.shift() : entry.queue[0]
if (!next) {
await writeResponse(res, {
status: 500,
body: { error: 'SCENARIO_DEPLETED', path: url.pathname, mode: entry.mode },
}, entry.delayMs)
return
}
await writeResponse(res, next, entry.delayMs)
})
await new Promise<void>((resolve, reject) => {
server.listen(0, '127.0.0.1', () => resolve())
server.once('error', reject)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('SCENARIO_SERVER_ADDRESS_INVALID')
}
const baseUrl = `http://127.0.0.1:${address.port}`
return {
baseUrl,
defineScenario(input: {
method: string
path: string
mode: FakeScenarioMode
submitResponse?: FakeResponseSpec
pollSequence?: FakeResponseSpec[]
errorCode?: string
delayMs?: number
}) {
const key = routeKey(input.method, input.path)
const queue: FakeResponseSpec[] = []
if (input.submitResponse) {
queue.push(input.submitResponse)
}
if (input.pollSequence && input.pollSequence.length > 0) {
queue.push(...input.pollSequence)
}
if (queue.length === 0) {
throw new Error(`SCENARIO_EMPTY_QUEUE: ${key}`)
}
const scenario: RouteScenario = {
mode: input.mode,
submitResponse: input.submitResponse,
pollSequence: input.pollSequence,
errorCode: input.errorCode,
delayMs: input.delayMs,
}
routes.set(key, {
queue,
mode: scenario.mode,
delayMs: scenario.delayMs,
})
requests.delete(key)
},
getRequests(method: string, path: string): FakeRequestRecord[] {
return [...(requests.get(routeKey(method, path)) || [])]
},
reset() {
routes.clear()
requests.clear()
},
async close() {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
},
}
}
+98
View File
@@ -0,0 +1,98 @@
import { randomUUID } from 'node:crypto'
import { prisma } from './prisma'
function suffix() {
return randomUUID().slice(0, 8)
}
export async function createFixtureUser() {
const id = suffix()
return await prisma.user.create({
data: {
name: `user_${id}`,
email: `user_${id}@example.com`,
},
})
}
export async function createFixtureProject(userId: string) {
const id = suffix()
return await prisma.project.create({
data: {
userId,
name: `project_${id}`,
},
})
}
export async function createFixtureNovelProject(projectId: string) {
return await prisma.novelPromotionProject.create({
data: {
projectId,
analysisModel: 'openrouter::anthropic/claude-sonnet-4',
characterModel: 'fal::banana/character',
locationModel: 'fal::banana/location',
storyboardModel: 'fal::banana/storyboard',
editModel: 'fal::banana/edit',
videoModel: 'fal::seedance/video',
videoRatio: '9:16',
imageResolution: '2K',
},
})
}
export async function createFixtureGlobalCharacter(userId: string, folderId: string | null = null) {
const id = suffix()
return await prisma.globalCharacter.create({
data: {
userId,
name: `character_${id}`,
...(folderId ? { folderId } : {}),
},
})
}
export async function createFixtureGlobalCharacterAppearance(characterId: string, appearanceIndex = 0) {
return await prisma.globalCharacterAppearance.create({
data: {
characterId,
appearanceIndex,
changeReason: 'default',
imageUrls: JSON.stringify(['images/test-0.jpg']),
selectedIndex: 0,
},
})
}
export async function createFixtureGlobalLocation(userId: string, folderId: string | null = null) {
const id = suffix()
return await prisma.globalLocation.create({
data: {
userId,
name: `location_${id}`,
...(folderId ? { folderId } : {}),
},
})
}
export async function createFixtureGlobalLocationImage(locationId: string, imageIndex = 0) {
return await prisma.globalLocationImage.create({
data: {
locationId,
imageIndex,
imageUrl: `images/location-${suffix()}.jpg`,
isSelected: imageIndex === 0,
},
})
}
export async function createFixtureEpisode(novelPromotionProjectId: string, episodeNumber = 1) {
return await prisma.novelPromotionEpisode.create({
data: {
novelPromotionProjectId,
episodeNumber,
name: `Episode ${episodeNumber}`,
novelText: 'test novel text',
},
})
}
+72
View File
@@ -0,0 +1,72 @@
import type { QueryKey } from '@tanstack/react-query'
interface QueryFilter {
queryKey: QueryKey
exact?: boolean
}
type Updater<T> = T | ((previous: T | undefined) => T | undefined)
interface StoredQueryEntry {
queryKey: QueryKey
data: unknown
}
function isPrefixQueryKey(target: QueryKey, prefix: QueryKey): boolean {
if (prefix.length > target.length) return false
return prefix.every((value, index) => Object.is(value, target[index]))
}
function keyOf(queryKey: QueryKey): string {
return JSON.stringify(queryKey)
}
export class MockQueryClient {
private readonly queryMap = new Map<string, StoredQueryEntry>()
async cancelQueries(filters: QueryFilter): Promise<void> {
void filters
}
seedQuery<T>(queryKey: QueryKey, data: T | undefined) {
this.queryMap.set(keyOf(queryKey), {
queryKey,
data,
})
}
getQueryData<T>(queryKey: QueryKey): T | undefined {
const entry = this.queryMap.get(keyOf(queryKey))
return entry?.data as T | undefined
}
setQueryData<T>(queryKey: QueryKey, updater: Updater<T | undefined>) {
const previous = this.getQueryData<T>(queryKey)
const next = typeof updater === 'function'
? (updater as (prev: T | undefined) => T | undefined)(previous)
: updater
this.seedQuery(queryKey, next)
}
getQueriesData<T>(filters: QueryFilter): Array<[QueryKey, T | undefined]> {
const matched: Array<[QueryKey, T | undefined]> = []
for (const { queryKey, data } of this.queryMap.values()) {
const isMatch = filters.exact
? keyOf(filters.queryKey) === keyOf(queryKey)
: isPrefixQueryKey(queryKey, filters.queryKey)
if (!isMatch) continue
matched.push([queryKey, data as T | undefined])
}
return matched
}
setQueriesData<T>(
filters: QueryFilter,
updater: (previous: T | undefined) => T | undefined,
) {
const matches = this.getQueriesData<T>(filters)
matches.forEach(([queryKey, previous]) => {
this.seedQuery(queryKey, updater(previous))
})
}
}
+6
View File
@@ -0,0 +1,6 @@
import { loadTestEnv } from '../setup/env'
import { prisma } from '@/lib/prisma'
loadTestEnv()
export { prisma }
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from 'next/server'
type HeaderMap = Record<string, string>
type QueryMap = Record<string, string | number | boolean>
function toJsonBody(body: unknown): string | undefined {
if (body === undefined) return undefined
return JSON.stringify(body)
}
function appendQuery(url: URL, query?: QueryMap) {
if (!query) return
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, String(value))
}
}
export function buildMockRequest(params: {
path: string
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
body?: unknown
headers?: HeaderMap
query?: QueryMap
}) {
const url = new URL(params.path, 'http://localhost:3000')
appendQuery(url, params.query)
const jsonBody = toJsonBody(params.body)
const headers: HeaderMap = {
...(params.headers || {}),
}
if (jsonBody !== undefined && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return new NextRequest(url, {
method: params.method,
headers,
...(jsonBody !== undefined ? { body: jsonBody } : {}),
})
}
export async function callRoute<TContext>(
handler: (req: NextRequest, ctx: TContext) => Promise<Response>,
params: {
path: string
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
body?: unknown
headers?: HeaderMap
query?: QueryMap
context: TContext
},
) {
const req = buildMockRequest({
path: params.path,
method: params.method,
body: params.body,
headers: params.headers,
query: params.query,
})
return await handler(req, params.context)
}