Files
democratic-money/scripts/full-site-test.ts
root 7eac42e820 Legal pages, Threads OAuth, live wallet pill, PvP money flow + resume
- Legal: /privacy-policy and /datadeletion (shared LegalPageLayout, footer
  links, sitemap + integration tests, NEXT_PUBLIC_LEGAL_CONTACT_EMAIL).
- Threads OAuth: server-side /api/threads-exchange and /threads-callback
  page (Suspense + client component, noindex, no leaked secrets).
- Username + live wallet pill in top nav. New NavWalletBalance component
  polls /api/wallet, refreshes on tab focus, and listens to the
  `wallet:refresh` event bus so cash-outs and refunds update the nav in
  real time. Flash animation on balance changes.
- useLiveWalletBalance hook now broadcasts `wallet:refresh` after every
  fetch so games, exchange panel, wallet actions, and nav all stay in
  sync without extra polling.
- PvP fund-locking (`POST /api/games/rooms`): creator funds debited
  atomically with room creation; ledger entry tagged with `gameRoomId`.
  Joiner debit happens at join. Old double-debit of the creator is gone.
- DELETE /api/games/rooms?id=... lets a creator cancel a WAITING room
  and get an idempotent refund. Coin Flip + Pong waiting screens show a
  Cancel & Refund button.
- Pong/Coin Flip recovery: expiry sweep + boot-time `recoverOrphaned
  RoomsOnBoot()` (runs before listen()) refund both parties for any
  ACTIVE/expired rooms so a server restart never strands locked credits.
- Schema migration `20260520000000_game_ledger_links` adds optional
  `gameSessionId` + `gameRoomId` FKs to LedgerEntry (with indexes) and
  extra indexes on GameSession/GameRoom for resume + sweep queries.
- GET /api/games/active returns a user's active solo session + open
  rooms (sanitized — no mine/bomb positions). Mines and Tower clients
  rehydrate on mount so a refresh mid-round resumes instead of dropping.
- ActiveGamesBanner surfaces unfinished rounds on /wallet and /casino
  with Resume / Rejoin / Cancel & refund actions.
- ExchangePanel unified with useLiveWalletBalance; per-game header gets
  an "Open wallet →" chip; Dice clears stale result on roll; Blackjack
  reveals full dealer hand on natural blackjack/push; Mines refund
  label fixed; Tower final multiplier fixed; GameHistory error path;
  Prediction "Resolved" tab.
- Site audit + redmeFIXES triage notes (REDME-FIXSES-TRIAGE.txt,
  SITE-AUDIT.txt).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 06:43:26 +00:00

765 lines
31 KiB
TypeScript

