Files

208 lines
7.8 KiB
JavaScript

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");
});
});