chore(P14-8): 完成运营化与验收收口

这个提交包含在:
2026-03-06 23:24:16 +08:00
父节点 b50b084100
当前提交 d8fff0ba21
共修改 28 个文件,包含 2820 行新增和 123 行删除
+2 -1
查看文件
@@ -19,8 +19,9 @@ STORAGE_DIR="generated/storage"
SIGNED_URL_TTL_SECONDS="600"
MAX_UPLOAD_SIZE_BYTES="10485760"
# ===== Metrics(Prometheus)=====
# ===== Metrics(Prometheus & OpenTelemetry)=====
# METRICS_ENABLED="true"
# OTEL_TRACES_ENABLED="false"
# ===== 单域托管 client/dist(试运行/生产建议)=====
# SERVE_CLIENT_DIST="true"
+1986 -10
查看文件
文件差异内容过多而无法显示 加载差异
+12 -8
查看文件
@@ -38,28 +38,32 @@
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"exceljs": "^4.4.0",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"nodemailer": "^6.10.1",
"node-cron": "^4.2.1",
"nodemailer": "^6.10.1",
"openid-client": "^6.8.2",
"prom-client": "^15.1.0",
"pg-boss": "^10.3.0",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.3.0",
"prom-client": "^15.1.0",
"tencentcloud-sdk-nodejs": "^4.1.14",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3"
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.1.8",
"dotenv": "^16.4.5",
"vitest": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8"
"vitest": "^2.1.8"
}
}
+26
查看文件
@@ -0,0 +1,26 @@
const fs = require("fs");
const { buildApp } = require("../src/app");
async function main() {
process.env.NODE_ENV = "development";
process.env.OPENAPI_DOCS_ENABLED = "true";
const app = await buildApp();
await app.ready();
const openapiObj = app.swagger();
const args = process.argv.slice(2);
const outIndex = args.indexOf("--out");
const outPath = outIndex !== -1 ? args[outIndex + 1] : "/tmp/openapi.json";
fs.writeFileSync(outPath, JSON.stringify(openapiObj, null, 2), "utf8");
console.log(`OpenAPI schema exported to ${outPath}`);
await app.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+20 -16
查看文件
@@ -77,9 +77,9 @@ async function buildApp() {
const allowedOrigins = config.appOrigin
? config.appOrigin
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
await app.register(cors, {
@@ -187,6 +187,10 @@ async function buildApp() {
app.log.info("OpenAPI docs enabled at /api/docs");
}
if (config.otelTracesEnabled) {
app.log.info("OTel tracing enabled");
}
app.get(
"/health",
{
@@ -233,19 +237,19 @@ async function buildApp() {
},
},
async (request, reply) => {
try {
await app.prisma.$queryRaw`SELECT 1`;
return reply.send({ requestId: request.id, data: { ok: true } });
} catch (error) {
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
throw new AppError({
code: "SYS_503",
message: "Service Unavailable",
statusCode: 503,
details: { db: "unreachable" },
});
}
},
try {
await app.prisma.$queryRaw`SELECT 1`;
return reply.send({ requestId: request.id, data: { ok: true } });
} catch (error) {
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
throw new AppError({
code: "SYS_503",
message: "Service Unavailable",
statusCode: 503,
details: { db: "unreachable" },
});
}
},
);
if (config.metricsEnabled) {
+2
查看文件
@@ -132,6 +132,8 @@ function getConfig() {
smtpSecure: process.env.SMTP_SECURE === "true",
smtpUser: process.env.SMTP_USER ?? "",
smtpPass: process.env.SMTP_PASS ?? "",
otelTracesEnabled: process.env.OTEL_TRACES_ENABLED === "true",
};
}
+6
查看文件
@@ -1,3 +1,9 @@
const { getConfig } = require("./config");
const { initTracing } = require("./observability/tracing");
const config = getConfig();
initTracing(config);
const { buildApp } = require("./app");
async function main() {
+42
查看文件
@@ -0,0 +1,42 @@
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
let sdk = null;
function initTracing(config) {
if (config.otelTracesEnabled !== true) {
return;
}
const exporter = new ConsoleSpanExporter();
sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'hyb-oa-server',
}),
traceExporter: exporter,
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
})]
});
try {
sdk.start();
console.log('OTel tracing initialized');
} catch (error) {
console.error('Error initializing OTel tracing', error);
}
process.on('SIGTERM', () => {
if (sdk) {
sdk.shutdown()
.then(() => console.log('OTel tracing terminated'))
.catch((error) => console.log('Error terminating OTel tracing', error));
}
});
}
module.exports = { initTracing };
+14 -12
查看文件
@@ -103,6 +103,7 @@ function registerInvoicePoolReadRoutes(fastify) {
fastify.get(
"/invoice-pool",
{
schema: { tags: ["Invoice Pool"], summary: "List Invoices" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
@@ -134,19 +135,19 @@ function registerInvoicePoolReadRoutes(fastify) {
...(ownerScope === "all"
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
],
}),
},
],
}),
};
const [items, total] = await Promise.all([
@@ -173,6 +174,7 @@ function registerInvoicePoolReadRoutes(fastify) {
fastify.get(
"/invoice-pool/:id",
{
schema: { tags: ["Invoice Pool"], summary: "Get Invoice Detail" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
+21 -18
查看文件
@@ -251,17 +251,17 @@ function mapReimbursementSummary(item) {
},
owner: item.owner
? {
id: item.owner.id,
employeeNo: item.owner.employee_no,
name: item.owner.name,
}
id: item.owner.id,
employeeNo: item.owner.employee_no,
name: item.owner.name,
}
: null,
submitter: item.submitter
? {
id: item.submitter.id,
employeeNo: item.submitter.employee_no,
name: item.submitter.name,
}
id: item.submitter.id,
employeeNo: item.submitter.employee_no,
name: item.submitter.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
@@ -403,6 +403,7 @@ async function reimbursementRoutes(fastify) {
fastify.post(
"/reimbursements",
{
schema: { tags: ["Reimbursements"], summary: "Create Reimbursement" },
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
@@ -526,6 +527,7 @@ async function reimbursementRoutes(fastify) {
fastify.get(
"/reimbursements",
{
schema: { tags: ["Reimbursements"], summary: "List Reimbursements" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
@@ -602,6 +604,7 @@ async function reimbursementRoutes(fastify) {
fastify.get(
"/reimbursements/:id",
{
schema: { tags: ["Reimbursements"], summary: "Get Reimbursement Detail" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
@@ -714,12 +717,12 @@ async function reimbursementRoutes(fastify) {
description: item.description,
expenseType: item.expense_type
? {
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
: null,
})),
budgetHint,
@@ -732,10 +735,10 @@ async function reimbursementRoutes(fastify) {
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
});
},
+46 -44
查看文件
@@ -140,10 +140,10 @@ function isoOrNull(value) {
function mapTaskSummary(task) {
const workflowInstance = task.workflow_instance
? {
entity_type: task.workflow_instance.entity_type,
entity_id: task.workflow_instance.entity_id,
current_status: task.workflow_instance.current_status,
}
entity_type: task.workflow_instance.entity_type,
entity_id: task.workflow_instance.entity_id,
current_status: task.workflow_instance.current_status,
}
: null;
return {
@@ -168,10 +168,10 @@ function mapTaskSummary(task) {
completedAt: isoOrNull(task.completed_at),
workflowInstance: workflowInstance
? {
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
currentStatus: workflowInstance.current_status,
}
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
currentStatus: workflowInstance.current_status,
}
: null,
};
}
@@ -425,6 +425,7 @@ async function workflowRoutes(fastify) {
fastify.get(
"/workflow/tasks",
{
schema: { tags: ["Workflow"], summary: "List Workflow Tasks" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
@@ -479,6 +480,7 @@ async function workflowRoutes(fastify) {
fastify.get(
"/workflow/tasks/:taskId",
{
schema: { tags: ["Workflow"], summary: "Get Workflow Task Detail" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
@@ -612,39 +614,39 @@ async function workflowRoutes(fastify) {
},
workflowInstance: task.workflow_instance
? {
id: task.workflow_instance.id,
currentStatus: task.workflow_instance.current_status,
currentStepId: task.workflow_instance.current_step_id,
startedAt: isoOrNull(task.workflow_instance.started_at),
completedAt: isoOrNull(task.workflow_instance.completed_at),
}
id: task.workflow_instance.id,
currentStatus: task.workflow_instance.current_status,
currentStepId: task.workflow_instance.current_step_id,
startedAt: isoOrNull(task.workflow_instance.started_at),
completedAt: isoOrNull(task.workflow_instance.completed_at),
}
: null,
reimbursement: task.workflow_instance?.reimbursement_form
? {
id: task.workflow_instance.reimbursement_form.id,
formNumber: task.workflow_instance.reimbursement_form.form_number,
status: task.workflow_instance.reimbursement_form.status,
reimbursementDate:
task.workflow_instance.reimbursement_form.reimbursement_date
?.toISOString?.()
?.slice(0, 10) ?? null,
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
description: task.workflow_instance.reimbursement_form.description,
owner: task.workflow_instance.reimbursement_form.owner
? {
id: task.workflow_instance.reimbursement_form.owner.id,
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
name: task.workflow_instance.reimbursement_form.owner.name,
}
: null,
submitter: task.workflow_instance.reimbursement_form.submitter
? {
id: task.workflow_instance.reimbursement_form.submitter.id,
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
name: task.workflow_instance.reimbursement_form.submitter.name,
}
: null,
}
id: task.workflow_instance.reimbursement_form.id,
formNumber: task.workflow_instance.reimbursement_form.form_number,
status: task.workflow_instance.reimbursement_form.status,
reimbursementDate:
task.workflow_instance.reimbursement_form.reimbursement_date
?.toISOString?.()
?.slice(0, 10) ?? null,
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
description: task.workflow_instance.reimbursement_form.description,
owner: task.workflow_instance.reimbursement_form.owner
? {
id: task.workflow_instance.reimbursement_form.owner.id,
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
name: task.workflow_instance.reimbursement_form.owner.name,
}
: null,
submitter: task.workflow_instance.reimbursement_form.submitter
? {
id: task.workflow_instance.reimbursement_form.submitter.id,
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
name: task.workflow_instance.reimbursement_form.submitter.name,
}
: null,
}
: null,
allowedActions: listAllowedActionsForTask(request, task, actionable),
history: historyItems.map((item) => ({
@@ -681,12 +683,12 @@ async function workflowRoutes(fastify) {
},
...(query.keyword
? {
OR: [
{ name: { contains: query.keyword, mode: "insensitive" } },
{ email: { contains: query.keyword, mode: "insensitive" } },
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
],
}
OR: [
{ name: { contains: query.keyword, mode: "insensitive" } },
{ email: { contains: query.keyword, mode: "insensitive" } },
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
],
}
: {}),
};
@@ -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");
});
});
+53
查看文件
@@ -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");
});
});
});