- 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>
765 lines
31 KiB
TypeScript
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(/&/g, "&")
|
|
.replace(/ /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);
|
|
});
|