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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user