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