/** * C8: tests for POST /api/orders/import — the symmetric counterpart of the * våg-3.4 CSV export and the first real product host of the @siax/ui * ImportWizard kernel. Follows the suite pattern of calling the route handler * directly with a hand-built NextRequest and mocked auth/store/audit. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; const mockOwner = { owner: { tenantId: "tenant-test", subject: "buyer-test" } }; const requireOrderOwnerMock = vi.fn(); vi.mock("@/src/auth/require-order-owner", () => ({ requireOrderOwner: (...args: unknown[]) => requireOrderOwnerMock(...args), })); const createOrderMock = vi.fn(); vi.mock("@/lib/orders-store", () => ({ createOrder: (...args: unknown[]) => createOrderMock(...args), })); const ingestEventMock = vi.fn().mockResolvedValue({}); vi.mock("@/src/clients/aud0-client", () => ({ createAud0Client: () => ({ ingestEvent: ingestEventMock }), })); import { POST, parseCsv, validateImport, MAX_IMPORT_ROWS } from "@/app/api/orders/import/route"; function postReq(body: unknown, withAuth = true): NextRequest { requireOrderOwnerMock.mockResolvedValue(withAuth ? mockOwner : { response: NextResponse.json({ error: "OPT_UNAUTHENTICATED" }, { status: 401 }) }); return new NextRequest(new URL("/api/orders/import", "http://localhost"), { method: "POST", body: JSON.stringify(body), headers: withAuth ? { authorization: "Bearer x" } : {}, }); } const HEADER = "flow,service_type,type,volume,frequency,markets"; function csv(lines: string[]): string { return [HEADER, ...lines].join("\r\n") + "\r\n"; } beforeEach(() => { createOrderMock.mockReset(); ingestEventMock.mockClear(); createOrderMock.mockImplementation(async (flow: string, config: unknown, _key: unknown, owner: { tenantId: string }) => ({ order: { order_id: `ord_${createOrderMock.mock.calls.length}`, flow, status: "draft", config, tenant_id: owner.tenantId }, replayed: false, })); }); describe("parseCsv (server-parsed, RFC 4180, no eval)", () => { it("parses quoted fields with doubled quotes", () => { const { rows } = parseCsv('flow,markets\r\ncustom,"A;""B,C"""\r\n'); expect(rows[0].flow).toBe("custom"); expect(rows[0].markets).toBe('A;"B,C"'); }); it("rejects unknown header columns (fail-closed, never silently dropped)", () => { const { headerError } = parseCsv("flow,evil\r\ncustom,x\r\n"); expect(headerError).toMatch(/Okänd kolumn: evil/); }); }); describe("validateImport — kernel + host rules", () => { it("reports missing required flow as missing_required", () => { const { report } = validateImport(csv([",icp,Bolag,10,Engångs,SE"])); expect(report.errorCount).toBeGreaterThan(0); expect(report.errors.some((e) => e.code === "missing_required")).toBe(true); }); it("reports bad host fields with row/field/code", () => { const { report } = validateImport(csv(["nope,icp,Bolag,-5,Engångs,SE"])); const fields = report.errors.map((e) => e.field).sort(); expect(fields).toContain("flow"); expect(fields).toContain("volume"); }); }); describe("POST /api/orders/import", () => { it("happy path: execute imports all valid rows through createOrder with the verified owner + emits AUD0", async () => { const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Fordon,2,Engångs,", "subscription,,Beslutsfattare,50,Veckovis,SE;NO"]) })); expect(r.status).toBe(200); const body = await r.json(); expect(body.imported).toBe(3); expect(body.errors).toHaveLength(0); expect(body.order_ids).toHaveLength(3); expect(createOrderMock).toHaveBeenCalledTimes(3); expect(createOrderMock.mock.calls[0][3]).toEqual({ tenantId: "tenant-test", subject: "buyer-test" }); expect(createOrderMock.mock.calls[0][1]).toMatchObject({ service_type: "icp", type: "Bolag", volume: 10, frequency: "Engångs", markets: ["SE"] }); expect(createOrderMock.mock.calls[2][1]).toMatchObject({ markets: ["SE", "NO"] }); expect(ingestEventMock).toHaveBeenCalledTimes(1); expect(ingestEventMock.mock.calls[0][0]).toMatchObject({ event_type: "0pt.orders.import", tenant_id: "tenant-test", risk_level: "L2" }); }); it("partial success: failing rows are reported per row and never created; passing rows import", async () => { const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "bogus_flow,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,notanumber,Engångs,SE"]) })); expect(r.status).toBe(200); const body = await r.json(); expect(body.imported).toBe(1); expect(body.errorCount).toBe(2); const badFlow = body.errors.find((e: { field: string }) => e.field === "flow"); const badVol = body.errors.find((e: { field: string }) => e.field === "volume"); expect(badFlow.row).toBe(2); expect(badVol.row).toBe(3); expect(createOrderMock).toHaveBeenCalledTimes(1); }); it("validate/dry_run never write (createOrder not called)", async () => { const r1 = await POST(postReq({ mode: "validate", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) })); expect(r1.status).toBe(200); const b1 = await r1.json(); expect(b1.validRows).toBe(1); expect(b1.would_import).toBe(1); const r2 = await POST(postReq({ mode: "dry_run", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) })); expect(r2.status).toBe(200); expect(createOrderMock).not.toHaveBeenCalled(); }); it("fail-closed auth: no principal → 401 and createOrder is never reached", async () => { const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) }, false)); expect(r.status).toBe(401); expect(createOrderMock).not.toHaveBeenCalled(); }); it("row cap: more than MAX_IMPORT_ROWS data rows → 413, no writes", async () => { const lines = Array.from({ length: MAX_IMPORT_ROWS + 1 }, () => "custom,icp,Bolag,10,Engångs,SE"); const r = await POST(postReq({ mode: "execute", csv: csv(lines) })); expect(r.status).toBe(413); expect(createOrderMock).not.toHaveBeenCalled(); }); it("unknown header → 400 fail-closed", async () => { const r = await POST(postReq({ mode: "execute", csv: "flow,evil\r\ncustom,x\r\n" })); expect(r.status).toBe(400); const body = await r.json(); expect(body.error.code).toBe("bad_header"); }); it("empty/missing csv → 400", async () => { const r = await POST(postReq({ mode: "execute", csv: " " })); expect(r.status).toBe(400); }); it("execute-time creation failure is reported per row without aborting the batch", async () => { createOrderMock.mockImplementationOnce(async () => { throw new Error("db down"); }); const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,1,Engångs,SE"]) })); expect(r.status).toBe(200); const body = await r.json(); expect(body.imported).toBe(1); expect(body.errors[0].row).toBe(1); expect(body.errors[0].message).toContain("db down"); }); });