import { test, expect, beforeAll, afterAll } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Hono } from 'hono'; import { validateMeta, mergeMissing } from './media-meta-core.js'; import { registerMediaMetaRoutes, createLimiter } from './media-meta.js'; const root = mkdtempSync(join(tmpdir(),'ytp-device-meta-')); process.env.DB_PATH = join(root,'test.db'); const db = await import('./db.js'), catalog = await import('./video-catalog.js'); const id='0gfX0dFLaBc', card={id,title:'Because You are God',artist:'Cathedral of Praise Worship',duration:240}; let calls=0, online=true, ready=false; const app=new Hono(); registerMediaMetaRoutes(app,{db,catalog,media:{getReady:async()=>ready,available:()=>online,status:async()=>({status:'failed'}),ensureCached:()=>{calls++;return new Promise(()=>{});}},log:{warn(){}}}); const post=(video=id,body=card)=>app.request(`/api/media/${video}/meta`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); beforeAll(db.initDb);afterAll(()=>{db.db.close();rmSync(root,{recursive:true,force:true});}); test('validation rejects non-YouTube ids, mismatches, malformed fields and unsafe thumbnail URLs',()=>{ for(const body of [{...card,id:'bbbbbbbbbbb'},{...card,title:{}},{...card,duration:Infinity},{...card,duration:-1},{...card,thumbnail:'http://127.0.0.1/art'},{...card,thumbnail:'https://i.ytimg.com:1234/art'}])expect(()=>validateMeta(id,body)).toThrow(); expect(()=>validateMeta('upl_123',card)).toThrow(); expect(validateMeta(id,card).channel).toBe(card.artist); expect(validateMeta(id,{...card,thumbnail:`/api/catalog/${id}/thumbnail`}).thumbnail).toContain('https://i.ytimg.com/'); expect(()=>validateMeta(id,{...card,title:'a'.repeat(301)})).toThrow(); }); test('merge only fills gaps and retains richer titles, durations, art and artist aliases',()=>{ const rich={title:'Full server title',duration:250,uploader:'Server artist',description:'Rich description',thumbnail:'rich-art'}; expect(mergeMissing(rich,validateMeta(id,card))).toMatchObject({...rich,channel:'Server artist'}); expect(mergeMissing({id,title:id,duration:0},validateMeta(id,card))).toMatchObject({title:card.title,duration:240}); }); test('unknown phone song gets a media row, catalog entry and one background cache job',async()=>{ const response=await post();expect(response.status).toBe(202);expect((await response.json()).known).toBe(false); const row=await db.getMedia(id);expect(JSON.parse(row.meta).title).toBe(card.title);expect(JSON.parse(row.meta).channel).toBe(card.artist);expect(row.duration).toBe(240); expect((await db.db.execute({sql:'SELECT card FROM video_meta WHERE id=?',args:[id]})).rows).toHaveLength(1); await post();expect(calls).toBe(1); }); test('atomic missing-field SQL and catalog merge retain existing server metadata',async()=>{ const video='bbbbbbbbbbb'; await db.upsertMedia(video,{meta:JSON.stringify({title:'Server title',uploader:'Server artist',duration:300,thumbnail:'https://i.ytimg.com/vi/bbbbbbbbbbb/maxresdefault.jpg',tags:['rich']}),duration:300,status:'ready'}); await catalog.ingest([{id:video,title:'Catalog title',channel:'Catalog artist',duration:310,tags:['catalog']}]); ready=true;const before=calls;const response=await post(video,{...card,id:video});expect(response.status).toBe(200);expect(calls).toBe(before);ready=false; const meta=JSON.parse((await db.getMedia(video)).meta);expect(meta.title).toBe('Server title');expect(meta.uploader).toBe('Server artist');expect(meta.channel).toBeUndefined();expect(meta.tags).toEqual(['rich']);expect((await db.getMedia(video)).duration).toBe(300); const c=JSON.parse((await db.db.execute({sql:'SELECT card FROM video_meta WHERE id=?',args:[video]})).rows[0].card);expect(c.title).toBe('Catalog title');expect(c.channel).toBe('Catalog artist'); }); test('offline cache volume retains labels without queuing a fetch',async()=>{ online=false;const before=calls;const response=await post('ccccccccccc',{...card,id:'ccccccccccc'});expect((await response.json()).cache).toBe('unavailable');expect(calls).toBe(before);expect(JSON.parse((await db.getMedia('ccccccccccc')).meta).title).toBe(card.title);online=true; }); test('bounded body and rate limits reject expensive or repeated reports',async()=>{ expect((await post(id,{...card,title:'x'.repeat(10000)})).status).toBe(400); let time=0;const limited=createLimiter({now:()=>time,perClient:2,global:3});expect(limited('a')).toBe(true);expect(limited('a')).toBe(true);expect(limited('a')).toBe(false);expect(limited('b')).toBe(true);expect(limited('c')).toBe(false);time=60000;expect(limited('a')).toBe(true); const throttled=new Hono();registerMediaMetaRoutes(throttled,{db,catalog,media:{},limiter:()=>false});expect((await throttled.request(`/api/media/${id}/meta`,{method:'POST',body:'{}'})).status).toBe(429); }); test('a full backfill queue keeps song metadata without starting another download',async()=>{ let fetched=0;const bounded=new Hono(); registerMediaMetaRoutes(bounded,{db,catalog,maxPending:1,media:{getReady:async()=>false,available:()=>true,ensureCached:()=>{fetched++;return new Promise(()=>{});}},log:{warn(){}}}); const report=video=>bounded.request(`/api/media/${video}/meta`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...card,id:video})}); expect((await (await report('ddddddddddd')).json()).cache).toBe('queued'); expect((await (await report('eeeeeeeeeee')).json()).cache).toBe('busy');expect(fetched).toBe(1); expect(JSON.parse((await db.getMedia('eeeeeeeeeee')).meta).title).toBe(card.title); });