feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
@@ -0,0 +1,201 @@
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
} = require("../../src/services/featureFlags");
|
||||
|
||||
function buildRecord({
|
||||
flagKey,
|
||||
overrideMode = "force_on",
|
||||
rolloutType = "global",
|
||||
departmentIds = [],
|
||||
}) {
|
||||
const now = new Date("2026-02-12T00:00:00.000Z");
|
||||
return {
|
||||
id: `flag-${flagKey}`,
|
||||
flag_key: flagKey,
|
||||
override_mode: overrideMode,
|
||||
rollout_type: rolloutType,
|
||||
department_ids: departmentIds,
|
||||
note: "unit-test",
|
||||
version: 1,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma(recordMap = {}) {
|
||||
return {
|
||||
featureFlag: {
|
||||
findUnique: async ({ where }) => recordMap[where.flag_key] ?? null,
|
||||
findMany: async () => Object.values(recordMap),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluateFeatureFlag", () => {
|
||||
it("returns env_hard_off when env gate is closed", async () => {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma: buildPrisma(),
|
||||
config: { erpSyncEnabled: false, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("env_hard_off");
|
||||
});
|
||||
|
||||
it("returns missing_flag_record when record does not exist", async () => {
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma: buildPrisma(),
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("missing_flag_record");
|
||||
});
|
||||
|
||||
it("returns flag_force_off when override mode is force_off", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
overrideMode: "force_off",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("flag_force_off");
|
||||
});
|
||||
|
||||
it("returns global enabled when inherit + global", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
overrideMode: "inherit",
|
||||
rolloutType: "global",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("env_inherit_global_on");
|
||||
});
|
||||
|
||||
it("returns allowlist guards for department rollout", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
rolloutType: "department_allowlist",
|
||||
departmentIds: ["dept-a"],
|
||||
}),
|
||||
});
|
||||
|
||||
const missingDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
const blockedDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: "dept-b",
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
const allowedDepartment = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: "dept-a",
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
expect(missingDepartment.reason).toBe("department_missing");
|
||||
expect(blockedDepartment.reason).toBe("department_not_allowed");
|
||||
expect(allowedDepartment.reason).toBe("flag_force_on_department_allowed");
|
||||
expect(allowedDepartment.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns department_deferred when strictDepartment is false", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
rolloutType: "department_allowlist",
|
||||
departmentIds: ["dept-a"],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("department_deferred");
|
||||
});
|
||||
|
||||
it("returns department_allowlist_empty when allowlist has no items", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
rolloutType: "department_allowlist",
|
||||
departmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("department_allowlist_empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listFeatureFlags", () => {
|
||||
it("returns all known keys even if part of records are missing", async () => {
|
||||
const prisma = buildPrisma({
|
||||
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
overrideMode: "inherit",
|
||||
}),
|
||||
});
|
||||
|
||||
const items = await listFeatureFlags(prisma, {
|
||||
erpSyncEnabled: true,
|
||||
invoiceVerificationEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((item) => item.flagKey).sort()).toEqual([
|
||||
FEATURE_FLAG_KEYS.erpSync,
|
||||
FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
]);
|
||||
|
||||
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.invoiceVerification);
|
||||
expect(missingItem.id).toBeNull();
|
||||
expect(missingItem.effectiveEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const { sanitizeDepartmentIds } = require("../../src/services/featureFlags");
|
||||
|
||||
describe("sanitizeDepartmentIds", () => {
|
||||
it("returns empty array when input is not array", () => {
|
||||
expect(sanitizeDepartmentIds(null)).toEqual([]);
|
||||
expect(sanitizeDepartmentIds("dept")).toEqual([]);
|
||||
});
|
||||
|
||||
it("trims ids, removes empty values and deduplicates", () => {
|
||||
const value = [" dept-a ", "dept-b", "", "dept-a", " "];
|
||||
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-b"]);
|
||||
});
|
||||
|
||||
it("filters non-string items", () => {
|
||||
const value = ["dept-a", 1, true, { id: "dept-b" }, "dept-c"];
|
||||
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const {
|
||||
IntegrationDispatchError,
|
||||
dispatchOutboxEvent,
|
||||
} = require("../../src/services/integrations/dispatcher");
|
||||
|
||||
describe("dispatchOutboxEvent", () => {
|
||||
it("handles mock ERP success", async () => {
|
||||
const result = await dispatchOutboxEvent({
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: "mock",
|
||||
},
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: {
|
||||
reimbursementId: "rb-1",
|
||||
formNumber: "BX-001",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("erp");
|
||||
expect(result.code).toBe("mock_synced");
|
||||
expect(result.response.externalId).toContain("BX-001");
|
||||
});
|
||||
|
||||
it("maps ERP disabled as non-retryable dispatch error", async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
config: {
|
||||
erpSyncEnabled: false,
|
||||
erpSyncProvider: "mock",
|
||||
},
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: {
|
||||
reimbursementId: "rb-2",
|
||||
formNumber: "BX-002",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "erp_sync_disabled",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps invalid ERP provider as non-retryable dispatch error", async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: "invalid-provider",
|
||||
},
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: {
|
||||
reimbursementId: "rb-3",
|
||||
formNumber: "BX-003",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "erp_sync_provider_invalid",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles IM notification outbox", async () => {
|
||||
const result = await dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "notification.im",
|
||||
payload: {
|
||||
channelType: "wecom",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("im");
|
||||
expect(result.code).toBe("mock_sent");
|
||||
expect(result.response.channelType).toBe("wecom");
|
||||
});
|
||||
|
||||
it("throws non-retryable error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
eventType: "unsupported.event",
|
||||
payload: {},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "unsupported_outbox_event",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves error class semantics", () => {
|
||||
const error = new IntegrationDispatchError({
|
||||
code: "unit_test",
|
||||
message: "unit test",
|
||||
retryable: true,
|
||||
statusCode: 503,
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.code).toBe("unit_test");
|
||||
expect(error.retryable).toBe(true);
|
||||
expect(error.statusCode).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
} = require("../../src/services/featureFlags");
|
||||
const {
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
} = require("../../src/services/verification");
|
||||
|
||||
function buildFlagRecord({
|
||||
overrideMode = "force_on",
|
||||
rolloutType = "global",
|
||||
departmentIds = [],
|
||||
}) {
|
||||
const now = new Date("2026-02-12T00:00:00.000Z");
|
||||
return {
|
||||
id: "verification-flag",
|
||||
flag_key: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
override_mode: overrideMode,
|
||||
rollout_type: rolloutType,
|
||||
department_ids: departmentIds,
|
||||
note: "unit-test",
|
||||
version: 1,
|
||||
created_at: now,
|
||||
created_by: null,
|
||||
updated_at: now,
|
||||
updated_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma(record) {
|
||||
return {
|
||||
featureFlag: {
|
||||
findUnique: async () => record,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluateVerificationAccess", () => {
|
||||
it("returns env_hard_off when env gate is closed", async () => {
|
||||
const result = await evaluateVerificationAccess({
|
||||
prisma: buildPrisma(buildFlagRecord({})),
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
invoiceVerificationEnabled: false,
|
||||
},
|
||||
departmentId: "dept-a",
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("env_hard_off");
|
||||
});
|
||||
|
||||
it("returns allowlist decision for department rollout", async () => {
|
||||
const prisma = buildPrisma(
|
||||
buildFlagRecord({ rolloutType: "department_allowlist", departmentIds: ["dept-a"] }),
|
||||
);
|
||||
|
||||
const blocked = await evaluateVerificationAccess({
|
||||
prisma,
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
invoiceVerificationEnabled: true,
|
||||
},
|
||||
departmentId: "dept-b",
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
const allowed = await evaluateVerificationAccess({
|
||||
prisma,
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
invoiceVerificationEnabled: true,
|
||||
},
|
||||
departmentId: "dept-a",
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
expect(blocked.enabled).toBe(false);
|
||||
expect(blocked.reason).toBe("department_not_allowed");
|
||||
expect(allowed.enabled).toBe(true);
|
||||
expect(allowed.reason).toBe("flag_force_on_department_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInvoiceByProvider", () => {
|
||||
const invoice = {
|
||||
invoice_code: "INV-001",
|
||||
invoice_number: "NO-001",
|
||||
total_amount: "100.00",
|
||||
remarks: "ok",
|
||||
};
|
||||
|
||||
it("uses mock provider and returns passed", async () => {
|
||||
const result = await verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "mock" },
|
||||
invoice,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.resultCode).toBe("verified");
|
||||
});
|
||||
|
||||
it("uses tax provider and returns provider-not-configured", async () => {
|
||||
const result = await verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "tax" },
|
||||
invoice,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.resultCode).toBe("tax_provider_not_configured");
|
||||
});
|
||||
|
||||
it("returns unsupported provider error", async () => {
|
||||
const result = await verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "unknown" },
|
||||
invoice,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.resultCode).toBe("provider_unsupported");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
const { getProvider } = require("../../src/services/verification");
|
||||
|
||||
describe("getProvider", () => {
|
||||
it("uses mock as default provider", () => {
|
||||
expect(getProvider({})).toBe("mock");
|
||||
});
|
||||
|
||||
it("normalizes case and whitespace", () => {
|
||||
expect(getProvider({ verificationProvider: " TAX " })).toBe("tax");
|
||||
expect(getProvider({ verificationProvider: "MoCk" })).toBe("mock");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户