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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user