1
10import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
11import { AddressInfo } from "node:net";
12import { afterAll, beforeAll, describe, expect, it } from "vitest";
13import { HttpDocStore } from "../src/http-store.js";
14
15const TOKEN = "svc-token";
16
17interface FakeState {
18
19 blocks: Array<{
20 id: string;
21 parentId: string | null;
22 type: string;
23 position: string;
24 content: Record<string, unknown>;
25 properties?: Record<string, unknown>;
26 }>;
27 version: number;
28
29 casBase: number;
30 requests: Array<{ method: string; path: string; auth: string | undefined }>;
31
32 external: Array<FakeState["blocks"][number]> | null;
33}
34
35function seedBlocks(n: number): FakeState["blocks"] {
36 const out: FakeState["blocks"] = [];
37 for (let i = 0; i < n; i += 1) {
38 out.push({
39 id: `blk_${String(i).padStart(3, "0")}`,
40 parentId: null,
41 type: i % 2 === 0 ? "paragraph" : "heading_1",
42 position: `p${String(i).padStart(4, "0")}`,
43 content: { text: `block ${i}` },
44 });
45 }
46 return out;
47}
48
49async function startFakeApi(state: FakeState): Promise<{ server: Server; url: string }> {
50 const server = createServer((req: IncomingMessage, res: ServerResponse) => {
51 state.requests.push({
52 method: req.method ?? "",
53 path: req.url ?? "",
54 auth: req.headers.authorization,
55 });
56 const send = (status: number, body: unknown) => {
57 res.writeHead(status, { "content-type": "application/json" });
58 res.end(JSON.stringify(body));
59 };
60
61
62 if (req.headers.authorization !== `Bearer ${TOKEN}`) {
63 return send(401, { error: { code: "UNAUTHORIZED", message: "bad token" } });
64 }
65
66 if (req.method === "GET" && req.url !== null) {
67 const u = new URL(req.url, "http://x");
68 const m = u.pathname.match(/^\/internal\/blocks\/([^/]+)$/);
69 if (m) {
70 const pageId = decodeURIComponent(m[1]);
71 if (pageId !== "pg_1") {
72 return send(404, { error: { code: "NOT_FOUND", message: "no page" } });
73 }
74 const pageSize = Number(u.searchParams.get("pageSize") ?? "500");
75 const cursor = u.searchParams.get("cursor");
76 let rows = state.blocks;
77 if (cursor !== null) rows = rows.filter((b) => b.position > (cursor as string));
78 const slice = rows.slice(0, pageSize);
79 const hasMore = rows.length > pageSize;
80 return send(200, {
81 pageId,
82 version: state.version,
83 blocks: slice,
84 nextCursor: hasMore ? slice[slice.length - 1].position : null,
85 hasMore,
86 });
87 }
88 return send(404, { error: { code: "NOT_FOUND", message: "no route" } });
89 }
90
91 if (req.method === "POST" && req.url === "/internal/blocks/flush") {
92 let raw = "";
93 req.on("data", (c) => (raw += c));
94 req.on("end", () => {
95 const body = JSON.parse(raw) as {
96 pageId: string;
97 baseVersion: number;
98 blocks: FakeState["blocks"];
99 };
100 if (body.pageId !== "pg_1") {
101 return send(404, { error: { code: "NOT_FOUND", message: "no page" } });
102 }
103 if (body.baseVersion !== state.casBase) {
104
105 return send(200, {
106 version: state.version,
107 conflict: { externalBlocks: state.external ?? [] },
108 });
109 }
110 state.blocks = body.blocks;
111 state.version = state.casBase + 1;
112 state.casBase = state.version;
113 return send(200, { version: state.version });
114 });
115 return;
116 }
117
118 send(404, { error: { code: "NOT_FOUND", message: "no route" } });
119 });
120 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
121 const addr = server.address() as AddressInfo;
122 return { server, url: `http://127.0.0.1:${addr.port}` };
123}
124
125describe("HttpDocStore", () => {
126 const state: FakeState = {
127 blocks: seedBlocks(1200),
128 version: 5,
129 casBase: 5,
130 requests: [],
131 external: null,
132 };
133 let server: Server;
134 let url: string;
135 let store: HttpDocStore;
136
137 beforeAll(async () => {
138 ({ server, url } = await startFakeApi(state));
139 store = new HttpDocStore({ apiUrl: url, serviceToken: TOKEN });
140 });
141 afterAll(async () => {
142 await new Promise<void>((r) => server.close(() => r()));
143 });
144
145 it("hydrate: cursor-paginates a 1200-block page (3 × 500) in order", async () => {
146 const result = await store.hydrate("pg_1");
147 expect(result.version).toBe(5);
148 expect(result.blocks).not.toBeNull();
149 expect((result.blocks as unknown[]).length).toBe(1200);
150 const first = (result.blocks as { id: string }[])[0];
151 const last = (result.blocks as { id: string }[])[1199];
152 expect(first.id).toBe("blk_000");
153 expect(last.id).toBe("blk_1199");
154
155
156 const positions = (result.blocks as { position: string }[]).map((b) => b.position);
157 for (let i = 1; i < positions.length; i += 1) {
158 expect(positions[i] > positions[i - 1]).toBe(true);
159 }
160
161 const reads = state.requests.filter((r) => r.path.startsWith("/internal/blocks/pg_1"));
162 expect(reads).toHaveLength(3);
163 expect(reads.every((r) => r.auth === `Bearer ${TOKEN}`)).toBe(true);
164 });
165
166 it("hydrate: an empty page → null blocks (the DocStore empty sentinel)", async () => {
167 const saved = state.blocks;
168 state.blocks = [];
169 try {
170 const result = await store.hydrate("pg_1");
171 expect(result.blocks).toBeNull();
172 } finally {
173 state.blocks = saved;
174 }
175 });
176
177 it("hydrate: 404 page → error (no silent empty doc)", async () => {
178 await expect(store.hydrate("pg_missing")).rejects.toThrow(/404/);
179 });
180
181 it("persist: success — the full set is flushed, the new version returned", async () => {
182 const before = state.version;
183 const result = await store.persist("pg_1", state.blocks, state.casBase);
184 expect(result.conflict).toBeUndefined();
185 expect(result.version).toBe(before + 1);
186 expect(state.version).toBe(before + 1);
187 });
188
189 it("persist: stale baseVersion → conflict with the external blocks (room rebase)", async () => {
190 state.external = [{ id: "ext_1", parentId: null, type: "paragraph", position: "z9", content: { text: "external" } }];
191 try {
192 const result = await store.persist("pg_1", state.blocks, 1 );
193 expect(result.version).toBe(state.version);
194 expect(result.conflict?.externalBlocks).toEqual(state.external);
195
196 expect(state.version).toBe(6);
197 } finally {
198 state.external = null;
199 }
200 });
201
202 it("ping: any HTTP answer (404) = up; a rejected auth still = up (the API is reachable)", async () => {
203 expect(await store.ping()).toBe(true);
204 });
205
206 it("ping: network failure = down", async () => {
207 const down = new HttpDocStore({ apiUrl: "http://127.0.0.1:1", serviceToken: TOKEN, timeoutMs: 500 });
208 expect(await down.ping()).toBe(false);
209 });
210
211 it("wrong token → 401 surfaces as a readable error (no token leak)", async () => {
212 const bad = new HttpDocStore({ apiUrl: url, serviceToken: "wrong" });
213 await expect(bad.hydrate("pg_1")).rejects.toThrow(/401/);
214 });
215});
216