123 lines
7.4 KiB
JavaScript
123 lines
7.4 KiB
JavaScript
import { expect, test, beforeAll } from 'bun:test';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Hono } from 'hono';
|
|
|
|
const root = mkdtempSync(join(tmpdir(), 'ytp-deep-search-'));
|
|
process.env.DB_PATH = join(root, 'test.db');
|
|
const { initDb, db } = await import('./db.js');
|
|
const catalog = await import('./video-catalog.js');
|
|
const searchCache = await import('./search-cache.js');
|
|
const { createDeepSearchService, expandQueryPlan } = await import('./deep-search.js');
|
|
const auth = async (_c, next) => next();
|
|
const sample = { id: 'd33psrch001', title: 'Worship in Cebuano', channel: 'Hope Choir' };
|
|
|
|
beforeAll(async () => { await initDb(); });
|
|
|
|
function makeApp(service) {
|
|
const app = new Hono(); service.register(app, auth); return app;
|
|
}
|
|
async function request(app, path, method = 'GET', body) {
|
|
const response = await app.request(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
return { response, json: await response.json() };
|
|
}
|
|
const cleanDb = async () => {
|
|
await db.batch(['DELETE FROM deep_search_jobs', 'DELETE FROM deep_search_daily', 'DELETE FROM video_meta', 'DELETE FROM video_meta_sources', 'DELETE FROM search_cache']);
|
|
};
|
|
|
|
test.serial('query plan expands modifiers, folds accents and deduplicates', () => {
|
|
expect(expandQueryPlan({ seeds: ['Adoración', 'adoracion', ' Gospel '], modifiers: ['live', 'LIVE'], maxQueries: 30 }))
|
|
.toEqual(['Adoración', 'Adoración live', 'Gospel', 'Gospel live']);
|
|
expect(expandQueryPlan({ seeds: ['one', 'two'], modifiers: ['live'], maxQueries: 2 })).toHaveLength(2);
|
|
});
|
|
|
|
test.serial('estimate and background job ingest to catalog, source and search cache', async () => {
|
|
await cleanDb();
|
|
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put,
|
|
searchDeep: async () => [sample], sleep: async () => {}, autoStart: false });
|
|
const app = makeApp(service);
|
|
const estimate = await request(app, '/api/admin/deep-search/estimate', 'POST', { seeds: ['worship'], modifiers: ['live','lyrics'], maxQueries: 4 });
|
|
expect(estimate.json.plan).toEqual(['worship', 'worship live', 'worship lyrics']);
|
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], modifiers: [], maxQueries: 1, delaySeconds: 1 });
|
|
expect(created.response.status).toBe(202);
|
|
await service.runNext();
|
|
const jobs = await request(app, '/api/admin/deep-search/jobs');
|
|
expect(jobs.json.jobs[0].status).toBe('complete');
|
|
expect(jobs.json.jobs[0].newVideos).toBe(1);
|
|
const row = (await db.execute({ sql: 'SELECT source FROM video_meta_sources WHERE video_id=?', args: [sample.id] })).rows[0];
|
|
expect(row.source).toBe('deep');
|
|
expect((await searchCache.get('worship')).results[0].id).toBe(sample.id);
|
|
});
|
|
|
|
test.serial('pause during upstream call does not ingest; resume continues from the persisted query', async () => {
|
|
await cleanDb(); let begin, release, calls = 0;
|
|
const began = new Promise(resolve => { begin = resolve; });
|
|
const gate = new Promise(resolve => { release = resolve; });
|
|
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put, autoStart: false,
|
|
sleep: async () => {}, searchDeep: async () => { calls++; if (calls === 1) { begin(); await gate; } return [sample]; } });
|
|
const app = makeApp(service);
|
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
|
const work = service.runNext(); await began;
|
|
await request(app, `/api/admin/deep-search/${created.json.id}/pause`, 'POST', {});
|
|
release(); await work;
|
|
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
|
|
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
|
|
await request(app, `/api/admin/deep-search/${created.json.id}/resume`, 'POST', {});
|
|
await service.runNext();
|
|
jobs = await request(app, '/api/admin/deep-search/jobs');
|
|
expect(jobs.json.jobs[0].status).toBe('complete'); expect(calls).toBe(2);
|
|
});
|
|
|
|
test.serial('cancelled in-flight job does not write its late result', async () => {
|
|
await cleanDb(); let begin, release;
|
|
const began = new Promise(resolve => { begin = resolve; }), gate = new Promise(resolve => { release = resolve; });
|
|
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {},
|
|
searchDeep: async () => { begin(); await gate; return [sample]; }, ingest: catalog.ingest, putSearch: searchCache.put });
|
|
const app = makeApp(service);
|
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
|
const work = service.runNext(); await began;
|
|
await request(app, `/api/admin/deep-search/${created.json.id}/cancel`, 'POST', {}); release(); await work;
|
|
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
|
|
const jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('cancelled');
|
|
});
|
|
|
|
test.serial('page checkpoint survives a retry without double-counting its videos', async () => {
|
|
await cleanDb(); let calls = 0;
|
|
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {}, ingest: catalog.ingest, putSearch: searchCache.put,
|
|
searchDeep: async (_query, { onPage }) => {
|
|
calls++;
|
|
await onPage([sample]);
|
|
if (calls === 1) throw Error('connection reset after first page');
|
|
return [sample];
|
|
} });
|
|
const app = makeApp(service);
|
|
await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
|
|
await service.runNext();
|
|
const row = (await db.execute({ sql: 'SELECT seen FROM video_meta WHERE id=?', args: [sample.id] })).rows[0];
|
|
expect(Number(row.seen)).toBe(1);
|
|
});
|
|
|
|
test.serial('daily cap pauses and worker resumes it on the next UTC day', async () => {
|
|
await cleanDb(); let time = Date.parse('2026-10-14T23:59:00Z');
|
|
const service = createDeepSearchService({ db, now: () => time, autoStart: false, sleep: async () => {},
|
|
searchDeep: async query => [{ ...sample, id: query === 'worship' ? 'd33psrch001' : 'd33psrch002' }],
|
|
ingest: catalog.ingest, putSearch: searchCache.put });
|
|
const app = makeApp(service);
|
|
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship','praise'], maxQueries: 2, delaySeconds: 1, dailyCap: 1 });
|
|
await service.runNext();
|
|
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
|
|
time += 120000; await service.runNext(); jobs = await request(app, '/api/admin/deep-search/jobs');
|
|
expect(jobs.json.jobs[0].status).toBe('complete'); expect(jobs.json.jobs[0].queriesDone).toBe(2);
|
|
});
|
|
|
|
test.serial('only one queued/running job is accepted', async () => {
|
|
await cleanDb();
|
|
const service = createDeepSearchService({ db, autoStart: false }); const app = makeApp(service);
|
|
const body = { seeds: ['worship'], maxQueries: 1 };
|
|
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(202);
|
|
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(409);
|
|
});
|
|
|
|
test.serial('cleanup test database', () => { rmSync(root, { recursive: true, force: true }); });
|