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 buildFlagRecord(flagKey, overrides = {}) { return { id: flagKey === "payment_batch_v1" ? "11111111-1111-4111-8111-111111111111" : "22222222-2222-4222-8222-222222222222", flag_key: flagKey, override_mode: "force_off", rollout_type: "global", department_ids: [], note: "before batch", version: flagKey === "payment_batch_v1" ? 2 : 4, created_at: new Date("2026-04-07T08:00:00.000Z"), created_by: null, updated_at: new Date("2026-04-07T08:00:00.000Z"), updated_by: null, ...overrides, }; } function cloneRecord(record) { return { ...record, department_ids: Array.isArray(record.department_ids) ? [...record.department_ids] : record.department_ids, created_at: new Date(record.created_at), updated_at: new Date(record.updated_at), }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, permissionCodes: ["system.settings"], }; const state = { flags: options.flags?.map((item) => cloneRecord(item)) ?? [ buildFlagRecord("payment_batch_v1"), buildFlagRecord("cutover_console_v1"), ], auditLogs: [], }; const topLevelFindUnique = vi.fn(async ({ where }) => { if (where?.flag_key) { return state.flags.find((item) => item.flag_key === where.flag_key) ?? null; } return null; }); const prisma = { auditLog: { findMany: vi.fn(), count: vi.fn(), create: vi.fn(), }, featureFlag: { findUnique: topLevelFindUnique, }, $transaction: vi.fn(async (callback) => { const txState = { flags: state.flags.map((item) => cloneRecord(item)), auditLogs: state.auditLogs.map((item) => ({ ...item })), }; const tx = { featureFlag: { findUnique: vi.fn(async ({ where }) => { if (where?.flag_key) { return txState.flags.find((item) => item.flag_key === where.flag_key) ?? null; } return null; }), updateMany: vi.fn(async ({ where, data }) => { const target = txState.flags.find( (item) => item.id === where.id && item.version === where.version, ); if (!target) { return { count: 0 }; } target.override_mode = data.override_mode; target.rollout_type = data.rollout_type; target.department_ids = Array.isArray(data.department_ids) ? [...data.department_ids] : data.department_ids; target.note = data.note; target.version += Number(data.version?.increment ?? 0); target.updated_by = data.updated_by; target.updated_at = new Date("2026-04-07T09:00:00.000Z"); return { count: 1 }; }), }, auditLog: { create: vi.fn(async ({ data }) => { txState.auditLogs.push(data); return { id: `audit-${txState.auditLogs.length}` }; }), }, }; try { const result = await callback(tx); state.flags = txState.flags; state.auditLogs = txState.auditLogs; return result; } catch (error) { throw error; } }), }; 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", prisma); 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, state, prisma }; } describe("feature flag batch route", () => { it("updates multiple flags in one transaction and writes one audit per flag", async () => { const { app, state, prisma } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/system/feature-flags/batch", payload: { items: [ { flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 2, }, { flagKey: "cutover_console_v1", overrideMode: "inherit", expectedVersion: 4, }, ], note: "R54 批量切换", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.count).toBe(2); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(state.flags).toEqual( expect.arrayContaining([ expect.objectContaining({ flag_key: "payment_batch_v1", override_mode: "force_on", note: "R54 批量切换", version: 3, updated_by: ACTOR_ID, }), expect.objectContaining({ flag_key: "cutover_console_v1", override_mode: "inherit", note: "R54 批量切换", version: 5, updated_by: ACTOR_ID, }), ]), ); expect(state.auditLogs).toHaveLength(2); expect(state.auditLogs[0]).toMatchObject({ action: "FEATURE_FLAG_UPDATED", target_type: "feature_flag", actor_id: ACTOR_ID, extra: expect.objectContaining({ flagKey: "payment_batch_v1", }), }); expect(state.auditLogs[1]).toMatchObject({ action: "FEATURE_FLAG_UPDATED", extra: expect.objectContaining({ flagKey: "cutover_console_v1", }), }); await app.close(); }); it("rolls back the whole batch when any flag hits a version conflict", async () => { const { app, state } = await buildApp(); const beforeFlags = state.flags.map((item) => ({ flag_key: item.flag_key, override_mode: item.override_mode, note: item.note, version: item.version, })); const response = await app.inject({ method: "POST", url: "/api/system/feature-flags/batch", payload: { items: [ { flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 2, }, { flagKey: "cutover_console_v1", overrideMode: "force_on", expectedVersion: 99, }, ], note: "should rollback", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toMatchObject({ featureFlag: "version_conflict", flagKey: "cutover_console_v1", expectedVersion: 99, currentVersion: 4, }); expect( state.flags.map((item) => ({ flag_key: item.flag_key, override_mode: item.override_mode, note: item.note, version: item.version, })), ).toEqual(beforeFlags); expect(state.auditLogs).toEqual([]); await app.close(); }); it("rejects batch updates without system.settings permission", async () => { const { app, state } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [], }, }); const response = await app.inject({ method: "POST", url: "/api/system/feature-flags/batch", payload: { items: [ { flagKey: "payment_batch_v1", overrideMode: "force_on", expectedVersion: 2, }, ], note: "should fail", }, }); expect(response.statusCode).toBe(403); expect(state.auditLogs).toEqual([]); await app.close(); }); });