test: Integrate fetchPlaylists into scheduler checkChannel as best-effo…

- "src/services/scheduler.ts"
- "src/__tests__/scheduler.test.ts"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-04-04 09:43:51 +00:00
parent ed901c8240
commit 8ebacba3e1
2 changed files with 127 additions and 0 deletions

View file

@ -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<PlaylistDiscoveryResult[]>>()
.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<PlaylistDiscoveryResult[]>>()
.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() ──

View file

@ -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<void> {
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 ──
/**