feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -131,6 +131,27 @@ describe("auth security configuration", () => {
|
||||
expect(() => getConfig()).toThrow(`Invalid ${name}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, 365],
|
||||
["180", 180],
|
||||
["365", 365],
|
||||
])("accepts the configured reimbursement age policy", (value, expected) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(getConfig().reimbursementMaxAgeDays).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["0", "181", "366", "invalid"])(
|
||||
"rejects unsupported reimbursement age policy %s",
|
||||
(value) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(() => getConfig()).toThrow("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores a forged X-Forwarded-For header by default", async () => {
|
||||
stubBaseEnv();
|
||||
const app = await buildIpProbeApp();
|
||||
|
||||
@@ -101,6 +101,12 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
||||
{ id: "item-1", amount: reimbursement.total_amount },
|
||||
]),
|
||||
aggregate: vi.fn(async () => ({
|
||||
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
||||
})),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||
@@ -245,6 +251,52 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit when the reimbursement has no items", async () => {
|
||||
const { app, tx } = await buildApp({ reimbursementItems: [] });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ items: "at_least_one_required" });
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit and identifies items with non-positive amounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
reimbursementItems: [
|
||||
{ id: "item-zero", amount: "0.00" },
|
||||
{ id: "item-valid", amount: "20.00" },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({
|
||||
items: "positive_amount_required",
|
||||
itemIds: ["item-zero"],
|
||||
});
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ total_amount: "320.00" }),
|
||||
}));
|
||||
expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ min_amount: { lte: "320.00" } }),
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns standardChecks from reimbursement precheck", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(
|
||||
buildPrecheck({
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
|
||||
|
||||
it("classifies every env contract key exactly once", async () => {
|
||||
const result = await validate();
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 164");
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 165");
|
||||
});
|
||||
|
||||
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const {
|
||||
buildReimbursementOperationalChecks,
|
||||
} = require("../../../src/services/feeControl");
|
||||
|
||||
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function buildTx() {
|
||||
return {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: REIMBURSEMENT_ID,
|
||||
items: [
|
||||
{
|
||||
id: "item-office",
|
||||
expense_date: new Date("2026-02-05T00:00:00.000Z"),
|
||||
expense_type: { category: "office" },
|
||||
},
|
||||
{
|
||||
id: "item-asset",
|
||||
expense_date: new Date("2025-08-03T00:00:00.000Z"),
|
||||
expense_type: { category: "assets" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
invoicePool: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: null },
|
||||
]),
|
||||
},
|
||||
reimbursementAttachment: {
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReimbursementOperationalChecks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("summarizes evidence, timeliness and asset candidates without blocking", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 365 },
|
||||
});
|
||||
|
||||
expect(result.evidenceSummary).toEqual({
|
||||
invoiceCount: 3,
|
||||
attachmentCount: 2,
|
||||
items: [
|
||||
{ itemId: "item-office", linkedInvoiceCount: 2, invoiceState: "linked" },
|
||||
{ itemId: "item-asset", linkedInvoiceCount: 0, invoiceState: "unlinked" },
|
||||
],
|
||||
});
|
||||
expect(result.timelinessChecks).toEqual([
|
||||
expect.objectContaining({ itemId: "item-office", ageDays: 180, overdue: false, crossCalendarYear: false }),
|
||||
expect.objectContaining({ itemId: "item-asset", ageDays: 366, overdue: true, crossCalendarYear: true }),
|
||||
]);
|
||||
expect(result.assetHint).toEqual({ required: true, itemCount: 1 });
|
||||
expect(result.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
|
||||
"expense_date_overdue_item-asset",
|
||||
"expense_date_cross_year_item-asset",
|
||||
"reimbursement_items_without_linked_invoice",
|
||||
"reimbursement_asset_registration_required",
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the 180-day policy and keeps the boundary day eligible", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 180 },
|
||||
});
|
||||
|
||||
expect(result.timelinessChecks[0]).toEqual(expect.objectContaining({
|
||||
ageDays: 180,
|
||||
maxAgeDays: 180,
|
||||
overdue: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户