Ctrl K
ComunidadGuías…

scripts/api-v1-probe.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * /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<string, string> = { "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 <T>(fn: () => Promise<T>): Promise<T | null> => {
    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();