/**
* Full-site integration test — runs against http://localhost:8008
* Tests: routes, auth flows, API endpoints, link integrity, images, spacing.
*
* Run: npx tsx scripts/full-site-test.ts
*/
const BASE = "http://localhost:8008";
interface Result {
name: string;
pass: boolean;
detail: string;
}
const results: Result[] = [];
let cookies = ""; // session cookies accumulated per test user
function pass(name: string, detail = "") {
results.push({ name, pass: true, detail });
console.log(`${name}${detail ? " → " + detail : ""}`);
}
function fail(name: string, detail = "") {
results.push({ name, pass: false, detail });
console.error(`${name}${detail ? " → " + detail : ""}`);
}
async function get(path: string, opts: RequestInit = {}) {
const res = await fetch(`${BASE}${path}`, {
redirect: "follow",
headers: { cookie: cookies, "user-agent": "SiteTestBot/1.0", ...(opts.headers as Record<string, string> ?? {}) },
...opts,
});
return res;
}
async function postJson(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
...extraHeaders,
},
body: JSON.stringify(body),
redirect: "follow",
});
// persist Set-Cookie
const sc = res.headers.get("set-cookie");
if (sc) cookies += "; " + sc.split(";")[0];
return res;
}
function extractLinks(html: string): string[] {
const hrefs: string[] = [];
const re = /href="([^"#][^"]*)"/g;
let m;
while ((m = re.exec(html)) !== null) {
hrefs.push(m[1]);
}
return hrefs;
}
function extractImgSrcs(html: string): string[] {
const srcs: string[] = [];
const re = /(?:src|srcset)="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) {
srcs.push(m[1].split(" ")[0]);
}
return srcs;
}
function detectSpacingIssues(html: string, context: string): void {
// Strip tags to check text nodes
const text = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ");
// Common double-space, missing space before/after common words
const issues: string[] = [];
if (/\w{2,}\s{2,}\w/.test(text)) issues.push("double-space detected");
if (/[a-z][A-Z][a-z]/.test(text.replace(/\s/g, ""))) {
// camelCase bleed into content is already stripped by tag removal above — skip
}
if (issues.length > 0) fail(`spacing: ${context}`, issues.join(", "));
else pass(`spacing: ${context}`);
}
// ─────────────────────────────────────────
// 1. PUBLIC ROUTES
// ─────────────────────────────────────────
async function testPublicRoutes() {
console.log("\n── 1. PUBLIC ROUTES ──────────────────────────────");
const routes: Array<{ path: string; minLen?: number }> = [
{ path: "/", minLen: 2000 },
{ path: "/raised", minLen: 500 },
{ path: "/register", minLen: 500 },
{ path: "/login", minLen: 300 },
{ path: "/forgot-password", minLen: 200 },
{ path: "/privacy-policy", minLen: 500 },
{ path: "/datadeletion", minLen: 500 },
{ path: "/donate", minLen: 500 },
{ path: "/donate/thank-you", minLen: 200 },
{ path: "/billboard", minLen: 200 },
{ path: "/boost", minLen: 200 },
{ path: "/spotlight", minLen: 200 },
{ path: "/cards", minLen: 200 },
{ path: "/faq-board", minLen: 200 },
{ path: "/missions", minLen: 200 },
{ path: "/initiatives", minLen: 200 },
{ path: "/vote/next-president", minLen: 200 },
{ path: "/robots.txt", minLen: 50 },
{ path: "/sitemap.xml", minLen: 100 },
{ path: "/opengraph-image", minLen: 100 },
{ path: "/favicon.ico" },
];
for (const r of routes) {
try {
const res = await get(r.path);
if (res.status === 200) {
const body = await res.text();
const len = body.length;
if (r.minLen && len < r.minLen) {
fail(`GET ${r.path}`, `body too short: ${len} < ${r.minLen}`);
} else {
pass(`GET ${r.path}`, `${res.status} (${len} bytes)`);
}
} else {
fail(`GET ${r.path}`, `HTTP ${res.status}`);
}
} catch (e) {
fail(`GET ${r.path}`, String(e));
}
}
}
// ─────────────────────────────────────────
// 2. SEO / META TAGS
// ─────────────────────────────────────────
async function testSeoMeta() {
console.log("\n── 2. SEO / META TAGS ────────────────────────────");
const res = await get("/");
const html = await res.text();
const checks: Array<{ name: string; pattern: RegExp }> = [
{ name: "title tag", pattern: /<title>/ },
{ name: "meta description", pattern: /name="description"/ },
{ name: "meta keywords", pattern: /name="keywords"/ },
{ name: "og:title", pattern: /property="og:title"/ },
{ name: "og:description", pattern: /property="og:description"/ },
{ name: "og:image", pattern: /property="og:image"/ },
{ name: "og:url", pattern: /property="og:url"/ },
{ name: "twitter:card", pattern: /name="twitter:card"/ },
{ name: "twitter:image", pattern: /name="twitter:image"/ },
{ name: "canonical link", pattern: /rel="canonical"/ },
{ name: "robots meta", pattern: /name="robots"/ },
{ name: "JSON-LD script", pattern: /application\/ld\+json/ },
{ name: "lang=en on html", pattern: /<html[^>]+lang="en"/ },
{ name: "viewport meta", pattern: /name="viewport"/ },
];
for (const c of checks) {
if (c.pattern.test(html)) pass(`seo: ${c.name}`);
else fail(`seo: ${c.name}`, "not found in /");
}
// robots.txt content
const robots = await (await get("/robots.txt")).text();
if (/Disallow.*\/wallet/.test(robots)) pass("robots.txt blocks /wallet");
else fail("robots.txt blocks /wallet", robots.substring(0, 200));
if (/Sitemap:/.test(robots)) pass("robots.txt has Sitemap directive");
else fail("robots.txt has Sitemap directive");
// sitemap.xml
const sitemap = await (await get("/sitemap.xml")).text();
const required = ["/", "/raised", "/register", "/login"];
for (const url of required) {
if (sitemap.includes(url)) pass(`sitemap: contains ${url}`);
else fail(`sitemap: missing ${url}`);
}
}
// ─────────────────────────────────────────
// 3. LINK INTEGRITY (no dead ends)
// ─────────────────────────────────────────
async function testLinks() {
console.log("\n── 3. LINK INTEGRITY ─────────────────────────────");
const pages = ["/", "/raised", "/register", "/login", "/forgot-password", "/donate", "/missions", "/initiatives"];
const knownExternal = new Set(["https://", "http://", "mailto:", "tel:"]);
const checked = new Set<string>();
for (const page of pages) {
const res = await get(page);
const html = await res.text();
const links = extractLinks(html).filter(
(l) => l.startsWith("/") && !l.startsWith("//") && !checked.has(l)
);
for (const link of links) {
// skip Next.js internals, API, and anchor-only hrefs
if (
link.startsWith("/_next") ||
link.startsWith("/api/") ||
link === "/#" ||
link.startsWith("/#")
) continue;
const clean = link.split("?")[0].split("#")[0];
if (!clean || checked.has(clean)) continue;
checked.add(clean);
try {
const r = await fetch(`${BASE}${clean}`, {
redirect: "manual",
headers: { cookie: cookies, "user-agent": "SiteTestBot/1.0" },
});
// 200 (page renders), 3xx (auth/short-link redirect), all considered alive.
if (r.status === 200 || (r.status >= 300 && r.status < 400)) {
pass(`link: ${clean}`, `${r.status}`);
} else {
fail(`link: ${clean}`, `HTTP ${r.status}`);
}
} catch (e) {
fail(`link: ${clean}`, String(e));
}
}
}
}
// ─────────────────────────────────────────
// 4. IMAGES
// ─────────────────────────────────────────
async function testImages() {
console.log("\n── 4. IMAGES ─────────────────────────────────────");
const res = await get("/");
const html = await res.text();
const srcs = extractImgSrcs(html);
// Also check known static assets
const staticAssets = [
"/favicon.ico",
"/opengraph-image",
];
const allToCheck = [...new Set([...srcs.filter((s) => s.startsWith("/")), ...staticAssets])];
for (const src of allToCheck.slice(0, 20)) {
// Skip data URIs and external
if (src.startsWith("data:") || src.startsWith("http")) continue;
// Skip _next/image with external src params
if (src.includes("url=http")) continue;
try {
const r = await get(src);
if (r.status === 200) pass(`image: ${src}`);
else fail(`image: ${src}`, `HTTP ${r.status}`);
} catch (e) {
fail(`image: ${src}`, String(e));
}
}
if (allToCheck.length === 0) pass("images: no <img> tags found (CSS backgrounds / SVG components)");
}
// ─────────────────────────────────────────
// 5. WORD SPACING
// ─────────────────────────────────────────
async function testSpacing() {
console.log("\n── 5. WORD SPACING ───────────────────────────────");
const pages = ["/", "/raised", "/register"];
for (const p of pages) {
const html = await (await get(p)).text();
detectSpacingIssues(html, p);
}
}
// ─────────────────────────────────────────
// 6. AUTH — REGISTRATION FLOW
// ─────────────────────────────────────────
const TEST_EMAIL = `testbot_${Date.now()}@sitetest.local`;
const TEST_PASS = "TestBot@1234!";
let CREATED_USERNAME = ""; // assigned by POST /api/register
async function testRegistration() {
console.log("\n── 6. REGISTRATION FLOW ──────────────────────────");
// Register new user
const res = await postJson("/api/register", {
name: "Site Test Bot",
email: TEST_EMAIL,
password: TEST_PASS,
});
if (res.status === 201 || res.status === 200) {
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
CREATED_USERNAME = typeof body.username === "string" ? body.username : "";
pass(
"POST /api/register",
`user created: ${typeof body.username === "string" ? `@${body.username}` : ""} ${body.email ?? ""}`.trim(),
);
} else {
const text = await res.text();
fail("POST /api/register", `${res.status}: ${text.substring(0, 120)}`);
return;
}
// Try duplicate registration
const dup = await postJson("/api/register", {
name: "Site Test Bot 2",
email: TEST_EMAIL,
password: TEST_PASS,
});
if (dup.status === 409 || dup.status === 400 || dup.status === 422) {
pass("duplicate registration blocked", `${dup.status}`);
} else {
fail("duplicate registration blocked", `expected 4xx, got ${dup.status}`);
}
}
// ─────────────────────────────────────────
// 7. AUTH — LOGIN FLOW (via NextAuth credentials)
// ─────────────────────────────────────────
async function testLogin() {
console.log("\n── 7. LOGIN FLOW ─────────────────────────────────");
// Get CSRF token
const csrfRes = await get("/api/auth/csrf");
let csrfToken = "";
try {
const j = await csrfRes.json() as { csrfToken: string };
csrfToken = j.csrfToken;
pass("GET /api/auth/csrf", `token: ${csrfToken.substring(0, 12)}`);
} catch (e) {
fail("GET /api/auth/csrf", String(e));
return;
}
// Store session cookie from CSRF
const sc = csrfRes.headers.get("set-cookie");
if (sc) cookies += "; " + sc.split(";")[0];
// Sign in via credentials
const formBody = new URLSearchParams({
email: TEST_EMAIL,
password: TEST_PASS,
csrfToken,
callbackUrl: "/wallet",
json: "true",
});
const loginRes = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: formBody.toString(),
redirect: "manual",
});
const loginSc = loginRes.headers.get("set-cookie");
if (loginSc) cookies += "; " + loginSc.split(";")[0];
if (loginRes.status === 200 || loginRes.status === 302 || loginRes.status === 303) {
pass("POST /api/auth/callback/credentials", `${loginRes.status} — session established`);
if (/authjs\.session-token|__Secure-authjs\.session-token/.test(loginSc ?? "") && /Max-Age=/i.test(loginSc ?? "")) {
pass("session cookie persistence", "authjs session cookie includes Max-Age");
} else {
fail("session cookie persistence", "missing persistent authjs session cookie Max-Age");
}
} else {
const body = await loginRes.text().catch(() => "");
fail("POST /api/auth/callback/credentials", `${loginRes.status}: ${body.substring(0, 200)}`);
}
// Bad password should fail
const badLogin = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: new URLSearchParams({
email: TEST_EMAIL,
password: "wrongpassword999",
csrfToken,
callbackUrl: "/wallet",
json: "true",
}).toString(),
redirect: "manual",
});
// NextAuth redirects to error page on bad creds — 302 to /api/auth/error or /login?error=
const badLocation = badLogin.headers.get("location") ?? "";
if (badLogin.status >= 400 || badLocation.includes("error") || badLocation.includes("Error")) {
pass("bad password rejected", `${badLogin.status}${badLocation.substring(0, 60)}`);
} else {
fail("bad password rejected", `${badLogin.status}${badLocation.substring(0, 80)}`);
}
if (!CREATED_USERNAME) {
fail("POST /api/auth/callback/credentials — username variant", "no CREATED_USERNAME from registration");
return;
}
const csrfResUser = await get("/api/auth/csrf");
let csrfTokUser = "";
try {
csrfTokUser = ((await csrfResUser.json()) as { csrfToken: string }).csrfToken;
} catch {
fail("login with username → GET /api/auth/csrf", "JSON parse failed");
return;
}
const scUser = csrfResUser.headers.get("set-cookie");
if (scUser) cookies += "; " + scUser.split(";")[0];
const userLogin = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: new URLSearchParams({
email: CREATED_USERNAME,
password: TEST_PASS,
csrfToken: csrfTokUser,
callbackUrl: "/wallet",
json: "true",
}).toString(),
redirect: "manual",
});
const userLoc = userLogin.headers.get("location") ?? "";
if (userLogin.status === 200 || userLogin.status === 302 || userLogin.status === 303) {
pass(`POST /api/auth/callback/credentials (username: ${CREATED_USERNAME})`, `${userLogin.status}`);
} else {
fail(
"POST /api/auth/callback/credentials (username)",
`${userLogin.status}: ${userLoc.substring(0, 80)}`,
);
}
}
// ─────────────────────────────────────────
// 8. AUTHENTICATED API ENDPOINTS
// ─────────────────────────────────────────
async function testAuthenticatedApis() {
console.log("\n── 8. AUTHENTICATED API ENDPOINTS ───────────────");
const endpoints: Array<{ method: string; path: string; expectCodes: number[]; body?: unknown }> = [
{ method: "GET", path: "/api/wallet", expectCodes: [200] },
{ method: "GET", path: "/api/wallet/ledger", expectCodes: [200] },
{ method: "GET", path: "/api/wallet/donations", expectCodes: [200] },
{ method: "GET", path: "/api/rewards/catalog", expectCodes: [200] },
{ method: "GET", path: "/api/exchange/rate", expectCodes: [200] },
{ method: "GET", path: "/api/public/stats", expectCodes: [200] },
];
for (const e of endpoints) {
try {
const res = e.method === "GET"
? await get(e.path)
: await postJson(e.path, e.body ?? {});
if (e.expectCodes.includes(res.status)) {
const body = await res.text();
pass(`${e.method} ${e.path}`, `${res.status}`);
} else {
fail(`${e.method} ${e.path}`, `HTTP ${res.status} (expected one of ${e.expectCodes.join(",")})`);
}
} catch (err) {
fail(`${e.method} ${e.path}`, String(err));
}
}
}
// ─────────────────────────────────────────
// 9. API GUARD TESTS (unauthenticated should be blocked)
// ─────────────────────────────────────────
async function testApiGuards() {
console.log("\n── 9. API AUTH GUARDS ────────────────────────────");
// These require auth — call without cookies
const guarded = [
"/api/wallet",
"/api/wallet/ledger",
"/api/wallet/donations",
];
for (const path of guarded) {
const res = await fetch(`${BASE}${path}`, {
headers: { "user-agent": "SiteTestBot/1.0" },
});
if (res.status === 401 || res.status === 403 || res.status === 302) {
pass(`guard: ${path}`, `${res.status} — correctly blocked`);
} else if (res.status === 200) {
// Check if the response is empty/null (some Next.js auth returns 200 with null user)
const body = await res.text();
if (body.includes('"user":null') || body.includes("null")) {
pass(`guard: ${path}`, `200 with null session — ok`);
} else {
fail(`guard: ${path}`, `${res.status} — unguarded! body: ${body.substring(0, 80)}`);
}
} else {
fail(`guard: ${path}`, `unexpected ${res.status}`);
}
}
}
// ─────────────────────────────────────────
// 10. PRIVATE PAGE REDIRECT
// ─────────────────────────────────────────
async function testPrivatePageRedirects() {
console.log("\n── 10. PRIVATE PAGE REDIRECTS / GUEST VIEWS ───────");
// /wallet renders a guest marketing view at 200; assert that view includes a sign-in CTA.
const wallet = await fetch(`${BASE}/wallet`, {
redirect: "manual",
headers: { "user-agent": "SiteTestBot/1.0" },
});
if (wallet.status === 200) {
const body = await wallet.text();
if (/sign in/i.test(body) && /register|join|enroll|create.*account/i.test(body)) {
pass(`guest view: /wallet`, `200 with sign-in + register CTAs`);
} else {
fail(`guest view: /wallet`, `200 but missing auth CTAs`);
}
} else if (wallet.status === 302 || wallet.status === 307 || wallet.status === 308) {
pass(`redirect: /wallet`, `${wallet.headers.get("location") ?? ""}`);
} else {
fail(`redirect or guest view: /wallet`, `unexpected ${wallet.status}`);
}
// Casino subroutes are auth-gated. Confirm anonymous hit redirects to /login with callbackUrl.
for (const casinoPath of ["/casino", "/casino/crash"]) {
const casinoSub = await fetch(`${BASE}${casinoPath}`, {
redirect: "manual",
headers: { "user-agent": "SiteTestBot/1.0" },
});
const loc = casinoSub.headers.get("location") ?? "";
if (casinoSub.status >= 300 && casinoSub.status < 400) {
if (loc.includes("callbackUrl") && loc.includes("casino")) {
pass(`redirect: ${casinoPath}`, `${casinoSub.status}${loc} (callbackUrl preserved)`);
} else {
fail(`redirect: ${casinoPath}`, `redirected but missing callbackUrl: ${loc}`);
}
} else {
fail(`redirect: ${casinoPath}`, `expected 3xx, got ${casinoSub.status}`);
}
}
}
// ─────────────────────────────────────────
// 11. CHECKOUT SESSION API (primary Stripe path)
// ─────────────────────────────────────────
async function testPaymentIntent() {
console.log("\n── 11. CHECKOUT SESSION API ──────────────────────");
const tiers = [500, 1000, 2000, 10000]; // cents
for (const amount of tiers) {
const res = await postJson("/api/stripe/create-checkout-session", { amountUsdCents: amount });
if (res.status === 200) {
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
if (body.clientSecret && body.sessionId) pass(`checkout-session $${amount / 100}`, "clientSecret + sessionId returned");
else fail(`checkout-session $${amount / 100}`, `missing fields: ${JSON.stringify(Object.keys(body))}`);
} else if (res.status === 401 || res.status === 403) {
pass(`checkout-session $${amount / 100}`, `${res.status} — requires auth (expected)`);
} else {
const text = await res.text().catch(() => "");
fail(`checkout-session $${amount / 100}`, `${res.status}: ${text.substring(0, 100)}`);
}
}
// Verify bad tier is rejected
const bad = await postJson("/api/stripe/create-checkout-session", { amountUsdCents: 777 });
if (bad.status === 400) {
pass("checkout-session: bad tier rejected", "400");
} else {
fail("checkout-session: bad tier rejected", `expected 400, got ${bad.status}`);
}
// Session status retrieval (GET) with invalid id returns 404
const statusRes = await get("/api/stripe/create-checkout-session?session_id=cs_test_invalid_id");
if (statusRes.status === 404 || statusRes.status === 200) {
pass("checkout-session GET status endpoint", `${statusRes.status}`);
} else {
fail("checkout-session GET status endpoint", `unexpected ${statusRes.status}`);
}
// Legacy payment-intent route still works for the home widget
const legacyRes = await postJson("/api/stripe/create-payment-intent", { amountUsdCents: 1000 });
if (legacyRes.status === 200) {
const body = await legacyRes.json().catch(() => ({})) as Record<string, unknown>;
if (body.clientSecret) pass("legacy payment-intent $10", "clientSecret returned");
else fail("legacy payment-intent $10", "no clientSecret");
} else if (legacyRes.status === 401 || legacyRes.status === 403) {
pass("legacy payment-intent $10", `${legacyRes.status} — requires auth (expected)`);
} else {
const text = await legacyRes.text().catch(() => "");
fail("legacy payment-intent $10", `${legacyRes.status}: ${text.substring(0, 100)}`);
}
}
// ─────────────────────────────────────────
// 12. EXCHANGE RATE API
// ─────────────────────────────────────────
async function testExchangeRate() {
console.log("\n── 12. EXCHANGE RATE API ─────────────────────────");
const res = await get("/api/exchange/rate");
if (res.status !== 200) { fail("GET /api/exchange/rate", `${res.status}`); return; }
const body = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!body) { fail("exchange rate: JSON parse", "no body"); return; }
// API returns blwUsd (or mtkUsd alias) for the BLW/BWT exchange rate
const rate = (body.blwUsd ?? body.mtkUsd ?? body.usdPerBlw) as number | undefined;
if (typeof rate === "number" && rate > 0) {
pass("exchange rate: blwUsd", `${rate}`);
} else {
fail("exchange rate: blwUsd", `got: ${JSON.stringify(body)}`);
}
}
// ─────────────────────────────────────────
// 13. PUBLIC STATS API
// ─────────────────────────────────────────
async function testPublicStats() {
console.log("\n── 13. PUBLIC STATS API ──────────────────────────");
const res = await get("/api/public/stats");
if (res.status !== 200) { fail("GET /api/public/stats", `${res.status}`); return; }
const body = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!body) { fail("public stats: JSON parse", "no body"); return; }
const required = ["raisedUsd", "donationCount"];
for (const key of required) {
if (key in body) pass(`public stats: ${key}`, String(body[key]));
else fail(`public stats: ${key}`, `missing from ${JSON.stringify(body)}`);
}
}
// ─────────────────────────────────────────
// 14. 404 PAGE
// ─────────────────────────────────────────
async function test404() {
console.log("\n── 14. 404 HANDLING ──────────────────────────────");
const paths = ["/does-not-exist", "/api/does-not-exist", "/raised/subpage"];
for (const path of paths) {
const res = await get(path);
if (res.status === 404) pass(`404: ${path}`);
else if (res.status === 200 && path.startsWith("/api")) pass(`404 (api returns 200): ${path}`, "ok for api");
else fail(`404: ${path}`, `got ${res.status}`);
}
}
// ─────────────────────────────────────────
// 15. SECURITY HEADERS
// ─────────────────────────────────────────
async function testSecurityHeaders() {
console.log("\n── 15. SECURITY HEADERS ──────────────────────────");
const res = await get("/");
const headers: Array<{ name: string; header: string }> = [
{ name: "X-Content-Type-Options", header: "x-content-type-options" },
{ name: "X-Frame-Options", header: "x-frame-options" },
{ name: "Referrer-Policy", header: "referrer-policy" },
];
for (const h of headers) {
const val = res.headers.get(h.header);
if (val) pass(`header: ${h.name}`, val);
else fail(`header: ${h.name}`, "missing");
}
}
// ─────────────────────────────────────────
// 16. FORGOT PASSWORD PAGE
// ─────────────────────────────────────────
async function testForgotPassword() {
console.log("\n── 16. FORGOT PASSWORD PAGE ──────────────────────");
const res = await get("/forgot-password");
if (res.status !== 200) { fail("GET /forgot-password", `${res.status}`); return; }
const html = await res.text();
if (html.includes("forgot") || html.includes("reset") || html.includes("email") || html.includes("password")) {
pass("forgot-password: form content present");
} else {
fail("forgot-password: form content present", "page seems empty");
}
}
// ─────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────
async function main() {
console.log("═══════════════════════════════════════════════");
console.log(" Democracy Rising — Full Site Test Suite");
console.log(` Target: ${BASE}`);
console.log(` Test user: ${TEST_EMAIL}`);
console.log("═══════════════════════════════════════════════");
await testPublicRoutes();
await testSeoMeta();
await testLinks();
await testImages();
await testSpacing();
await testRegistration();
await testLogin();
await testAuthenticatedApis();
await testApiGuards();
await testPrivatePageRedirects();
await testPaymentIntent();
await testExchangeRate();
await testPublicStats();
await test404();
await testSecurityHeaders();
await testForgotPassword();
// ── SUMMARY ──────────────────────────────────
console.log("\n═══════════════════════════════════════════════");
console.log(" RESULTS SUMMARY");
console.log("═══════════════════════════════════════════════");
const passed = results.filter((r) => r.pass);
const failed = results.filter((r) => !r.pass);
console.log(` ✅ Passed : ${passed.length}`);
console.log(` ❌ Failed : ${failed.length}`);
console.log(` 📋 Total : ${results.length}`);
if (failed.length > 0) {
console.log("\n FAILURES:");
for (const f of failed) {
console.error(`${f.name}${f.detail}`);
}
}
console.log("\n═══════════════════════════════════════════════");
process.exit(failed.length > 0 ? 1 : 0);
}
main().catch((e) => {
console.error("Test runner crashed:", e);
process.exit(2);
});