import { afterAll, beforeEach, describe, expect, it } from '@jest/globals'; import { API, accessTokenFor, disconnect, makeRequest, prisma, rawDataColumn, resetDb, seedModel, seedRole, seedUser, unique, type SeededUser, } from './helpers/harness'; import { POST as createItem, GET as listItems, PUT as updateItem } from '@/app/api/admin/items/route'; import { POST as workflowPost } from '@/app/api/admin/items/[id]/workflow/route'; import { getPublishedItems } from '@/lib/cms/data-server'; import { parseCmsData } from '@/lib/cms/validate-content-data'; import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types'; import type { FieldDefinition } from '@/lib/cms/types'; type Json = Record; async function jsonOf(response: Response): Promise { return (await response.json()) as Json; } // 字段声明直接取自生产注册表(零编造):service 的 metrics[] 带 select 型 basis 字段, // 见 src/lib/cms/content-types.ts:8-18 与 :176-186。 const SERVICE_FIELDS: FieldDefinition[] = CONTENT_TYPE_CONFIGS.service.model.fields; function declaredOptions(arrayField: string, fieldName: string): string[] { const parent = SERVICE_FIELDS.find((f) => f.name === arrayField); const target = parent?.fields?.find((f) => f.name === fieldName); const options = (target?.options ?? []).map((o) => String(o.value)); if (options.length === 0) { throw new Error(`生产字段声明里找不到 ${arrayField}.${fieldName} 的 options,测试前提已失效`); } return options; } interface EditableModel { code: string; id: string; editor: SeededUser; token: string; } /** 每个用例一套独立的 modelCode:既隔离权限,也绕开 data-server 里 React cache() 的按参数记忆 */ async function seedEditableModel(withPublish = true): Promise { const code = unique('service'); const { id } = await seedModel(code, SERVICE_FIELDS); const roleCode = `role-${code}`; const actions = ['create', 'read', 'update', 'delete', ...(withPublish ? ['publish'] : [])]; await seedRole(roleCode, actions.map((action) => ({ modelCode: code, action }))); const editor = await seedUser(`editor-${code}`, [roleCode]); return { code, id, editor, token: accessTokenFor(editor) }; } async function createDraft( model: EditableModel, slug: string, data: Json, ): Promise { const response = await createItem( makeRequest({ url: API.items, method: 'POST', token: model.token, body: { modelId: model.id, modelCode: model.code, title: `集成用例 ${slug}`, slug, data }, }), ); const body = await jsonOf(response); return String(body.id ?? ''); } /** 发布态只能来自 workflow 流转(B-1:POST /items 拒绝调用方自带 status) */ async function publishThroughWorkflow(model: EditableModel, itemId: string): Promise { for (const action of ['submit', 'approve'] as const) { const response = await workflowPost( makeRequest({ url: API.workflow(itemId), method: 'POST', token: model.token, body: { action }, }), { params: Promise.resolve({ id: itemId }) }, ); expect(response.status).toBe(200); } } /** * A-8 (a)/(b):真实读写路径上的 CMS JSON 列。 * 单测里 `@/lib/cms/data-server` 被整体 mock(jest.setup.js:29-50)、`next/server` 被替换成 * 手写假对象(jest.setup.js:90-109),所以「脏列让公开读取崩掉」「写入侧不校验声明」 * 这两类缺陷在单测层永远不可见。 */ describe('CMS 内容读写(真实 SQLite)', () => { beforeEach(async () => { await resetDb(); }); afterAll(async () => { await disconnect(); }); it('POST 落库的是一行真实记录,GET 读回来的是解析后的对象', async () => { const model = await seedEditableModel(); const basis = declaredOptions('metrics', 'basis'); const itemId = await createDraft(model, 'itest-service', { metrics: [{ value: '12', label: '交付项目', basis: basis[0] ?? 'target' }], }); expect(itemId).not.toBe(''); // 真库断言:行确实存在,data 列是字符串 expect(await prisma.contentItem.count({ where: { id: itemId } })).toBe(1); expect(typeof (await rawDataColumn(itemId))).toBe('string'); const list = await listItems(makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token })); expect(list.status).toBe(200); const body = await jsonOf(list); const items = body.items as Json[]; expect(items).toHaveLength(1); expect(items[0]?.data).toMatchObject({ metrics: [{ value: '12' }] }); expect(body.total).toBe(1); }); it('(a) PUT data:null 落库为 "{}",读取路径拿到空对象而不是崩溃', async () => { const model = await seedEditableModel(); const itemId = await createDraft(model, 'itest-null-data', { a: 1 }); const updated = await updateItem( makeRequest({ url: `${API.items}?id=${itemId}`, method: 'PUT', token: model.token, body: { data: null } }), ); expect(updated.status).toBe(200); // 库里落的是 '{}',而不是 'null' —— 后者会让读取侧 JSON.parse 产出 null 并在解引用时炸掉 expect(await rawDataColumn(itemId)).toBe('{}'); const list = await listItems(makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token })); const items = (await jsonOf(list)).items as Json[]; expect(items.find((i) => i.id === itemId)?.data).toEqual({}); }); it('(a) 直接写进库里的脏 data 列不会让公开读取路径崩溃(parseCmsData 守卫)', async () => { const dirtyValues = ['not-json{', '', 'null', '42', '"just a string"']; for (const dirty of dirtyValues) { const model = await seedEditableModel(); const itemId = await createDraft(model, 'itest-dirty', { a: 1 }); expect(itemId).not.toBe(''); await publishThroughWorkflow(model, itemId); const written = await prisma.$executeRawUnsafe( 'UPDATE "ContentItem" SET data = ? WHERE id = ?', dirty, itemId, ); expect(written).toBe(1); expect(await rawDataColumn(itemId)).toBe(dirty); // 公开页面读取路径:data-server → 真实 prisma → toContentItem → parseCmsData const published = await getPublishedItems(model.code); expect(published).toHaveLength(1); expect(published[0]?.data).toEqual({}); // 管理端读取路径:route.ts 的 parseItem const list = await listItems( makeRequest({ url: `${API.items}?modelCode=${model.code}`, token: model.token }), ); expect(list.status).toBe(200); const items = (await jsonOf(list)).items as Json[]; expect(items.find((i) => i.id === itemId)?.data).toEqual({}); } // 守卫对非字符串入参的分支(真库 data 列是 NOT NULL,SQL NULL 只能来自历史脏库/手工修数据) expect(parseCmsData(null)).toEqual({}); expect(parseCmsData(undefined)).toEqual({}); }); it('(b) 声明了 options 的字段:越界 basis 值被 400 拒绝,且一行都不落库', async () => { const model = await seedEditableModel(); const basis = declaredOptions('metrics', 'basis'); const forged = 'customer-verified-2026'; expect(basis).not.toContain(forged); expect(await prisma.contentItem.count({ where: { modelCode: model.code } })).toBe(0); const rejected = await createItem( makeRequest({ url: API.items, method: 'POST', token: model.token, body: { modelId: model.id, modelCode: model.code, title: '越界口径', slug: 'itest-forged', data: { metrics: [{ value: '99%', label: '客户满意度', basis: forged }] }, }, }), ); expect(rejected.status).toBe(400); const body = await jsonOf(rejected); expect(body.code).toBe('VALIDATION_ERROR'); expect(JSON.stringify(body)).toContain('metrics[0].basis'); expect(await prisma.contentItem.count({ where: { modelCode: model.code } })).toBe(0); }); it('(b) PUT 越界 basis 值被 400 拒绝,已落库的 data 列与 version 保持原值', async () => { const model = await seedEditableModel(); const basis = declaredOptions('metrics', 'basis'); const itemId = await createDraft(model, 'itest-put-basis', { metrics: [{ value: '1', label: 'x', basis: basis[0] ?? 'target' }], }); const original = await rawDataColumn(itemId); const rejected = await updateItem( makeRequest({ url: `${API.items}?id=${itemId}`, method: 'PUT', token: model.token, body: { data: { metrics: [{ value: '1', label: 'x', basis: 'VERIFIED' }] } }, }), ); expect(rejected.status).toBe(400); expect(await rawDataColumn(itemId)).toBe(original); // 拒绝必须完全无副作用:版本号不能被推进 const stored = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } }); expect(stored.version).toBe(1); expect(stored.updatedBy).toBe(model.editor.username); }); it('(B-3 能力边界,非豁免声明)basis:"verified" 本身是合法 options,枚举校验拦不住伪造口径', async () => { // 这条用例钉住的是「写入侧校验做不到什么」:content-types.ts:16 把 'verified' 列为合法选项, // 所以 validateContentData 的 options 检查对「无实测出处却声称 verified」完全无感。 // 需要的是出处校验(B-3 的另一半),不是枚举校验。断言当前真实行为,行为变化即变红。 const model = await seedEditableModel(); const basis = declaredOptions('metrics', 'basis'); expect(basis).toContain('verified'); const itemId = await createDraft(model, 'itest-verified-claim', { metrics: [{ value: '100%', label: '客户续约率', basis: 'verified' }], }); expect(itemId).not.toBe(''); expect(await rawDataColumn(itemId)).toContain('verified'); }); it('(a) 状态流转成功后,脏 data 列不得让 workflow 路由 500(route.ts:21 用的是裸 JSON.parse)', async () => { const model = await seedEditableModel(); const itemId = await createDraft(model, 'itest-wf-dirty', { a: 1 }); await prisma.$executeRawUnsafe('UPDATE "ContentItem" SET data = ? WHERE id = ?', 'broken json {', itemId); const response = await workflowPost( makeRequest({ url: API.workflow(itemId), method: 'POST', token: model.token, body: { action: 'submit' }, }), { params: Promise.resolve({ id: itemId }) }, ); // 先证明写侧已经提交:状态确实流转成了 review。 // 于是不通过的下一条断言只可能指向一件事 —— 读回阶段把已提交的写变成了 500。 const stored = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } }); expect(stored.status).toBe('review'); // 契约:脏列不得让响应崩溃(items/route.ts 同一位置已改用 parseCmsData,这里仍是裸 JSON.parse)。 // 当前为红:workflow/route.ts:21 抛 SyntaxError → internalError() 500,而写已落库(B-4 未收口)。 expect(response.status).toBe(200); }); });