fix(eims): drain pre-reservation rejections in auto-submit sweep

A BadRequestException thrown before reserve() (config assertion, DEB/CRE
validation) left the invoice NOT_SUBMITTED with nothing persisted, so the
same row was retried every tick forever — a permanent head-of-line block on
every invoice behind it. Now marked FAILED, guarded by a fresh status
re-read so a reservation's own SUBMITTING/UNKNOWN/blocked state is never
clobbered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-15 06:44:34 +00:00
parent 83d9265e85
commit 87c51d7676
2 changed files with 98 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
@@ -12,6 +13,9 @@ const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
/**
* `query` is answered by shape: the first call is the system-state guard, the second is the
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
*
* `managerRow` backs `dataSource.manager.findOne`/`.update` — only exercised by the
* pre-reservation-rejection path (`failStalledCandidate`), so it defaults to the candidate itself.
*/
const build = (
opts: {
@@ -19,6 +23,7 @@ const build = (
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
candidate?: { id: string; invoiceNumber: string } | null;
register?: jest.Mock;
managerRow?: { eimsStatus: EimsInvoiceStatus } | null;
} = {},
) => {
const register =
@@ -34,12 +39,17 @@ const build = (
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
});
const managerUpdate = jest.fn().mockResolvedValue(undefined);
const managerFindOne = jest
.fn()
.mockResolvedValue(opts.managerRow === undefined ? { eimsStatus: EimsInvoiceStatus.NotSubmitted } : opts.managerRow);
const service = new EimsAutoSubmitService(
{ query } as unknown as DataSource,
{ query, manager: { findOne: managerFindOne, update: managerUpdate } } as unknown as DataSource,
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
);
return { service, register, query };
return { service, register, query, managerUpdate, managerFindOne };
};
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
@@ -121,6 +131,40 @@ describe("EimsAutoSubmitService.tick", () => {
expect(register).toHaveBeenCalledTimes(1);
});
it("drains a pre-reservation rejection so the sweep advances, without touching the DB row's own reservation state", async () => {
const register = jest
.fn()
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
const { service, managerFindOne, managerUpdate } = build({ candidate, register });
await expect(service.tick()).resolves.toBeUndefined();
expect(managerFindOne).toHaveBeenCalledTimes(1);
expect(managerUpdate).toHaveBeenCalledWith(
expect.anything(),
INVOICE_ID,
expect.objectContaining({
eimsStatus: EimsInvoiceStatus.Failed,
eimsLastError: expect.objectContaining({ message: "no related invoice" }),
}),
);
});
it("leaves a row alone if it already moved past NOT_SUBMITTED by the time the rejection is handled", async () => {
const register = jest
.fn()
.mockRejectedValue(new BadRequestException({ code: "EIMS_RELATED_INVOICE_REQUIRED", message: "no related invoice" }));
const { service, managerUpdate } = build({
candidate,
register,
managerRow: { eimsStatus: EimsInvoiceStatus.Submitting },
});
await expect(service.tick()).resolves.toBeUndefined();
expect(managerUpdate).not.toHaveBeenCalled();
});
it("does not start a second tick while one is still filing", async () => {
let release: () => void = () => {};
const register = jest.fn().mockImplementation(