Files
0pt/tests/api/order-notes.test.ts
admin 04813c34b0
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 7s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 18s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / quality (push) Failing after 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 31s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (pull_request) Failing after 21s
feat(collab): order-anteckningar — @siax/collaboration-kernelns första produktkonsument (spår C)
Kernel 0.3.0 används som capability, inte som paket-dekor:
- lib/order-notes-store.ts: varje note ÄR en CollaborationOperation;
  nextLamportClock för nya klockor, mergeOperationLog/compareOperations
  för deterministisk listning; optimistisk version -> 409 vid stale skrivning.
- lib/order-notes-client.ts: kernel-CollaborationTransport över host-API;
  offline = medvetet no-op + varning (ingen tyst förlust), 409 -> ConflictError.
- app/api/admin/order-notes: requireRouteAuth (fail-closed 401),
  server-authoritative tenant-guard (403), AUD0-audit via hostens klient.
- app/admin/notes: admin-yta med offline-växel, konflikt-banner + reload.
- sql/016_order_notes.sql: persistens (DB-tabell, version + Lamport-klocka).

Tester: API (happy/conflict 409/empty/400/401/403/404), klient-adapter
(offline no-op utan fetch, 409-mappning, presence), SSR-render. 18 nya test.
2026-09-30 17:07:21 +02:00

227 lines
8.6 KiB
TypeScript

/**
* 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<string, string>; 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<typeof vi.fn>;
};
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;