release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

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
tests/helpers/auth.ts Normal file
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')
}

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
tests/helpers/db-reset.ts Normal file
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()
}

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,
}
}

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

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

99
tests/helpers/fixtures.ts Normal file
View File

@@ -0,0 +1,99 @@
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, mode: 'novel-promotion' | 'general' = 'novel-promotion') {
const id = suffix()
return await prisma.project.create({
data: {
userId,
mode,
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',
},
})
}

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
tests/helpers/prisma.ts Normal file
View File

@@ -0,0 +1,6 @@
import { loadTestEnv } from '../setup/env'
import { prisma } from '@/lib/prisma'
loadTestEnv()
export { prisma }

62
tests/helpers/request.ts Normal file
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)
}