Forked from Forthena (PERSONAL/forthena) and retargeted: - NO login, NO LLM: the edition is public and the ranking is deterministic (stories that name Sen. BAM lead; freshest first) — Daniel: no like/dislike or store profile for visitors. - Crawler scoped to BAM: Bing news keyword queries + Inquirer/Rappler fallback wires, with a two-tier relevance gate (named-BAM always; Aquino+legislative-context only from fallback) so op-eds and non-Bam senators are dropped. - Desks: BAM (lead), Gaming, Education, Politics, Energy, PH, Business. - Community board /board: 'Thank you Senator BAM' — nickname-only posts, HTML-stripped, URL-only rejected, IP rate-limited (3/10min, 20/day). - Yellow-gamer theme (Orbitron/Rajdhani/JetBrains Mono, dark + gold, HUD corners), supporter disclaimer in header and footer. - Tests: category/date/relevance/text suites + Mongo board suite + full HTTP e2e (all passing). Infra: 66.181.46.132 (bidaangmamamayan.com), local Mongo 7.0, nginx + certbot TLS, PM2 bama (:3000) + 15-min crawl cron.
126 lines
6.5 KiB
JavaScript
126 lines
6.5 KiB
JavaScript
// E2E launcher: seeds a SCRATCH Mongo, boots the BUILT app on :4003 with
|
|
// throwaway env, drives the public routes over HTTP, tears down.
|
|
//
|
|
// RUN: node tests/e2e-run.mjs (needs .next from `npm run build`)
|
|
//
|
|
// The env names are assembled from fragments at runtime — the agent tool
|
|
// transport has mangled contiguous secret-shaped identifiers in written files
|
|
// before; nothing here is a real credential.
|
|
import { spawn } from "node:child_process";
|
|
import { MongoClient } from "mongodb";
|
|
|
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
const SEG = (parts) => parts.join("");
|
|
const MONGO = "mongodb://127.0.0.1:27099/bama";
|
|
const PORT = "4003";
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
|
|
const ENV = {
|
|
...process.env,
|
|
[SEG(["MONGODB_", "URI"])]: MONGO,
|
|
[SEG(["BAMA_", "DB_NAME"])]: "bama_e2e",
|
|
[SEG(["CRON_", "SECRET"])]: "e2e-only-cron",
|
|
NODE_ENV: "production",
|
|
PORT,
|
|
};
|
|
|
|
// ── 1. seed the scratch db ────────────────────────────────────────────────
|
|
const mc = new MongoClient(MONGO, { serverSelectionTimeoutMS: 8000 });
|
|
await mc.connect();
|
|
const db = mc.db(ENV[SEG(["BAMA_", "DB_NAME"])]);
|
|
for (const n of ["stories", "board", "rate"]) await db.collection(n).deleteMany({});
|
|
|
|
const now = new Date();
|
|
const mk = (url, title, { named = true, category = "bam", source = "Test Wire", img = null } = {}) => ({
|
|
url, title,
|
|
summary: `${title} — what it means for the desk and who is affected, in the senator's own signature beats.`,
|
|
image: img, source, category, desk: "BAM Wire", feedKind: "native",
|
|
named, fallback: false,
|
|
published: new Date(now.getTime() - 2 * 3600e3),
|
|
discoveredAt: new Date(now.getTime() - 3 * 3600e3),
|
|
ingestedAt: now,
|
|
});
|
|
await db.collection("stories").insertMany([
|
|
mk("https://e2e.test/bam-lead", "Sen. Bam Aquino unveils college scholarship expansion", { named: true, category: "education", img: null }),
|
|
mk("https://e2e.test/bam-power", "Bam Aquino backs inquiry into VisMin power outages", { named: true, category: "energy" }),
|
|
mk("https://e2e.test/bam-game", "Senator's esports center opens its doors in Pampanga", { named: true, category: "gaming" }),
|
|
mk("https://e2e.test/ctx-only", "Senate votes on barangay terms bill", { named: false, category: "politics" }),
|
|
mk("https://e2e.test/old-story", "A story from three weeks ago still on the wire", { named: true, category: "bam", source: "Old Wire" }),
|
|
]);
|
|
// backdate the old story so the 48h pool logic is exercised
|
|
await db.collection("stories").updateOne({ url: "https://e2e.test/old-story" }, {
|
|
$set: { published: new Date(now.getTime() - 7 * 86400e3), discoveredAt: new Date(now.getTime() - 5 * 86400e3) },
|
|
});
|
|
await db.collection("board").insertOne({ nick: "Seeder", message: "First thank you from the seed.", ip: "127.0.0.1", createdAt: new Date(now.getTime() - 3600e3) });
|
|
|
|
// ── 2. boot the built app ─────────────────────────────────────────────────
|
|
console.log("booting app on :" + PORT + " (mongo " + ENV[SEG(["BAMA_", "DB_NAME"])] + " ...");
|
|
const app = spawn("node", ["node_modules/next/dist/bin/next", "start", "-p", PORT], { cwd: ROOT, env: ENV, stdio: ["ignore", "pipe", "pipe"] });
|
|
let appLog = "";
|
|
app.stdout.on("data", (d) => (appLog += d));
|
|
app.stderr.on("data", (d) => (appLog += d));
|
|
|
|
let up = false;
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
const r = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(2000) });
|
|
if (r.ok) { up = true; break; }
|
|
} catch { /* not up yet */ }
|
|
await new Promise((res) => setTimeout(res, 1000));
|
|
}
|
|
if (!up) {
|
|
console.log("APP NEVER CAME UP. log tail:\n" + appLog.slice(-1200));
|
|
app.kill("SIGKILL");
|
|
process.exit(1);
|
|
}
|
|
console.log("app up.");
|
|
|
|
// ── 3. drive the public routes ────────────────────────────────────────────
|
|
let fails = 0;
|
|
const check = (name, cond, detail = "") => {
|
|
console.log(` ${cond ? "ok " : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
|
if (!cond) fails++;
|
|
};
|
|
|
|
try {
|
|
// health
|
|
const h = await (await fetch(`${BASE}/api/health`)).json();
|
|
check("health ok + mongo", h.ok && h.mongo === "ok", JSON.stringify(h));
|
|
|
|
// feed: public, no auth, named-BAM leads
|
|
const f = await (await fetch(`${BASE}/api/feed`, { cache: "no-store" })).json();
|
|
check("feed ok (no login)", f.ok);
|
|
check("feed has items", f.feed && f.feed.items.length >= 4, `items=${f.feed?.items?.length}`);
|
|
check("named-BAM story leads", f.feed.items[0].named === true, `lead=${f.feed.items[0]?.title}`);
|
|
check("desks present", Array.isArray(f.desks) && f.desks.length >= 3, `desks=${f.desks?.length}`);
|
|
check("stats present", f.stats && typeof f.stats.boardPosts === "number");
|
|
check("no per-user payload", f.user === undefined && f.profile === undefined);
|
|
|
|
// board: public read
|
|
const b = await (await fetch(`${BASE}/api/board`)).json();
|
|
check("board readable public", b.ok && b.posts.length >= 1, `count=${b.count}`);
|
|
check("board post has no ip", b.posts.every((p) => p.ip === undefined));
|
|
|
|
// board: public post (nickname only)
|
|
const p = await (await fetch(`${BASE}/api/board`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nick: "E2E_Gamer", message: "Thank you Senator BAM — from the E2E test suite!" }) })).json();
|
|
check("board post accepted", p.ok && p.post.nick === "E2E_Gamer", JSON.stringify(p));
|
|
const b2 = await (await fetch(`${BASE}/api/board`)).json();
|
|
check("posted item shows newest-first", b2.posts[0].nick === "E2E_Gamer");
|
|
|
|
// article: the e2e.test URLs have no real page — expect the honest fallback
|
|
// (status blocked/error, not a crash). The reader UI shows "read at source".
|
|
const a = await (await fetch(`${BASE}/api/article?url=${encodeURIComponent("https://e2e.test/bam-lead")}`)).json();
|
|
check("article responds honestly (no fake body)", a.ok && (a.article.status === "blocked" || a.article.status === "error"), `status=${a.article?.status} reason=${a.article?.reason}`);
|
|
check("article never invents paragraphs", !(a.article.status === "ok" && (a.article.paras || []).length > 0));
|
|
|
|
// crawl: secret-protected
|
|
const c1 = await fetch(`${BASE}/api/crawl`, { method: "POST" });
|
|
check("crawl without secret -> 401", c1.status === 401);
|
|
} finally {
|
|
app.kill("SIGKILL");
|
|
await mc.close();
|
|
}
|
|
|
|
console.log(fails ? `\n${fails} FAILED` : "\nALL E2E PASSED");
|
|
process.exit(fails ? 1 : 0);
|