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
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.
227 lines
8.6 KiB
TypeScript
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;
|