204 lines
7.6 KiB
JavaScript
204 lines
7.6 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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|