import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { createApp } from "../server/index.js"; describe("Spectra YouTube Suggest Engine - Test Suite", () => { let app; let server; let baseUrl; let testDbPath; before(async () => { testDbPath = path.join(os.tmpdir(), `spectra-test-${Date.now()}.db`); app = createApp(testDbPath); server = app.server; await new Promise(resolve => { server.listen(0, "127.0.0.1", () => { const port = server.address().port; baseUrl = `http://127.0.0.1:${port}`; resolve(); }); }); }); after(async () => { await new Promise(resolve => server.close(resolve)); if (fs.existsSync(testDbPath)) { try { fs.unlinkSync(testDbPath); } catch (e) {} } }); it("1. GET /api/health returns 200 with database healthy", async () => { const res = await fetch(`${baseUrl}/api/health`); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.status, "ok"); assert.equal(data.database, "healthy"); assert.equal(data.app, "spectra"); // Also verify HEAD request for Homepage / uptime monitoring const headRes = await fetch(`${baseUrl}/api/health`, { method: "HEAD" }); assert.equal(headRes.status, 200); }); it("2. GET /api/topics returns curated starter topics and people", async () => { const res = await fetch(`${baseUrl}/api/topics`); assert.equal(res.status, 200); const data = await res.json(); assert.ok(data.topics.length >= 8); const hasScreenprint = data.topics.some(t => t.id === "vintage-screenprint"); const hasEspresso = data.topics.some(t => t.id === "specialty-espresso"); const hasJamesHoffmann = data.topics.some(t => t.name.includes("James Hoffmann")); assert.ok(hasScreenprint, "Should include vintage screenprint"); assert.ok(hasEspresso, "Should include specialty espresso"); assert.ok(hasJamesHoffmann, "Should include James Hoffmann"); }); it("3. GET /api/suggestions generates suggestions with no repeats", async () => { // Generate Batch 1 const res1 = await fetch(`${baseUrl}/api/suggestions?limit=6&live=false`); assert.equal(res1.status, 200); const data1 = await res1.json(); assert.ok(data1.suggestions.length > 0); const batch1Ids = new Set(data1.suggestions.map(s => s.id)); // Generate Batch 2 (without force_fresh, strictly no repeats) const res2 = await fetch(`${baseUrl}/api/suggestions?limit=6&live=false`); assert.equal(res2.status, 200); const data2 = await res2.json(); // Verify ZERO overlap between Batch 1 and Batch 2 for (const item of data2.suggestions) { assert.ok(!batch1Ids.has(item.id), `Video ${item.id} was repeated in Batch 2!`); } }); it("4. Watchlist preservation: adding to watchlist persists across regenerations", async () => { // Add item to watchlist const addRes = await fetch(`${baseUrl}/api/watchlist`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "watch-test-123", title: "Building an ESP32 E-Ink Weather Glass", channel: "Maker Lab", duration: "14:20", topic_id: "esp32-eink" }) }); assert.equal(addRes.status, 201); // Call suggestion regeneration const regenRes = await fetch(`${baseUrl}/api/suggestions/regenerate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ limit: 4, fetchLive: false }) }); assert.equal(regenRes.status, 200); // Verify watchlist still contains the saved item! const wlRes = await fetch(`${baseUrl}/api/watchlist`); assert.equal(wlRes.status, 200); const wlData = await wlRes.json(); const found = wlData.items.find(i => i.video_id === "watch-test-123"); assert.ok(found, "Saved watchlist item must be preserved after regeneration!"); assert.equal(found.title, "Building an ESP32 E-Ink Weather Glass"); assert.equal(found.watched, 0); // Toggle watched state const toggleRes = await fetch(`${baseUrl}/api/watchlist/watch-test-123/toggle`, { method: "PATCH" }); assert.equal(toggleRes.status, 200); const toggled = await toggleRes.json(); assert.equal(toggled.watched, 1); // Delete from watchlist const delRes = await fetch(`${baseUrl}/api/watchlist/watch-test-123`, { method: "DELETE" }); assert.equal(delRes.status, 200); const checkRes = await fetch(`${baseUrl}/api/watchlist`); const checkData = await checkRes.json(); assert.ok(!checkData.items.some(i => i.video_id === "watch-test-123")); }); it("5. Feedback steering: Thumbs Up boosts topic weight and Thumbs Down reduces it", async () => { const topicBefore = app.db.getTopicById("specialty-espresso"); const weightBefore = topicBefore.weight; // Send Thumbs Up const upRes = await fetch(`${baseUrl}/api/feedback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoId: "xK3vM9p0s2Q", action: "thumbs_up", channel: "James Hoffmann", topicId: "specialty-espresso" }) }); assert.equal(upRes.status, 200); const topicAfterUp = app.db.getTopicById("specialty-espresso"); assert.ok(topicAfterUp.weight > weightBefore, "Thumbs up should increase topic weight"); // Send Thumbs Down const downRes = await fetch(`${baseUrl}/api/feedback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoId: "xK3vM9p0s2Q", action: "thumbs_down", channel: "James Hoffmann", topicId: "specialty-espresso" }) }); assert.equal(downRes.status, 200); const topicAfterDown = app.db.getTopicById("specialty-espresso"); assert.ok(topicAfterDown.weight < topicAfterUp.weight, "Thumbs down should decrease topic weight"); }); it("6. Big Nope: permanently blacklists channel and video from suggestions", async () => { const nopeChannel = "Catspit Productions"; const nopeVideoId = "mK9vX2t4w1L"; const nopeRes = await fetch(`${baseUrl}/api/feedback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoId: nopeVideoId, action: "big_nope", channel: nopeChannel, topicId: "vintage-screenprint" }) }); assert.equal(nopeRes.status, 200); // Verify it is in nopes table const nopesRes = await fetch(`${baseUrl}/api/nopes`); const nopesData = await nopesRes.json(); assert.ok(nopesData.nopes.some(n => n.target.toLowerCase() === nopeChannel.toLowerCase())); assert.ok(nopesData.nopes.some(n => n.target === nopeVideoId)); // Verify database isNoped check assert.ok(app.db.isNoped(nopeVideoId, nopeChannel)); // Verify subsequent suggestions NEVER include this channel or video const sugRes = await fetch(`${baseUrl}/api/suggestions?force_fresh=true&live=false&limit=20`); const sugData = await sugRes.json(); const hasNoped = sugData.suggestions.some(s => s.id === nopeVideoId || s.channel === nopeChannel); assert.equal(hasNoped, false, "Big Noped content must never appear in suggestions!"); }); it("7. Tuning API: reset seen history clears seen count", async () => { const tuningBefore = await (await fetch(`${baseUrl}/api/tuning`)).json(); assert.ok(tuningBefore.seenCount > 0); const resetRes = await fetch(`${baseUrl}/api/tuning/reset-seen`, { method: "POST" }); assert.equal(resetRes.status, 200); const tuningAfter = await (await fetch(`${baseUrl}/api/tuning`)).json(); assert.equal(tuningAfter.seenCount, 0, "Seen count must be 0 after reset"); }); });