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; 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; }); });