Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts
Hagernesh 28c9dd93e0 feat(eims): seller identity enriched from e-Trade, cached (bootstrap only)
Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity,
duplicating data the platform already has via the same e-Trade lookup used
for every customer company at onboarding. EimsSellerCacheService now enriches
it — but static config remains the source of truth: MoR validates
SellerDetails against its own taxpayer registry (rule 7017, already cleared
against the current static values), so e-Trade fills a field only when the
static value is blank, never overrides one already confirmed. The static
config is therefore the durable fallback, not the cache; an in-memory
snapshot lost on restart is harmless.

ETradeService has no request timeout of its own and no AbortController, so
the cache enforces one locally (stops waiting, doesn't cancel the request)
and de-duplicates concurrent refresh() calls into the same in-flight promise.
getSellerDetails() is fully synchronous — zero I/O — so live registration
never depends on e-Trade being reachable, at boot or per invoice.

VatNumber and Email stay on static config permanently — confirmed by reading
e-Trade's actual response shapes, neither field exists anywhere in what it
returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather
than adding seller-specific ones — the geography is objective, not
buyer-specific.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 07:43:04 +00:00

156 lines
5.9 KiB
TypeScript

import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
mobilePhone: "0911000000",
regularPhone: "",
...over,
});
const build = (cfg: EimsConfig = eimsConfig()) => {
const resolveCompanyData = jest.fn();
const extractRegistrationData = jest.fn().mockReturnValue(registrationData());
const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService;
const config = { get: () => cfg } as unknown as ConfigService;
const service = new EimsSellerCacheService(etrade, config);
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
companyInfo: {},
businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked
});
await service.refresh();
const seller = service.getSellerDetails(cfg);
// The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's
// differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)".
expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C.");
});
it("e-Trade fills a field only when the static value is blank", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({
sellerLegalName: "",
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await service.refresh();
const seller = service.getSellerDetails(cfg);
expect(seller.VatNumber).toBe("0000000000");
expect(seller.Email).toBe("finance@example.et");
});
it("falls back to the static config entirely when e-Trade has never been reachable", () => {
const cfg = eimsConfig();
const { service } = build(cfg);
// No refresh() ever called/succeeded — cached stays null.
const seller = service.getSellerDetails(cfg);
expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName);
expect(seller.Region).toBe(cfg.invoice.sellerRegion);
});
it("does no I/O at all — filing never triggers an e-Trade request", () => {
const { service, resolveCompanyData, cfg } = build();
service.getSellerDetails(cfg);
service.getSellerDetails(cfg);
expect(resolveCompanyData).not.toHaveBeenCalled();
});
});
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down"));
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
});
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves
const refreshing = service.refresh();
await jest.advanceTimersByTimeAsync(10_000);
await refreshing;
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
} finally {
jest.useRealTimers();
}
});
it("does not start a second e-Trade request while one is already in flight", async () => {
const { service, resolveCompanyData } = build();
let resolveCall: (value: unknown) => void = () => {};
resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve)));
const first = service.refresh();
const second = service.refresh();
resolveCall({ companyInfo: {}, businessInfo: {} });
await Promise.all([first, second]);
expect(resolveCompanyData).toHaveBeenCalledTimes(1);
});
});