mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
747 lines
28 KiB
TypeScript
747 lines
28 KiB
TypeScript
import { BadRequestException, ConflictException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { DataSource } from "typeorm";
|
|
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { Invoice } from "../billing/entities/invoice.entity";
|
|
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
|
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
|
import { NotificationsService } from "../notifications/notifications.service";
|
|
import { EimsAuthService } from "./eims-auth.service";
|
|
import { EimsClientService } from "./eims-client.service";
|
|
import { EimsApiException } from "./eims.errors";
|
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
|
|
|
const SYSTEM_NUMBER = "B0360154BA";
|
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
|
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
|
|
|
const config = (over: Partial<EimsConfig["invoice"]> = {}): EimsConfig =>
|
|
({
|
|
enabled: true,
|
|
baseUrl: "https://core.mor.gov.et",
|
|
clientId: "cid",
|
|
clientSecret: "secret",
|
|
apiKey: "key",
|
|
tin: "0000034558",
|
|
systemNumber: SYSTEM_NUMBER,
|
|
systemType: "SYS",
|
|
privateKeyPath: "/dev/null",
|
|
certificatePath: "/dev/null",
|
|
httpTimeoutMs: 30_000,
|
|
tokenSkewMs: 45_000,
|
|
invoice: eimsInvoiceConfig(over),
|
|
}) as EimsConfig;
|
|
|
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
|
({
|
|
id: INVOICE_ID,
|
|
invoiceNumber: "INV-20260807-00042",
|
|
currency: "ETB",
|
|
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
|
totalAmount: "10000.00",
|
|
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
|
eimsIrn: null,
|
|
eimsInvoiceCounter: null,
|
|
eimsSubmittedAt: null,
|
|
eimsAckDate: null,
|
|
eimsLastError: null,
|
|
company: {
|
|
name: "ABC Trading PLC",
|
|
tin: "0999930000",
|
|
vatNumber: "123475885858",
|
|
phone: "0912345678",
|
|
email: "buyer@abc.et",
|
|
region: "13",
|
|
zone: "SHA",
|
|
woreda: "574",
|
|
kebele: "03",
|
|
houseNo: "NEW",
|
|
country: "Ethiopia",
|
|
},
|
|
...over,
|
|
}) as unknown as Invoice;
|
|
|
|
const LINES = [
|
|
{
|
|
chargeType: "RAIL_FREIGHT",
|
|
description: "Addis to Djibouti",
|
|
quantity: "1.00",
|
|
unitRate: "10000.00",
|
|
amount: "10000.00",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()`
|
|
* returns the live object — enough to assert ordering, values and the reservation lifecycle without
|
|
* a database.
|
|
*/
|
|
class FakeDb {
|
|
invoices = new Map<string, Invoice>();
|
|
state: EimsSystemState | null = null;
|
|
/** Runs before every transaction body, to simulate a concurrent writer. */
|
|
onTransaction: (() => void) | null = null;
|
|
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
|
|
companyContact: { phone: string | null; email: string | null } | null = null;
|
|
|
|
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
|
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
|
this.state = {
|
|
id: "state-1",
|
|
systemNumber: SYSTEM_NUMBER,
|
|
nextInvoiceCounter: 7,
|
|
nextDocumentNumber: 5,
|
|
previousIrn: null,
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: null,
|
|
inFlightDocumentNumber: null,
|
|
blockedReason: null,
|
|
...state,
|
|
} as EimsSystemState;
|
|
}
|
|
|
|
private manager = {
|
|
createQueryBuilder: (entity: unknown) => {
|
|
const isInvoice = entity === Invoice;
|
|
let id: string | undefined;
|
|
const builder = {
|
|
setLock: () => builder,
|
|
where: (_clause: string, params: Record<string, string>) => {
|
|
id = params.invoiceId ?? params.systemNumber;
|
|
return builder;
|
|
},
|
|
getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state),
|
|
};
|
|
return builder;
|
|
},
|
|
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
|
|
this.invoices.get(options.where.id) ?? null,
|
|
update: async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
|
if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch);
|
|
else Object.assign(this.state!, patch);
|
|
},
|
|
query: async () => [],
|
|
getRepository: () => ({
|
|
findOne: async (options: { where: { id: string } }) =>
|
|
this.invoices.get(options.where.id) ?? null,
|
|
}),
|
|
};
|
|
|
|
asDataSource(): DataSource {
|
|
return {
|
|
manager: this.manager,
|
|
getRepository: this.manager.getRepository,
|
|
query: async (sql: string) => {
|
|
if (sql.includes("eims_system_state")) {
|
|
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
|
|
}
|
|
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
|
|
return LINES;
|
|
},
|
|
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
|
this.onTransaction?.();
|
|
return body(this.manager);
|
|
},
|
|
} as unknown as DataSource;
|
|
}
|
|
}
|
|
|
|
/** The source system comes from the access token, so the service is handed a session, not config. */
|
|
const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" };
|
|
|
|
const build = (
|
|
db: FakeDb,
|
|
postSigned: jest.Mock,
|
|
cfg: EimsConfig = config(),
|
|
postBearer: jest.Mock = jest.fn(),
|
|
getSessionContext: jest.Mock | undefined = undefined,
|
|
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
|
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
|
) =>
|
|
new EimsInvoiceRegistrationService(
|
|
db.asDataSource(),
|
|
{ get: () => cfg } as unknown as ConfigService,
|
|
{ postSigned, postBearer } as unknown as EimsClientService,
|
|
{
|
|
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
|
} as unknown as EimsAuthService,
|
|
{ notify } as unknown as NotificationInboxService,
|
|
{ directSend } as unknown as NotificationsService,
|
|
);
|
|
|
|
/**
|
|
* Document number the fixtures register under; `/v1/verify` must echo it back.
|
|
*
|
|
* A plain integer, not our `invoiceNumber`: MoR validates the field against
|
|
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
|
|
*/
|
|
const DOCUMENT_NUMBER = "5";
|
|
|
|
/**
|
|
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
|
|
* `irn`.
|
|
*
|
|
* The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection
|
|
* pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of
|
|
* the mock rather than gateway behaviour; asserting against that inconsistency would encode the
|
|
* mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for,
|
|
* and these fixtures exercise that honestly.
|
|
*/
|
|
const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
|
statusCode: 200,
|
|
message: "SUCCESS",
|
|
body: {
|
|
Irn: IRN,
|
|
TransactionType: "B2B",
|
|
DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" },
|
|
Version: "1",
|
|
...over,
|
|
},
|
|
});
|
|
|
|
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
|
|
statusCode: 200,
|
|
message: "SUCCESS",
|
|
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
|
|
});
|
|
|
|
const apiError = (kind: string, status?: number) =>
|
|
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
|
|
|
describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|
it("registers, persists the IRN and advances the chain", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
|
expect(postSigned.mock.calls[0][0]).toBe("/v1/register");
|
|
expect(view).toMatchObject({
|
|
eimsStatus: EimsInvoiceStatus.Registered,
|
|
eimsIrn: IRN,
|
|
eimsInvoiceCounter: 7,
|
|
eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]",
|
|
});
|
|
expect(db.state).toMatchObject({
|
|
previousIrn: IRN,
|
|
nextInvoiceCounter: 8,
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: null,
|
|
blockedReason: null,
|
|
});
|
|
});
|
|
|
|
it("persists signedQR alongside the IRN", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
|
|
|
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(view.eimsSignedQr).toBe("signed-qr-payload");
|
|
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
|
|
});
|
|
|
|
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(view.eimsSignedQr).toBeNull();
|
|
});
|
|
|
|
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
|
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
|
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
|
.registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
|
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
|
|
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
|
|
});
|
|
|
|
it("does not fail registration when the buyer notification itself fails", async () => {
|
|
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
|
db.companyContact = { phone: "+251911000000", email: null };
|
|
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
|
.registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
|
});
|
|
|
|
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
|
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
|
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
|
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
|
|
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
|
|
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
|
});
|
|
|
|
it("takes SourceSystem from the token session, not from configuration", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
// Config disagrees on purpose: only the session may reach the wire.
|
|
const cfg = config();
|
|
(cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY";
|
|
(cfg as { systemType: string }).systemType = "MAN";
|
|
|
|
await build(
|
|
db,
|
|
postSigned,
|
|
cfg,
|
|
jest.fn(),
|
|
jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }),
|
|
).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
|
expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN");
|
|
expect(request.SourceSystem.SystemType).toBe("POS");
|
|
});
|
|
|
|
it("does not consume a counter when authentication fails", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn();
|
|
const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed"));
|
|
|
|
await expect(
|
|
build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toThrow(/login failed/);
|
|
|
|
expect(postSigned).not.toHaveBeenCalled();
|
|
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
|
});
|
|
|
|
it("is idempotent — an invoice with an IRN never reaches EIMS", async () => {
|
|
const db = new FakeDb([
|
|
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
|
|
]);
|
|
const postSigned = jest.fn();
|
|
|
|
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(postSigned).not.toHaveBeenCalled();
|
|
expect(view.eimsIrn).toBe(IRN);
|
|
});
|
|
|
|
it("lets only one of two concurrent calls reach EIMS", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
let resolvePost: (v: unknown) => void = () => {};
|
|
const postSigned = jest
|
|
.fn()
|
|
.mockImplementation(() => new Promise((resolve) => (resolvePost = resolve)));
|
|
const service = build(db, postSigned);
|
|
|
|
const first = service.registerInvoiceWithEims(INVOICE_ID);
|
|
// Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
|
|
|
const second = service.registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
await expect(second).rejects.toBeInstanceOf(ConflictException);
|
|
resolvePost(okResponse());
|
|
await first;
|
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("blocks a different invoice while a submission is in flight (survives a restart)", async () => {
|
|
// A committed reservation left behind by a dead process.
|
|
const db = new FakeDb(
|
|
[
|
|
invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }),
|
|
invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }),
|
|
],
|
|
{ inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 },
|
|
);
|
|
const postSigned = jest.fn();
|
|
|
|
await expect(
|
|
build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID),
|
|
).rejects.toThrow(/already in flight/);
|
|
expect(postSigned).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn();
|
|
|
|
await expect(
|
|
build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(postSigned).not.toHaveBeenCalled();
|
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
|
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
|
});
|
|
|
|
it.each([
|
|
["SCHEMA_VALIDATION", 400],
|
|
["RULE_VALIDATION", 406],
|
|
])("marks %s (%i) FAILED and clears the global block", async (kind, status) => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockRejectedValue(apiError(kind, status));
|
|
|
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
|
EimsApiException,
|
|
);
|
|
|
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
|
eimsStatus: EimsInvoiceStatus.Failed,
|
|
eimsIrn: null,
|
|
});
|
|
expect(db.state).toMatchObject({
|
|
inFlightInvoiceId: null,
|
|
blockedReason: null,
|
|
previousIrn: null,
|
|
// Returned, not consumed: MoR tracks the sequence and rejects a gap
|
|
// ("Invoice counter is not correct. expected : 1").
|
|
nextInvoiceCounter: 7,
|
|
});
|
|
});
|
|
|
|
it("treats a success response with no IRN as a failed registration", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
|
|
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
|
/returned no IRN/,
|
|
);
|
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
|
expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null });
|
|
});
|
|
|
|
it("marks a timeout UNKNOWN and keeps the system blocked", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
|
|
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
|
EimsApiException,
|
|
);
|
|
|
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
|
eimsIrn: null,
|
|
});
|
|
expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID);
|
|
expect(db.state!.blockedReason).toMatch(/never acknowledged/);
|
|
expect(db.state!.previousIrn).toBeNull();
|
|
});
|
|
|
|
it("an UNKNOWN result blocks a different invoice too", async () => {
|
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
|
const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT"));
|
|
const service = build(db, postSigned);
|
|
|
|
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
|
EimsApiException,
|
|
);
|
|
await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow(
|
|
/registration is blocked/,
|
|
);
|
|
expect(postSigned).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
|
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
|
const postSigned = jest
|
|
.fn()
|
|
.mockRejectedValueOnce(apiError("RULE_VALIDATION", 406))
|
|
.mockResolvedValueOnce(okResponse());
|
|
const service = build(db, postSigned);
|
|
|
|
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
|
EimsApiException,
|
|
);
|
|
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
|
|
|
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
|
|
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
|
|
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
|
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
|
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
|
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
|
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
|
expect(second.DocumentDetails.DocumentNumber).toBe("5");
|
|
});
|
|
|
|
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const notify = jest.fn().mockResolvedValue(undefined);
|
|
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
|
|
|
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
|
|
// that actually failed live (eims_irn too narrow for the real value MoR returned).
|
|
let call = 0;
|
|
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
|
|
const realUpdate = manager.update;
|
|
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
|
call++;
|
|
if (call === 3) throw new Error("value too long for type character varying(64)");
|
|
return realUpdate(entity, id, patch);
|
|
});
|
|
|
|
await expect(
|
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toThrow(/value too long/);
|
|
|
|
// The IRN is never lost, even though the normal success path never committed.
|
|
expect(db.state?.blockedReason).toContain(IRN);
|
|
expect(db.state?.blockedReason).toContain("ACCEPTED");
|
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
|
|
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
const sent = notify.mock.calls[0][0];
|
|
expect(sent.priority).toBe("HIGH");
|
|
expect(sent.body).toContain(IRN);
|
|
});
|
|
});
|
|
|
|
describe("EimsInvoiceRegistrationService staff alerting", () => {
|
|
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const notify = jest.fn().mockResolvedValue(undefined);
|
|
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
|
|
|
await expect(
|
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toBeInstanceOf(EimsApiException);
|
|
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
const sent = notify.mock.calls[0][0];
|
|
expect(sent.priority).toBe("HIGH");
|
|
expect(sent.title).toMatch(/blocked/i);
|
|
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
|
|
});
|
|
|
|
it("raises a normal-priority alert for a deterministic rejection", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const notify = jest.fn().mockResolvedValue(undefined);
|
|
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
|
|
|
await expect(
|
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toBeInstanceOf(EimsApiException);
|
|
|
|
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
|
|
});
|
|
|
|
it("does not alert on a successful filing", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const notify = jest.fn();
|
|
|
|
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
|
|
.registerInvoiceWithEims(INVOICE_ID);
|
|
|
|
expect(notify).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets the filing outcome stand even if the alert itself fails", async () => {
|
|
const db = new FakeDb([invoiceRow()]);
|
|
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
|
|
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
|
|
|
|
await expect(
|
|
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
|
INVOICE_ID,
|
|
),
|
|
).rejects.toThrow(/EIMS register failed \(406\)/);
|
|
|
|
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
|
});
|
|
});
|
|
|
|
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
|
it("verifies the stored IRN over the unsigned bearer transport", async () => {
|
|
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
|
const postSigned = jest.fn();
|
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
|
|
|
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
|
INVOICE_ID,
|
|
);
|
|
|
|
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
|
|
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
|
expect(postSigned).not.toHaveBeenCalled();
|
|
expect(result.body).toMatchObject({ Irn: IRN });
|
|
});
|
|
|
|
it("rejects a 200 that carries no Irn", async () => {
|
|
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
|
).rejects.toThrow(/returned no Irn/);
|
|
});
|
|
|
|
it("refuses to verify an invoice with no IRN", async () => {
|
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
|
|
const postBearer = jest.fn();
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
|
).rejects.toThrow(/no EIMS IRN to verify/);
|
|
expect(postBearer).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
|
const blocked = () =>
|
|
new FakeDb(
|
|
[
|
|
invoiceRow({
|
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
|
eimsInvoiceCounter: 7,
|
|
eimsDocumentNumber: DOCUMENT_NUMBER,
|
|
}),
|
|
],
|
|
{
|
|
inFlightInvoiceId: INVOICE_ID,
|
|
inFlightCounter: 7,
|
|
nextInvoiceCounter: 8,
|
|
blockedReason: "never acknowledged",
|
|
});
|
|
|
|
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
|
|
const db = blocked();
|
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
|
|
|
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
|
INVOICE_ID,
|
|
{ irn: IRN },
|
|
);
|
|
|
|
// The IRN is confirmed at the gateway before it is ever written.
|
|
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
|
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
|
|
expect(db.state).toMatchObject({
|
|
previousIrn: IRN,
|
|
inFlightInvoiceId: null,
|
|
blockedReason: null,
|
|
});
|
|
});
|
|
|
|
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
|
|
const db = blocked();
|
|
const postBearer = jest
|
|
.fn()
|
|
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
|
).rejects.toThrow(/answered the lookup for IRN/);
|
|
|
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
|
eimsIrn: null,
|
|
});
|
|
expect(db.state).toMatchObject({
|
|
inFlightInvoiceId: INVOICE_ID,
|
|
blockedReason: "never acknowledged",
|
|
previousIrn: null,
|
|
});
|
|
});
|
|
|
|
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
|
|
const db = blocked();
|
|
const postBearer = jest.fn().mockResolvedValue(
|
|
verifyResponse({
|
|
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
|
).rejects.toThrow(/not 5/);
|
|
|
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
|
eimsStatus: EimsInvoiceStatus.Unknown,
|
|
eimsIrn: null,
|
|
});
|
|
expect(db.state).toMatchObject({
|
|
inFlightInvoiceId: INVOICE_ID,
|
|
blockedReason: "never acknowledged",
|
|
previousIrn: null,
|
|
});
|
|
});
|
|
|
|
it("refuses an IRN the gateway does not acknowledge at all", async () => {
|
|
const db = blocked();
|
|
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
|
).rejects.toThrow(/returned no Irn/);
|
|
expect(db.state!.blockedReason).toBe("never acknowledged");
|
|
});
|
|
|
|
it("discards the attempt, leaving the chain where it was", async () => {
|
|
const db = blocked();
|
|
const postBearer = jest.fn();
|
|
|
|
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
|
INVOICE_ID,
|
|
{ discard: true },
|
|
);
|
|
|
|
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
|
|
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
|
|
expect(db.state).toMatchObject({
|
|
previousIrn: null,
|
|
inFlightInvoiceId: null,
|
|
blockedReason: null,
|
|
});
|
|
});
|
|
|
|
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
|
const db = blocked();
|
|
db.invoices.set(
|
|
OTHER_INVOICE_ID,
|
|
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
|
);
|
|
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
|
irn: IRN,
|
|
}),
|
|
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
|
});
|
|
|
|
it("requires either an IRN or an explicit discard", async () => {
|
|
await expect(
|
|
build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
});
|