Files
0pt/tests/api/orders-import.test.ts
admin c1648c04a7
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / security (push) Successful in 22s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
masterplan-lock / check (push) Successful in 31s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / quality (push) Successful in 1m1s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m56s
feat(orders): CSV-import admin-yta — första riktiga ImportWizard-värden (C8)
Symmetrisk mot våg 3.4 CSV-export: POST /api/orders/import (validate |
dry_run | execute) + /admin/import som renderar @siax/ui ImportWizard
(0.14.0) över @siax/import-export runtime (0.2.0).

- Servern är enda auktoritet: RFC4180-parse (ingen eval), omvalidering
  via frysta @siax/contracts-kerneln (validateRows) + hostregler,
  exekvering genom BEFINTLIG orderlogik (createOrder, tenant-guardad).
- Fail-closed: 401 utan principal, 400 okänd header, 413 vid radtak
  (500), per-rad-atomsäkra inserts — felrad kan aldrig skada OK-rader.
- Partiell import: OK-rader skapas, felrader rapporteras per rad
  (row/field/code). AUD0-audit via 0pts enda producer-boundary
  (durable outbox under DATABASE_URL).
- Reload-persistens: efter execute läses /api/orders om och de nya
  raderna listas på sidan.
- Tester: 12 route-test (happy, partial, auth fail-closed, tak,
  no-write validate, header, quoting, rad-fel mitt i batch) + 3
  SSR-render-test av ImportWizard över runtime-storen.
- Deps: @siax/ui ^0.14.0, @siax/charts ^0.5.0, @siax/import-export
  ^0.2.0; pnpm-overrides till contracts 0.10.0 / import-export 0.2.0
  (ui 0.14.0 deklarerar 0.11.0/0.2.1 som ännu ej publicerats).
2026-09-30 15:36:02 +02:00

157 lines
7.0 KiB
TypeScript

/**
* 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");
});
});