Files
novalon-website/tests-integration/01-cms-data-path.itest.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

275 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
async function jsonOf(response: Response): Promise<Json> {
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<EditableModel> {
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<string> {
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<void> {
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);
});
});