Files
cyclone/src/lib/x12-detect.test.ts
T
Nora 3bc5740e8b feat(sp35): Upload page auto-detects 837P vs 835 from file content
Layer A of the SP35 defense-in-depth fix. Before SP35 the dropdown
silently defaulted to '837p' and never changed when a file was dropped
on the page — uploading an 835 file routed it to /api/parse-837 which
(prior to SP35 Task 2) silently persisted an empty batch.

The change:

1. New pure helper src/lib/x12-detect.ts:
   - detectKindFromText(text) reads the first ~4KB and returns the
     DetectedKind ('837p' | '835' | '999' | '277ca' | 'ta1' | 'unknown')
     by matching the ST01 segment (or the bare TA1 segment for the
     no-ST envelope). Cheap substring scan; never invokes tokenize().
   - detectKindFromFile(file) is the File-aware wrapper used by the UI.
   - detectedKindToParsedBatchKind maps the DetectedKind to the kind
     the Upload dropdown supports. Returns null for 999/277CA/TA1 so
     the UI can surface a clean 'this file isn't supported here' hint.

2. Upload.tsx: pickFile is now async and reads the file before storing
   it. If the detected kind differs from the dropdown's current value,
   it switches the dropdown and toasts a hint. If the detected kind is
   999/277CA/TA1 (Upload doesn't ingest those), it shows an error toast.

20 new tests in src/lib/x12-detect.test.ts cover the 6 DetectedKind
paths, the File wrapper, case-insensitivity, garbage input, the
ST*8370 false-positive guard, and the detectedKindToParsedBatchKind
mapping.
2026-07-06 09:58:26 -06:00

148 lines
5.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
detectKindFromText,
detectKindFromFile,
detectedKindToParsedBatchKind,
} from "./x12-detect";
// Minimal X12 envelopes — just enough to exercise the ST01 match.
// Real fixtures live in backend/tests/fixtures; we use synthetic ones
// here because the frontend doesn't need the full file content, just
// the first ~4KB.
const ISA_837P = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260617*1937*^*00501*000000001*0*P*:~" +
"GS*HC*SENDER*RECEIVER*20260706*1937*1*X*005010X222A1~" +
"ST*837*0001*005010X222A1~"
);
const ISA_835 = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260617*1937*^*00501*000000001*0*P*:~" +
"GS*HP*SENDER*RECEIVER*20260706*1937*1*X*005010X221A1~" +
"ST*835*0001~"
);
const ISA_999 = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260617*1937*^*00501*000000001*0*P*:~" +
"GS*FA*SENDER*RECEIVER*20260706*1937*1*X*005010X231A1~" +
"ST*999*0001~"
);
const ISA_277CA = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260617*1937*^*00501*000000001*0*P*:~" +
"GS*HN*SENDER*RECEIVER*20260706*1937*1*X*005010X214~" +
"ST*277*0001*005010X214~"
);
const ISA_277CA_QUALIFIED = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260617*1937*^*00501*000000001*0*P*:~" +
"GS*HN*SENDER*RECEIVER*20260706*1937*1*X*005010X214~" +
"ST*277CA*0001*005010X214~"
);
const TA1_FILE = (
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " +
"*260520*1750*^*00501*000000001*0*P*:~" +
"TA1*000000001*20260520*1750*A*000*20260520~" +
"IEA*1*000000001~"
);
describe("detectKindFromText", () => {
it("detects ST*837* as 837p", () => {
expect(detectKindFromText(ISA_837P)).toBe("837p");
});
it("detects ST*837P* as 837p (professional qualifier)", () => {
const withQualifier = ISA_837P.replace("ST*837*", "ST*837P*");
expect(detectKindFromText(withQualifier)).toBe("837p");
});
it("detects ST*835* as 835", () => {
expect(detectKindFromText(ISA_835)).toBe("835");
});
it("detects ST*999* as 999", () => {
expect(detectKindFromText(ISA_999)).toBe("999");
});
it("detects ST*277* as 277ca", () => {
expect(detectKindFromText(ISA_277CA)).toBe("277ca");
});
it("detects ST*277CA* as 277ca (qualified form)", () => {
expect(detectKindFromText(ISA_277CA_QUALIFIED)).toBe("277ca");
});
it("detects TA1 envelope as ta1 (no ST, bare TA1 segment)", () => {
expect(detectKindFromText(TA1_FILE)).toBe("ta1");
});
it("returns unknown for garbage input", () => {
expect(detectKindFromText("not edi at all")).toBe("unknown");
});
it("returns unknown for an empty string", () => {
expect(detectKindFromText("")).toBe("unknown");
});
it("is case-insensitive on ST segment", () => {
// Real X12 is uppercase, but be lenient — files from various tools
// sometimes have mixed case.
expect(detectKindFromText(ISA_835.toLowerCase())).toBe("835");
});
it("does not false-positive on ST*8370 (5 chars after ST*)", () => {
// Catches a regression where the matcher would substring-match too
// eagerly (e.g. matching ST*837 against ST*8370*). The require is
// that the char after the digits is `*`, not another digit.
const noise = "ISA*00*...*ST*8370*0001~";
expect(detectKindFromText(noise)).toBe("unknown");
});
});
describe("detectedKindToParsedBatchKind", () => {
it("maps 837p → 837p", () => {
expect(detectedKindToParsedBatchKind("837p")).toBe("837p");
});
it("maps 835 → 835", () => {
expect(detectedKindToParsedBatchKind("835")).toBe("835");
});
it("returns null for 999 (Upload page doesn't ingest 999s)", () => {
expect(detectedKindToParsedBatchKind("999")).toBeNull();
});
it("returns null for 277ca", () => {
expect(detectedKindToParsedBatchKind("277ca")).toBeNull();
});
it("returns null for ta1", () => {
expect(detectedKindToParsedBatchKind("ta1")).toBeNull();
});
it("returns null for unknown", () => {
expect(detectedKindToParsedBatchKind("unknown")).toBeNull();
});
});
describe("detectKindFromFile", () => {
it("detects 837p from a File blob", async () => {
const file = new File([ISA_837P], "test.x12", { type: "text/plain" });
expect(await detectKindFromFile(file)).toBe("837p");
});
it("detects 835 from a File blob (the original SP35 repro)", async () => {
// The exact scenario the user hit: drop an 835 file on the Upload
// page while the dropdown still says "837p". The helper must
// return "835" so the UI can switch the dropdown before the user
// hits Parse.
const file = new File([ISA_835], "tp11525703-835.x12", { type: "text/plain" });
expect(await detectKindFromFile(file)).toBe("835");
});
it("returns unknown on an empty file (no crash)", async () => {
const file = new File([""], "empty.x12", { type: "text/plain" });
expect(await detectKindFromFile(file)).toBe("unknown");
});
});