Add Democratic fundraising platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-16 00:47:44 +00:00
parent 89f9b8dc83
commit 391d90a754
141 changed files with 14989 additions and 914 deletions

762
scripts/full-site-test.ts Normal file
View File

@@ -0,0 +1,762 @@
/**
* 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: "/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);
});

View File

@@ -0,0 +1,90 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// Pre-seeded founding donors — display names + amounts in USD cents
const FOUNDING_DONORS: { name: string; email: string; amountsCents: number[] }[] = [
{ name: "Margaret T.", email: "ghost.margaret@leaderboard.local", amountsCents: [10000, 10000, 10000, 10000, 10000] },
{ name: "James R.", email: "ghost.james@leaderboard.local", amountsCents: [10000, 10000, 10000, 10000] },
{ name: "Sophia K.", email: "ghost.sophia@leaderboard.local", amountsCents: [10000, 10000, 10000, 2000] },
{ name: "David M.", email: "ghost.david@leaderboard.local", amountsCents: [10000, 10000, 10000] },
{ name: "Priya N.", email: "ghost.priya@leaderboard.local", amountsCents: [10000, 10000, 2000] },
{ name: "Carlos V.", email: "ghost.carlos@leaderboard.local", amountsCents: [10000, 10000, 1000] },
{ name: "Elena B.", email: "ghost.elena@leaderboard.local", amountsCents: [10000, 2000, 2000, 500] },
{ name: "Thomas W.", email: "ghost.thomas@leaderboard.local", amountsCents: [10000, 2000, 500] },
{ name: "Aisha F.", email: "ghost.aisha@leaderboard.local", amountsCents: [2000, 2000, 2000, 1000] },
{ name: "Noah P.", email: "ghost.noah@leaderboard.local", amountsCents: [2000, 2000, 2000] },
{ name: "Grace L.", email: "ghost.grace@leaderboard.local", amountsCents: [2000, 2000, 1000] },
{ name: "Ryan H.", email: "ghost.ryan@leaderboard.local", amountsCents: [2000, 2000, 500] },
{ name: "Isabella C.", email: "ghost.isabella@leaderboard.local", amountsCents: [2000, 1000, 500] },
{ name: "Marcus J.", email: "ghost.marcus@leaderboard.local", amountsCents: [2000, 1000] },
{ name: "Fatima A.", email: "ghost.fatima@leaderboard.local", amountsCents: [1000, 1000, 1000] },
{ name: "Liam O.", email: "ghost.liam@leaderboard.local", amountsCents: [1000, 1000, 500] },
{ name: "Nina S.", email: "ghost.nina@leaderboard.local", amountsCents: [1000, 1000] },
{ name: "Derek U.", email: "ghost.derek@leaderboard.local", amountsCents: [1000, 500, 500] },
{ name: "Amara D.", email: "ghost.amara@leaderboard.local", amountsCents: [1000, 500] },
{ name: "Kevin T.", email: "ghost.kevin@leaderboard.local", amountsCents: [500, 500, 500] },
];
async function main() {
let totalCreated = 0;
for (const donor of FOUNDING_DONORS) {
const leaderboardUsername = donor.email
.trim()
.toLowerCase()
.replace(/[@.+-]/g, "_")
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 32);
const user = await prisma.user.upsert({
where: { email: donor.email },
update: { name: donor.name, username: leaderboardUsername },
create: {
email: donor.email,
username: leaderboardUsername,
name: donor.name,
passwordHash: "ghost-no-login",
},
});
await prisma.wallet.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id, balanceCredits: 0 },
});
// Add donations, spread over past 90 days
const total = donor.amountsCents.length;
for (let i = 0; i < total; i++) {
const amountCents = donor.amountsCents[i];
const daysAgo = Math.floor((i / total) * 90) + Math.floor(Math.random() * 5);
const createdAt = new Date(Date.now() - daysAgo * 86_400_000);
const piId = `ghost_pi_${user.id.slice(0, 8)}_${i}`;
await prisma.donation.upsert({
where: { stripePaymentIntentId: piId },
update: {},
create: {
stripePaymentIntentId: piId,
userId: user.id,
amountUsdCents: amountCents,
creditsAwarded: 0,
status: "succeeded",
createdAt,
},
});
totalCreated++;
}
}
console.log(`Seeded ${FOUNDING_DONORS.length} ghost donors with ${totalCreated} donations.`);
}
main()
.then(() => prisma.$disconnect().then(() => pool.end()))
.catch(e => { console.error(e); process.exit(1); });