Add Democratic fundraising platform.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
762
scripts/full-site-test.ts
Normal file
762
scripts/full-site-test.ts
Normal 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(/&/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: "/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);
|
||||
});
|
||||
90
scripts/seed-leaderboard.ts
Normal file
90
scripts/seed-leaderboard.ts
Normal 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); });
|
||||
Reference in New Issue
Block a user