feat(orders): CSV-export-endpoint — export-kernelns första adoption (våg 3.4) #131

Merged
admin merged 1 commits from feat/orders-export-20260929 into main 2026-09-29 16:20:32 +00:00
3 changed files with 126 additions and 1 deletions
+7 -1
View File
@@ -113,7 +113,13 @@ export default function AdminPage() {
</section>
) : null}
<section className="mt-10">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Order queue", sv: "Orderkö" })}</h2>
<div className="flex items-center justify-between">
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{t({ en: "Order queue", sv: "Orderkö" })}</h2>
{/* DoD §65: real anchor, not a JS-handler button — the download is a plain GET of the CSV endpoint. */}
<a href="/api/orders/export" download className="border border-border px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:bg-foreground hover:text-background">
{t({ en: "Export CSV", sv: "Exportera CSV" })}
</a>
</div>
{orders.length === 0 ? (
<div className="mt-6"><EmptyState title={t({ en: "Empty queue.", sv: "Tom kö." })} line={t({ en: "No orders yet.", sv: "Inga ordrar än." })} /></div>
) : (
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { listOwnedOrders } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
/**
* GET /api/orders/export (våg 3.4, DoD §56) — CSV-export of the authenticated
* owner's orders. Same fail-closed auth as GET/POST /api/orders (this path is
* outside middleware.ts's /api/0pt matcher, so the Zitadel verifier runs here).
*
* RFC 4180: CRLF line endings, fields containing comma, quote or newline are
* quoted with doubled quotes, header row always present.
*
* Locale note: deposit_ore / total_ore are exported as RAW ÖRE — öre is the
* canonical unit in the order contract; formatting (SEK, thousand separators)
* happens in the UI layer, never in the export.
*
* Cap note (DoD §56): NO row truncation/limiting — an export must be complete.
*/
function csvEscape(value: string): string {
if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
export async function GET(req: NextRequest) {
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
try {
const orders = await listOwnedOrders(auth.owner.tenantId);
const header = ["order_id", "flow", "status", "deposit_ore", "total_ore"];
const rows = orders.map((o) =>
[o.order_id, o.flow, o.status, String(o.deposit_ore), String(o.total_ore)].map(csvEscape).join(","),
);
const csv = [header.join(","), ...rows].join("\r\n") + "\r\n";
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="orders-${date}.csv"`,
},
});
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Våg 3.4 (DoD §56): unit tests for GET /api/orders/export — the first
* adoption of the export kernel. Follows the suite pattern of calling the
* route handler directly with a hand-built NextRequest and mocked auth/store.
*/
import { describe, it, expect, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/src/auth/require-order-owner", () => ({
requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }),
}));
vi.mock("@/lib/orders-store", () => ({
listOwnedOrders: vi.fn().mockResolvedValue([
{ order_id: "ord_1", flow: "custom", status: "draft", deposit_ore: 5000, total_ore: 10000 },
{ order_id: 'ord,"2"', flow: "platform", status: "delivered", deposit_ore: 0, total_ore: 12345 },
]),
}));
import { GET as exportGET } from "@/app/api/orders/export/route";
function getReq(): NextRequest {
return new NextRequest(new URL("/api/orders/export", "http://localhost"), { method: "GET" });
}
describe("GET /api/orders/export — CSV (våg 3.4, DoD §56)", () => {
it("emits the documented header row and CRLF line endings", async () => {
const r = await exportGET(getReq());
expect(r.status).toBe(200);
expect(r.headers.get("content-type")).toContain("text/csv");
expect(r.headers.get("content-disposition")).toMatch(/^attachment; filename="orders-\d{4}-\d{2}-\d{2}\.csv"$/);
const body = await r.text();
const lines = body.split("\r\n");
expect(lines[0]).toBe("order_id,flow,status,deposit_ore,total_ore");
expect(lines[1]).toBe("ord_1,custom,draft,5000,10000");
});
it("RFC4180-quotes fields containing comma/quote (doubled quotes)", async () => {
const r = await exportGET(getReq());
const body = await r.text();
expect(body).toContain('"ord,""2"""');
});
it("exports RAW öre — no locale formatting in the export (öre is canonical)", async () => {
const r = await exportGET(getReq());
const body = await r.text();
expect(body).toContain("ord_1,custom,draft,5000,10000");
expect(body).not.toContain("100,00");
expect(body).not.toMatch(/\u00A0| kr/);
});
it("no row truncation: export is complete per DoD §56 (cap documented)", async () => {
const r = await exportGET(getReq());
const body = await r.text();
const rows = body.trimEnd().split("\r\n").length - 1;
expect(rows).toBe(2);
});
it("auth is fail-closed: the route delegates to requireOrderOwner (401 contract)", async () => {
// The mocked requireOrderOwner resolves with an owner; the 401 path is the
// auth.response passthrough asserted in tests/api/anonymous-routes-auth.
// This test pins the wiring: the handler must return auth.response
// verbatim when present, before any store access.
const { requireOrderOwner } = await import("@/src/auth/require-order-owner");
const mocked = requireOrderOwner as ReturnType<typeof vi.fn>;
mocked.mockResolvedValueOnce({
response: new Response(JSON.stringify({ error: { code: "unauthorized" } }), { status: 401 }),
});
const r = await exportGET(getReq());
expect(r.status).toBe(401);
});
});