chore(P14-8): 完成运营化与验收收口
这个提交包含在:
@@ -0,0 +1,99 @@
|
||||
|
||||
const featureFlags = require("../../../src/services/featureFlags");
|
||||
|
||||
describe("FeatureFlags Service", () => {
|
||||
describe("normalizeOverrideMode", () => {
|
||||
it("should default to force_off", () => {
|
||||
expect(featureFlags.OVERRIDE_MODE_LIST.includes("force_off")).toBe(true);
|
||||
// internal function tested indirectly or we can just mock config
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFeatureFlag", () => {
|
||||
it("returns env_hard_off when config disables it globally", async () => {
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: {},
|
||||
config: { invoiceVerificationEnabled: false },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("env_hard_off");
|
||||
});
|
||||
|
||||
it("evaluates using db record when config enabled", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("env_inherit_global_on");
|
||||
});
|
||||
|
||||
it("blocks if department is not in allowlist", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: ["dep_A"],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
strictDepartment: true
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("department_not_allowed");
|
||||
});
|
||||
|
||||
it("allows if department is in allowlist", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: ["dep_A", "dep_B"],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("env_inherit_department_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFeatureFlag", () => {
|
||||
it("returns not_found for unknown flag", async () => {
|
||||
const result = await featureFlags.updateFeatureFlag({}, { flagKey: "unknown" });
|
||||
expect(result.status).toBe("not_found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||
|
||||
describe("Integration Dispatcher", () => {
|
||||
it("dispatches erp.reimbursement.paid", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
});
|
||||
expect(result.channel).toBe("erp");
|
||||
expect(result.code).toBe("mock_synced");
|
||||
});
|
||||
|
||||
it("throws IntegrationDispatchError if erp fails", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: false },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
})
|
||||
).rejects.toThrow(dispatcher.IntegrationDispatchError);
|
||||
});
|
||||
|
||||
it("dispatches notification.im", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "notification.im",
|
||||
payload: { channelType: "slack" },
|
||||
});
|
||||
expect(result.channel).toBe("im");
|
||||
expect(result.response.channelType).toBe("slack");
|
||||
});
|
||||
|
||||
it("throws error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "unknown.event",
|
||||
})
|
||||
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
const erp = require("../../../../src/services/integrations/erp");
|
||||
|
||||
describe("ERP Sync Services", () => {
|
||||
it("builds correct payload map", () => {
|
||||
const payload = erp.buildErpPayload({
|
||||
reimbursement: {
|
||||
id: "r1",
|
||||
form_number: "F001",
|
||||
payment_date: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(payload.formNumber).toBe("F001");
|
||||
expect(payload.paymentDate).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
it("returns mock synced when provider is mock", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
{ formNumber: "F001" }
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe("mock_synced");
|
||||
});
|
||||
|
||||
it("returns invalid provider if unsupported", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "invalid_xyz" },
|
||||
{}
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_provider_invalid");
|
||||
});
|
||||
|
||||
it("returns disabled if disabled in config", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp({ erpSyncEnabled: false }, {});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
const outbox = require("../../../../src/services/integrations/outbox");
|
||||
|
||||
describe("Integration Outbox", () => {
|
||||
it("enqueues new event", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 1, status: "pending" }),
|
||||
},
|
||||
};
|
||||
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test" });
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.duplicated).toBe(false);
|
||||
});
|
||||
|
||||
it("detects duplicated event", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: 2, status: "pending" }),
|
||||
},
|
||||
};
|
||||
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test", dedupeKey: "key" });
|
||||
expect(result.id).toBe(2);
|
||||
expect(result.duplicated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks as processing", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockResolvedValue({ id: 1, status: "processing" }),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxProcessing(mockPrisma, 1, "actor");
|
||||
expect(res.status).toBe("processing");
|
||||
});
|
||||
|
||||
it("fetches pending events", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
},
|
||||
};
|
||||
const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 });
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("marks outbox succeeded", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockResolvedValue({ id: 1, status: "succeeded" }),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxSucceeded(mockPrisma, 1, "actor");
|
||||
expect(res.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("handles failed outbox and schedules retry", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
return { status: data.status, retry: data.next_retry_at };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 0 }, { retryable: true }, "actor");
|
||||
expect(res.status).toBe("failed");
|
||||
expect(res.retry).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("moves to dead_letter if max attempts reached", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
return { status: data.status, retry: data.next_retry_at };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 5, max_attempts: 5 }, { retryable: true }, "actor");
|
||||
expect(res.status).toBe("dead_letter");
|
||||
expect(res.retry).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const verification = require("../../../src/services/verification");
|
||||
|
||||
describe("Verification Service", () => {
|
||||
it("determines if verification is enabled from config", () => {
|
||||
expect(verification.isVerificationEnabled({ invoiceVerificationEnabled: true })).toBe(true);
|
||||
expect(verification.isVerificationEnabled({ invoiceVerificationEnabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("selects correct provider from config", () => {
|
||||
expect(verification.getProvider({})).toBe("mock");
|
||||
expect(verification.getProvider({ verificationProvider: "tax" })).toBe("tax");
|
||||
expect(verification.getProvider({ verificationProvider: "UNKNOWN" })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("calls mock provider correctly", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({ config: {}, invoice: { id: 1 } });
|
||||
expect(result.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("calls tax provider correctly", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "tax" },
|
||||
invoice: { id: 2 }
|
||||
});
|
||||
expect(result.status).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns error for unsupported provider", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "magic" },
|
||||
invoice: { id: 3 }
|
||||
});
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.resultCode).toBe("provider_unsupported");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
const workflowUtils = require("../../../src/utils/workflow");
|
||||
|
||||
describe("Workflow Utils", () => {
|
||||
describe("parseWorkflowSteps", () => {
|
||||
it("throws error for invalid steps array", () => {
|
||||
expect(() => workflowUtils.parseWorkflowSteps({})).toThrow(/参数校验失败/);
|
||||
expect(() => workflowUtils.parseWorkflowSteps({ steps: [] })).toThrow(/参数校验失败/);
|
||||
});
|
||||
|
||||
it("parses valid steps array", () => {
|
||||
expect(workflowUtils.parseWorkflowSteps({ steps: [{ id: 1 }] })).toEqual([{ id: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkflowStepsFromApprovalRule", () => {
|
||||
it("builds steps correctly for valid rule", () => {
|
||||
const result = workflowUtils.buildWorkflowStepsFromApprovalRule({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
});
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].assignee_rule.type).toBe("department_manager");
|
||||
expect(result[1].assignee_rule.type).toBe("role");
|
||||
expect(result[1].assignee_rule.value).toBe("finance");
|
||||
});
|
||||
|
||||
it("throws if rule is empty or missing roles", () => {
|
||||
expect(() => workflowUtils.buildWorkflowStepsFromApprovalRule({})).toThrow(/参数校验失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssigneeFromRule", () => {
|
||||
it("resolves role type", async () => {
|
||||
const res = await workflowUtils.resolveAssigneeFromRule({ type: "role", value: "hr" });
|
||||
expect(res.assigneeRole).toBe("hr");
|
||||
expect(res.assigneeId).toBe(null);
|
||||
});
|
||||
|
||||
it("throws for invalid rule", async () => {
|
||||
await expect(workflowUtils.resolveAssigneeFromRule(null)).rejects.toThrow(/参数校验失败/);
|
||||
await expect(workflowUtils.resolveAssigneeFromRule({ type: "unknown" })).rejects.toThrow(/参数校验失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toMoneyString", () => {
|
||||
it("converts values to string correctly", () => {
|
||||
expect(workflowUtils.toMoneyString(null)).toBe(null);
|
||||
expect(workflowUtils.toMoneyString("10.0")).toBe("10.0");
|
||||
expect(workflowUtils.toMoneyString(10)).toBe("10");
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户