diff --git a/README.md b/README.md index 4545d05..42010a3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,17 @@ pnpm build pnpm start ``` +## API transport and checks + +Resource adapters live in `lib/recoup/` and share `requestAdminJson` for Bearer +requests and JSON errors. Each adapter owns its URL/query and response projection; +sandbox endpoints retain their message-only error contract. `useIsAdmin` keeps its +separate false-on-denial policy. + +Run `pnpm test` with Node 22.15+ (or Node 24+) for mocked-network transport regression +checks. The suite loads the real TypeScript adapters and never contacts the API. +Run `pnpm exec tsc --noEmit` and `pnpm build` for application checks. + ## Project Structure ```text diff --git a/hooks/useAdminSandboxes.ts b/hooks/useAdminSandboxes.ts index cab54e5..650c313 100644 --- a/hooks/useAdminSandboxes.ts +++ b/hooks/useAdminSandboxes.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { usePrivy } from "@privy-io/react-auth"; -import { fetchAdminSandboxes } from "@/lib/fetchAdminSandboxes"; +import { fetchAdminSandboxes } from "@/lib/recoup/fetchAdminSandboxes"; /** * Fetches account sandbox statistics from GET /api/admins/sandboxes diff --git a/lib/recoup/fetchAccountPulseEmails.ts b/lib/recoup/fetchAccountPulseEmails.ts index 19ef461..bdfbc24 100644 --- a/lib/recoup/fetchAccountPulseEmails.ts +++ b/lib/recoup/fetchAccountPulseEmails.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; /** @@ -35,15 +36,9 @@ export async function fetchAccountPulseEmails( const url = new URL(`${API_BASE_URL}/api/admins/emails`); url.searchParams.set("account_id", accountId); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - const data = await res.json(); + const data = await requestAdminJson<{ emails?: PulseEmail[] }>( + url.toString(), + accessToken, + ); return data.emails ?? []; } diff --git a/lib/recoup/fetchAccountTaskRuns.ts b/lib/recoup/fetchAccountTaskRuns.ts index 5f4486e..214f3f1 100644 --- a/lib/recoup/fetchAccountTaskRuns.ts +++ b/lib/recoup/fetchAccountTaskRuns.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { TaskRun } from "@/types/sandbox"; @@ -19,15 +20,9 @@ export async function fetchAccountTaskRuns( url.searchParams.set("account_id", accountId); url.searchParams.set("limit", String(limit)); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - const data = await res.json(); + const data = await requestAdminJson<{ runs?: TaskRun[] }>( + url.toString(), + accessToken, + ); return data.runs ?? []; } diff --git a/lib/recoup/fetchAdminSandboxOrgs.ts b/lib/recoup/fetchAdminSandboxOrgs.ts index 1047815..a8fe14b 100644 --- a/lib/recoup/fetchAdminSandboxOrgs.ts +++ b/lib/recoup/fetchAdminSandboxOrgs.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { OrgRepoRow } from "@/types/sandbox"; @@ -11,15 +12,10 @@ import type { OrgRepoRow } from "@/types/sandbox"; export async function fetchAdminSandboxOrgs( accessToken: string, ): Promise { - const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes/orgs`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message ?? `HTTP ${res.status}`); - } - - const data = await res.json(); + const data = await requestAdminJson<{ repos?: OrgRepoRow[] }>( + `${API_BASE_URL}/api/admins/sandboxes/orgs`, + accessToken, + ["message"], + ); return data.repos ?? []; } diff --git a/lib/fetchAdminSandboxes.ts b/lib/recoup/fetchAdminSandboxes.ts similarity index 61% rename from lib/fetchAdminSandboxes.ts rename to lib/recoup/fetchAdminSandboxes.ts index 49a1586..a46b0be 100644 --- a/lib/fetchAdminSandboxes.ts +++ b/lib/recoup/fetchAdminSandboxes.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { AccountSandboxRow } from "@/types/sandbox"; @@ -11,15 +12,10 @@ import type { AccountSandboxRow } from "@/types/sandbox"; export async function fetchAdminSandboxes( accessToken: string, ): Promise { - const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message ?? `HTTP ${res.status}`); - } - - const data = await res.json(); + const data = await requestAdminJson<{ accounts?: AccountSandboxRow[] }>( + `${API_BASE_URL}/api/admins/sandboxes`, + accessToken, + ["message"], + ); return data.accounts ?? []; } diff --git a/lib/recoup/fetchCodingPrStatus.ts b/lib/recoup/fetchCodingPrStatus.ts index d73abb2..bbc1262 100644 --- a/lib/recoup/fetchCodingPrStatus.ts +++ b/lib/recoup/fetchCodingPrStatus.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { CodingPrStatusResponse } from "@/types/coding-agent"; @@ -20,14 +21,5 @@ export async function fetchCodingPrStatus( const url = new URL(`${API_BASE_URL}/api/admins/coding/pr`); pullRequests.forEach((pr) => url.searchParams.append("pull_requests", pr)); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/fetchContentSlackTags.ts b/lib/recoup/fetchContentSlackTags.ts index b7aeeaf..2245cf6 100644 --- a/lib/recoup/fetchContentSlackTags.ts +++ b/lib/recoup/fetchContentSlackTags.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { AdminPeriod } from "@/types/admin"; import type { ContentSlackResponse } from "@/types/contentSlack"; @@ -9,14 +10,5 @@ export async function fetchContentSlackTags( const url = new URL(`${API_BASE_URL}/api/admins/content/slack`); url.searchParams.set("period", period); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/fetchCreditsEvents.ts b/lib/recoup/fetchCreditsEvents.ts index 0faf36d..139ae68 100644 --- a/lib/recoup/fetchCreditsEvents.ts +++ b/lib/recoup/fetchCreditsEvents.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { CreditsPeriod, CreditsEventsResponse } from "@/types/credits"; @@ -29,14 +30,5 @@ export async function fetchCreditsEvents({ url.searchParams.set("page", String(page)); url.searchParams.set("limit", String(limit)); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/fetchCreditsRollup.ts b/lib/recoup/fetchCreditsRollup.ts index de0d39a..f1aae0e 100644 --- a/lib/recoup/fetchCreditsRollup.ts +++ b/lib/recoup/fetchCreditsRollup.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { CreditsPeriod, CreditsRollupResponse } from "@/types/credits"; @@ -23,14 +24,5 @@ export async function fetchCreditsRollup({ url.searchParams.set("page", "1"); url.searchParams.set("limit", "500"); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/fetchPrivyLogins.ts b/lib/recoup/fetchPrivyLogins.ts index 8ac70b7..6047c43 100644 --- a/lib/recoup/fetchPrivyLogins.ts +++ b/lib/recoup/fetchPrivyLogins.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { PrivyLoginsPeriod, PrivyLoginsResponse } from "@/types/privy"; @@ -16,14 +17,5 @@ export async function fetchPrivyLogins( const url = new URL(`${API_BASE_URL}/api/admins/privy`); url.searchParams.set("period", period); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/fetchPulseEmailById.ts b/lib/recoup/fetchPulseEmailById.ts index b83da99..abc7de9 100644 --- a/lib/recoup/fetchPulseEmailById.ts +++ b/lib/recoup/fetchPulseEmailById.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { PulseEmail } from "./fetchAccountPulseEmails"; @@ -15,15 +16,9 @@ export async function fetchPulseEmailById( const url = new URL(`${API_BASE_URL}/api/admins/emails`); url.searchParams.set("email_id", emailId); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - const data = await res.json(); + const data = await requestAdminJson<{ emails?: PulseEmail[] }>( + url.toString(), + accessToken, + ); return data.emails?.[0] ?? null; } diff --git a/lib/recoup/fetchSlackTags.ts b/lib/recoup/fetchSlackTags.ts index 12500e8..8f76257 100644 --- a/lib/recoup/fetchSlackTags.ts +++ b/lib/recoup/fetchSlackTags.ts @@ -1,3 +1,4 @@ +import { requestAdminJson } from "@/lib/recoup/requestAdminJson"; import { API_BASE_URL } from "@/lib/consts"; import type { SlackTagsPeriod, SlackTagsResponse } from "@/types/coding-agent"; @@ -16,14 +17,5 @@ export async function fetchSlackTags( const url = new URL(`${API_BASE_URL}/api/admins/coding/slack`); url.searchParams.set("period", period); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); - } - - return res.json(); + return requestAdminJson(url.toString(), accessToken); } diff --git a/lib/recoup/requestAdminJson.ts b/lib/recoup/requestAdminJson.ts new file mode 100644 index 0000000..ed63a5d --- /dev/null +++ b/lib/recoup/requestAdminJson.ts @@ -0,0 +1,24 @@ +/** Authenticated JSON transport; resource adapters own paths and response shapes. */ +export async function requestAdminJson( + url: string | URL, + accessToken: string, + errorFields: readonly string[] = ["error", "message"], +): Promise { + const response = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + const errorBody = + typeof body === "object" && body !== null && !Array.isArray(body) + ? (body as Record) + : undefined; + const message = errorFields + .map((field) => errorBody?.[field]) + .find((value) => value !== null && value !== undefined); + throw new Error(String(message ?? `HTTP ${response.status}`)); + } + + return response.json(); +} diff --git a/package.json b/package.json index 297aac4..07111b9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "node --experimental-strip-types --import ./tests/register-paths.mjs --test tests/*.test.mjs" }, "dependencies": { "@privy-io/react-auth": "^1.88.4", diff --git a/tests/adminRequests.test.mjs b/tests/adminRequests.test.mjs new file mode 100644 index 0000000..d57280e --- /dev/null +++ b/tests/adminRequests.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { API_BASE_URL } from "../lib/consts.ts"; +import { fetchAccountPulseEmails } from "../lib/recoup/fetchAccountPulseEmails.ts"; +import { fetchAccountTaskRuns } from "../lib/recoup/fetchAccountTaskRuns.ts"; +import { fetchAdminSandboxOrgs } from "../lib/recoup/fetchAdminSandboxOrgs.ts"; +import { fetchAdminSandboxes } from "../lib/recoup/fetchAdminSandboxes.ts"; +import { fetchCodingPrStatus } from "../lib/recoup/fetchCodingPrStatus.ts"; +import { fetchContentSlackTags } from "../lib/recoup/fetchContentSlackTags.ts"; +import { fetchCreditsEvents } from "../lib/recoup/fetchCreditsEvents.ts"; +import { fetchCreditsRollup } from "../lib/recoup/fetchCreditsRollup.ts"; +import { fetchPrivyLogins } from "../lib/recoup/fetchPrivyLogins.ts"; +import { fetchPulseEmailById } from "../lib/recoup/fetchPulseEmailById.ts"; +import { fetchSlackTags } from "../lib/recoup/fetchSlackTags.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); +const token = "test-admin-token"; +const record = { id: "record-1" }; +const response = { status: "success", total_count: 1, rows: [record] }; +const prs = [ + "https://github.com/recoupable/api/pull/1", + "https://github.com/recoupable/chat/pull/2", +]; +const cases = [ + [ + "account emails", + () => fetchAccountPulseEmails(token, "account 1"), + "/api/admins/emails", + [["account_id", "account 1"]], + { emails: [record] }, + [record], + ], + [ + "task runs", + () => fetchAccountTaskRuns(token, "account 1", 7), + "/api/tasks/runs", + [ + ["account_id", "account 1"], + ["limit", "7"], + ], + { runs: [record] }, + [record], + ], + [ + "sandbox organizations", + () => fetchAdminSandboxOrgs(token), + "/api/admins/sandboxes/orgs", + [], + { repos: [record] }, + [record], + ], + [ + "sandboxes", + () => fetchAdminSandboxes(token), + "/api/admins/sandboxes", + [], + { accounts: [record] }, + [record], + ], + [ + "PR status", + () => fetchCodingPrStatus(token, prs), + "/api/admins/coding/pr", + prs.map((pr) => ["pull_requests", pr]), + response, + response, + ], + [ + "content tags", + () => fetchContentSlackTags(token, "weekly"), + "/api/admins/content/slack", + [["period", "weekly"]], + response, + response, + ], + [ + "credits events", + () => + fetchCreditsEvents({ + accessToken: token, + accountId: "account 1", + period: "daily", + page: 2, + limit: 20, + }), + "/api/admins/credits/events", + [ + ["account_id", "account 1"], + ["period", "daily"], + ["page", "2"], + ["limit", "20"], + ], + response, + response, + ], + [ + "credits rollup", + () => fetchCreditsRollup({ accessToken: token, period: "monthly" }), + "/api/admins/credits/rollup", + [ + ["period", "monthly"], + ["page", "1"], + ["limit", "500"], + ], + response, + response, + ], + [ + "Privy logins", + () => fetchPrivyLogins(token, "all"), + "/api/admins/privy", + [["period", "all"]], + response, + response, + ], + [ + "one email", + () => fetchPulseEmailById(token, "email 1"), + "/api/admins/emails", + [["email_id", "email 1"]], + { emails: [record, { id: "second" }] }, + record, + ], + [ + "coding tags", + () => fetchSlackTags(token, "daily"), + "/api/admins/coding/slack", + [["period", "daily"]], + response, + response, + ], +]; + +for (const [name, request, pathname, query, body, expected] of cases) { + test(`${name}: preserves the authenticated request and response shape`, async () => { + const calls = []; + globalThis.fetch = async (...args) => { + calls.push(args); + return Response.json(body); + }; + assert.deepEqual(await request(), expected); + assert.equal(calls.length, 1); + const [url, options] = calls[0]; + const parsed = new URL(url); + assert.equal(parsed.origin, API_BASE_URL); + assert.equal(parsed.pathname, pathname); + assert.deepEqual([...parsed.searchParams], query); + assert.deepEqual(options, { + headers: { Authorization: `Bearer ${token}` }, + }); + }); + test(`${name}: preserves error selection and malformed-response fallback`, async () => { + const messageOnly = + name === "sandboxes" || name === "sandbox organizations"; + globalThis.fetch = async () => + Response.json({ error: "detail", message: "summary" }, { status: 403 }); + await assert.rejects(request(), { + message: messageOnly ? "summary" : "detail", + }); + globalThis.fetch = async () => + Response.json({ message: "summary" }, { status: 403 }); + await assert.rejects(request(), { message: "summary" }); + globalThis.fetch = async () => + Response.json({ error: "detail" }, { status: 403 }); + await assert.rejects(request(), { + message: messageOnly ? "HTTP 403" : "detail", + }); + globalThis.fetch = async () => new Response("not JSON", { status: 502 }); + await assert.rejects(request(), { message: "HTTP 502" }); + globalThis.fetch = async () => { + throw new TypeError("offline"); + }; + await assert.rejects(request(), { message: "offline" }); + }); +} + +test("empty PR list does not send a request", async () => { + globalThis.fetch = () => { + throw new Error("unexpected network request"); + }; + assert.deepEqual(await fetchCodingPrStatus(token, []), { + status: "success", + pull_requests: [], + }); +}); + +test("optional collections keep empty defaults and single email keeps null", async () => { + globalThis.fetch = async () => Response.json({}); + for (const read of [ + () => fetchAccountPulseEmails(token, "a"), + () => fetchAccountTaskRuns(token, "a"), + () => fetchAdminSandboxOrgs(token), + () => fetchAdminSandboxes(token), + ]) { + assert.deepEqual(await read(), []); + } + assert.equal(await fetchPulseEmailById(token, "missing"), null); +}); + +test("successful malformed JSON remains a rejected read", async () => { + globalThis.fetch = async () => new Response("not JSON"); + await assert.rejects( + fetchCreditsRollup({ accessToken: token, period: "daily" }), + SyntaxError, + ); +}); + +test("malformed-shaped error JSON keeps the HTTP status for every adapter", async () => { + for (const [, request] of cases) { + for (const body of [null, 0, false, "upstream error", ["upstream error"]]) { + globalThis.fetch = async () => Response.json(body, { status: 503 }); + await assert.rejects(request(), { message: "HTTP 503" }); + } + } +}); + +test("error fields retain nullish priority and JavaScript string conversion", async () => { + const values = [ + [0, "0"], + [false, "false"], + ["", ""], + [42, "42"], + [{ code: "upstream" }, "[object Object]"], + [["first", "second"], "first,second"], + ]; + for (const [name, request] of cases) { + const messageOnly = + name === "sandboxes" || name === "sandbox organizations"; + for (const [value, expected] of values) { + const body = messageOnly + ? { error: "ignored", message: value } + : { error: value, message: "lower-priority" }; + globalThis.fetch = async () => Response.json(body, { status: 403 }); + await assert.rejects(request(), { message: expected }); + globalThis.fetch = async () => + Response.json({ error: null, message: value }, { status: 403 }); + await assert.rejects(request(), { message: expected }); + } + globalThis.fetch = async () => + Response.json({ error: null, message: null }, { status: 403 }); + await assert.rejects(request(), { message: "HTTP 403" }); + } +}); diff --git a/tests/register-paths.mjs b/tests/register-paths.mjs new file mode 100644 index 0000000..9c90ecb --- /dev/null +++ b/tests/register-paths.mjs @@ -0,0 +1,14 @@ +import { registerHooks } from "node:module"; + +// The transport tests run the real TypeScript adapters using the app's @/ alias. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + return nextResolve( + new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, + context, + ); + } + return nextResolve(specifier, context); + }, +});