恢复付款入口与常开基线(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 23:12:23 +08:00
父节点 aa6b9bc373
当前提交 37b4ef2d10
共修改 20 个文件,包含 655 行新增和 244 行删除
@@ -104,4 +104,23 @@ describe("deployment safety contract", () => {
expect(external).not.toContain("from=${config.emailFrom}");
expect(runtime).not.toContain("${adminEmployee.employee_no}");
});
it("keeps the formal payment batch baseline enabled and restores QA caller state", async () => {
const envExample = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/.env.example"), "utf8");
const runtime = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-trialrun-runtime.js"), "utf8");
const shortWindow = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "scripts/qa/run-p2-fin-live-window.sh"), "utf8");
const enableWindow = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "scripts/qa/run-fin-prod-enable-window.sh"), "utf8");
const liveSpec = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts"), "utf8");
expect(envExample).toMatch(/^PAYMENT_BATCH_ENABLED=true$/m);
expect(runtime).toContain("PAYMENT_BATCH_ENABLED: 'true'");
expect(runtime).toContain("FEATURE_FLAG_KEYS.paymentBatch");
expect(shortWindow).toContain("restore-caller-flag-snapshot");
expect(shortWindow).toContain("P2_FIN_LIVE_ALREADY_ON='$ALREADY_ON_MODE'");
expect(enableWindow).toContain("restore-caller-flag-snapshot");
expect(liveSpec).toContain("restoreFlagSnapshot");
expect(liveSpec).not.toContain("forcePaymentBatchOff");
expect(shortWindow).not.toContain("force_payment_batch_flag_off");
expect(enableWindow).not.toContain("force_payment_batch_flag_off");
});
});
@@ -0,0 +1,148 @@
const { execFile } = require("node:child_process");
const { mkdtemp, readFile, rm } = require("node:fs/promises");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const ROOT_DIR = path.resolve(__dirname, "../../../..");
const SNAPSHOT_SCRIPT = path.join(ROOT_DIR, "scripts/qa/payment-batch-flag-snapshot.mjs");
async function readJsonBody(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
}
async function withFlagApi(initialFlag, callback) {
let flag = { ...initialFlag, departmentIds: [...initialFlag.departmentIds] };
const server = http.createServer(async (request, response) => {
response.setHeader("content-type", "application/json");
if (request.method === "POST" && request.url === "/api/auth/login") {
response.end(JSON.stringify({ data: { accessToken: "synthetic-access-token" } }));
return;
}
if (request.method === "GET" && request.url === "/api/system/feature-flags") {
response.end(JSON.stringify({ data: { items: [flag] } }));
return;
}
if (request.method === "PUT" && request.url === "/api/system/feature-flags/payment_batch_v1") {
const body = await readJsonBody(request);
if (body.expectedVersion !== flag.version) {
response.statusCode = 409;
response.end(JSON.stringify({ error: { code: "CONFLICT" } }));
return;
}
flag = {
...flag,
overrideMode: body.overrideMode,
rolloutType: body.rolloutType,
departmentIds: [...body.departmentIds],
note: body.note,
version: flag.version + 1,
effectiveEnabled: body.overrideMode === "force_on",
effectiveReason: `mock_${body.overrideMode}`,
};
response.end(JSON.stringify({ data: { item: flag } }));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: { code: "NOT_FOUND" } }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await callback({
baseUrl,
getFlag: () => flag,
setFlag: (next) => {
flag = { ...next, departmentIds: [...next.departmentIds] };
},
});
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
}
function runSnapshotScript(action, baseUrl, snapshotFile) {
return execFileAsync(process.execPath, [SNAPSHOT_SCRIPT, action, "--snapshot-file", snapshotFile], {
env: {
...process.env,
P2_FIN_LIVE_APPROVED: "1",
LIVE_SMOKE_BASE_URL: baseUrl,
LIVE_SMOKE_ADMIN_IDENTIFIER: "synthetic-admin",
LIVE_SMOKE_ADMIN_PASSWORD: "synthetic-password",
},
});
}
describe.each([
{
label: "force_off allowlist",
initial: {
flagKey: "payment_batch_v1",
overrideMode: "force_off",
rolloutType: "department_allowlist",
departmentIds: ["00000000-0000-4000-8000-000000000001"],
note: "original off note",
version: 3,
effectiveEnabled: false,
effectiveReason: "flag_force_off",
},
},
{
label: "force_on global",
initial: {
flagKey: "payment_batch_v1",
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note: "original on note",
version: 8,
effectiveEnabled: true,
effectiveReason: "flag_force_on_global",
},
},
])("payment batch flag snapshot: $label", ({ initial }) => {
it("restores the caller configuration instead of forcing a fixed mode", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-payment-flag-"));
const snapshotFile = path.join(tempDir, "snapshot.json");
try {
await withFlagApi(initial, async ({ baseUrl, getFlag, setFlag }) => {
await runSnapshotScript("capture", baseUrl, snapshotFile);
const saved = JSON.parse(await readFile(snapshotFile, "utf8"));
expect(saved).toMatchObject({
overrideMode: initial.overrideMode,
rolloutType: initial.rolloutType,
departmentIds: initial.departmentIds,
note: initial.note,
effectiveEnabled: initial.effectiveEnabled,
});
setFlag({
...getFlag(),
overrideMode: initial.overrideMode === "force_on" ? "force_off" : "force_on",
rolloutType: "global",
departmentIds: [],
note: "temporary window state",
version: getFlag().version + 1,
effectiveEnabled: initial.overrideMode !== "force_on",
effectiveReason: "temporary",
});
await runSnapshotScript("restore", baseUrl, snapshotFile);
expect(getFlag()).toMatchObject({
overrideMode: initial.overrideMode,
rolloutType: initial.rolloutType,
departmentIds: initial.departmentIds,
note: initial.note,
});
});
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
@@ -1,8 +1,10 @@
const {
DEFAULT_APPROVAL_RULES,
DEFAULT_FEATURE_FLAGS,
ENABLED_EXPENSE_TYPES,
seedApprovalRules,
seedExpenseTypes,
seedFeatureFlags,
} = require("../../../src/scripts/seed-config");
describe("default travel expense types", () => {
@@ -108,3 +110,35 @@ describe("small-team default reimbursement approval rules", () => {
});
});
});
describe("trialrun payment batch baseline", () => {
it("creates payment_batch_v1 as globally enabled", () => {
expect(DEFAULT_FEATURE_FLAGS).toContainEqual({
flag_key: "payment_batch_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_on",
});
});
it("preserves an existing payment flag instead of overwriting it", async () => {
const create = vi.fn(async ({ data }) => {
if (data.flag_key === "payment_batch_v1") {
throw Object.assign(new Error("unique conflict"), { code: "P2002" });
}
return data;
});
const prisma = { featureFlag: { create } };
await expect(seedFeatureFlags(prisma)).resolves.toBeUndefined();
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({
flag_key: "payment_batch_v1",
override_mode: "force_on",
}),
});
expect(prisma.featureFlag.update).toBeUndefined();
expect(prisma.featureFlag.upsert).toBeUndefined();
});
});