/** * AI Software Factory — Unit: internal block endpoints (TASK-0092 slice 2) * * Boots the REAL Nest app (AppModule's production guard stack — AuthGuard + * RateLimitGuard as global guards — the controller's InternalServiceGuard, * and main.ts's exact `setGlobalPrefix("api/v1", {exclude})` wiring) with a * mocked Prisma + audit + event seam, and drives it over real HTTP on an * ephemeral port. * * Covers: * - prefix exclusion: `/internal/*` routes are NOT under `api/v1`; * - the service-token guard: fail-closed (empty env), missing header, * mismatched token (a user JWT can never be replayed against the * internal surface); * - the hydrator read: position order + `pages.version`, cursor paging; * - the flush's `pages.version` CAS: success (version bump + upsert + * soft-delete + audit + `page.updated` with `blockDelta`) and the * conflict fast path (no-op + `conflict.externalBlocks`). */ import "reflect-metadata"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { INestApplication, Module, RequestMethod } from "@nestjs/common"; import { APP_GUARD, NestFactory } from "@nestjs/core"; import { AllExceptionsFilter } from "../../../common/filters/all-exceptions.filter.js"; import { AuthGuard } from "../../../common/guards/auth.guard.js"; import { RateLimitGuard } from "../../../common/guards/rate-limit.guard.js"; import { EventBus } from "../../../common/event-bus.js"; import { AuditService } from "../../../common/audit.service.js"; import { PrismaService } from "../../../common/prisma.service.js"; import { RedisService } from "../../../common/redis.service.js"; import { resetEnvCache } from "../../../config/env.js"; import { InternalBlocksController } from "../internal.controller.js"; import { InternalBlocksService } from "../internal.service.js"; const TOKEN = "svc-test-token-123"; const PAGE_ID = "pg_internal"; /** A mutable in-memory "database" + the prisma-shaped delegate over it. */ interface DbState { page: { id: string; workspaceId: string; version: number; deletedAt: null } | null; blocks: Array<{ id: string; pageId: string; workspaceId: string; parentId: string | null; type: string; position: string; content: Record; properties: Record; deletedAt: null | string; }>; } function seedState(): DbState { return { page: { id: PAGE_ID, workspaceId: "ws_internal", version: 7, deletedAt: null }, blocks: [ { id: "blk_a", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "PARAGRAPH", position: "a1", content: { text: "one" }, properties: {}, deletedAt: null }, { id: "blk_b", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "HEADING_1", position: "a2", content: { text: "two" }, properties: {}, deletedAt: null }, { id: "blk_c", pageId: PAGE_ID, workspaceId: "ws_internal", parentId: null, type: "TODO", position: "a3", content: { text: "three" }, properties: { checked: false }, deletedAt: null } ] }; } /** The prisma-shaped mock (page + block delegates, `$transaction` = same * delegates — the flush runs its CAS inside the tx callback). */ function mockPrisma(state: DbState) { const live = (rows: DbState["blocks"]) => rows.filter((b) => b.deletedAt === null); const pageDelegate = { async findUnique(args: { where: { id: string } }) { const p = state.page; if (p === null || p.id !== args.where.id) return null; return { id: p.id, workspaceId: p.workspaceId, version: p.version, deletedAt: p.deletedAt }; }, async updateMany(args: { where: { id: string; version: number }; data: { version: number; updatedAt: Date } }) { const p = state.page; if (p === null || p.id !== args.where.id || p.version !== args.where.version) return { count: 0 }; p.version = args.data.version; return { count: 1 }; } }; const blockDelegate = { async findMany(args: { where: { pageId?: string; deletedAt?: null; position?: { gt: string } }; select?: unknown; orderBy?: { position: "asc" | "desc" }; take?: number; }) { let rows = state.blocks.filter((b) => args.where.pageId === undefined || b.pageId === args.where.pageId); if (args.where.deletedAt === null) rows = rows.filter((b) => b.deletedAt === null); if (args.where.position?.gt) rows = rows.filter((b) => b.position > (args.where.position as { gt: string }).gt); rows = [...rows].sort((a, b) => (a.position < b.position ? -1 : 1)); if (args.take !== undefined) rows = rows.slice(0, args.take); return rows; }, async findUnique(args: { where: { id: string } }) { return state.blocks.find((b) => b.id === args.where.id) ?? null; }, async updateMany(args: { where: { id: { in: string[] }; deletedAt: null }; data: { deletedAt: Date }; }) { let n = 0; for (const b of state.blocks) { if (args.where.id.in.includes(b.id) && b.deletedAt === null) { b.deletedAt = (args.data.deletedAt as Date).toISOString(); n += 1; } } return { count: n }; }, async upsert(args: { where: { id: string }; create: Omit; update: Record; }) { const existing = state.blocks.find((b) => b.id === args.where.id); if (existing) { Object.assign(existing, args.update, { deletedAt: null }); return existing; } const created: DbState["blocks"][number] = { ...args.create, deletedAt: null }; state.blocks.push(created); return created; } }; /** * The TASK-0135 N+1 fix upserts all flushed blocks in ONE * `INSERT … ON CONFLICT (id) DO UPDATE` — this mock executes that exact * statement against the in-memory state (identical semantics to the old * per-block upsert: create absent ids, update present ones, revive * soft-deleted rows). The tagged template receives the `Prisma.join`'d * Sql whose `values` is the FLAT row-value list, 9 columns per block: * id, pageId, workspaceId, parentId, type, position, content, properties, * updatedAt. */ const flushUpsert = (strings: TemplateStringsArray, ...values: unknown[]) => { const flat = (values[0] as { values: unknown[] }).values; for (let i = 0; i < flat.length; i += 9) { const [id, pageId, workspaceId, parentId, type, position, content, properties] = flat.slice( i, i + 9 ) as [string, string, string, string | null, string, string, unknown, unknown]; const parse = (v: unknown): unknown => (typeof v === "string" ? JSON.parse(v) : v); const existing = state.blocks.find((b) => b.id === id); if (existing) { Object.assign(existing, { parentId, type, position, content: parse(content), properties: parse(properties), deletedAt: null }); } else { state.blocks.push({ id, pageId, workspaceId, parentId, type, position, content: parse(content), properties: parse(properties), deletedAt: null } as unknown as DbState["blocks"][number]); } } return 0; }; const prisma = { page: pageDelegate, block: blockDelegate, auditLog: { async create(): Promise { return {}; } }, async $transaction(fn: (tx: unknown) => Promise) { return fn({ page: pageDelegate, block: blockDelegate, $executeRaw: flushUpsert }); }, async $executeRaw(strings: TemplateStringsArray, ...values: unknown[]): Promise { return flushUpsert(strings, ...values); }, async $connect(): Promise {}, async $disconnect(): Promise {}, }; void live; return prisma; } async function buildApp(opts: { serviceToken: string }): Promise<{ app: INestApplication; state: DbState; auditRows: Array<{ action: string; metadata?: Record }>; events: Array<{ event: string; payload: Record }>; baseUrl: string; }> { const state = seedState(); const auditRows: Array<{ action: string; metadata?: Record }> = []; const events: Array<{ event: string; payload: Record }> = []; process.env.JWT_SECRET = "test-jwt-secret"; process.env.INTERNAL_SERVICE_TOKEN = opts.serviceToken; process.env.REDIS_URL = "redis://localhost:1"; resetEnvCache(); // The guard STACK is the production one (the real AuthGuard + // RateLimitGuard classes, the real InternalServiceGuard on the // controller via @UseGuards). The service is a plain `useValue` (its // collaborators are the mocks); the controller class is instantiated by // Nest — its `@Inject(InternalBlocksService)` decorator (explicit, not // inferred metadata) resolves it under both vitest/esbuild (no // `design:paramtypes` emission) and production tsc. const prisma = mockPrisma(state) as unknown as PrismaService; const auditStub: AuditService = { write: async (input: { action: string; metadata?: Record }) => { auditRows.push({ action: input.action, metadata: input.metadata }); } } as unknown as AuditService; const eventBusStub: EventBus = { publish: async (event: string, payload: Record) => { events.push({ event, payload }); } } as unknown as EventBus; const redisStub = { getRedis: () => { throw new Error("no redis in unit tests"); }, ping: async () => false, onModuleInit() {}, onModuleDestroy: async () => {} } as unknown as RedisService; const service = new InternalBlocksService(prisma, auditStub, eventBusStub); @Module({ controllers: [InternalBlocksController], providers: [ { provide: InternalBlocksService, useValue: service }, { provide: PrismaService, useValue: prisma }, { provide: AuditService, useValue: auditStub }, { provide: EventBus, useValue: eventBusStub }, { provide: RedisService, useValue: redisStub }, AuthGuard, RateLimitGuard, { provide: APP_GUARD, useExisting: AuthGuard }, { provide: APP_GUARD, useExisting: RateLimitGuard } ] }) class TestModule {} const app = await NestFactory.create(TestModule, { logger: false }); app.useGlobalFilters(new AllExceptionsFilter()); // No global ValidationPipe here: every internal parameter is validated by // its own ZodValidate pipe (the app's class-DTO fallback needs // class-validator, which this repo doesn't install). // EXACTLY main.ts's wiring — the prefix + the exclude (the unrouted // `internal/*` 404 test below proves it). app.setGlobalPrefix("api/v1", { exclude: [{ path: "internal/:path*", method: RequestMethod.ALL }] }); await app.init(); await app.listen(0); const address = app.getHttpServer().address() as { port: number }; return { app, state, auditRows, events, baseUrl: `http://127.0.0.1:${address.port}` }; } const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; describe("internal endpoints — routing + guard", () => { let ctx: Awaited>; beforeAll(async () => { ctx = await buildApp({ serviceToken: TOKEN }); }, 30_000); afterAll(async () => { await ctx.app.close(); }); it("serves /internal/blocks/:pageId (NOT under api/v1)", async () => { const ok = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, { headers: { authorization: `Bearer ${TOKEN}` } }); expect(ok.status).toBe(200); const underPrefix = await fetch(`${ctx.baseUrl}/api/v1/internal/blocks/${PAGE_ID}`, { headers: { authorization: `Bearer ${TOKEN}` } }); expect(underPrefix.status).toBe(404); }); it("401 service_token_missing without a bearer header", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`); expect(res.status).toBe(401); const body = await json(res); expect(JSON.stringify(body)).toContain("service_token_missing"); }); it("401 service_token_mismatch with the wrong token (a user JWT can't replay here)", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, { headers: { authorization: "Bearer some-user-jwt-or-fk_apikey" } }); expect(res.status).toBe(401); const body = await json(res); expect(JSON.stringify(body)).toContain("service_token_mismatch"); }); it("flush route: same guard on POST /internal/blocks/flush", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/flush`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pageId: PAGE_ID, baseVersion: 7, blocks: [] }) }); expect(res.status).toBe(401); }); }); describe("internal endpoints — fail-closed (empty token env)", () => { let ctx: Awaited>; beforeAll(async () => { ctx = await buildApp({ serviceToken: "" }); }, 30_000); afterAll(async () => { await ctx.app.close(); }); it("rejects EVERYTHING when INTERNAL_SERVICE_TOKEN is empty", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, { headers: { authorization: "Bearer anything-goes" } }); expect(res.status).toBe(401); const body = await json(res); expect(JSON.stringify(body)).toContain("service_token_not_configured"); }); }); describe("internal endpoints — hydrator read", () => { let ctx: Awaited>; beforeAll(async () => { ctx = await buildApp({ serviceToken: TOKEN }); }, 30_000); afterAll(async () => { await ctx.app.close(); }); it("returns position-ordered codec blocks + the page version", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}`, { headers: { authorization: `Bearer ${TOKEN}` } }); expect(res.status).toBe(200); const body = await json(res); expect(body.version).toBe(7); const blocks = body.blocks as Array>; expect(blocks.map((b) => b.id)).toEqual(["blk_a", "blk_b", "blk_c"]); // codec convention: lowercase types (DB is uppercase). expect(blocks.map((b) => b.type)).toEqual(["paragraph", "heading_1", "todo"]); expect(body.hasMore).toBe(false); expect(body.nextCursor).toBeNull(); }); it("cursor-paginates by position (pageSize 2 → page 2 has the rest)", async () => { const page1 = await json( await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=2`, { headers: { authorization: `Bearer ${TOKEN}` } }) ); expect((page1.blocks as unknown[]).length).toBe(2); expect(page1.hasMore).toBe(true); expect(page1.nextCursor).toBe("a2"); const page2 = await json( await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=2&cursor=a2`, { headers: { authorization: `Bearer ${TOKEN}` } }) ); expect((page2.blocks as Array>).map((b) => b.id)).toEqual(["blk_c"]); expect(page2.hasMore).toBe(false); }); it("404s an unknown page", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/pg_missing`, { headers: { authorization: `Bearer ${TOKEN}` } }); expect(res.status).toBe(404); }); it("rejects a bad pageSize (validation)", async () => { const res = await fetch(`${ctx.baseUrl}/internal/blocks/${PAGE_ID}?pageSize=10000`, { headers: { authorization: `Bearer ${TOKEN}` } }); expect(res.status).toBe(400); }); }); describe("internal endpoints — flush (version CAS)", () => { let ctx: Awaited>; const flush = (body: unknown): Promise => fetch(`${ctx.baseUrl}/internal/blocks/flush`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, body: JSON.stringify(body) }); beforeAll(async () => { ctx = await buildApp({ serviceToken: TOKEN }); }, 30_000); afterAll(async () => { await ctx.app.close(); }); it("success: bumps the version, upserts, soft-deletes, audits + emits page.updated", async () => { const res = await flush({ pageId: PAGE_ID, baseVersion: 7, blocks: [ // unchanged { id: "blk_a", type: "paragraph", position: "a1", content: { text: "one" } }, // content update { id: "blk_b", type: "heading_1", position: "a2", content: { text: "two-edited" } }, // moved (position changed) { id: "blk_c", type: "todo", position: "a4", content: { text: "three" }, properties: { checked: false } }, // inserted { id: "blk_d", type: "paragraph", position: "a3", content: { text: "new" } } // blk_c's old slot + nothing deleted here yet — delete in the delta test ] }); expect(res.status).toBe(200); const body = await json(res); expect(body.version).toBe(8); expect(body.conflict).toBeUndefined(); // DB state: version bumped, the new block present, nothing soft-deleted. expect(ctx.state.page?.version).toBe(8); expect(ctx.state.blocks.find((b) => b.id === "blk_d")?.content).toEqual({ text: "new" }); expect(ctx.state.blocks.find((b) => b.id === "blk_b")?.content).toEqual({ text: "two-edited" }); // Audit: the system attribution (actor 'realtime-flush'). expect(ctx.auditRows.length).toBe(1); expect(ctx.auditRows[0].action).toBe("block.flush"); expect(ctx.auditRows[0].metadata?.actor).toBe("realtime-flush"); // Event: page.updated with version + blockDelta (the codec diff counts: // 1 insert (blk_d), 1 update (blk_b content), 1 move (blk_c position)). expect(ctx.events.length).toBe(1); expect(ctx.events[0].event).toBe("page.updated"); expect(ctx.events[0].payload.version).toBe(8); expect(ctx.events[0].payload.blockDelta).toEqual({ inserted: 1, updated: 1, moved: 1, deleted: 0 }); }); it("soft-deletes blocks missing from the flushed set (counted as deleted)", async () => { const res = await flush({ pageId: PAGE_ID, baseVersion: 8, blocks: [ { id: "blk_a", type: "paragraph", position: "a1", content: { text: "one" } }, { id: "blk_b", type: "heading_1", position: "a2", content: { text: "two-edited" } }, { id: "blk_d", type: "paragraph", position: "a3", content: { text: "new" } } // blk_c dropped from the doc → soft delete ] }); expect(res.status).toBe(200); expect((await json(res)).version).toBe(9); const gone = ctx.state.blocks.find((b) => b.id === "blk_c"); expect(gone?.deletedAt).not.toBeNull(); expect((ctx.events.at(-1)?.payload.blockDelta as Record).deleted).toBe(1); }); it("conflict fast path: stale baseVersion → no-op + externalBlocks (nothing written)", async () => { const before = JSON.stringify(ctx.state.blocks.map((b) => [b.id, b.position, b.content, b.deletedAt])); const res = await flush({ pageId: PAGE_ID, baseVersion: 1, // stale — the current version is 9 blocks: [{ id: "blk_e", type: "paragraph", position: "z1", content: { text: "clobber attempt" } }] }); expect(res.status).toBe(200); const body = await json(res); expect(body.version).toBe(9); const conflict = body.conflict as { externalBlocks: Array> }; expect(conflict.externalBlocks.map((b) => b.id)).toEqual(["blk_a", "blk_b", "blk_d"]); // The clobber was NOT written (no version bump, no new row). expect(ctx.state.page?.version).toBe(9); expect(ctx.state.blocks.find((b) => b.id === "blk_e")).toBeUndefined(); expect(JSON.stringify(ctx.state.blocks.map((b) => [b.id, b.position, b.content, b.deletedAt]))).toBe(before); }); it("404s flushing an unknown page", async () => { const res = await flush({ pageId: "pg_missing", baseVersion: 1, blocks: [] }); expect(res.status).toBe(404); }); it("rejects a flush with an unknown block type (validation)", async () => { const res = await flush({ pageId: PAGE_ID, baseVersion: 9, blocks: [{ id: "blk_x", type: "NOT_A_TYPE", position: "z1", content: {} }] }); expect(res.status).toBe(400); }); });