R54 A1-A6:Cutover 控制台与特性开关增强
- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
这个提交包含在:
@@ -0,0 +1,179 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const featureFlagsRoutes = require("../../../src/routes/featureFlags");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildAuditLog(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
actor_id: ACTOR_ID,
|
||||
created_at: new Date("2026-04-07T10:00:00.000Z"),
|
||||
extra: {
|
||||
flagKey: "payment_batch_v1",
|
||||
from: {
|
||||
overrideMode: "force_off",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "old note",
|
||||
version: 1,
|
||||
},
|
||||
to: {
|
||||
overrideMode: "force_on",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "new note",
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
name: "系统管理员",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ["system.settings"],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildAuditLog()]);
|
||||
const count = options.count ?? vi.fn().mockResolvedValue(1);
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", {});
|
||||
app.decorate("prisma", {
|
||||
auditLog: {
|
||||
findMany,
|
||||
count,
|
||||
create: vi.fn(),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
return { app, findMany, count };
|
||||
}
|
||||
|
||||
describe("feature flag changelog route", () => {
|
||||
it("lists paginated changelog records for a specific flag", async () => {
|
||||
const { app, findMany, count } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/payment_batch_v1/changelog?page=2&pageSize=10",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
target_type: "feature_flag",
|
||||
extra: {
|
||||
path: ["flagKey"],
|
||||
equals: "payment_batch_v1",
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
target_type: "feature_flag",
|
||||
extra: {
|
||||
path: ["flagKey"],
|
||||
equals: "payment_batch_v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.page).toBe(2);
|
||||
expect(payload.data.pageSize).toBe(10);
|
||||
expect(payload.data.total).toBe(1);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
actorId: ACTOR_ID,
|
||||
actorName: "系统管理员",
|
||||
from: {
|
||||
overrideMode: "force_off",
|
||||
},
|
||||
to: {
|
||||
overrideMode: "force_on",
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns an empty list when no changelog records exist", async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/cutover_console_v1/changelog",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects requests without system.settings permission", async () => {
|
||||
const { app, findMany, count } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/payment_batch_v1/changelog",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
expect(count).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户