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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(<NotConnectedPanel url="https://admin.example.com" />);
|
||||
|
||||
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(<NotConnectedPanel url="" />);
|
||||
expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="grid max-w-2xl gap-3 rounded border border-expired bg-panel p-5">
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
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 <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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 = <T,>(path: string, payload?: unknown) =>
|
||||
req<T>(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<string, string>;
|
||||
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<Session>("/auth/me"),
|
||||
login: (email: string, password: string) => post<Session>("/auth/login", { email, password }),
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/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<AccountResponse>("/api/account"),
|
||||
link: (instance_id: string, name: string) =>
|
||||
post<Instance>("/api/instances/link", { instance_id, name }),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/instances/${id}/relink`, { instance_id }),
|
||||
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
|
||||
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
|
||||
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
|
||||
|
||||
staff: {
|
||||
accounts: (q?: string) =>
|
||||
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
account: (id: string) => req<StaffAccountResponse>(`/api/staff/accounts/${id}`),
|
||||
instances: (params?: Record<string, string>) =>
|
||||
req<Instance[]>(
|
||||
`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`,
|
||||
),
|
||||
instance: (id: string) => req<StaffInstanceResponse>(`/api/staff/instances/${id}`),
|
||||
issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
|
||||
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
|
||||
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(plan),
|
||||
}),
|
||||
audit: (accountId?: string) =>
|
||||
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
|
||||
injectionHealth: () =>
|
||||
req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
|
||||
subscriptions: (status?: string) =>
|
||||
req<Subscription[]>(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user