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.
90 lines
3.5 KiB
TypeScript
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();
|
|
});
|
|
});
|