#!/usr/bin/env node /** * e2e-test.mjs — End-to-end tests for the LibNovel v3 stack. * * Hits live services via https://localhost (self-signed cert, TLS verify skipped). * Requires: Node 18+ (built-in fetch with TLS options via --experimental-fetch or * native in Node 21+). Run with: node --experimental-vm-modules scripts/e2e-test.mjs * or simply: node scripts/e2e-test.mjs * * Services tested: * - Caddy / UI https://localhost * - Go backend via UI proxy routes * - PocketBase via UI server-side (indirect) * * Usage: * node scripts/e2e-test.mjs * node scripts/e2e-test.mjs --verbose */ import { createServer } from 'node:https'; import { request as httpRequest } from 'node:https'; import { URL } from 'node:url'; const BASE = 'https://localhost'; const VERBOSE = process.argv.includes('--verbose'); // ─── Helpers ────────────────────────────────────────────────────────────────── let passed = 0; let failed = 0; const failures = []; function log(...args) { if (VERBOSE) console.log(...args); } function pass(name) { passed++; console.log(` ✓ ${name}`); } function fail(name, reason) { failed++; const msg = ` ✗ ${name}: ${reason}`; console.log(msg); failures.push({ name, reason }); } /** * fetch() that ignores TLS certificate errors (self-signed cert on localhost). */ async function get(path, { headers = {}, followRedirects = false } = {}) { const url = path.startsWith('http') ? path : `${BASE}${path}`; const res = await fetch(url, { redirect: followRedirects ? 'follow' : 'manual', headers, // Node 18/19 uses undici which respects NODE_TLS_REJECT_UNAUTHORIZED }); return res; } async function post(path, body, { headers = {}, cookie = '' } = {}) { const url = path.startsWith('http') ? path : `${BASE}${path}`; const res = await fetch(url, { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}), ...headers, }, body: JSON.stringify(body), }); return res; } async function del(path, { cookie = '' } = {}) { const url = path.startsWith('http') ? path : `${BASE}${path}`; const res = await fetch(url, { method: 'DELETE', redirect: 'manual', headers: cookie ? { Cookie: cookie } : {}, }); return res; } /** Extract Set-Cookie header value(s) as a single cookie string. */ function extractCookies(res) { const raw = res.headers.getSetCookie?.() ?? []; return raw.map((c) => c.split(';')[0]).join('; '); } async function assert(name, fn) { try { await fn(); pass(name); } catch (e) { fail(name, e.message); } } function expect(val, label) { return { toBe(expected) { if (val !== expected) throw new Error(`${label}: expected ${expected}, got ${val}`); }, toBeOneOf(...options) { if (!options.includes(val)) throw new Error(`${label}: expected one of [${options.join(', ')}], got ${val}`); }, toBeOk() { if (!val) throw new Error(`${label} was falsy`); }, toContainKey(key) { if (!(key in val)) throw new Error(`${label}: missing key "${key}"`); }, toBeArray() { if (!Array.isArray(val)) throw new Error(`${label}: expected array, got ${typeof val}`); }, toBeAbove(n) { if (!(val > n)) throw new Error(`${label}: expected > ${n}, got ${val}`); }, }; } // ─── Test suite ─────────────────────────────────────────────────────────────── process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // Pick a known book slug from the database (first available) let TEST_SLUG = null; console.log('\nLibNovel v3 — End-to-End Tests'); console.log('================================\n'); // ── 1. Health checks ────────────────────────────────────────────────────────── console.log('1. Health checks'); await assert('UI health endpoint returns 200', async () => { const res = await get('/health'); expect(res.status, 'status').toBe(200); }); await assert('Home page returns 200', async () => { const res = await get('/', { followRedirects: true }); expect(res.status, 'status').toBe(200); const html = await res.text(); expect(html.includes(' { const res = await get('/browse', { followRedirects: true }); expect(res.status, 'status').toBe(200); }); // ── 2. Home API ─────────────────────────────────────────────────────────────── console.log('\n2. Home API'); let homeData = null; await assert('GET /api/home returns continue_reading and recently_updated', async () => { const res = await get('/api/home', { followRedirects: true }); expect(res.status, 'status').toBe(200); homeData = await res.json(); expect(homeData, 'data').toContainKey('continue_reading'); expect(homeData, 'data').toContainKey('recently_updated'); expect(homeData.continue_reading, 'continue_reading').toBeArray(); expect(homeData.recently_updated, 'recently_updated').toBeArray(); }); await assert('GET /api/home stats has totalBooks and totalChapters', async () => { if (!homeData) { const res = await get('/api/home', { followRedirects: true }); homeData = await res.json(); } expect(homeData, 'data').toContainKey('stats'); expect(typeof homeData.stats.totalBooks, 'totalBooks type').toBe('number'); expect(typeof homeData.stats.totalChapters, 'totalChapters type').toBe('number'); }); // ── 3. Browse / ranking / search ────────────────────────────────────────────── console.log('\n3. Browse / ranking / search'); await assert('GET /api/browse-page returns novels array', async () => { const res = await get('/api/browse-page?page=1', { followRedirects: true }); expect(res.status, 'status').toBeOneOf(200, 503); if (res.status === 200) { const data = await res.json(); // Accepts { novels: [...] } or { error: ... } (when MinIO cache is empty) expect(typeof data, 'response type').toBe('object'); } }); await assert('GET /api/ranking returns array or 502 (no data yet)', async () => { const res = await get('/api/ranking', { followRedirects: true }); // 200 = ranking data exists; 502 = no ranking data scraped yet — both are expected expect(res.status, 'status').toBeOneOf(200, 502); if (res.status === 200) { const data = await res.json(); expect(data, 'data').toBeArray(); } }); await assert('GET /api/search?q=shadow returns results object', async () => { const res = await get('/api/search?q=shadow', { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); expect(typeof data, 'response type').toBe('object'); }); // ── 4. Books ────────────────────────────────────────────────────────────────── console.log('\n4. Books'); // Find a real slug from /api/home await assert('GET /api/home books are accessible', async () => { if (!homeData) { const res = await get('/api/home', { followRedirects: true }); homeData = await res.json(); } const books = homeData?.recently_updated ?? []; if (books.length > 0) { TEST_SLUG = books[0].slug; log(` Using test slug: ${TEST_SLUG}`); } // Pass regardless — we just want to find a slug }); if (TEST_SLUG) { await assert(`GET /api/book/${TEST_SLUG} returns book metadata`, async () => { const res = await get(`/api/book/${TEST_SLUG}`, { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); // Returns { book: { slug, ... }, chapters: [...] } expect(data, 'data').toContainKey('book'); expect(data.book, 'book').toContainKey('slug'); expect(data.book.slug, 'slug').toBe(TEST_SLUG); }); await assert(`Book detail page /${TEST_SLUG} returns 200`, async () => { const res = await get(`/${TEST_SLUG}`, { followRedirects: true }); expect(res.status, 'status').toBe(200); }); } else { console.log(' ⚠ No books in database — skipping book-specific tests'); } // ── 5. Voices ───────────────────────────────────────────────────────────────── console.log('\n5. Voices'); await assert('GET /api/voices returns voices array', async () => { const res = await get('/api/voices', { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); // Returns { voices: [...] } expect(data, 'data').toContainKey('voices'); expect(data.voices, 'voices').toBeArray(); expect(data.voices.length, 'voice count').toBeAbove(0); }); // ── 6. Auth flow ────────────────────────────────────────────────────────────── console.log('\n6. Auth flow'); const TEST_USER = `e2e_test_${Date.now()}`; const TEST_PASS = 'E2eTestPassword1!'; let authCookie = ''; await assert('POST /api/auth/register creates new user', async () => { const res = await post('/api/auth/register', { username: TEST_USER, password: TEST_PASS }); expect(res.status, 'status').toBeOneOf(200, 201); const data = await res.json(); // Returns { token: "...", user: { id, username, role } } expect(data, 'response').toContainKey('user'); expect(data.user, 'user').toContainKey('username'); expect(data.user.username, 'username').toBe(TEST_USER); // Build cookie from token if (data.token) { authCookie = `libnovel_auth=${data.token}`; } else { authCookie = extractCookies(res); } log(` Auth cookie: ${authCookie.slice(0, 40)}...`); }); await assert('GET /api/auth/me returns current user when logged in', async () => { const res = await get('/api/auth/me', { followRedirects: true, headers: { Cookie: authCookie } }); expect(res.status, 'status').toBe(200); const data = await res.json(); expect(data, 'data').toContainKey('username'); expect(data.username, 'username').toBe(TEST_USER); }); await assert('POST /api/auth/logout clears session', async () => { const res = await post('/api/auth/logout', {}, { cookie: authCookie }); expect(res.status, 'status').toBeOneOf(200, 204); }); await assert('POST /api/auth/login works after register', async () => { const res = await post('/api/auth/login', { username: TEST_USER, password: TEST_PASS }); expect(res.status, 'status').toBe(200); const data = await res.json(); // Returns { token: "...", user: { id, username, role } } expect(data, 'response').toContainKey('user'); expect(data.user.username, 'username').toBe(TEST_USER); if (data.token) { authCookie = `libnovel_auth=${data.token}`; } else { authCookie = extractCookies(res); } }); await assert('GET /api/auth/me unauthenticated returns 401', async () => { const res = await get('/api/auth/me', { followRedirects: true }); expect(res.status, 'status').toBe(401); }); // ── 7. Progress ─────────────────────────────────────────────────────────────── console.log('\n7. Progress'); // /api/progress (root) is POST-only. Per-slug is GET/POST/DELETE via /api/progress/[slug]. if (TEST_SLUG) { await assert(`POST /api/progress/${TEST_SLUG} sets progress`, async () => { const res = await post(`/api/progress/${TEST_SLUG}`, { chapter: 1 }); expect(res.status, 'status').toBeOneOf(200, 201); const data = await res.json(); expect(data, 'data').toContainKey('ok'); }); await assert(`DELETE /api/progress/${TEST_SLUG} removes progress`, async () => { const res = await del(`/api/progress/${TEST_SLUG}`); expect(res.status, 'status').toBeOneOf(200, 204); }); } else { console.log(' ⚠ No books — skipping progress tests'); } // ── 8. Library ──────────────────────────────────────────────────────────────── console.log('\n8. Library'); await assert('GET /api/library returns object', async () => { const res = await get('/api/library', { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); expect(typeof data, 'data type').toBe('object'); }); if (TEST_SLUG) { await assert(`POST /api/library/${TEST_SLUG} saves book`, async () => { const res = await post(`/api/library/${TEST_SLUG}`, {}); expect(res.status, 'status').toBeOneOf(200, 201); }); await assert(`DELETE /api/library/${TEST_SLUG} removes book`, async () => { const res = await del(`/api/library/${TEST_SLUG}`); expect(res.status, 'status').toBeOneOf(200, 204); }); } // ── 9. Settings ─────────────────────────────────────────────────────────────── console.log('\n9. Settings'); await assert('GET /api/settings returns settings object', async () => { const res = await get('/api/settings', { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); expect(typeof data, 'data type').toBe('object'); }); // ── 10. Sessions ────────────────────────────────────────────────────────────── console.log('\n10. Sessions'); await assert('GET /api/sessions (authenticated) returns sessions array', async () => { const res = await get('/api/sessions', { followRedirects: true, headers: { Cookie: authCookie } }); expect(res.status, 'status').toBe(200); const data = await res.json(); // Returns { sessions: [...] } expect(data, 'data').toContainKey('sessions'); expect(data.sessions, 'sessions').toBeArray(); }); // ── 11. Comments ────────────────────────────────────────────────────────────── console.log('\n11. Comments'); if (TEST_SLUG) { await assert(`GET /api/comments/${TEST_SLUG} returns comments`, async () => { const res = await get(`/api/comments/${TEST_SLUG}`, { followRedirects: true }); expect(res.status, 'status').toBe(200); const data = await res.json(); // Returns { comments: [...], myVotes: {}, avatarUrls: {} } expect(data, 'data').toContainKey('comments'); expect(data.comments, 'comments').toBeArray(); }); } // ── 12. Chapter endpoints ───────────────────────────────────────────────────── console.log('\n12. Chapter endpoints'); if (TEST_SLUG) { await assert(`GET /api/chapter/${TEST_SLUG}/1 returns 200 or 404`, async () => { const res = await get(`/api/chapter/${TEST_SLUG}/1`, { followRedirects: true }); // 200 if chapter exists in MinIO, 404 if not expect(res.status, 'status').toBeOneOf(200, 404); }); await assert(`GET /api/chapter-text-preview/${TEST_SLUG}/1 returns 200 or error`, async () => { const res = await get(`/api/chapter-text-preview/${TEST_SLUG}/1`, { followRedirects: true }); expect(res.status, 'status').toBeOneOf(200, 404, 500, 503); }); } // ── Summary ─────────────────────────────────────────────────────────────────── console.log('\n─────────────────────────────────────'); console.log(`Results: ${passed} passed, ${failed} failed`); if (failures.length > 0) { console.log('\nFailures:'); for (const f of failures) { console.log(` ✗ ${f.name}`); console.log(` ${f.reason}`); } } console.log('─────────────────────────────────────\n'); process.exit(failed > 0 ? 1 : 0);