Files
PubScript/packages/core/src/storage.contract.test.ts
T
Schwitzz a1b5587fcb server: reconcile ai_sessions persistence to Chunk 9 AISession shape
Reshape the ai_sessions table (migration 003) and mapping/routes to the
full AISession contract: title, kind, notes, created_at, last_message_at;
drop legacy role/model/settings. toAISession now maps the new columns and
routes create/patch/messages handlers use the new shape.
2026-10-29 15:03:32 -05:00

251 lines
9.8 KiB
TypeScript

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<string, any>();
const chapters = new Map<string, any>();
const entities = new Map<string, any>();
const boards = new Map<string, any>();
const sessions = new Map<string, any>();
let n = 0;
const id = () => `id-${++n}`;
const handler = async (url: string, init?: RequestInit): Promise<Response> => {
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);
});
});