421 行
15 KiB
JavaScript
421 行
15 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const featureFlagState = vi.hoisted(() => ({ enabled: true }));
|
|
|
|
vi.mock("../../../src/services/featureFlags", () => ({
|
|
FEATURE_FLAG_KEYS: { announcements: "announcements_v1" },
|
|
evaluateFeatureFlag: vi.fn(async () => ({
|
|
enabled: featureFlagState.enabled,
|
|
reason: featureFlagState.enabled ? "mock_on" : "mock_off",
|
|
})),
|
|
}));
|
|
|
|
const announcementsRoutes = require("../../../src/routes/announcements");
|
|
|
|
const ADMIN_ID = "00000000-0000-4000-8000-000000000001";
|
|
const EMPLOYEE_ID = "00000000-0000-4000-8000-000000000002";
|
|
const EMPLOYEE_2_ID = "00000000-0000-4000-8000-000000000003";
|
|
const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
|
|
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
|
|
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
|
|
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
|
|
|
|
function buildEmployee(overrides = {}) {
|
|
return {
|
|
id: EMPLOYEE_ID,
|
|
employee_no: "E001",
|
|
name: "员工一",
|
|
status: "active",
|
|
permissionCodes: ["expense.view_own"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildAnnouncement(overrides = {}) {
|
|
const now = new Date("2026-05-28T02:00:00.000Z");
|
|
return {
|
|
id: POLICY_ID,
|
|
title: "费用制度签收",
|
|
summary: "请确认费用制度 V1。",
|
|
link_url: null,
|
|
starts_at: null,
|
|
ends_at: null,
|
|
enabled: true,
|
|
priority: 10,
|
|
category: "policy",
|
|
requires_acknowledgement: true,
|
|
acknowledgement_statement: "我已阅读并理解该制度。",
|
|
acknowledgement_due_at: new Date("2026-06-01T15:59:59.000Z"),
|
|
created_at: now,
|
|
created_by: ADMIN_ID,
|
|
updated_at: now,
|
|
updated_by: ADMIN_ID,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function cloneWithInclude(item, include, acknowledgements) {
|
|
const row = { ...item };
|
|
if (include?.acknowledgements) {
|
|
let rows = acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === item.id);
|
|
if (include.acknowledgements.where?.employee_id) {
|
|
rows = rows.filter((acknowledgement) => acknowledgement.employee_id === include.acknowledgements.where.employee_id);
|
|
}
|
|
row.acknowledgements = rows.map((acknowledgement) => ({
|
|
id: acknowledgement.id,
|
|
employee_id: acknowledgement.employee_id,
|
|
acknowledged_at: acknowledgement.acknowledged_at,
|
|
}));
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function matchAnnouncementWhere(item, where = {}) {
|
|
if (where.enabled !== undefined && item.enabled !== where.enabled) {
|
|
return false;
|
|
}
|
|
const now = new Date("2026-05-28T02:00:00.000Z");
|
|
if (where.OR && item.starts_at && item.starts_at > now) {
|
|
return false;
|
|
}
|
|
if (where.AND && item.ends_at && item.ends_at < now) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createPrismaState(options = {}) {
|
|
const employees = options.employees ?? [
|
|
buildEmployee({ id: EMPLOYEE_ID, employee_no: "E001", name: "员工一" }),
|
|
buildEmployee({ id: EMPLOYEE_2_ID, employee_no: "E002", name: "员工二" }),
|
|
buildEmployee({ id: INACTIVE_EMPLOYEE_ID, employee_no: "E003", name: "离职员工", status: "archived" }),
|
|
];
|
|
const announcements = options.announcements ?? [
|
|
buildAnnouncement(),
|
|
buildAnnouncement({
|
|
id: GENERAL_ID,
|
|
title: "普通公告",
|
|
summary: "无需签收。",
|
|
category: "general",
|
|
requires_acknowledgement: false,
|
|
acknowledgement_statement: null,
|
|
acknowledgement_due_at: null,
|
|
priority: 20,
|
|
}),
|
|
buildAnnouncement({
|
|
id: HIDDEN_ID,
|
|
title: "未来公告",
|
|
starts_at: new Date("2026-06-01T00:00:00.000Z"),
|
|
}),
|
|
];
|
|
const acknowledgements = options.acknowledgements ?? [];
|
|
const auditLogs = [];
|
|
|
|
const portalAnnouncement = {
|
|
findMany: vi.fn(async ({ where, include }) =>
|
|
announcements
|
|
.filter((item) => matchAnnouncementWhere(item, where))
|
|
.sort((left, right) => left.priority - right.priority || right.created_at.getTime() - left.created_at.getTime())
|
|
.map((item) => cloneWithInclude(item, include, acknowledgements)),
|
|
),
|
|
findUnique: vi.fn(async ({ where, include }) => {
|
|
const item = announcements.find((announcement) => announcement.id === where.id);
|
|
return item ? cloneWithInclude(item, include, acknowledgements) : null;
|
|
}),
|
|
create: vi.fn(async ({ data }) => {
|
|
const created = buildAnnouncement({
|
|
...data,
|
|
id: `10000000-0000-4000-8000-${String(announcements.length + 10).padStart(12, "0")}`,
|
|
link_url: data.link_url ?? null,
|
|
starts_at: data.starts_at ?? null,
|
|
ends_at: data.ends_at ?? null,
|
|
created_at: new Date("2026-05-28T02:00:00.000Z"),
|
|
updated_at: new Date("2026-05-28T02:00:00.000Z"),
|
|
});
|
|
announcements.push(created);
|
|
return created;
|
|
}),
|
|
update: vi.fn(async ({ where, data }) => {
|
|
const index = announcements.findIndex((announcement) => announcement.id === where.id);
|
|
if (index < 0) {
|
|
throw new Error("not_found");
|
|
}
|
|
announcements[index] = { ...announcements[index], ...data, updated_at: new Date("2026-05-28T03:00:00.000Z") };
|
|
return announcements[index];
|
|
}),
|
|
};
|
|
|
|
const portalAnnouncementAcknowledgement = {
|
|
count: vi.fn(async ({ where }) =>
|
|
acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === where.announcement_id).length,
|
|
),
|
|
create: vi.fn(async ({ data }) => {
|
|
const duplicated = acknowledgements.find(
|
|
(acknowledgement) =>
|
|
acknowledgement.announcement_id === data.announcement_id &&
|
|
acknowledgement.employee_id === data.employee_id,
|
|
);
|
|
if (duplicated) {
|
|
const error = new Error("duplicate_acknowledgement");
|
|
error.code = "P2002";
|
|
throw error;
|
|
}
|
|
const created = {
|
|
id: `20000000-0000-4000-8000-${String(acknowledgements.length + 1).padStart(12, "0")}`,
|
|
acknowledged_at: new Date("2026-05-28T02:30:00.000Z"),
|
|
created_at: new Date("2026-05-28T02:30:00.000Z"),
|
|
...data,
|
|
};
|
|
acknowledgements.push(created);
|
|
return created;
|
|
}),
|
|
findUnique: vi.fn(async ({ where }) => {
|
|
const compound = where.announcement_id_employee_id;
|
|
if (!compound) return null;
|
|
return acknowledgements.find(
|
|
(acknowledgement) =>
|
|
acknowledgement.announcement_id === compound.announcement_id &&
|
|
acknowledgement.employee_id === compound.employee_id,
|
|
) ?? null;
|
|
}),
|
|
};
|
|
|
|
const auditLog = {
|
|
create: vi.fn(async ({ data }) => {
|
|
auditLogs.push(data);
|
|
return { id: `audit-${auditLogs.length}`, ...data };
|
|
}),
|
|
};
|
|
|
|
const prisma = {
|
|
portalAnnouncement,
|
|
portalAnnouncementAcknowledgement,
|
|
employee: {
|
|
findMany: vi.fn(async ({ where }) => employees.filter((employee) => employee.status === where.status)),
|
|
},
|
|
auditLog,
|
|
featureFlag: {
|
|
findUnique: vi.fn(async () => ({
|
|
id: "flag-announcements",
|
|
flag_key: "announcements_v1",
|
|
override_mode: "force_on",
|
|
rollout_type: "global",
|
|
department_ids: [],
|
|
note: "",
|
|
version: 1,
|
|
created_at: new Date("2026-05-28T00:00:00.000Z"),
|
|
created_by: null,
|
|
updated_at: new Date("2026-05-28T00:00:00.000Z"),
|
|
updated_by: null,
|
|
})),
|
|
},
|
|
$transaction: vi.fn(async (callback) => callback({ portalAnnouncementAcknowledgement, auditLog })),
|
|
};
|
|
|
|
return { prisma, employees, announcements, acknowledgements, auditLogs };
|
|
}
|
|
|
|
async function buildApp(options = {}) {
|
|
const state = createPrismaState(options);
|
|
const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] });
|
|
const app = Fastify({ logger: false });
|
|
|
|
app.decorate("config", { announcementsEnabled: true });
|
|
app.decorate("prisma", state.prisma);
|
|
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
|
|
app.decorate("assertPermission", (employee, permissionCode) => {
|
|
if (!app.hasPermission(employee, permissionCode)) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
details: { permission: permissionCode },
|
|
});
|
|
}
|
|
});
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
await app.authenticate(request);
|
|
app.assertPermission(request.employee, permissionCode);
|
|
});
|
|
app.setErrorHandler((error, request, reply) => {
|
|
if (error instanceof AppError) {
|
|
return reply.status(error.statusCode).send({
|
|
requestId: request.id,
|
|
error: { code: error.code, message: error.message, details: error.details },
|
|
});
|
|
}
|
|
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: error.message, details: {} } });
|
|
});
|
|
await app.register(announcementsRoutes, { prefix: "/api" });
|
|
return { app, state };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
featureFlagState.enabled = true;
|
|
});
|
|
|
|
describe("announcements policy acknowledgement routes", () => {
|
|
it("rejects policy acknowledgement announcement without statement", async () => {
|
|
const { app } = await buildApp({
|
|
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/announcements",
|
|
payload: {
|
|
title: "制度公告",
|
|
summary: "缺少签收声明。",
|
|
category: "policy",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement");
|
|
await app.close();
|
|
});
|
|
|
|
it("returns only current employee acknowledgement status for normal list", async () => {
|
|
const { app } = await buildApp();
|
|
|
|
const response = await app.inject({ method: "GET", url: "/api/announcements" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
|
expect(item.acknowledgement).toMatchObject({ status: "pending", acknowledgedAt: null, overdue: false });
|
|
expect(item.acknowledgementSummary).toBeUndefined();
|
|
expect(item.pendingEmployees).toBeUndefined();
|
|
await app.close();
|
|
});
|
|
|
|
it("creates acknowledgement once and writes audit log idempotently", async () => {
|
|
const { app, state } = await buildApp();
|
|
|
|
const first = await app.inject({
|
|
method: "POST",
|
|
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
|
payload: { confirmed: true },
|
|
});
|
|
const second = await app.inject({
|
|
method: "POST",
|
|
url: `/api/announcements/${POLICY_ID}/acknowledge`,
|
|
payload: { confirmed: true },
|
|
});
|
|
|
|
expect(first.statusCode).toBe(201);
|
|
expect(second.statusCode).toBe(200);
|
|
expect(state.acknowledgements).toHaveLength(1);
|
|
expect(state.acknowledgements[0]).toMatchObject({
|
|
announcement_id: POLICY_ID,
|
|
employee_id: EMPLOYEE_ID,
|
|
statement_snapshot: "我已阅读并理解该制度。",
|
|
title_snapshot: "费用制度签收",
|
|
});
|
|
expect(state.auditLogs).toHaveLength(1);
|
|
expect(state.auditLogs[0]).toMatchObject({
|
|
action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED",
|
|
target_type: "portal_announcement_acknowledgement",
|
|
});
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects acknowledgement for general or invisible announcements", async () => {
|
|
const { app } = await buildApp();
|
|
|
|
const general = await app.inject({
|
|
method: "POST",
|
|
url: `/api/announcements/${GENERAL_ID}/acknowledge`,
|
|
payload: { confirmed: true },
|
|
});
|
|
const hidden = await app.inject({
|
|
method: "POST",
|
|
url: `/api/announcements/${HIDDEN_ID}/acknowledge`,
|
|
payload: { confirmed: true },
|
|
});
|
|
|
|
expect(general.statusCode).toBe(409);
|
|
expect(general.json().error.details.acknowledgement).toBe("not_required");
|
|
expect(hidden.statusCode).toBe(404);
|
|
await app.close();
|
|
});
|
|
|
|
it("returns admin acknowledgement summary and minimal pending employees only", async () => {
|
|
const { app } = await buildApp({
|
|
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
|
acknowledgements: [
|
|
{
|
|
id: "20000000-0000-4000-8000-000000000099",
|
|
announcement_id: POLICY_ID,
|
|
employee_id: EMPLOYEE_ID,
|
|
actor_id: EMPLOYEE_ID,
|
|
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
|
statement_snapshot: "我已阅读并理解该制度。",
|
|
title_snapshot: "费用制度签收",
|
|
summary_snapshot: "请确认费用制度 V1。",
|
|
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
|
|
expect(item.acknowledgementSummary).toMatchObject({
|
|
required: true,
|
|
totalActiveEmployees: 2,
|
|
acknowledgedCount: 1,
|
|
pendingCount: 1,
|
|
overdueCount: 0,
|
|
});
|
|
expect(item.pendingEmployees).toEqual([
|
|
{ id: EMPLOYEE_2_ID, employeeNo: "E002", name: "员工二", status: "pending" },
|
|
]);
|
|
expect(JSON.stringify(item.pendingEmployees)).not.toMatch(/phone|email|wecom/i);
|
|
await app.close();
|
|
});
|
|
|
|
it("denies manage query for employee without system settings permission", async () => {
|
|
const { app } = await buildApp();
|
|
|
|
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects protected policy content edits after acknowledgement exists", async () => {
|
|
const { app } = await buildApp({
|
|
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
|
|
acknowledgements: [
|
|
{
|
|
id: "20000000-0000-4000-8000-000000000099",
|
|
announcement_id: POLICY_ID,
|
|
employee_id: EMPLOYEE_ID,
|
|
actor_id: EMPLOYEE_ID,
|
|
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
|
|
statement_snapshot: "我已阅读并理解该制度。",
|
|
title_snapshot: "费用制度签收",
|
|
summary_snapshot: "请确认费用制度 V1。",
|
|
created_at: new Date("2026-05-28T01:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/announcements/${POLICY_ID}`,
|
|
payload: { title: "费用制度签收 V2" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement");
|
|
await app.close();
|
|
});
|
|
});
|