mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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>
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
import { BadRequestException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { DataSource } from "typeorm";
|
|
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
|
import { eimsConfig } from "./eims-test-fixtures";
|
|
|
|
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: {
|
|
cfg?: Partial<EimsConfig>;
|
|
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 =
|
|
opts.register ??
|
|
jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" });
|
|
|
|
const query = jest.fn().mockImplementation((sql: string) => {
|
|
if (sql.includes("eims_system_state")) {
|
|
return Promise.resolve(
|
|
opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [],
|
|
);
|
|
}
|
|
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, 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, managerUpdate, managerFindOne };
|
|
};
|
|
|
|
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
|
|
|
describe("EimsAutoSubmitService.tick", () => {
|
|
it("files the oldest eligible invoice through the registration service", async () => {
|
|
const { service, register } = build({ candidate });
|
|
|
|
await service.tick();
|
|
|
|
expect(register).toHaveBeenCalledTimes(1);
|
|
expect(register).toHaveBeenCalledWith(INVOICE_ID);
|
|
});
|
|
|
|
it("files nothing when EIMS_AUTO_SUBMIT is off", async () => {
|
|
const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate });
|
|
|
|
await service.tick();
|
|
|
|
expect(register).not.toHaveBeenCalled();
|
|
expect(query).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => {
|
|
const { service, register, query } = build({ cfg: { enabled: false }, candidate });
|
|
|
|
await service.tick();
|
|
|
|
expect(register).not.toHaveBeenCalled();
|
|
expect(query).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not submit while another submission is in flight", async () => {
|
|
const { service, register } = build({
|
|
state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" },
|
|
candidate,
|
|
});
|
|
|
|
await service.tick();
|
|
|
|
expect(register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not submit while the system number is blocked", async () => {
|
|
const { service, register } = build({
|
|
state: { blocked_reason: "never acknowledged" },
|
|
candidate,
|
|
});
|
|
|
|
await service.tick();
|
|
|
|
expect(register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when no invoice is eligible", async () => {
|
|
const { service, register } = build({ candidate: null });
|
|
|
|
await service.tick();
|
|
|
|
expect(register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => {
|
|
const { service, query } = build({ candidate });
|
|
|
|
await service.tick();
|
|
|
|
const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!;
|
|
expect(sql).toContain("i.eims_status = $1");
|
|
expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted);
|
|
expect(sql).toContain("i.issued_at IS NOT NULL");
|
|
});
|
|
|
|
it("survives a filing failure so the job keeps running", async () => {
|
|
const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)"));
|
|
const { service } = build({ candidate, register });
|
|
|
|
await expect(service.tick()).resolves.toBeUndefined();
|
|
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(
|
|
() => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))),
|
|
);
|
|
const { service } = build({ candidate, register });
|
|
|
|
const first = service.tick();
|
|
await new Promise((r) => setImmediate(r));
|
|
await service.tick(); // overlapping tick, must be a no-op
|
|
|
|
expect(register).toHaveBeenCalledTimes(1);
|
|
release();
|
|
await first;
|
|
});
|
|
});
|