/** * Spår C — @siax/collaboration-kernelns första produktkonsument: * /api/admin/order-notes (persisted collaboration state på admin-orders). * * Mönstret följer tests/api/anonymous-routes-auth.test.ts + * orders-idempotency.test.ts: route-handlers anropas direkt med handbyggda * NextRequest. Ingen DATABASE_URL i test-miljön => lib/order-notes-store.ts * faller tillbaka på sin dokumenterade in-memory-store (production vägrar * den vägen via resolveStoreMode — se lib/db.ts). */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest, NextResponse } from "next/server"; vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: vi.fn(), })); vi.mock("@/src/clients/aud0-client", () => ({ createAud0Client: vi.fn(() => ({ ingestEvent: vi.fn().mockResolvedValue({}) })), })); import { requireRouteAuth } from "@/src/auth/require-route-auth"; import { createAud0Client } from "@/src/clients/aud0-client"; import { GET, PATCH, POST } from "@/app/api/admin/order-notes/route"; import { getOrder, createOrder } from "@/lib/orders-store"; import { addNote, listNotes, resetOrderNotesMemoryForTests, } from "@/lib/order-notes-store"; const authed = { tenantId: "tenant-test", actorId: "admin-simon", requestId: "req-1" }; function mockAuth(ctx = authed) { vi.mocked(requireRouteAuth).mockResolvedValue({ ctx } as never); } function req( url: string, init: { method?: string; headers?: Record; body?: string } = {}, ): NextRequest { return new NextRequest(new URL(url, "http://localhost"), init); } function jsonReq(url: string, body: unknown, method = "POST"): NextRequest { return req(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } let orderId: string; beforeEach(async () => { vi.mocked(requireRouteAuth).mockReset(); vi.mocked(createAud0Client).mockClear(); resetOrderNotesMemoryForTests(); mockAuth(); const created = await createOrder("custom", { markets: ["SE"], type: "Bolag", volume: 10, }); orderId = created.order.order_id; // Legacy anonymous draft får annoteras av admin — men en tenant-ägd order // behöver token-match. Skapa därför testordern anonym (tenant null) och // testa tenant-guarden separat med en ägd order. }); afterEach(() => { resetOrderNotesMemoryForTests(); }); describe("GET /api/admin/order-notes", () => { it("empty order -> 200 med tom lista (explicit empty behavior)", async () => { const res = await GET(req(`/api/admin/order-notes?order_id=${orderId}`)); expect(res.status).toBe(200); const body = await res.json(); expect(body.notes).toEqual([]); }); it("saknad order_id -> 400; okänd order -> 404", async () => { expect((await GET(req("/api/admin/order-notes"))).status).toBe(400); expect((await GET(req("/api/admin/order-notes?order_id=nope"))).status).toBe(404); }); it("utan auth-svar från requireRouteAuth -> fail-closed (401), aldrig data", async () => { vi.mocked(requireRouteAuth).mockResolvedValueOnce({ response: new NextResponse(null, { status: 401 }), } as never); const res = await GET(req(`/api/admin/order-notes?order_id=${orderId}`)); expect(res.status).toBe(401); }); }); describe("POST + GET happy path (kernel-semantik)", () => { it("201 skapar noten, GET listar deterministiskt (Lamport-ordning, inte insättningsordning)", async () => { const r1 = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: "Första" })); expect(r1.status).toBe(201); const first = await r1.json(); expect(first.version).toBe(1); expect(first.clock).toBe(1); const r2 = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: "Andra" })); expect(r2.status).toBe(201); const list = await (await GET(req(`/api/admin/order-notes?order_id=${orderId}`))).json(); expect(list.notes).toHaveLength(2); // kernel mergeOperationLog: stigande (clock, actorId, id) const clocks = list.notes.map((n: { clock: number }) => n.clock); expect([...clocks].sort((a: number, b: number) => a - b)).toEqual(clocks); expect(list.notes[0].body).toBe("Första"); // audit event emitterades via hostens AUD0-klient const ingest = vi.mocked(createAud0Client).mock.results.at(-1)?.value as { ingestEvent: ReturnType; }; expect(ingest.ingestEvent).toHaveBeenCalled(); const evt = ingest.ingestEvent.mock.calls[0][0]; expect(evt.capability_id).toBe("admin.order_notes"); expect(evt.event_type).toBe("order_note.created"); }); it("tom body -> 400 (server-authoritative validering)", async () => { const res = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: " " })); expect(res.status).toBe(400); }); it("tenant-guard: order tillhör annan tenant än token -> 403", async () => { const created = await createOrder("custom", { markets: ["SE"], volume: 5 }, undefined, { tenantId: "tenant-other", subject: "buyer-other", }); const res = await POST( jsonReq("/api/admin/order-notes", { order_id: created.order.order_id, body: "hej" }), ); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toBe("OPT_ACCESS_DENIED"); }); }); describe("PATCH — optimistisk samtidighet (conflict behavior)", () => { it("rätt version -> 200, version+1, klockan tickar (nextLamportClock)", async () => { const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" }); const res = await PATCH( jsonReq("/api/admin/order-notes", { note_id: note.note_id, body: "v2", version: 1 }, "PATCH"), ); expect(res.status).toBe(200); const updated = await res.json(); expect(updated.body).toBe("v2"); expect(updated.version).toBe(2); expect(updated.clock).toBe(note.clock + 1); }); it("stale version -> 409 med aktuell version (multi-user semantics)", async () => { const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" }); await updateNoteViaApi(note.note_id, "v2 fran annan skribent"); // samtidig skribant -> v2 const res = await PATCH( jsonReq("/api/admin/order-notes", { note_id: note.note_id, body: "v3 stale", version: 1 }, "PATCH"), ); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toBe("version_conflict"); expect(body.current_version).toBe(2); }); it("okänd note -> 404; ogiltig version -> 400", async () => { expect( ( await PATCH( jsonReq( "/api/admin/order-notes", { note_id: "note_nope", body: "x", version: 1 }, "PATCH", ), ) ).status, ).toBe(404); const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" }); expect( ( await PATCH( jsonReq( "/api/admin/order-notes", { note_id: note.note_id, body: "x", version: 1.5 }, "PATCH", ), ) ).status, ).toBe(400); }); it("PATCH utan auth -> 401 fail-closed", async () => { vi.mocked(requireRouteAuth).mockResolvedValueOnce({ response: new NextResponse(null, { status: 401 }), } as never); const res = await PATCH( jsonReq("/api/admin/order-notes", { note_id: "x", body: "x", version: 1 }, "PATCH"), ); expect(res.status).toBe(401); }); }); async function updateNoteViaApi(noteId: string, body: string) { const res = await PATCH(jsonReq("/api/admin/order-notes", { note_id: noteId, body, version: 1 }, "PATCH")); expect(res.status).toBe(200); return res.json(); } describe("kernel-återanvändning (capability, inte paket)", () => { it("listNotes är kernel-mergead: ordningen följer compareOperations (klocka -> actorId -> id), två listningar identiska", async () => { const a = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "A" }); const b = await addNote({ order_id: orderId, tenant_id: null, author_id: "b", body: "B" }); const first = await listNotes(orderId); const second = await listNotes(orderId); expect(second.map((n) => n.note_id)).toEqual(first.map((n) => n.note_id)); // stigande klocka = deterministisk kernel-ordning, oberoende av minnes-mapens iteration expect(first.map((n) => n.clock)).toEqual([a.clock, b.clock].sort((x, y) => x - y)); expect(first.map((n) => n.note_id)).toEqual([a.note_id, b.note_id]); }); }); // getOrder används i routen (server-authoritative tenant-uppslag) — håll // importen levande så refaktorer inte tyst tappar guarden. void getOrder;