修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-28 13:42:48 +08:00
父节点 37b625c5dc
当前提交 35948729cf
共修改 31 个文件,包含 980 行新增和 74 行删除
+1
查看文件
@@ -16,6 +16,7 @@
"prisma:migrate:dev": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"seed:config": "node src/scripts/seed-config.js",
"apply:ceo-finance-fallback": "node src/scripts/apply-ceo-finance-fallback.js",
"seed:dev-users": "node src/scripts/seed-dev.js",
"seed:dev": "node src/scripts/dev-seed-safety.js && npm run seed:config && npm run seed:dev-users",
"seed:trialrun": "node src/scripts/seed-trialrun.js",
+5 -3
查看文件
@@ -4,10 +4,12 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { ROLE_PERMISSIONS } = require("../constants/permissions");
const { BUSINESS_APPROVAL_ROLE_CODES } = require("../utils/workflow");
const {
BUSINESS_APPROVAL_ROLE_CODES,
OWNER_SELF_FALLBACK_ROLE_CODES,
} = require("../utils/workflow");
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"];
const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter(
(roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"),
);
@@ -144,7 +146,7 @@ async function approvalRulesRoutes(fastify) {
level3_role: body.level3Role ?? null,
owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")
? body.ownerSelfFallbackRole ?? null
: "finance_mgr",
: "finance",
enabled: body.enabled ?? true,
priority,
created_by: request.employee.id,
@@ -0,0 +1,303 @@
require("dotenv/config");
const crypto = require("node:crypto");
const { Pool } = require("pg");
const APPLY_ENV_KEY = "CEO_FINANCE_FALLBACK_APPLY";
const TARGET_BY_BAND = Object.freeze({
small: Object.freeze({
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
}),
medium: Object.freeze({
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
}),
large: Object.freeze({
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance",
owner_self_fallback_role: "finance",
}),
});
const EXPECTED_CURRENT_BY_BAND = Object.freeze({
small: Object.freeze({
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
}),
medium: Object.freeze({
level1_role: "manager",
level2_role: "finance_mgr",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
}),
large: Object.freeze({
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance_mgr",
owner_self_fallback_role: "finance_mgr",
}),
});
function parseArgs(args) {
const options = { apply: false, format: "text" };
for (const arg of args) {
if (arg === "--apply") options.apply = true;
else if (arg === "--dry-run") options.apply = false;
else if (arg === "--format=json") options.format = "json";
else if (arg === "--help" || arg === "-h") options.help = true;
else throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
function getBand(row) {
const min = Number(row.min_amount);
const max = row.max_amount == null ? null : Number(row.max_amount);
if (min === 0 && max === 500) return "small";
if (min === 500 && max === 5000) return "medium";
if (min === 5000 && max == null) return "large";
return null;
}
function sameRoles(row, expected) {
return ["level1_role", "level2_role", "level3_role", "owner_self_fallback_role"]
.every((field) => (row[field] ?? null) === expected[field]);
}
function buildRulePlan(rows) {
if (!Array.isArray(rows) || rows.length !== 3) {
throw new Error("approval_rule_count_drift");
}
const plan = [];
const seen = new Set();
for (const row of rows) {
const band = getBand(row);
if (!band || seen.has(band)) {
throw new Error("approval_rule_amount_band_drift");
}
if (!sameRoles(row, EXPECTED_CURRENT_BY_BAND[band])) {
throw new Error(`approval_rule_role_drift:${band}`);
}
seen.add(band);
plan.push({ id: row.id, band, before: EXPECTED_CURRENT_BY_BAND[band], after: TARGET_BY_BAND[band] });
}
if (seen.size !== 3) throw new Error("approval_rule_amount_band_drift");
return plan.sort((left, right) => ["small", "medium", "large"].indexOf(left.band)
- ["small", "medium", "large"].indexOf(right.band));
}
function assertPeopleBaseline({ ceos, finances, admins }) {
if (ceos.length !== 1) throw new Error("active_ceo_count_drift");
if (ceos[0].direct_manager_id != null) throw new Error("ceo_manager_drift");
if (!ceos[0].has_wecom_binding) throw new Error("ceo_binding_missing");
if (ceos[0].roles.includes("finance") || ceos[0].roles.includes("finance_mgr")) {
throw new Error("ceo_holds_fallback_role");
}
if (finances.length !== 1) throw new Error("active_non_ceo_finance_count_drift");
if (!finances[0].has_wecom_binding) throw new Error("finance_binding_missing");
if (admins.length !== 1) throw new Error("active_admin_actor_count_drift");
}
async function loadRoleHolders(client, roleCode, excludedEmployeeId = null) {
const result = await client.query(
`
SELECT
e.id::text,
e.direct_manager_id::text,
EXISTS (
SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id
) AS has_wecom_binding,
COALESCE(array_agg(all_roles.role_code::text ORDER BY all_roles.role_code::text), ARRAY[]::text[]) AS roles
FROM employees e
JOIN employee_roles selected_role
ON selected_role.employee_id = e.id
AND selected_role.role_code = $1::"RoleCode"
LEFT JOIN employee_roles all_roles ON all_roles.employee_id = e.id
WHERE e.status = 'active'
AND e.archived_at IS NULL
AND ($2::uuid IS NULL OR e.id <> $2::uuid)
GROUP BY e.id, e.direct_manager_id
ORDER BY e.id
`,
[roleCode, excludedEmployeeId],
);
return result.rows;
}
async function loadInvariantCounts(client) {
const result = await client.query(`
SELECT
(SELECT COUNT(*)::int FROM employees WHERE status = 'active' AND archived_at IS NULL) AS active_employees,
(
SELECT COUNT(*)::int
FROM employee_roles er
JOIN employees e ON e.id = er.employee_id
WHERE e.status = 'active' AND e.archived_at IS NULL
) AS active_role_rows,
(
SELECT COUNT(*)::int
FROM wecom_identity_bindings w
JOIN employees e ON e.id = w.employee_id
WHERE e.status = 'active' AND e.archived_at IS NULL
) AS active_bindings,
(
SELECT COUNT(*)::int
FROM workflow_instances
WHERE context_data ? 'workflow_steps_snapshot'
) AS workflow_snapshots
`);
return result.rows[0];
}
function countsEqual(left, right) {
return ["active_employees", "active_role_rows", "active_bindings", "workflow_snapshots"]
.every((field) => Number(left[field]) === Number(right[field]));
}
async function execute(options, pool = new Pool({ connectionString: process.env.DATABASE_URL })) {
if (!process.env.DATABASE_URL) throw new Error("missing_database_url");
if (options.apply && process.env[APPLY_ENV_KEY] !== "1") {
throw new Error(`apply_requires_${APPLY_ENV_KEY}`);
}
const client = await pool.connect();
let transactionOpen = false;
try {
await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
transactionOpen = true;
const rulesResult = await client.query(`
SELECT id::text, min_amount::text, max_amount::text,
level1_role::text, level2_role::text, level3_role::text,
owner_self_fallback_role::text
FROM approval_rules
WHERE enabled = true
ORDER BY priority, id
FOR UPDATE
`);
const plan = buildRulePlan(rulesResult.rows);
const ceos = await loadRoleHolders(client, "ceo");
const finances = await loadRoleHolders(client, "finance", ceos[0]?.id ?? null);
const admins = await loadRoleHolders(client, "admin");
assertPeopleBaseline({ ceos, finances, admins });
const beforeCounts = await loadInvariantCounts(client);
const requestId = `ceo-finance-fallback-${crypto.randomUUID()}`;
for (const item of plan) {
const updated = await client.query(
`
UPDATE approval_rules
SET level1_role = $2::"RoleCode",
level2_role = $3::"RoleCode",
level3_role = $4::"RoleCode",
owner_self_fallback_role = $5::"RoleCode",
updated_by = $6::uuid,
updated_at = NOW()
WHERE id = $1::uuid
RETURNING id::text
`,
[item.id, item.after.level1_role, item.after.level2_role, item.after.level3_role,
item.after.owner_self_fallback_role, admins[0].id],
);
if (updated.rowCount !== 1) throw new Error(`approval_rule_update_failed:${item.band}`);
await client.query(
`
INSERT INTO audit_logs
(request_id, actor_id, action, target_type, target_id, ip_address, user_agent, extra)
VALUES
($1, $2::uuid, 'APPROVAL_RULE_UPDATED', 'approval_rule', $3::uuid, NULL,
'controlled-script/07-28-ceo-finance-fallback', $4::jsonb)
`,
[requestId, admins[0].id, item.id, JSON.stringify({
task: "07-28-ceo-finance-fallback",
band: item.band,
before: item.before,
after: item.after,
})],
);
}
const afterRules = await client.query(`
SELECT id::text, min_amount::text, max_amount::text,
level1_role::text, level2_role::text, level3_role::text,
owner_self_fallback_role::text
FROM approval_rules
WHERE enabled = true
ORDER BY priority, id
`);
if (afterRules.rows.length !== 3 || afterRules.rows.some((row) => {
const band = getBand(row);
return !band || !sameRoles(row, TARGET_BY_BAND[band]);
})) {
throw new Error("approval_rule_postcondition_failed");
}
const afterCounts = await loadInvariantCounts(client);
if (!countsEqual(beforeCounts, afterCounts)) throw new Error("protected_invariant_changed");
if (options.apply) {
await client.query("COMMIT");
transactionOpen = false;
} else {
await client.query("ROLLBACK");
transactionOpen = false;
}
return {
status: options.apply ? "APPLIED" : "DRY_RUN_PASS",
rules: plan.length,
auditRows: options.apply ? plan.length : 0,
protectedCountsStable: true,
realReimbursementsCreated: 0,
wecomWrites: 0,
};
} catch (error) {
if (transactionOpen) await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
await pool.end();
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write("Usage: node src/scripts/apply-ceo-finance-fallback.js [--dry-run|--apply] [--format=json]\n");
return;
}
const result = await execute(options);
process.stdout.write(options.format === "json" ? `${JSON.stringify(result)}\n` : [
`[ceo-finance-fallback] status=${result.status}`,
`[ceo-finance-fallback] rules=${result.rules} auditRows=${result.auditRows}`,
`[ceo-finance-fallback] protectedCountsStable=${result.protectedCountsStable}`,
].join("\n") + "\n");
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[ceo-finance-fallback] failed=${error.message}\n`);
process.exit(1);
});
}
module.exports = {
EXPECTED_CURRENT_BY_BAND,
TARGET_BY_BAND,
assertPeopleBaseline,
buildRulePlan,
getBand,
parseArgs,
};
+28 -13
查看文件
@@ -56,7 +56,7 @@ const DEFAULT_APPROVAL_RULES = [
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
owner_self_fallback_role: "finance",
priority: 10,
},
{
@@ -64,9 +64,9 @@ const DEFAULT_APPROVAL_RULES = [
min_amount: "500",
max_amount: "5000",
level1_role: "manager",
level2_role: "finance_mgr",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
owner_self_fallback_role: "finance",
priority: 20,
},
{
@@ -75,8 +75,8 @@ const DEFAULT_APPROVAL_RULES = [
max_amount: null,
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance_mgr",
owner_self_fallback_role: "finance_mgr",
level3_role: "finance",
owner_self_fallback_role: "finance",
priority: 30,
},
];
@@ -563,7 +563,11 @@ async function seedExpenseTypes(prisma) {
}
async function seedApprovalRules(prisma) {
await prisma.approvalRule.deleteMany({});
const existingCount = await prisma.approvalRule.count();
if (existingCount > 0) {
return { created: 0, preserved: existingCount };
}
await prisma.approvalRule.createMany({
data: DEFAULT_APPROVAL_RULES.map((item) => ({
...item,
@@ -572,6 +576,8 @@ async function seedApprovalRules(prisma) {
updated_by: null,
})),
});
return { created: DEFAULT_APPROVAL_RULES.length, preserved: 0 };
}
async function seedDocumentSequence(prisma) {
@@ -1022,7 +1028,7 @@ async function main() {
try {
await seedExpenseTypes(prisma);
await seedApprovalRules(prisma);
const approvalRuleSeed = await seedApprovalRules(prisma);
await seedDocumentSequence(prisma);
await seedDocumentCategories(prisma);
await seedWorkflowDefinition(prisma);
@@ -1037,7 +1043,9 @@ async function main() {
// eslint-disable-next-line no-console
console.log(`- expense types: ${ENABLED_EXPENSE_TYPES.length} enabled + ${DISABLED_LEGACY_EXPENSE_TYPES.length} disabled`);
// eslint-disable-next-line no-console
console.log("- approval rules: 3");
console.log(
`- approval rules: ${approvalRuleSeed.created} created / ${approvalRuleSeed.preserved} preserved`,
);
// eslint-disable-next-line no-console
console.log("- document sequence: reimbursement(reset_period=none)");
// eslint-disable-next-line no-console
@@ -1070,8 +1078,15 @@ async function main() {
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
if (require.main === module) {
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});
}
module.exports = {
DEFAULT_APPROVAL_RULES,
seedApprovalRules,
};
+3 -1
查看文件
@@ -19,7 +19,8 @@ const BUSINESS_APPROVAL_ROLE_CODES = Object.freeze([
"administration",
]);
const BUSINESS_APPROVAL_ROLE_CODE_SET = new Set(BUSINESS_APPROVAL_ROLE_CODES);
const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(["finance_mgr"]);
const OWNER_SELF_FALLBACK_ROLE_CODES = Object.freeze(["finance", "finance_mgr"]);
const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(OWNER_SELF_FALLBACK_ROLE_CODES);
const OWNER_SELF_FALLBACK_REASONS = new Set([
"no_manager_found",
"chain_broken",
@@ -555,6 +556,7 @@ function toMoneyString(value) {
module.exports = {
BUSINESS_APPROVAL_ROLE_CODES,
OWNER_SELF_FALLBACK_ROLE_CODES,
buildApprovalWorkflowSnapshot,
buildWorkflowStepsFromApprovalRule,
getWorkflowStepsSnapshot,
@@ -14,7 +14,7 @@ function buildCreatePayload(roleCode) {
level1Role: roleCode,
level2Role: null,
level3Role: null,
ownerSelfFallbackRole: "finance_mgr",
ownerSelfFallbackRole: "finance",
enabled: true,
priority: 1,
};
@@ -82,6 +82,74 @@ async function buildApp() {
}
describe("reimbursement approval-rule role whitelist", () => {
it.each(["finance", "finance_mgr"])(
"allows owner self fallback role %s",
async (fallbackRole) => {
const testApp = await buildApp();
try {
const response = await testApp.app.inject({
method: "POST",
url: "/api/approval-rules",
payload: {
...buildCreatePayload("manager"),
ownerSelfFallbackRole: fallbackRole,
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.ownerSelfFallbackRole).toBe(fallbackRole);
} finally {
await testApp.app.close();
}
},
);
it("defaults owner self fallback to finance when omitted", async () => {
const testApp = await buildApp();
try {
const payload = buildCreatePayload("manager");
delete payload.ownerSelfFallbackRole;
const response = await testApp.app.inject({
method: "POST",
url: "/api/approval-rules",
payload,
});
expect(response.statusCode).toBe(200);
expect(response.json().data.ownerSelfFallbackRole).toBe("finance");
expect(testApp.approvalRuleCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ owner_self_fallback_role: "finance" }),
}),
);
} finally {
await testApp.app.close();
}
});
it.each(["admin", "system_admin", "hr"])(
"rejects owner self fallback role %s",
async (fallbackRole) => {
const testApp = await buildApp();
try {
const response = await testApp.app.inject({
method: "POST",
url: "/api/approval-rules",
payload: {
...buildCreatePayload("manager"),
ownerSelfFallbackRole: fallbackRole,
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
expect(testApp.transaction).not.toHaveBeenCalled();
} finally {
await testApp.app.close();
}
},
);
it.each(["manager", "ceo", "finance", "finance_mgr"])(
"allows role %s because it carries expense.approve",
async (roleCode) => {
@@ -399,7 +399,7 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("uses finance_mgr fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => {
it("uses one finance fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app, state } = await buildApp({
@@ -409,8 +409,8 @@ describe("reimbursements routes expense standard integration", () => {
id: "55555555-5555-4555-8555-555555555555",
level1_role: "manager",
level2_role: "finance",
level3_role: "ceo",
owner_self_fallback_role: "finance_mgr",
level3_role: null,
owner_self_fallback_role: "finance",
priority: 1,
},
});
@@ -424,7 +424,7 @@ describe("reimbursements routes expense standard integration", () => {
expect(state.workflowTaskCreatePayload).toEqual(
expect.objectContaining({
assignee_id: null,
assignee_role: "finance_mgr",
assignee_role: "finance",
}),
);
expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback");
@@ -432,10 +432,7 @@ describe("reimbursements routes expense standard integration", () => {
state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map(
(step) => step.assignee_rule,
),
).toEqual([
{ type: "role", value: "finance_mgr" },
{ type: "role", value: "finance" },
]);
).toEqual([{ type: "role", value: "finance" }]);
await app.close();
});
@@ -452,7 +449,7 @@ describe("reimbursements routes expense standard integration", () => {
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
owner_self_fallback_role: "finance",
priority: 1,
},
});
@@ -467,7 +464,7 @@ describe("reimbursements routes expense standard integration", () => {
expect.objectContaining({
workflow: "no_actionable_approver",
reason: "owner_self_fallback_role_unavailable",
fallbackRole: "finance_mgr",
fallbackRole: "finance",
}),
);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
@@ -0,0 +1,103 @@
const {
assertPeopleBaseline,
buildRulePlan,
parseArgs,
} = require("../../../src/scripts/apply-ceo-finance-fallback");
function currentRows() {
return [
{
id: "small-id",
min_amount: "0.00",
max_amount: "500.00",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
},
{
id: "medium-id",
min_amount: "500.00",
max_amount: "5000.00",
level1_role: "manager",
level2_role: "finance_mgr",
level3_role: null,
owner_self_fallback_role: "finance_mgr",
},
{
id: "large-id",
min_amount: "5000.00",
max_amount: null,
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance_mgr",
owner_self_fallback_role: "finance_mgr",
},
];
}
describe("CEO finance fallback production apply contract", () => {
it("builds the exact three-band target without exposing identifiers in the result shape", () => {
const plan = buildRulePlan(currentRows());
expect(plan.map((item) => ({ band: item.band, after: item.after }))).toEqual([
{
band: "small",
after: {
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
},
},
{
band: "medium",
after: {
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
},
},
{
band: "large",
after: {
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance",
owner_self_fallback_role: "finance",
},
},
]);
});
it.each([
[currentRows().slice(0, 2), "approval_rule_count_drift"],
[[...currentRows(), currentRows()[0]], "approval_rule_count_drift"],
[currentRows().map((row, index) => index === 1 ? { ...row, level2_role: "finance" } : row), "approval_rule_role_drift:medium"],
[currentRows().map((row, index) => index === 2 ? { ...row, min_amount: "6000" } : row), "approval_rule_amount_band_drift"],
])("fails closed on approval-rule drift", (rows, message) => {
expect(() => buildRulePlan(rows)).toThrow(message);
});
it("requires one bound CEO without manager/fallback role, one bound non-CEO finance, and one admin", () => {
expect(() => assertPeopleBaseline({
ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo"] }],
finances: [{ has_wecom_binding: true, roles: ["finance"] }],
admins: [{ roles: ["admin"] }],
})).not.toThrow();
expect(() => assertPeopleBaseline({ ceos: [], finances: [], admins: [] }))
.toThrow("active_ceo_count_drift");
expect(() => assertPeopleBaseline({
ceos: [{ direct_manager_id: null, has_wecom_binding: true, roles: ["ceo", "finance"] }],
finances: [{ has_wecom_binding: true, roles: ["finance"] }],
admins: [{ roles: ["admin"] }],
})).toThrow("ceo_holds_fallback_role");
});
it("defaults to dry-run and rejects unknown arguments", () => {
expect(parseArgs([])).toEqual({ apply: false, format: "text" });
expect(parseArgs(["--apply", "--format=json"])).toEqual({ apply: true, format: "json" });
expect(() => parseArgs(["--force"])).toThrow("Unsupported argument");
});
});
@@ -0,0 +1,67 @@
const {
DEFAULT_APPROVAL_RULES,
seedApprovalRules,
} = require("../../../src/scripts/seed-config");
describe("small-team default reimbursement approval rules", () => {
it("uses finance for every owner fallback and finance review node", () => {
expect(DEFAULT_APPROVAL_RULES).toEqual([
expect.objectContaining({
name: "小额",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
}),
expect.objectContaining({
name: "中额",
level1_role: "manager",
level2_role: "finance",
level3_role: null,
owner_self_fallback_role: "finance",
}),
expect.objectContaining({
name: "大额",
level1_role: "manager",
level2_role: "ceo",
level3_role: "finance",
owner_self_fallback_role: "finance",
}),
]);
});
it("preserves existing production approval rules instead of replacing them", async () => {
const approvalRule = {
count: vi.fn().mockResolvedValue(3),
createMany: vi.fn(),
deleteMany: vi.fn(),
};
await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({
created: 0,
preserved: 3,
});
expect(approvalRule.createMany).not.toHaveBeenCalled();
expect(approvalRule.deleteMany).not.toHaveBeenCalled();
});
it("creates the three defaults only for an empty approval-rule table", async () => {
const approvalRule = {
count: vi.fn().mockResolvedValue(0),
createMany: vi.fn().mockResolvedValue({ count: 3 }),
};
await expect(seedApprovalRules({ approvalRule })).resolves.toEqual({
created: 3,
preserved: 0,
});
expect(approvalRule.createMany).toHaveBeenCalledWith({
data: DEFAULT_APPROVAL_RULES.map((item) => ({
...item,
enabled: true,
created_by: null,
updated_by: null,
})),
});
});
});
+25 -3
查看文件
@@ -164,6 +164,28 @@ describe("workflow assignee resolution", () => {
expect(resolved.actionable.assigneeRole).toBe("finance_mgr");
});
it.each([
["small", { level1_role: "manager", level2_role: "finance", level3_role: null }],
["medium", { level1_role: "manager", level2_role: "finance", level3_role: null }],
["large", { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }],
])("uses one finance fallback node for CEO owner %s rules", async (_name, rule) => {
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
const steps = await workflowUtils.buildApprovalWorkflowSnapshot({
tx,
ownerId: OWNER_ID,
approvalRule: {
...rule,
owner_self_fallback_role: "finance",
},
});
expect(steps.map((step) => step.assignee_rule)).toEqual([
{ type: "role", value: "finance" },
]);
expect(steps[0].id).toContain("owner_self_fallback");
});
it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => {
const tx = buildTx({ ownerRoles: ["ceo"], managerId: null });
@@ -225,7 +247,7 @@ describe("workflow assignee resolution", () => {
ownerId: OWNER_ID,
approvalRule: {
level1_role: "manager",
owner_self_fallback_role: "finance_mgr",
owner_self_fallback_role: "finance",
},
}),
).rejects.toMatchObject({
@@ -238,7 +260,7 @@ describe("workflow assignee resolution", () => {
it("blocks fallback when the owner also holds the fallback role", async () => {
const tx = buildTx({
ownerRoles: ["ceo", "finance_mgr"],
ownerRoles: ["ceo", "finance"],
managerId: null,
});
@@ -248,7 +270,7 @@ describe("workflow assignee resolution", () => {
ownerId: OWNER_ID,
approvalRule: {
level1_role: "manager",
owner_self_fallback_role: "finance_mgr",
owner_self_fallback_role: "finance",
},
}),
).rejects.toMatchObject({