feat(orders): CSV-export-endpoint — export-kernelns första adoption (våg 3.4) #131
+7
-1
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user