/** * /api/v1 live-ish probe (#4, BURN-BACKLOG-4 3.2) — REAL better-sqlite3 DB * (throwaway file, migrated via drizzle), REAL modules, REAL crypto: creates * an API key through the same module the admin action uses, then invokes the * actual route handlers with real NextRequest objects and asserts the * envelope shapes end to end. * * Usage: * DATABASE_PATH=/tmp/lb-api-probe/probe.db \ * UPLOAD_DIR=/tmp/lb-api-probe/uploads \ * npx tsx scripts/api-v1-probe.ts * * Refuses to run without an explicit DATABASE_PATH (never touch the dev DB). */ import * as fs from "node:fs"; import * as path from "node:path"; import Module from "node:module"; import { NextRequest } from "next/server"; async function main() { // 'server-only' is a Next-internal alias (the package is never installed); // vitest maps it to the same empty stub (vitest.config.ts) — mirror that // resolution here so plain tsx can load the real modules. `next/cache`'s // revalidatePath needs the Next runtime's static-generation store, so it // gets a pass-through stub (see scripts/__probe_stubs__/next-cache.cjs). type Resolver = (request: string, ...rest: unknown[]) => string; const mod = Module as unknown as { _resolveFilename: Resolver }; const originalResolve = mod._resolveFilename; mod._resolveFilename = (request, ...rest) => { const g = globalThis as { __LB_PROBE_REAL_CACHE?: boolean }; if (request === "server-only") { return path.join(process.cwd(), "src", "__mocks__", "empty.ts"); } if (request === "next/cache" && !g.__LB_PROBE_REAL_CACHE) { return path.join(process.cwd(), "scripts", "__probe_stubs__", "next-cache.cjs"); } return originalResolve(request, ...rest); }; const dbPath = process.env.DATABASE_PATH; if (!dbPath) { console.error("DATABASE_PATH is required (use a throwaway file)."); process.exit(2); } fs.mkdirSync(path.dirname(dbPath), { recursive: true }); fs.mkdirSync(process.env.UPLOAD_DIR || path.join(path.dirname(dbPath), "uploads"), { recursive: true, }); // Stale WAL siblings over a fresh file make drizzle's boot migrate die with // SQLITE_IOERR_SHORT_READ (skill gotcha) — start clean every run. for (const sibling of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { fs.rmSync(sibling, { force: true }); } // Imports that touch @/db MUST come after the env is in place. const { migrate } = await import("drizzle-orm/better-sqlite3/migrator"); const { db } = await import("../src/db"); const { createApiKey } = await import("../src/server/api-keys"); const { getAllLinks } = await import("../src/server/queries"); migrate(db, { migrationsFolder: path.join(process.cwd(), "src", "db", "migrations") }); const linksRoute = await import("../src/app/api/v1/links/route"); const linkIdRoute = await import("../src/app/api/v1/links/[id]/route"); const profileRoute = await import("../src/app/api/v1/profile/route"); const analyticsRoute = await import("../src/app/api/v1/analytics/route"); const openapiRoute = await import("../src/app/api/v1/openapi/route"); const { key } = await createApiKey(); let ipCounter = 0; const req = (method: string, body?: unknown, auth = true, path = "/api/v1/links"): NextRequest => { ipCounter += 1; const headers: Record = { "x-forwarded-for": `10.50.0.${ipCounter}` }; if (auth) headers.authorization = ["Bearer", key].join(" "); if (body !== undefined) headers["content-type"] = "application/json"; return new NextRequest(`http://localhost:3000${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); }; const results: Array<{ step: string; ok: boolean; detail: string }> = []; const check = (step: string, ok: boolean, detail: string) => { results.push({ step, ok, detail }); console.log(`${ok ? "PASS" : "FAIL"} ${step} ${detail}`); }; check("createApiKey", /^lbk_[A-Za-z0-9_-]{43}$/.test(key), "plaintext issued once, shaped key"); // 1. auth reject on the real handler const denied = await linksRoute.GET(req("GET", undefined, false)); const deniedBody = await denied.json(); check( "GET /links unauthenticated", denied.status === 401 && deniedBody.error?.code === "unauthorized", `status=${denied.status} code=${deniedBody.error?.code}`, ); // 2. create through the real handler (queries hit the migrated file DB) let revalidateBroken: string | null = null; const invoke = async (fn: () => Promise): Promise => { try { return await fn(); } catch (err) { revalidateBroken = err instanceof Error ? err.message : String(err); return null; } }; const created = await invoke(() => linksRoute.POST( req("POST", { title: "Probe link", url: "https://probe.example", redirectSlug: "probe-slug" }), ), ); let createdId: number | null = null; if (created) { const body = (await created.json()) as { data?: { id?: number; title?: string } }; createdId = body.data?.id ?? null; check( "POST /links create", created.status === 201 && body.data?.title === "Probe link" && createdId !== null, `status=${created.status} id=${createdId}`, ); } else { check("POST /links create", false, `handler threw: ${revalidateBroken}`); } // 3. the write is REAL: visible through the queries layer (no handler involved) const rows = await getAllLinks(); check( "DB persistence (queries layer)", rows.some((r) => r.title === "Probe link"), `links in db=${rows.length}`, ); if (created && createdId !== null) { const linkId = createdId; const updated = await invoke(() => linkIdRoute.PUT(req("PUT", { title: "Probe link v2" }), { params: Promise.resolve({ id: String(linkId) }), }), ); if (updated) { const body = await updated.json(); check( "PUT /links/:id partial update", updated.status === 200 && body.data?.title === "Probe link v2", `status=${updated.status} title=${body.data?.title}`, ); } else { check("PUT /links/:id partial update", false, `handler threw: ${revalidateBroken}`); } // E.64 live: an empty effective patch is a 200 no-op (was a 500 via // drizzle "No values to set" before the route-level short-circuit). const noopPut = await invoke(() => linkIdRoute.PUT(req("PUT", {}), { params: Promise.resolve({ id: String(linkId) }), }), ); if (noopPut) { const noopBody = await noopPut.json(); check( "PUT /links/:id empty-patch no-op", noopPut.status === 200 && noopBody.data?.title === "Probe link v2", `status=${noopPut.status} title=${noopBody.data?.title}`, ); } else { check("PUT /links/:id empty-patch no-op", false, `handler threw: ${revalidateBroken}`); } const analytics = await analyticsRoute.GET(req("GET")); const analyticsBody = await analytics.json(); check( "GET /analytics shape", analytics.status === 200 && typeof analyticsBody.data?.retentionDays === "number" && typeof analyticsBody.data?.exportedAt === "string", `status=${analytics.status} range=${analyticsBody.data?.range}`, ); // E.37: conditional analytics on the REAL db — the metrics-only // etagPayload (clock fields excluded) must be stable across calls and // answer If-None-Match with an empty 304. const A_URL = "/api/v1/analytics?range=7d"; const a1 = await analyticsRoute.GET(req("GET", undefined, true, A_URL)); const etagA = a1.headers.get("etag"); const a2 = await analyticsRoute.GET(req("GET", undefined, true, A_URL)); const etagB = a2.headers.get("etag"); const condReq = req("GET", undefined, true, A_URL); if (etagA) condReq.headers.set("if-none-match", etagA); const a3 = await analyticsRoute.GET(condReq); check( "GET /analytics conditional (metrics-only etag)", a1.status === 200 && etagA !== null && etagA === etagB && a3.status === 304, `status=${a1.status} etagStable=${etagA === etagB} replay=${a3.status}`, ); const openapi = await openapiRoute.GET(req("GET")); const doc = await openapi.json(); check( "GET /openapi document", openapi.status === 200 && doc.openapi === "3.1.0", `status=${openapi.status} paths=${Object.keys(doc.paths ?? {}).length}`, ); // E.8/E.11 conditional read on the REAL row (before we delete it). const single = await invoke(() => linkIdRoute.GET(req("GET", undefined, true, `/api/v1/links/${linkId}`), { params: Promise.resolve({ id: String(linkId) }), }), ); if (single) { const etag = single.headers.get("etag"); const conditional = await linkIdRoute.GET( (() => { const r = req("GET", undefined, true, `/api/v1/links/${linkId}`); if (etag) r.headers.set("if-none-match", etag); return r; })(), { params: Promise.resolve({ id: String(linkId) }) }, ); check( "GET /links/:id ETag + If-None-Match 304", single.status === 200 && !!etag && conditional.status === 304, `status=${single.status} etag=${etag ? "set" : "missing"} replay=${conditional.status}`, ); } else { check("GET /links/:id ETag + If-None-Match 304", false, `handler threw: ${revalidateBroken}`); } const removed = await invoke(() => linkIdRoute.DELETE(req("DELETE"), { params: Promise.resolve({ id: String(linkId) }) }), ); if (removed) { check("DELETE /links/:id", removed.status === 200, `status=${removed.status}`); } else { check("DELETE /links/:id", false, `handler threw: ${revalidateBroken}`); } } // 3b. E.7 sections CRUD against the migrated file DB (page from step 3). const sectionsRoute = await import("../src/app/api/v1/sections/route"); const sectionIdRoute = await import("../src/app/api/v1/sections/[id]/route"); const probePageId = rows[0]?.pageId ?? 1; const section = await invoke(() => sectionsRoute.POST( req("POST", { pageId: probePageId, title: "Probe section" }, true, "/api/v1/sections"), ), ); if (section) { const sb = (await section.json()) as { data?: { id?: number } }; const sid = sb.data?.id; check( "POST /sections create", section.status === 201 && typeof sid === "number", `status=${section.status} id=${sid ?? "none"}`, ); if (typeof sid === "number") { const sUpdated = await invoke(() => sectionIdRoute.PUT( req("PUT", { title: "Probe section v2" }, true, `/api/v1/sections/${sid}`), { params: Promise.resolve({ id: String(sid) }) }, ), ); check( "PUT /sections/:id partial update", sUpdated !== null && sUpdated.status === 200, `status=${sUpdated?.status ?? "threw"}`, ); const sRemoved = await invoke(() => sectionIdRoute.DELETE(req("DELETE", undefined, true, `/api/v1/sections/${sid}`), { params: Promise.resolve({ id: String(sid) }), }), ); const sRemovedAgain = await invoke(() => sectionIdRoute.DELETE(req("DELETE", undefined, true, `/api/v1/sections/${sid}`), { params: Promise.resolve({ id: String(sid) }), }), ); check( "DELETE /sections/:id (+404 on repeat)", sRemoved?.status === 200 && sRemovedAgain?.status === 404, `first=${sRemoved?.status ?? "threw"} second=${sRemovedAgain?.status ?? "threw"}`, ); } } else { check("POST /sections create", false, `handler threw: ${revalidateBroken}`); } // 3c. E.10 bulk import with partial failure, through the real handler. const importRoute = await import("../src/app/api/v1/links/import/route"); const beforeImport = (await getAllLinks()).length; const imported = await invoke(() => importRoute.POST( req( "POST", { items: [ { title: "Probe bulk 1", url: "https://bulk1.example" }, { title: "Probe bulk 2", url: "https://bulk2.example" }, { title: "", url: "https://bulk-bad.example" }, ], }, true, "/api/v1/links/import", ), ), ); if (imported) { const ib = (await imported.json()) as { data?: { created?: number; failed?: number }; }; const afterImport = (await getAllLinks()).length; check( "POST /links/import partial failure", imported.status === 200 && ib.data?.created === 2 && ib.data?.failed === 1 && afterImport === beforeImport + 2, `status=${imported.status} created=${ib.data?.created} failed=${ib.data?.failed} db+${afterImport - beforeImport}`, ); } else { check("POST /links/import partial failure", false, `handler threw: ${revalidateBroken}`); } // 4. profile 404 on a fresh instance (honest envelope, not a crash) const profile = await profileRoute.GET(req("GET")); const profileBody = await profile.json(); check( "GET /profile (empty instance)", profile.status === 404 && profileBody.error?.code === "not_found", `status=${profile.status} code=${profileBody.error?.code}`, ); const failed = results.filter((r) => !r.ok); console.log( `\nprobe summary: ${results.length - failed.length}/${results.length} passed` + (revalidateBroken ? ` (revalidatePath note: ${revalidateBroken})` : ""), ); process.exit(failed.length === 0 ? 0 : 1); } void main();