a1b5587fcb
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.
251 lines
9.8 KiB
TypeScript
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);
|
|
});
|
|
});
|