Files
ytplayer/server/related.test.js

44 lines
3.3 KiB
JavaScript

import { test, expect } from 'bun:test';
import { Hono } from 'hono';
import { createRelatedService, relatedQueries, selectRelated, registerRelatedRoute } from './related.js';
const seed = { id: 'aaaaaaaaaaa', title: 'Grace (Official Lyric Video)', channel: 'Artist' };
const card = { id: 'bbbbbbbbbbb', title: 'Another song', channel: 'Artist' };
const fail = async () => { throw new Error('YouTube HTTP 429'); };
test('fallback queries remove video boilerplate and deduplicate; cards exclude seed', () => {
expect(relatedQueries(seed)).toEqual(['Grace Artist', 'Grace', 'Artist']);
expect(selectRelated([{ ...card, id: seed.id }, card, card, null], seed.id)).toEqual([card]);
});
test('watch-next results bypass deep search and collect metadata', async () => {
let searches = 0, collected;
const service = createRelatedService({ next: async () => [card], search: async () => { searches++; return []; }, local: async () => [], collect: async cards => { collected = cards; } });
expect((await service.load(seed)).source).toBe('youtube'); expect(searches).toBe(0); expect(collected).toEqual([card]);
});
test('YouTube next failure and empty narrow query fall back to title search', async () => {
const queries = [];
const service = createRelatedService({ next: fail, search: async q => { queries.push(q); return q === 'Grace' ? [card] : []; }, local: async () => [] });
expect((await service.load(seed)).source).toBe('search'); expect(queries).toEqual(['Grace Artist', 'Grace']);
});
test('catalog fallback works without YouTube and metadata failures do not hide cards', async () => {
const service = createRelatedService({ next: fail, search: fail, local: async q => q === 'Artist' ? [card] : [], collect: fail });
const answer = await service.load(seed); expect(answer.ok).toBe(true); expect(answer.source).toBe('catalog'); expect(answer.results).toEqual([card]);
});
test('shared inflight request, successful cache, and explicit refresh', async () => {
let finish, calls=0;
const service=createRelatedService({next:async()=>{calls++;return new Promise(resolve=>{finish=resolve;});},search:fail,local:async()=>[]});
const a=service.load(seed),b=service.load(seed); finish([card]); await Promise.all([a,b]); expect(calls).toBe(1);
await service.load(seed); expect(calls).toBe(1);
const retry=service.load(seed,{refresh:true}); finish([card]); await retry; expect(calls).toBe(2);
});
test('failed responses are never cached, and exhausted budget goes straight to catalog', async () => {
let time=0, calls=0, searches=0;
const service=createRelatedService({now:()=>time,budgetMs:10,next:async()=>{calls++;time+=20;throw new Error('timeout');},search:async()=>{searches++;return[];},local:async()=>[]});
expect((await service.load(seed)).ok).toBe(false); expect((await service.load(seed)).ok).toBe(false);
expect(calls).toBe(2); expect(searches).toBe(0);
});
test('endpoint validates ids and provides a friendly retryable failure', async () => {
const app=new Hono();registerRelatedRoute(app,createRelatedService({next:fail,search:fail,local:async()=>[]}));
expect((await app.request('/api/related?videoId=bad')).status).toBe(400);
const response=await app.request(`/api/related?videoId=${seed.id}&title=Grace`); expect(response.status).toBe(503);
const body=await response.json(); expect(body.error).toContain('Please try again');expect(body.detail).toContain('429');
});