1
20import "reflect-metadata";
21import { afterAll, beforeAll, describe, expect, it } from "vitest";
22import { INestApplication, Module, RequestMethod } from "@nestjs/common";
23import { APP_GUARD, NestFactory } from "@nestjs/core";
24import { AllExceptionsFilter } from "../../../common/filters/all-exceptions.filter.js";
25import { AuthGuard } from "../../../common/guards/auth.guard.js";
26import { RateLimitGuard } from "../../../common/guards/rate-limit.guard.js";
27import { EventBus } from "../../../common/event-bus.js";
28import { AuditService } from "../../../common/audit.service.js";
29import { PrismaService } from "../../../common/prisma.service.js";
30import { RedisService } from "../../../common/redis.service.js";
31import { resetEnvCache } from "../../../config/env.js";
32import { InternalBlocksController } from "../internal.controller.js";
33import { InternalBlocksService } from "../internal.service.js";
34
35const TOKEN = "svc-test-token-123";
36const PAGE_ID = "pg_internal";
37
38
39interface DbState {
40 page: { id: string; workspaceId: string; version: number; deletedAt: null } | null;
41 blocks: Array<{
42 id: string;
43 pageId: string;
44 workspaceId: string;
45 parentId: string | null;
46 type: string;
47 position: string;
48 content: Record<string, unknown>;
49 properties: Record<string, unknown>;
50 deletedAt: null | string;
51 }>;
52}
53
54function seedState(): DbState {
55 return {
56 page: { id: PAGE_ID, workspaceId: "ws_internal", version: 7, deletedAt: null },
57 blocks: [
58 { id: "blk_a", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "PARAGRAPH", position: "a1", content: { text: "one" }, properties: {}, deletedAt: null },
59 { id: "blk_b", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "HEADING_1", position: "a2", content: { text: "two" }, properties: {}, deletedAt: null },
60 { id: "blk_c", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "TODO", position: "a3", content: { text: "three" }, properties: { checked: false }, deletedAt: null }
61 ]
62 };
63}
64
65
67function mockPrisma(state: DbState) {
68 const live = (rows: DbState["blocks"]) =>
69 rows.filter((b) => b.deletedAt === null);
70
71 const pageDelegate = {
72 async findUnique(args: { where: { id: string } }) {
73 const p = state.page;
74 if (p === null || p.id !== args.where.id) return null;
75 return { id: p.id, workspaceId: p.workspaceId, version: p.version, deletedAt: p.deletedAt };
76 },
77 async updateMany(args: { where: { id: string; version: number }; data: { version: number; updatedAt: Date } }) {
78 const p = state.page;
79 if (p === null || p.id !== args.where.id || p.version !== args.where.version) return { count: 0 };
80 p.version = args.data.version;
81 return { count: 1 };
82 }
83 };
84
85 const blockDelegate = {
86 async findMany(args: {
87 where: { pageId?: string; deletedAt?: null; position?: { gt: string } };
88 select?: unknown;
89 orderBy?: { position: "asc" | "desc" };
90 take?: number;
91 }) {
92 let rows = state.blocks.filter((b) => args.where.pageId === undefined || b.pageId === args.where.pageId);
93 if (args.where.deletedAt === null) rows = rows.filter((b) => b.deletedAt === null);
94 if (args.where.position?.gt) rows = rows.filter((b) => b.position > (args.where.position as { gt: string }).gt);
95 rows = [...rows].sort((a, b) => (a.position < b.position ? -1 : 1));
96 if (args.take !== undefined) rows = rows.slice(0, args.take);
97 return rows;
98 },
99 async findUnique(args: { where: { id: string } }) {
100 return state.blocks.find((b) => b.id === args.where.id) ?? null;
101 },
102 async updateMany(args: {
103 where: { id: { in: string[] }; deletedAt: null };
104 data: { deletedAt: Date };
105 }) {
106 let n = 0;
107 for (const b of state.blocks) {
108 if (args.where.id.in.includes(b.id) && b.deletedAt === null) {
109 b.deletedAt = (args.data.deletedAt as Date).toISOString();
110 n += 1;
111 }
112 }
113 return { count: n };
114 },
115 async upsert(args: {
116 where: { id: string };
117 create: Omit<DbState["blocks"][number], "deletedAt">;
118 update: Record<string, unknown>;
119 }) {
120 const existing = state.blocks.find((b) => b.id === args.where.id);
121 if (existing) {
122 Object.assign(existing, args.update, { deletedAt: null });
123 return existing;
124 }
125 const created: DbState["blocks"][number] = { ...args.create, deletedAt: null };
126 state.blocks.push(created);
127 return created;
128 }
129 };
130
131
141 const flushUpsert = (strings: TemplateStringsArray, ...values: unknown[]) => {
142 const flat = (values[0] as { values: unknown[] }).values;
143 for (let i = 0; i < flat.length; i += 9) {
144 const [id, pageId, workspaceId, parentId, type, position, content, properties] = flat.slice(
145 i,
146 i + 9
147 ) as [string, string, string, string | null, string, string, unknown, unknown];
148 const parse = (v: unknown): unknown => (typeof v === "string" ? JSON.parse(v) : v);
149 const existing = state.blocks.find((b) => b.id === id);
150 if (existing) {
151 Object.assign(existing, {
152 parentId,
153 type,
154 position,
155 content: parse(content),
156 properties: parse(properties),
157 deletedAt: null
158 });
159 } else {
160 state.blocks.push({
161 id,
162 pageId,
163 workspaceId,
164 parentId,
165 type,
166 position,
167 content: parse(content),
168 properties: parse(properties),
169 deletedAt: null
170 } as unknown as DbState["blocks"][number]);
171 }
172 }
173 return 0;
174 };
175
176 const prisma = {
177 page: pageDelegate,
178 block: blockDelegate,
179 auditLog: { async create(): Promise<unknown> { return {}; } },
180 async $transaction(fn: (tx: unknown) => Promise<unknown>) {
181 return fn({ page: pageDelegate, block: blockDelegate, $executeRaw: flushUpsert });
182 },
183 async $executeRaw(strings: TemplateStringsArray, ...values: unknown[]): Promise<number> {
184 return flushUpsert(strings, ...values);
185 },
186 async $connect(): Promise<void> {},
187 async $disconnect(): Promise<void> {},
188 };
189 void live;
190 return prisma;
191}
192
193async function buildApp(opts: { serviceToken: string }): Promise<{
194 app: INestApplication;
195 state: DbState;
196 auditRows: Array<{ action: string; metadata?: Record<string, unknown> }>;
197 events: Array<{ event: string; payload: Record<string, unknown> }>;
198 baseUrl: string;
199}> {
200 const state = seedState();
201 const auditRows: Array<{ action: string; metadata?: Record<string, unknown> }> = [];
202 const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
203
204 process.env.JWT_SECRET = "test-jwt-secret";
205 process.env.INTERNAL_SERVICE_TOKEN = opts.serviceToken;
206 process.env.REDIS_URL = "redis://localhost:1";
207 resetEnvCache();
208
209
210
211
212
213
214
215
216 const prisma = mockPrisma(state) as unknown as PrismaService;
217 const auditStub: AuditService = {
218 write: async (input: { action: string; metadata?: Record<string, unknown> }) => {
219 auditRows.push({ action: input.action, metadata: input.metadata });
220 }
221 } as unknown as AuditService;
222 const eventBusStub: EventBus = {
223 publish: async (event: string, payload: Record<string, unknown>) => {
224 events.push({ event, payload });
225 }
226 } as unknown as EventBus;
227 const redisStub = {
228 getRedis: () => {
229 throw new Error("no redis in unit tests");
230 },
231 ping: async () => false,
232 onModuleInit() {},
233 onModuleDestroy: async () => {}
234 } as unknown as RedisService;
235 const service = new InternalBlocksService(prisma, auditStub, eventBusStub);
236
237 @Module({
238 controllers: [InternalBlocksController],
239 providers: [
240 { provide: InternalBlocksService, useValue: service },
241 { provide: PrismaService, useValue: prisma },
242 { provide: AuditService, useValue: auditStub },
243 { provide: EventBus, useValue: eventBusStub },
244 { provide: RedisService, useValue: redisStub },
245 AuthGuard,
246 RateLimitGuard,
247 { provide: APP_GUARD, useExisting: AuthGuard },
248 { provide: APP_GUARD, useExisting: RateLimitGuard }
249 ]
250 })
251 class TestModule {}
252
253 const app = await NestFactory.create(TestModule, { logger: false });
254 app.useGlobalFilters(new AllExceptionsFilter());
255
256
257
258
259
260 app.setGlobalPrefix("api/v1", {
261 exclude: [{ path: "internal/:path*", method: RequestMethod.ALL }]
262 });
263 await app.init();
264 await app.listen(0);
265 const address = app.getHttpServer().address() as { port: number };
266 return {
267 app,
268 state,
269 auditRows,
270 events,
271 baseUrl: `http://127.0.0.1:${address.port}`
272 };
273}
274
275const json = async (res: Response): Promise<Record<string, unknown>> =>
276 (await res.json().catch(() => ({}))) as Record<string, unknown>;
277
278describe("internal endpoints — routing + guard", () => {
279 let ctx: Awaited<ReturnType<typeof buildApp>>;
280
281 beforeAll(async () => {
282 ctx = await buildApp({ serviceToken: TOKEN });
283 }, 30_000);
284 afterAll(async () => {
285 await ctx.app.close();
286 });
287
288 it("serves /internal/blocks/:pageId (NOT under api/v1)", async () => {
289 const ok = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, {
290 headers: { authorization: `Bearer ${TOKEN}` }
291 });
292 expect(ok.status).toBe(200);
293 const underPrefix = await fetch(`${ctx.baseUrl}/api/v1/internal/blocks/${PAGE_ID}`, {
294 headers: { authorization: `Bearer ${TOKEN}` }
295 });
296 expect(underPrefix.status).toBe(404);
297 });
298
299 it("401 service_token_missing without a bearer header", async () => {
300 const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`);
301 expect(res.status).toBe(401);
302 const body = await json(res);
303 expect(JSON.stringify(body)).toContain("service_token_missing");
304 });
305
306 it("401 service_token_mismatch with the wrong token (a user JWT can't replay here)", async () => {
307 const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, {
308 headers: { authorization: "Bearer some-user-jwt-or-fk_apikey" }
309 });
310 expect(res.status).toBe(401);
311 const body = await json(res);
312 expect(JSON.stringify(body)).toContain("service_token_mismatch");
313 });
314
315 it("flush route: same guard on POST /internal/blocks/flush", async () => {
316 const res = await fetch(`${ctx.baseUrl}/internal/blocks/flush`, {
317 method: "POST",
318 headers: { "content-type": "application/json" },
319 body: JSON.stringify({ pageId: PAGE_ID, baseVersion: 7, blocks: [] })
320 });
321 expect(res.status).toBe(401);
322 });
323});
324
325describe("internal endpoints — fail-closed (empty token env)", () => {
326 let ctx: Awaited<ReturnType<typeof buildApp>>;
327
328 beforeAll(async () => {
329 ctx = await buildApp({ serviceToken: "" });
330 }, 30_000);
331 afterAll(async () => {
332 await ctx.app.close();
333 });
334
335 it("rejects EVERYTHING when INTERNAL_SERVICE_TOKEN is empty", async () => {
336 const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, {
337 headers: { authorization: "Bearer anything-goes" }
338 });
339 expect(res.status).toBe(401);
340 const body = await json(res);
341 expect(JSON.stringify(body)).toContain("service_token_not_configured");
342 });
343});
344
345describe("internal endpoints — hydrator read", () => {
346 let ctx: Awaited<ReturnType<typeof buildApp>>;
347
348 beforeAll(async () => {
349 ctx = await buildApp({ serviceToken: TOKEN });
350 }, 30_000);
351 afterAll(async () => {
352 await ctx.app.close();
353 });
354
355 it("returns position-ordered codec blocks + the page version", async () => {
356 const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, {
357 headers: { authorization: `Bearer ${TOKEN}` }
358 });
359 expect(res.status).toBe(200);
360 const body = await json(res);
361 expect(body.version).toBe(7);
362 const blocks = body.blocks as Array<Record<string, unknown>>;
363 expect(blocks.map((b) => b.id)).toEqual(["blk_a", "blk_b", "blk_c"]);
364
365 expect(blocks.map((b) => b.type)).toEqual(["paragraph", "heading_1", "todo"]);
366 expect(body.hasMore).toBe(false);
367 expect(body.nextCursor).toBeNull();
368 });
369
370 it("cursor-paginates by position (pageSize 2 → page 2 has the rest)", async () => {
371 const page1 = await json(
372 await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=2`, {
373 headers: { authorization: `Bearer ${TOKEN}` }
374 })
375 );
376 expect((page1.blocks as unknown[]).length).toBe(2);
377 expect(page1.hasMore).toBe(true);
378 expect(page1.nextCursor).toBe("a2");
379
380 const page2 = await json(
381 await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=2&cursor=a2`, {
382 headers: { authorization: `Bearer ${TOKEN}` }
383 })
384 );
385 expect((page2.blocks as Array<Record<string, unknown>>).map((b) => b.id)).toEqual(["blk_c"]);
386 expect(page2.hasMore).toBe(false);
387 });
388
389 it("404s an unknown page", async () => {
390 const res = await fetch(`${ctx.baseUrl}/internal/blocks/pg_missing`, {
391 headers: { authorization: `Bearer ${TOKEN}` }
392 });
393 expect(res.status).toBe(404);
394 });
395
396 it("rejects a bad pageSize (validation)", async () => {
397 const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=10000`, {
398 headers: { authorization: `Bearer ${TOKEN}` }
399 });
400 expect(res.status).toBe(400);
401 });
402});
403
404describe("internal endpoints — flush (version CAS)", () => {
405 let ctx: Awaited<ReturnType<typeof buildApp>>;
406
407 const flush = (body: unknown): Promise<Response> =>
408 fetch(`${ctx.baseUrl}/internal/blocks/flush`, {
409 method: "POST",
410 headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
411 body: JSON.stringify(body)
412 });
413
414 beforeAll(async () => {
415 ctx = await buildApp({ serviceToken: TOKEN });
416 }, 30_000);
417 afterAll(async () => {
418 await ctx.app.close();
419 });
420
421 it("success: bumps the version, upserts, soft-deletes, audits + emits page.updated", async () => {
422 const res = await flush({
423 pageId: PAGE_ID,
424 baseVersion: 7,
425 blocks: [
426
427 { id: "blk_a", type: "paragraph", position: "a1", content: { text: "one" } },
428
429 { id: "blk_b", type: "heading_1", position: "a2", content: { text: "two-edited" } },
430
431 { id: "blk_c", type: "todo", position: "a4", content: { text: "three" }, properties: { checked: false } },
432
433 { id: "blk_d", type: "paragraph", position: "a3", content: { text: "new" } }
434
435 ]
436 });
437 expect(res.status).toBe(200);
438 const body = await json(res);
439 expect(body.version).toBe(8);
440 expect(body.conflict).toBeUndefined();
441
442
443 expect(ctx.state.page?.version).toBe(8);
444 expect(ctx.state.blocks.find((b) => b.id === "blk_d")?.content).toEqual({ text: "new" });
445 expect(ctx.state.blocks.find((b) => b.id === "blk_b")?.content).toEqual({ text: "two-edited" });
446
447
448 expect(ctx.auditRows.length).toBe(1);
449 expect(ctx.auditRows[0].action).toBe("block.flush");
450 expect(ctx.auditRows[0].metadata?.actor).toBe("realtime-flush");
451
452
453
454 expect(ctx.events.length).toBe(1);
455 expect(ctx.events[0].event).toBe("page.updated");
456 expect(ctx.events[0].payload.version).toBe(8);
457 expect(ctx.events[0].payload.blockDelta).toEqual({ inserted: 1, updated: 1, moved: 1, deleted: 0 });
458 });
459
460 it("soft-deletes blocks missing from the flushed set (counted as deleted)", async () => {
461 const res = await flush({
462 pageId: PAGE_ID,
463 baseVersion: 8,
464 blocks: [
465 { id: "blk_a", type: "paragraph", position: "a1", content: { text: "one" } },
466 { id: "blk_b", type: "heading_1", position: "a2", content: { text: "two-edited" } },
467 { id: "blk_d", type: "paragraph", position: "a3", content: { text: "new" } }
468
469 ]
470 });
471 expect(res.status).toBe(200);
472 expect((await json(res)).version).toBe(9);
473 const gone = ctx.state.blocks.find((b) => b.id === "blk_c");
474 expect(gone?.deletedAt).not.toBeNull();
475 expect((ctx.events.at(-1)?.payload.blockDelta as Record<string, number>).deleted).toBe(1);
476 });
477
478 it("conflict fast path: stale baseVersion → no-op + externalBlocks (nothing written)", async () => {
479 const before = JSON.stringify(ctx.state.blocks.map((b) => [b.id, b.position, b.content, b.deletedAt]));
480 const res = await flush({
481 pageId: PAGE_ID,
482 baseVersion: 1,
483 blocks: [{ id: "blk_e", type: "paragraph", position: "z1", content: { text: "clobber attempt" } }]
484 });
485 expect(res.status).toBe(200);
486 const body = await json(res);
487 expect(body.version).toBe(9);
488 const conflict = body.conflict as { externalBlocks: Array<Record<string, unknown>> };
489 expect(conflict.externalBlocks.map((b) => b.id)).toEqual(["blk_a", "blk_b", "blk_d"]);
490
491 expect(ctx.state.page?.version).toBe(9);
492 expect(ctx.state.blocks.find((b) => b.id === "blk_e")).toBeUndefined();
493 expect(JSON.stringify(ctx.state.blocks.map((b) => [b.id, b.position, b.content, b.deletedAt]))).toBe(before);
494 });
495
496 it("404s flushing an unknown page", async () => {
497 const res = await flush({ pageId: "pg_missing", baseVersion: 1, blocks: [] });
498 expect(res.status).toBe(404);
499 });
500
501 it("rejects a flush with an unknown block type (validation)", async () => {
502 const res = await flush({
503 pageId: PAGE_ID,
504 baseVersion: 9,
505 blocks: [{ id: "blk_x", type: "NOT_A_TYPE", position: "z1", content: {} }]
506 });
507 expect(res.status).toBe(400);
508 });
509});
510