- 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 配置收敛。
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
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);
|
||
});
|
||
});
|