Files
novalon-website/tests-integration/02-publishing-workflow.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

216 lines
8.3 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,
resetDb,
seedModel,
seedRole,
seedUser,
unique,
type SeededUser,
} from './helpers/harness';
import { POST as createItem } from '@/app/api/admin/items/route';
import { POST as workflowPost } from '@/app/api/admin/items/[id]/workflow/route';
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
type Json = Record<string, unknown>;
async function jsonOf(response: Response): Promise<Json> {
return (await response.json()) as Json;
}
async function workflow(
itemId: string,
token: string,
body: Json,
): Promise<{ status: number; body: Json }> {
const response = await workflowPost(
makeRequest({ url: API.workflow(itemId), method: 'POST', token, body }),
{ params: Promise.resolve({ id: itemId }) },
);
return { status: response.status, body: await jsonOf(response) };
}
interface Fixture {
code: string;
modelId: string;
editor: SeededUser;
editorToken: string;
reviewer: SeededUser;
reviewerToken: string;
}
/**
* 两个真实账号 + 两套真实权限行:
* editor 只有 create/read/update(无 publish),reviewer 额外有 publish。
* 这样「谁能推进到哪一步」由数据库里的 Permission 表决定,而不是由 mock 决定。
*/
async function seedWorkflowFixture(): Promise<Fixture> {
const code = unique('case-study');
const { id } = await seedModel(code, CONTENT_TYPE_CONFIGS['case-study'].model.fields);
await seedRole(`editor-${code}`, [
{ modelCode: code, action: 'create' },
{ modelCode: code, action: 'read' },
{ modelCode: code, action: 'update' },
]);
await seedRole(`reviewer-${code}`, [
{ modelCode: code, action: 'read' },
{ modelCode: code, action: 'publish' },
]);
const editor = await seedUser(`editor-${code}`, [`editor-${code}`]);
const reviewer = await seedUser(`reviewer-${code}`, [`reviewer-${code}`]);
return {
code,
modelId: id,
editor,
editorToken: accessTokenFor(editor),
reviewer,
reviewerToken: accessTokenFor(reviewer),
};
}
async function createDraft(fx: Fixture, slug: string, token?: string): Promise<string> {
const response = await createItem(
makeRequest({
url: API.items,
method: 'POST',
token: token ?? fx.editorToken,
body: {
modelId: fx.modelId,
modelCode: fx.code,
title: `工作流用例 ${slug}`,
slug,
data: { client: '某制造集团', color: 'blue' },
},
}),
);
return String((await jsonOf(response)).id ?? '');
}
/**
* A-8 (d):发布工作流在真实库上的完整链路。
* 单测里 workflow.ts 的 prisma 是 jest.setup.js 的假对象,
* 所以「状态机是否真的落库、审计与通知是否真的写入」从未被验证过。
*/
describe('发布工作流(真实 SQLite)', () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await disconnect();
});
it('draft → review → publish 全程落库:状态、版本、publishedAt、审计、通知', async () => {
const fx = await seedWorkflowFixture();
const itemId = await createDraft(fx, 'itest-happy');
expect(itemId).not.toBe('');
const initial = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(initial.status).toBe('draft');
expect(initial.publishedAt).toBeNull();
// 1) 提交审核(editor 有 update 权限)
const submitted = await workflow(itemId, fx.editorToken, { action: 'submit' });
expect(submitted.status).toBe(200);
expect(submitted.body.status).toBe('review');
let row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(row.status).toBe('review');
// 审核人收到一条真实的通知行
expect(await prisma.notification.count({ where: { userId: fx.reviewer.id, type: 'review_pending' } })).toBe(1);
// 2) editor 无 publish 权限 → 403,且状态必须停在 review
const forbiddenAttempt = await workflow(itemId, fx.editorToken, { action: 'approve' });
expect(forbiddenAttempt.status).toBe(403);
row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(row.status).toBe('review');
// 3) reviewer 审核通过 → published,publishedAt 落库
const approved = await workflow(itemId, fx.reviewerToken, { action: 'approve' });
expect(approved.status).toBe(200);
expect(approved.body.status).toBe('published');
row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(row.status).toBe('published');
expect(row.publishedAt).toBeInstanceOf(Date);
expect(row.version).toBe(initial.version + 2);
// 4) 审计链:submit / approve 两步各写一行 AuditLog
const logs = await prisma.auditLog.findMany({ where: { targetId: itemId }, orderBy: { createdAt: 'asc' } });
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs.map((l) => l.operator)).toContain(fx.reviewer.username);
// 5) 公开读取路径此刻才看得见这条内容
const published = await prisma.contentItem.findMany({ where: { modelCode: fx.code, status: 'published' } });
expect(published.map((i) => i.id)).toContain(itemId);
});
it('非法流转被拒绝且库内状态不变;驳回回到 draft 并通知创建人', async () => {
const fx = await seedWorkflowFixture();
const itemId = await createDraft(fx, 'itest-reject');
// draft 不能直接 approve
const bad = await workflow(itemId, fx.reviewerToken, { action: 'approve' });
expect(bad.status).toBe(400);
expect((await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } })).status).toBe('draft');
// draft 不能直接 archive
expect((await workflow(itemId, fx.reviewerToken, { action: 'archive' })).status).toBe(400);
await workflow(itemId, fx.editorToken, { action: 'submit' });
const rejected = await workflow(itemId, fx.reviewerToken, { action: 'reject', reason: '数据口径缺佐证' });
expect(rejected.status).toBe(200);
const row = await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } });
expect(row.status).toBe('draft');
expect(
await prisma.notification.count({ where: { userId: fx.editor.id, type: 'review_rejected' } }),
).toBe(1);
});
it('B-1:POST /api/admin/items 携带 status:"published" 不得绕过工作流与 publish 权限', async () => {
const fx = await seedWorkflowFixture();
const before = await prisma.contentItem.count({ where: { modelCode: fx.code, status: 'published' } });
const bypass = await createItem(
makeRequest({
url: `${API.items}?status=published`,
method: 'POST',
token: fx.editorToken, // 该账号只有 create/read/update,没有 publish
body: {
modelId: fx.modelId,
modelCode: fx.code,
title: '越权直发',
slug: 'itest-bypass',
status: 'published',
data: { client: '某集团', color: 'blue' },
},
}),
);
// 契约(两选一):要么被拒(403/400),要么走工作流(落库状态为 review/draft)。
// 当前实现既不拒绝也不流转:POST 只要求 'create' 权限(route.ts:132),
// 却把 body.status 原样写库(route.ts:160)并顺手补上 publishedAt(route.ts:165)。
// ⇒ 只有 create、没有 publish 的账号可越过 draft→review→publish 直接发布。本用例保持为红。
const persisted = await prisma.contentItem.count({ where: { modelCode: fx.code, status: 'published' } });
expect(persisted).toBe(before);
expect([400, 403]).toContain(bypass.status);
});
it('未知 action 与不存在的条目分别返回 400 / 404,且不产生任何审计', async () => {
const fx = await seedWorkflowFixture();
const itemId = await createDraft(fx, 'itest-badargs');
expect((await workflow(itemId, fx.editorToken, { action: 'publish' })).status).toBe(400);
expect((await workflow(itemId, fx.editorToken, {})).status).toBe(400);
expect((await workflow('no-such-id', fx.editorToken, { action: 'submit' })).status).toBe(404);
const logs = await prisma.auditLog.findMany({ where: { targetId: 'no-such-id' } });
expect(logs).toHaveLength(0);
expect((await prisma.contentItem.findUniqueOrThrow({ where: { id: itemId } })).status).toBe('draft');
});
});