From 3e447fd024032dfe762cf135c9a7e7f7808bae38 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 20:57:51 +0100 Subject: [PATCH] feat(adminsite): test harness, typed client and the not-connected state The repo's first frontend test setup: Vitest, React Testing Library, jsdom. Scoped to the flows that lose money or leak data when broken, per spec 4. lib/api.ts collapses every failure into three the UI can act on: NotConnected (unreachable, or no URL baked in), ApiError 401 (redirect), and ApiError with the backend's own message, which is customer-facing and shown verbatim rather than replaced with something vaguer. The not-connected panel names the variable, the value baked in, and both reasons it fails -- unreachable from the browser, or missing from admin's ADMIN_ORIGIN. Proven by test before it existed. Co-Authored-By: Claude Opus 5 --- adminsite/components/NotConnected.test.tsx | 23 +++ adminsite/components/NotConnected.tsx | 28 +++ adminsite/lib/api.ts | 224 +++++++++++++++++++++ adminsite/lib/format.ts | 33 +++ adminsite/lib/query-client.ts | 16 ++ adminsite/test/setup.ts | 8 + adminsite/vitest.config.ts | 14 ++ 7 files changed, 346 insertions(+) create mode 100644 adminsite/components/NotConnected.test.tsx create mode 100644 adminsite/components/NotConnected.tsx create mode 100644 adminsite/lib/api.ts create mode 100644 adminsite/lib/format.ts create mode 100644 adminsite/lib/query-client.ts create mode 100644 adminsite/test/setup.ts create mode 100644 adminsite/vitest.config.ts diff --git a/adminsite/components/NotConnected.test.tsx b/adminsite/components/NotConnected.test.tsx new file mode 100644 index 0000000..6a1143b --- /dev/null +++ b/adminsite/components/NotConnected.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { NotConnectedPanel } from "./NotConnected"; + +describe("NotConnectedPanel", () => { + it("names the variable that is wrong and where it is set", () => { + render(); + + expect(screen.getByRole("heading")).toHaveTextContent( + /not connected to the licensing service/i, + ); + expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument(); + expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument(); + // The two mistakes that actually cause this, both named. + expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument(); + expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument(); + }); + + it("says the value is missing when no URL was baked in", () => { + render(); + expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument(); + }); +}); diff --git a/adminsite/components/NotConnected.tsx b/adminsite/components/NotConnected.tsx new file mode 100644 index 0000000..21d1f12 --- /dev/null +++ b/adminsite/components/NotConnected.tsx @@ -0,0 +1,28 @@ +/* + * The deployment failure this repo makes most often, made legible. It names the + * variable, the value baked in, and both reasons it fails — unreachable from + * the browser, or missing from admin's ADMIN_ORIGIN. + */ +export function NotConnectedPanel({ url }: { url: string }) { + return ( +
+

Not connected to the licensing service

+ {url ? ( +

+ This build points at ADMIN_API_URL ={" "} + {url}, which did not respond. +

+ ) : ( +

+ ADMIN_API_URL was not set when this app was + built, so there is nowhere to send requests. +

+ )} +

+ The value is baked in when the image is built and has to be reachable from your + browser, not just from the server. It also has to appear in the licensing + service’s ADMIN_ORIGIN, or the browser blocks every request. +

+
+ ); +} diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts new file mode 100644 index 0000000..724f1ee --- /dev/null +++ b/adminsite/lib/api.ts @@ -0,0 +1,224 @@ +/* + * The typed client for the licensing service. + * + * The browser calls admin directly, so every request carries credentials and + * every failure mode is one of three: the API is unreachable (NotConnected), + * the caller is not signed in (ApiError 401, which layouts redirect on), or the + * request was refused (ApiError with the backend's own message, which is + * customer-facing and should be shown verbatim). + */ + +export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, ""); + +export class NotConnected extends Error { + constructor() { + super("not connected"); + this.name = "NotConnected"; + } +} + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function req(path: string, init?: RequestInit): Promise { + if (!API_BASE) throw new NotConnected(); + + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + } catch { + // Network-level failure, DNS, or a CORS preflight the browser refused. + throw new NotConnected(); + } + + if (res.status === 204) return undefined as T; + + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`); + } + return body as T; +} + +const post = (path: string, payload?: unknown) => + req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined }); + +// --- types --------------------------------------------------------------- + +export type Deployment = "cloud" | "self_hosted"; +export type Tier = "free" | "professional" | "self_hosted"; +export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled"; + +export interface Session { + kind: "staff" | "customer"; + email: string; + account_id?: string; +} + +export interface Limits { + max_servers: number; + max_secret_groups: number; + max_channels: number; +} + +export interface Account { + account_id: string; + name: string; + billing_email: string; + paddle_customer_id?: string; + status: "active" | "suspended"; + created_at: string; +} + +export interface Instance { + instance_id: string; + account_id: string; + name: string; + slug?: string; + deployment: Deployment; + tier?: Tier; + status: InstanceStatus; + current_license?: string; + relink_count: number; + inject_failed_at?: string | null; + created_at: string; +} + +export interface License { + license_id: string; + instance_id: string; + account_id: string; + tier: Tier; + deployment: Deployment; + limits: Limits; + features: string[]; + issued_at: string; + expires_at: string; + superseded_by?: string; + issued_by: string; + reason: "new" | "renewal" | "tier_change" | "relink" | "manual"; +} + +export interface Subscription { + subscription_id: string; + account_id: string; + instance_id?: string; + tier: Tier; + term: string; + status: string; + current_period_end: string; +} + +export interface Plan { + tier: Tier; + name: string; + deployment: Deployment; + limits: Limits; + features: string[]; + paddle_product_id?: string; + paddle_price_ids?: Record; + active: boolean; +} + +export interface CustomerUser { + user_id: string; + account_id: string; + email: string; + verified_at?: string | null; + created_at: string; +} + +export interface AuditEntry { + actor: string; + action: string; + account_id?: string; + target?: string; + detail?: string; + ip?: string; + created_at: string; +} + +export interface AccountResponse { + account: Account; + instances: Instance[]; + max_relinks: number; +} + +export interface StaffAccountResponse { + account: Account; + instances: Instance[]; + subscriptions: Subscription[]; + users: CustomerUser[]; + audit: AuditEntry[]; +} + +export type InjectionState = "current" | "stale" | "missing" | "none_issued"; + +export interface StaffInstanceResponse { + instance: Instance; + account: Account; + licenses: License[]; + injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null }; +} + +// --- calls --------------------------------------------------------------- + +export const api = { + me: () => req("/auth/me"), + login: (email: string, password: string) => post("/auth/login", { email, password }), + staffLogin: (email: string, password: string) => + post("/auth/staff/login", { email, password }), + logout: () => post<{ ok: boolean }>("/auth/logout"), + signup: (payload: { name: string; email: string; password: string; website?: string }) => + post<{ pending: boolean }>("/auth/signup", payload), + verify: (token: string) => + req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`), + + account: () => req("/api/account"), + link: (instance_id: string, name: string) => + post("/api/instances/link", { instance_id, name }), + relink: (id: string, instance_id: string) => + post(`/api/instances/${id}/relink`, { instance_id }), + license: (id: string) => req(`/api/instances/${id}/license`), + licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`, + subscriptions: () => req("/api/subscriptions"), + + staff: { + accounts: (q?: string) => + req(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`), + account: (id: string) => req(`/api/staff/accounts/${id}`), + instances: (params?: Record) => + req( + `/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`, + ), + instance: (id: string) => req(`/api/staff/instances/${id}`), + issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) => + post(`/api/staff/instances/${id}/issue`, payload), + relink: (id: string, instance_id: string) => + post(`/api/staff/instances/${id}/relink`, { instance_id }), + licenses: (params?: Record) => + req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`), + plans: () => req("/api/staff/plans"), + updatePlan: (tier: Tier, plan: Omit) => + req<{ updated: boolean }>(`/api/staff/plans/${tier}`, { + method: "PUT", + body: JSON.stringify(plan), + }), + audit: (accountId?: string) => + req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`), + injectionHealth: () => + req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"), + subscriptions: (status?: string) => + req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`), + }, +}; diff --git a/adminsite/lib/format.ts b/adminsite/lib/format.ts new file mode 100644 index 0000000..e6c154a --- /dev/null +++ b/adminsite/lib/format.ts @@ -0,0 +1,33 @@ +export type LicenceState = "valid" | "warn" | "expired" | "none"; + +/** Amber inside 14 days, matching the window staff chase renewals on. */ +export const EXPIRY_WARNING_DAYS = 14; + +export function daysRemaining(iso: string): number { + const ms = new Date(iso).getTime() - Date.now(); + return Math.ceil(ms / 86_400_000); +} + +export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState { + if (!hasLicence || !expiresAt) return "none"; + const days = daysRemaining(expiresAt); + if (days <= 0) return "expired"; + if (days <= EXPIRY_WARNING_DAYS) return "warn"; + return "valid"; +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export function formatStamp(iso: string): string { + return `${new Date(iso).toISOString().slice(11, 19)} UTC`; +} + +export function limitLabel(n: number): string { + return n === -1 ? "unlimited" : String(n); +} diff --git a/adminsite/lib/query-client.ts b/adminsite/lib/query-client.ts new file mode 100644 index 0000000..7b36461 --- /dev/null +++ b/adminsite/lib/query-client.ts @@ -0,0 +1,16 @@ +"use client"; + +import { QueryClient } from "@tanstack/react-query"; +import { ApiError, NotConnected } from "./api"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // Retrying a 401 or a missing API URL just delays the redirect and + // the not-connected panel. + retry: (count, error) => + error instanceof NotConnected || error instanceof ApiError ? false : count < 1, + }, + }, +}); diff --git a/adminsite/test/setup.ts b/adminsite/test/setup.ts new file mode 100644 index 0000000..0aee9a2 --- /dev/null +++ b/adminsite/test/setup.ts @@ -0,0 +1,8 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); diff --git a/adminsite/vitest.config.ts b/adminsite/vitest.config.ts new file mode 100644 index 0000000..2693258 --- /dev/null +++ b/adminsite/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { alias: { "@": resolve(__dirname, ".") } }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test/setup.ts"], + include: ["**/*.test.{ts,tsx}"], + }, +});