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
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+274
View File
@@ -0,0 +1,274 @@
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);
});
});