Files
0pt/tests/lib/order-notes-client.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

90 lines
3.5 KiB
TypeScript

/**
* Klient-adaptern (kernel-transport över host-API): offline = no-op + varning
* (INGEN fetch, inget tyst förlorat), 409 -> ConflictError med aktuell
* version, presence via kernelns upsertPresence.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OfflineError,
OrderNotesClient,
OrderNotesTransport,
} from "@/lib/order-notes-client";
describe("OrderNotesTransport (kernel CollaborationTransport)", () => {
it("send() i offline-läge kastar OfflineError utan nätverksanrop (no-op, ej tyst)", async () => {
const t = new OrderNotesTransport("admin-simon");
t.setOffline(true);
await expect(
t.send({ id: "n1", actorId: "x", clock: 1, type: "note.update", payload: { body: "y" } }),
).rejects.toBeInstanceOf(OfflineError);
});
it("connect/disconnect växlar presence via upsertPresence (online -> offline)", async () => {
const t = new OrderNotesTransport("admin-simon");
await t.connect();
expect(t.getPresence().find((p) => p.participantId === "admin-simon")?.status).toBe("online");
await t.disconnect();
expect(t.getPresence().find((p) => p.participantId === "admin-simon")?.status).toBe("offline");
});
it("observeRemote advanserar Lamport-klockan och notifyar listeners", async () => {
const t = new OrderNotesTransport("me");
const seen: string[] = [];
t.onOperation((op) => seen.push(`${op.id}@${op.clock}`));
t.observeRemote({ id: "n1", actorId: "anna", clock: 4, type: "note.upsert", payload: { body: "x" } });
expect(seen).toEqual(["n1@4"]);
expect(t.getPresence().find((p) => p.participantId === "anna")?.status).toBe("online");
});
});
describe("OrderNotesClient över host-API", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("offline-läge: add() är no-op — fetch anropas EJ, OfflineError kastas (ingen tyst förlust)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const client = new OrderNotesClient("admin-simon");
client.transport.setOffline(true);
await expect(client.add("order-1", "utkast")).rejects.toBeInstanceOf(OfflineError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("409 från servern -> ConflictError med serverns aktuella version", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "version_conflict", current_version: 3 }), { status: 409 }),
);
const client = new OrderNotesClient("admin-simon");
await expect(
client.update(
{ note_id: "n1", order_id: "o1", author_id: "a", body: "old", version: 1, clock: 1, updated_at: "" },
"new",
1,
),
).rejects.toMatchObject({ currentVersion: 3 });
});
it("load(): varje servernot replays som kernel-operation (klockor advanserar)", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
notes: [
{ note_id: "n1", order_id: "o1", author_id: "anna", body: "A", version: 1, clock: 2, updated_at: "" },
{ note_id: "n2", order_id: "o1", author_id: "bosse", body: "B", version: 2, clock: 5, updated_at: "" },
],
}),
{ status: 200 },
),
);
const client = new OrderNotesClient("admin-simon");
const { notes, versions } = await client.load("o1");
expect(notes).toHaveLength(2);
expect(versions.get("n2")).toBe(2);
expect(client.transport.getPresence().find((p) => p.participantId === "bosse")).toBeTruthy();
});
});