Files
waoowaoo/tests/integration/api/contract/crud-routes.test.ts

397 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
import { buildMockRequest } from '../../../helpers/request'
type RouteMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
type AuthState = {
authenticated: boolean
}
type RouteContext = {
params: Promise<Record<string, string>>
}
const authState = vi.hoisted<AuthState>(() => ({
authenticated: false,
}))
const prismaMock = vi.hoisted(() => ({
globalCharacter: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
globalAssetFolder: {
findUnique: vi.fn(),
},
characterAppearance: {
findUnique: vi.fn(),
update: vi.fn(),
},
novelPromotionLocation: {
findUnique: vi.fn(),
update: vi.fn(),
},
locationImage: {
updateMany: vi.fn(),
update: vi.fn(),
},
novelPromotionClip: {
update: vi.fn(),
},
}))
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' } } }
},
requireProjectAuth: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
}
},
requireProjectAuthLight: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
}
},
}
})
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/cos', () => ({
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
}))
function toModuleImportPath(routeFile: string): string {
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
}
function resolveParamValue(paramName: string): string {
const key = paramName.toLowerCase()
if (key.includes('project')) return 'project-1'
if (key.includes('character')) return 'character-1'
if (key.includes('location')) return 'location-1'
if (key.includes('appearance')) return '0'
if (key.includes('episode')) return 'episode-1'
if (key.includes('storyboard')) return 'storyboard-1'
if (key.includes('panel')) return 'panel-1'
if (key.includes('clip')) return 'clip-1'
if (key.includes('folder')) return 'folder-1'
if (key === 'id') return 'id-1'
return `${paramName}-1`
}
function toApiPath(routeFile: string): { path: string; params: Record<string, string> } {
const withoutPrefix = routeFile
.replace(/^src\/app/, '')
.replace(/\/route\.ts$/, '')
const params: Record<string, string> = {}
const path = withoutPrefix.replace(/\[([^\]]+)\]/g, (_full, paramName: string) => {
const value = resolveParamValue(paramName)
params[paramName] = value
return value
})
return { path, params }
}
function buildGenericBody() {
return {
id: 'id-1',
name: 'Name',
type: 'character',
userInstruction: 'instruction',
characterId: 'character-1',
locationId: 'location-1',
appearanceId: 'appearance-1',
modifyPrompt: 'modify prompt',
storyboardId: 'storyboard-1',
panelId: 'panel-1',
panelIndex: 0,
episodeId: 'episode-1',
content: 'x'.repeat(140),
voicePrompt: 'voice prompt',
previewText: 'preview text',
referenceImageUrl: 'https://example.com/ref.png',
referenceImageUrls: ['https://example.com/ref.png'],
lineId: 'line-1',
audioModel: 'fal::audio-model',
videoModel: 'fal::video-model',
insertAfterPanelId: 'panel-1',
sourcePanelId: 'panel-2',
variant: { video_prompt: 'variant prompt' },
currentDescription: 'description',
modifyInstruction: 'instruction',
currentPrompt: 'prompt',
all: false,
}
}
async function invokeRouteMethod(
routeFile: string,
method: RouteMethod,
): Promise<Response> {
const { path, params } = toApiPath(routeFile)
const modulePath = toModuleImportPath(routeFile)
const mod = await import(modulePath)
const handler = mod[method] as ((req: Request, ctx?: RouteContext) => Promise<Response>) | undefined
if (!handler) {
throw new Error(`Route ${routeFile} missing method ${method}`)
}
const req = buildMockRequest({
path,
method,
...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),
})
return await handler(req, { params: Promise.resolve(params) })
}
describe('api contract - crud routes (behavior)', () => {
const routes = ROUTE_CATALOG.filter(
(entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',
)
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = false
prismaMock.globalCharacter.findUnique.mockResolvedValue({
id: 'character-1',
userId: 'user-1',
})
prismaMock.globalAssetFolder.findUnique.mockResolvedValue({
id: 'folder-1',
userId: 'user-1',
})
prismaMock.globalCharacter.update.mockResolvedValue({
id: 'character-1',
name: 'Alice',
userId: 'user-1',
appearances: [],
})
prismaMock.globalCharacter.delete.mockResolvedValue({ id: 'character-1' })
prismaMock.characterAppearance.findUnique.mockResolvedValue({
id: 'appearance-1',
characterId: 'character-1',
imageUrls: JSON.stringify(['cos/char-0.png', 'cos/char-1.png']),
imageUrl: null,
selectedIndex: null,
character: { id: 'character-1', name: 'Alice' },
})
prismaMock.characterAppearance.update.mockResolvedValue({
id: 'appearance-1',
selectedIndex: 1,
imageUrl: 'cos/char-1.png',
})
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
id: 'location-1',
name: 'Old Town',
images: [
{ id: 'img-0', imageIndex: 0, imageUrl: 'cos/loc-0.png' },
{ id: 'img-1', imageIndex: 1, imageUrl: 'cos/loc-1.png' },
],
})
prismaMock.locationImage.updateMany.mockResolvedValue({ count: 2 })
prismaMock.locationImage.update.mockResolvedValue({
id: 'img-1',
imageIndex: 1,
imageUrl: 'cos/loc-1.png',
isSelected: true,
})
prismaMock.novelPromotionLocation.update.mockResolvedValue({
id: 'location-1',
selectedImageId: 'img-1',
})
prismaMock.novelPromotionClip.update.mockResolvedValue({
id: 'clip-1',
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
})
})
it('crud route group exists', () => {
expect(routes.length).toBeGreaterThan(0)
})
it('all crud route methods reject unauthenticated requests (no 2xx pass-through)', async () => {
const methods: ReadonlyArray<RouteMethod> = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']
let checkedMethodCount = 0
for (const entry of routes) {
const modulePath = toModuleImportPath(entry.routeFile)
const mod = await import(modulePath)
for (const method of methods) {
if (typeof mod[method] !== 'function') continue
checkedMethodCount += 1
const res = await invokeRouteMethod(entry.routeFile, method)
expect(res.status, `${entry.routeFile}#${method} should reject unauthenticated`).toBeGreaterThanOrEqual(400)
expect(res.status, `${entry.routeFile}#${method} should not be server-error on auth gate`).toBeLessThan(500)
}
}
expect(checkedMethodCount).toBeGreaterThan(0)
})
it('PATCH /asset-hub/characters/[characterId] writes normalized fields to prisma.globalCharacter.update', async () => {
authState.authenticated = true
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
const req = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'PATCH',
body: {
name: ' Alice ',
aliases: ['A'],
profileConfirmed: true,
folderId: 'folder-1',
},
})
const res = await mod.PATCH(req, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.globalCharacter.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'character-1' },
data: expect.objectContaining({
name: 'Alice',
aliases: ['A'],
profileConfirmed: true,
folderId: 'folder-1',
}),
}))
})
it('DELETE /asset-hub/characters/[characterId] deletes owned character and blocks non-owner', async () => {
authState.authenticated = true
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
id: 'character-1',
userId: 'user-1',
})
const okReq = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'DELETE',
})
const okRes = await mod.DELETE(okReq, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(okRes.status).toBe(200)
expect(prismaMock.globalCharacter.delete).toHaveBeenCalledWith({ where: { id: 'character-1' } })
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
id: 'character-1',
userId: 'other-user',
})
const forbiddenReq = buildMockRequest({
path: '/api/asset-hub/characters/character-1',
method: 'DELETE',
})
const forbiddenRes = await mod.DELETE(forbiddenReq, { params: Promise.resolve({ characterId: 'character-1' }) })
expect(forbiddenRes.status).toBe(403)
})
it('POST /novel-promotion/[projectId]/select-character-image writes selectedIndex and imageUrl key', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/select-character-image/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/select-character-image',
method: 'POST',
body: {
characterId: 'character-1',
appearanceId: 'appearance-1',
selectedIndex: 1,
},
})
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
where: { id: 'appearance-1' },
data: {
selectedIndex: 1,
imageUrl: 'cos/char-1.png',
},
})
const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }
expect(payload).toEqual({
success: true,
selectedIndex: 1,
imageUrl: 'https://signed.example/cos/char-1.png',
})
})
it('POST /novel-promotion/[projectId]/select-location-image toggles selected state and selectedImageId', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/select-location-image/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/select-location-image',
method: 'POST',
body: {
locationId: 'location-1',
selectedIndex: 1,
},
})
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
expect(res.status).toBe(200)
expect(prismaMock.locationImage.updateMany).toHaveBeenCalledWith({
where: { locationId: 'location-1' },
data: { isSelected: false },
})
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
where: { locationId_imageIndex: { locationId: 'location-1', imageIndex: 1 } },
data: { isSelected: true },
})
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
where: { id: 'location-1' },
data: { selectedImageId: 'img-1' },
})
})
it('PATCH /novel-promotion/[projectId]/clips/[clipId] writes provided editable fields', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/clips/[clipId]/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/clips/clip-1',
method: 'PATCH',
body: {
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
},
})
const res = await mod.PATCH(req, {
params: Promise.resolve({ projectId: 'project-1', clipId: 'clip-1' }),
})
expect(res.status).toBe(200)
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-1' },
data: {
characters: JSON.stringify(['Alice']),
location: 'Old Town',
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
},
})
})
})