import "fake-indexeddb/auto"; import { describe, expect, it } from "vitest"; import { createServerClient, type ProseMirrorDoc, type Character, type StorageAdapter, type WorldItem } from "@pubscript/core"; import { storageLocal } from "@pubscript/core/local"; const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] }); /** * A minimal in-memory REST mock implementing exactly the endpoints the * `StorageAdapter` scenario exercises. This lets us run the *server* adapter * in Node without a live server, and compare it against the *local* adapter. */ function mockServerFetch(): typeof fetch { const projects = new Map(); const chapters = new Map(); const entities = new Map(); const boards = new Map(); const sessions = new Map(); let n = 0; const id = () => `id-${++n}`; const handler = async (url: string, init?: RequestInit): Promise => { const u = new URL(url); const method = (init?.method ?? "GET").toUpperCase(); const body = init?.body ? (JSON.parse(init.body as string) as any) : undefined; const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } }); if (method === "POST" && u.pathname === "/api/projects") { const p = { id: id(), projectId: "", title: body.title, ownerId: "u1", sharedWith: [], settings: body.settings ?? {}, cover: body.cover ?? { color: "#000" }, createdAt: "t", updatedAt: "t", }; p.projectId = p.id; projects.set(p.id, p); return json({ project: p }); } if (method === "GET" && u.pathname === "/api/projects") return json({ projects: [...projects.values()] }); let m = u.pathname.match(/^\/api\/projects\/([^/]+)$/); if (method === "GET" && m) { const p = projects.get(m[1]!); return p ? json({ project: p }) : json({ error: "not_found" }, 404); } m = u.pathname.match(/^\/api\/chapters\/([^/]+)\/autosave$/); if (method === "POST" && m) { const existing = chapters.get(m[1]!); const rev = (existing?.rev ?? 0) + 1; const ch = { id: m[1]!, projectId: "proj", title: "Untitled", doc: body.doc, rev, meta: { wordCount: body.wordCount, scenes: 0, updatedAt: "t" } }; chapters.set(m[1]!, ch); return json({ chapter: ch, rev }); } m = u.pathname.match(/^\/api\/chapters\/([^/]+)$/); if (method === "GET" && m) { const ch = chapters.get(m[1]!); return ch ? json({ chapter: ch }) : json({ error: "not_found" }, 404); } m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities$/); if (method === "POST" && m) { const e = { id: body.id ?? id(), projectId: m[1]!, ...body }; entities.set(`${m[1]!}|${body.kind}|${e.id}`, e); return json({ entity: e }); } if (method === "GET" && m) { const projectId = m[1]!; const kind = u.searchParams.get("kind"); const q = u.searchParams.get("q") ?? ""; const all = [...entities.values()].filter((e) => e.projectId === projectId && (!kind || e.kind === kind)); const filtered = q ? all.filter((e) => JSON.stringify(e).toLowerCase().includes(q.toLowerCase())) : all; return json({ entities: filtered }); } m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities\/([^/]+)\/([^/]+)$/); if (method === "GET" && m) { const projectId = m[1]!; const kind = m[2]!; const id = m[3]!; const e = entities.get(`${projectId}|${kind}|${id}`); return e ? json({ entity: e }) : json({ error: "not_found" }, 404); } if (method === "DELETE" && m) { const projectId = m[1]!; const kind = m[2]!; const id = m[3]!; entities.delete(`${projectId}|${kind}|${id}`); return json({ ok: true }); } m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/); if (method === "POST" && m) { const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] }; boards.set(b.id, b); return json({ board: b }); } m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/); if (method === "POST" && m) { const now = new Date().toISOString(); const s = { id: body.id ?? id(), projectId: m[1]!, title: body.title ?? "", kind: body.kind ?? "session", messages: body.messages ?? [], suggestions: body.suggestions ?? [], notes: body.notes ?? [], createdAt: body.createdAt ?? now, updatedAt: body.updatedAt ?? now, lastMessageAt: body.lastMessageAt ?? null }; sessions.set(s.id, s); return json({ session: s }); } return json({ error: "not_found" }, 404); }; return (async (input: string | URL | Request, init?: RequestInit) => handler(typeof input === "string" ? input : input.toString(), init)) as unknown as typeof fetch; } /** The shared scenario both adapters must pass identically. */ async function runScenario(adapter: StorageAdapter) { const project = await adapter.createProject({ title: "Test Novel" }); const fetched = await adapter.getProject(project.id); const list = await adapter.listProjects(); const docId = "doc-1"; await adapter.saveDocument({ id: docId, projectId: project.id, title: "Chapter 1", doc: doc("Hello world"), rev: 0 }); const chapter = await adapter.getChapter(docId); const chapterText = chapter && "content" in (chapter.doc as object) ? ((chapter.doc as { content?: Array<{ text?: string }> }).content?.[0]?.text) : undefined; await adapter.saveEntity({ id: "char-1", projectId: project.id, name: "Ada", role: "protagonist", bio: "x", appearance: "", voice: "", relationships: [], arc: "", status: "active", tags: [], }); await adapter.saveBoard({ id: "board-1", projectId: project.id, nodes: [{ id: "n1", type: "character", position: { x: 0, y: 0 }, data: { refId: "char-1" } }], edges: [], }); await adapter.saveAISession({ id: "sess-1", projectId: project.id, title: "Session", kind: "session", messages: [], suggestions: [], notes: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lastMessageAt: null, }); const unsub = adapter.onEvent(() => {}); expect(typeof unsub).toBe("function"); unsub(); return { title: project.title, hasId: typeof project.id === "string" && project.id.length > 0, fetchedTitle: fetched.title, listed: list.some((p) => p.title === "Test Novel"), chapterText, chapterRev: chapter?.rev, }; } const EXPECTED = { title: "Test Novel", hasId: true, fetchedTitle: "Test Novel", listed: true, chapterText: "Hello world", chapterRev: 1, }; /** Entity CRUD + search scenario both adapters must pass identically. */ async function entityScenario(adapter: StorageAdapter) { const projectId = "proj-ent"; await adapter.createProject({ title: "Entities" }); const ada: Character = { id: "ada", projectId, name: "Ada Lovelace", role: "protagonist", bio: "A mathematician and first computer programmer.", appearance: "sharp eyes, Victorian dress", voice: "precise, confident", relationships: [{ targetId: "babbage", kind: "ally", note: "worked together" }], arc: "From curiosity to genius.", status: "active", tags: ["math", "victorian"], }; const babbage: Character = { id: "babbage", projectId, name: "Charles Babbage", role: "mentor", bio: "An obsessed inventor of computing machines.", appearance: "bearded, bespectacled", voice: "grumbly", relationships: [], arc: "", status: "active", tags: ["inventor"], }; const clocktower: WorldItem = { id: "clocktower", projectId, kind: "location", name: "The Clocktower", fields: { era: "1840s", significance: "meeting place" }, }; await adapter.saveEntity(ada); await adapter.saveEntity(babbage); await adapter.saveEntity(clocktower); // list all const all = await adapter.listEntities(projectId); expect(all.map((e) => e.id).sort()).toEqual(["ada", "babbage", "clocktower"]); // list by kind const chars = await adapter.listEntities(projectId, "character"); expect(chars.map((e) => e.id).sort()).toEqual(["ada", "babbage"]); // getEntity: found expect(((await adapter.getEntity(projectId, "character", "ada")) as Character | null)?.name).toBe("Ada Lovelace"); // getEntity: not found expect(await adapter.getEntity(projectId, "character", "nope")).toBeNull(); // search: fuzzy match, best-first const babbageRes = await adapter.searchEntities(projectId, "babbage"); expect(babbageRes.map((e) => e.id)).toContain("babbage"); const adaRes = await adapter.searchEntities(projectId, "ada"); expect(adaRes[0]?.id).toBe("ada"); // search: empty returns all expect((await adapter.searchEntities(projectId, "")).length).toBe(3); // delete await adapter.deleteEntity(projectId, "character", "ada"); expect(await adapter.getEntity(projectId, "character", "ada")).toBeNull(); expect((await adapter.listEntities(projectId)).map((e) => e.id)).toEqual(["babbage", "clocktower"]); } describe.each([ { name: "local (IndexedDB)", adapter: storageLocal }, { name: "server (REST)", adapter: createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() }) }, ])("$name adapter honors the entity contract", async ({ adapter }) => { it("lists, fetches, searches, and deletes entities", () => entityScenario(adapter)); }); describe("StorageAdapter contract (web/local vs server)", () => { it("local (IndexedDB) adapter honors the contract", async () => { await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED); }); it("server (REST) adapter honors the contract", async () => { const client = createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() }); await expect(runScenario(client)).resolves.toMatchObject(EXPECTED); }); });