import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ApiError, fetchChannels, fetchVideos } from '../src/lib/api.js'; const jsonResponse = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); const noSleep = async () => {}; const videoItem = (id) => ({ id, snippet: { title: `Title ${id}`, channelId: 'UC1', channelTitle: 'Chan', publishedAt: '2026-10-01T00:00:00Z', thumbnails: { high: { url: `https://i.ytimg.com/vi/${id}/hqdefault.jpg` } }, }, statistics: { viewCount: '12345' }, contentDetails: { duration: 'PT10M' }, }); test('fetchVideos splits ids into batches of 50 and normalizes items', async () => { const ids = Array.from({ length: 120 }, (_, i) => `v${String(i).padStart(10, '0')}`); const seen = []; const fetchImpl = async (url) => { const u = new URL(url); const batch = u.searchParams.get('id').split(','); seen.push({ path: u.pathname, size: batch.length, key: u.searchParams.get('key'), part: u.searchParams.get('part') }); return jsonResponse(200, { items: batch.map(videoItem) }); }; const { videos, missingIds } = await fetchVideos(ids, { apiKey: 'KEY', fetchImpl, sleep: noSleep }); assert.deepEqual(seen.map((s) => s.size), [50, 50, 20]); assert.ok(seen.every((s) => s.path === '/youtube/v3/videos' && s.key === 'KEY')); assert.ok(seen.every((s) => s.part === 'snippet,statistics,contentDetails')); assert.equal(videos.length, 120); assert.deepEqual(missingIds, []); assert.deepEqual(videos[0], { videoId: ids[0], title: `Title ${ids[0]}`, thumbnailUrl: `https://i.ytimg.com/vi/${ids[0]}/hqdefault.jpg`, channelId: 'UC1', channelTitle: 'Chan', views: 12345, publishedAt: '2026-10-01T00:00:00Z', durationSec: 600, }); }); test('fetchVideos reports ids the API did not return', async () => { const fetchImpl = async (url) => { const batch = new URL(url).searchParams.get('id').split(','); return jsonResponse(200, { items: batch.filter((id) => id !== 'missing0001').map(videoItem) }); }; const { videos, missingIds } = await fetchVideos(['abcdefghijk', 'missing0001'], { apiKey: 'K', fetchImpl, sleep: noSleep, }); assert.equal(videos.length, 1); assert.deepEqual(missingIds, ['missing0001']); }); test('requests are retried on 5xx with exponential backoff', async () => { const delays = []; let calls = 0; const fetchImpl = async () => { calls += 1; return calls < 3 ? jsonResponse(503, {}) : jsonResponse(200, { items: [videoItem('abcdefghijk')] }); }; const { videos } = await fetchVideos(['abcdefghijk'], { apiKey: 'K', fetchImpl, sleep: async (ms) => delays.push(ms), }); assert.equal(videos.length, 1); assert.equal(calls, 3); assert.deepEqual(delays, [500, 1000]); }); test('requests give up after three retries', async () => { let calls = 0; const fetchImpl = async () => { calls += 1; return jsonResponse(500, {}); }; await assert.rejects( fetchVideos(['abcdefghijk'], { apiKey: 'K', fetchImpl, sleep: noSleep }), (err) => err instanceof ApiError && err.kind === 'server' && err.status === 500, ); assert.equal(calls, 4); }); test('network failures are retried and reported as kind network', async () => { let calls = 0; const fetchImpl = async () => { calls += 1; throw new TypeError('Failed to fetch'); }; await assert.rejects( fetchVideos(['abcdefghijk'], { apiKey: 'K', fetchImpl, sleep: noSleep }), (err) => err instanceof ApiError && err.kind === 'network', ); assert.equal(calls, 4); }); test('quota errors are not retried', async () => { let calls = 0; const fetchImpl = async () => { calls += 1; return jsonResponse(403, { error: { message: 'quota', errors: [{ reason: 'quotaExceeded' }] } }); }; await assert.rejects( fetchVideos(['abcdefghijk'], { apiKey: 'K', fetchImpl, sleep: noSleep }), (err) => err instanceof ApiError && err.kind === 'quota' && err.message === 'quota', ); assert.equal(calls, 1); }); test('invalid key errors are not retried', async () => { let calls = 0; const fetchImpl = async () => { calls += 1; return jsonResponse(400, { error: { message: 'API key not valid.', errors: [{ reason: 'badRequest' }], details: [{ reason: 'API_KEY_INVALID' }] }, }); }; await assert.rejects( fetchVideos(['abcdefghijk'], { apiKey: 'bad', fetchImpl, sleep: noSleep }), (err) => err instanceof ApiError && err.kind === 'invalid-key', ); assert.equal(calls, 1); }); test('fetchChannels normalizes subscriber counts and channel urls', async () => { let params; const fetchImpl = async (url) => { params = new URL(url).searchParams; return jsonResponse(200, { items: [ { id: 'UC1', snippet: { title: 'One', customUrl: '@one' }, statistics: { subscriberCount: '1500' } }, { id: 'UC2', snippet: { title: 'Two' }, statistics: { hiddenSubscriberCount: true } }, { id: 'UC3', snippet: { title: 'Three', customUrl: 'legacyname' }, statistics: { subscriberCount: '7' } }, ], }); }; const channels = await fetchChannels(['UC1', 'UC2', 'UC3'], { apiKey: 'K', fetchImpl, sleep: noSleep }); assert.equal(params.get('part'), 'statistics,snippet'); assert.equal(params.get('maxResults'), '50'); assert.equal(params.get('id'), 'UC1,UC2,UC3'); assert.deepEqual(channels, [ { channelId: 'UC1', title: 'One', url: 'https://www.youtube.com/@one', subscribers: 1500 }, { channelId: 'UC2', title: 'Two', url: 'https://www.youtube.com/channel/UC2', subscribers: null }, { channelId: 'UC3', title: 'Three', url: 'https://www.youtube.com/channel/UC3', subscribers: 7 }, ]); }); test('fetchChannels of an empty list makes no request', async () => { const fetchImpl = async () => { throw new Error('should not be called'); }; assert.deepEqual(await fetchChannels([], { apiKey: 'K', fetchImpl, sleep: noSleep }), []); });