From 8ebacba3e1d217308d848c9dac03cb158d493582 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 09:43:51 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Integrate=20fetchPlaylists=20into=20sch?= =?UTF-8?q?eduler=20checkChannel=20as=20best-effo=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/scheduler.ts" - "src/__tests__/scheduler.test.ts" GSD-Task: S03/T01 --- src/__tests__/scheduler.test.ts | 87 +++++++++++++++++++++++++++++++++ src/services/scheduler.ts | 40 +++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/__tests__/scheduler.test.ts b/src/__tests__/scheduler.test.ts index 58238df..e4f3609 100644 --- a/src/__tests__/scheduler.test.ts +++ b/src/__tests__/scheduler.test.ts @@ -33,9 +33,11 @@ import type { Channel, PlatformSourceMetadata, PlatformContentMetadata, + PlaylistDiscoveryResult, } from '../types/index'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; +import { getPlaylistsByChannelId } from '../db/repositories/playlist-repository'; // ── Rate Limiter Tests ── @@ -891,6 +893,91 @@ describe('SchedulerService', () => { scheduler.stop(); }); + + it('calls fetchPlaylists after successful scan when source supports it', async () => { + const channel = await insertTestChannel(); + + // Build a registry with fetchPlaylists support + const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise>() + .mockResolvedValueOnce([ + { + platformPlaylistId: 'PL_test_1', + title: 'Test Playlist', + videoIds: [], + }, + ]); + const mockSourceWithPlaylists: PlatformSource = { + resolveChannel: mockResolveChannel, + fetchRecentContent: mockFetchRecentContent, + fetchPlaylists: fetchPlaylistsFn, + }; + const reg = new PlatformRegistry(); + reg.register(Platform.YouTube, mockSourceWithPlaylists); + + const localLimiter = new RateLimiter({ + [Platform.YouTube]: { minIntervalMs: 0 }, + }); + const scheduler = new SchedulerService(db, reg, localLimiter); + + mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `plsync_${channel.id}`)); + const result = await scheduler.checkChannel(channel); + + expect(result.status).toBe('success'); + // Allow background sidecar to complete + await new Promise((r) => setTimeout(r, 100)); + expect(fetchPlaylistsFn).toHaveBeenCalledOnce(); + + // Verify playlist was persisted + const playlists = await getPlaylistsByChannelId(db, channel.id); + expect(playlists.length).toBe(1); + expect(playlists[0].title).toBe('Test Playlist'); + + scheduler.stop(); + }); + + it('scan succeeds even when playlist sync throws', async () => { + const channel = await insertTestChannel(); + + const fetchPlaylistsFn = vi.fn<(ch: Channel) => Promise>() + .mockRejectedValueOnce(new Error('Playlist fetch exploded')); + const mockSourceWithPlaylists: PlatformSource = { + resolveChannel: mockResolveChannel, + fetchRecentContent: mockFetchRecentContent, + fetchPlaylists: fetchPlaylistsFn, + }; + const reg = new PlatformRegistry(); + reg.register(Platform.YouTube, mockSourceWithPlaylists); + + const localLimiter = new RateLimiter({ + [Platform.YouTube]: { minIntervalMs: 0 }, + }); + const scheduler = new SchedulerService(db, reg, localLimiter); + + mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(2, `plfail_${channel.id}`)); + const result = await scheduler.checkChannel(channel); + + expect(result.status).toBe('success'); + expect(result.newItems).toBe(2); + // Allow background sidecar to attempt and fail + await new Promise((r) => setTimeout(r, 100)); + expect(fetchPlaylistsFn).toHaveBeenCalledOnce(); + + scheduler.stop(); + }); + + it('skips playlist sync when source does not support fetchPlaylists', async () => { + const channel = await insertTestChannel(); + const scheduler = new SchedulerService(db, registry, rateLimiter); + + // Default mock source has no fetchPlaylists + mockFetchRecentContent.mockResolvedValueOnce(makeCannedContent(1, `noplsync_${channel.id}`)); + const result = await scheduler.checkChannel(channel); + + expect(result.status).toBe('success'); + expect(result.newItems).toBe(1); + + scheduler.stop(); + }); }); // ── addChannel() / removeChannel() ── diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts index df50d99..6b706c0 100644 --- a/src/services/scheduler.ts +++ b/src/services/scheduler.ts @@ -18,6 +18,7 @@ import { updateContentItem, } from '../db/repositories/content-repository'; import { getPlatformSettings } from '../db/repositories/platform-settings-repository'; +import { upsertPlaylists } from '../db/repositories/playlist-repository'; // ── Types ── @@ -330,6 +331,18 @@ export class SchedulerService { }); } + // 10. Best-effort playlist sync (K011 sidecar pattern) + // Runs after content scan succeeds — failure never affects the scan result. + if (source.fetchPlaylists && !effectiveSignal.aborted) { + this.syncPlaylists(channel, source) + .catch((err) => { + console.error( + `[scheduler] Playlist sync failed for channel ${channel.id}:`, + err instanceof Error ? err.message : err + ); + }); + } + return { channelId: channel.id, channelName: channel.name, @@ -517,6 +530,33 @@ export class SchedulerService { }); } + /** + * Best-effort playlist sync for a channel. + * Fetches playlists from the platform source and upserts them into the DB. + * Failure is logged but never propagated (K011 sidecar pattern). + */ + private async syncPlaylists(channel: Channel, source: PlatformSource): Promise { + if (!source.fetchPlaylists) return; + + try { + const discoveryResults = await source.fetchPlaylists(channel); + if (discoveryResults.length === 0) { + console.log(`[scheduler] Playlist sync: no playlists found for channel ${channel.id}`); + return; + } + + const upserted = await upsertPlaylists(this.db, channel.id, discoveryResults); + console.log( + `[scheduler] Playlist sync complete for channel ${channel.id}: ${upserted.length} playlists synced` + ); + } catch (err) { + console.error( + `[scheduler] Playlist sync error for channel ${channel.id}:`, + err instanceof Error ? err.message : err + ); + } + } + // ── Internal ── /**