Initial commit: Spectra YouTube Suggest Engine
This commit is contained in:
commit
e90505a39b
16 files changed
+4495
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user