import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { type FastifyInstance } from 'fastify'; import { initDatabaseAsync, closeDatabase } from '../db/index'; import { runMigrations } from '../db/migrate'; import { buildServer } from '../server/index'; import { systemConfig } from '../db/schema/index'; import { eq } from 'drizzle-orm'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { createChannel } from '../db/repositories/channel-repository'; import { createContentItem } from '../db/repositories/content-repository'; import type { Channel, ContentItem } from '../types/index'; // Mock execYtDlp to avoid real yt-dlp calls vi.mock('../sources/yt-dlp', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, execYtDlp: vi.fn(), checkYtDlpAvailable: vi.fn().mockResolvedValue(true), getYtDlpVersion: vi.fn().mockResolvedValue('2024.12.23'), }; }); import { execYtDlp } from '../sources/yt-dlp'; const mockedExecYtDlp = vi.mocked(execYtDlp); /** * Integration tests for playlist API endpoints: * GET /api/v1/channel/:id/playlists — list playlists + content mappings * POST /api/v1/channel/:id/playlists/refresh — refresh playlists from platform */ describe('playlist-api', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; let channel: Channel; const contentItemsList: ContentItem[] = []; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-playlist-api-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); server = await buildServer({ db }); await server.ready(); // Read API key const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); apiKey = rows[0]?.value ?? ''; expect(apiKey).toBeTruthy(); // Create a test channel (YouTube) channel = await createChannel(db, { name: 'Playlist Test Channel', platform: 'youtube', platformId: 'UC_PLAYLIST_TEST', url: 'https://www.youtube.com/@PlaylistTestChannel', monitoringEnabled: true, checkInterval: 360, imageUrl: null, metadata: null, formatProfileId: null, }); // Create content items that can be linked to playlists const videoDefs = [ { platformContentId: 'vid_pl_1', title: 'Playlist Video One' }, { platformContentId: 'vid_pl_2', title: 'Playlist Video Two' }, { platformContentId: 'vid_pl_3', title: 'Playlist Video Three' }, { platformContentId: 'vid_pl_4', title: 'Playlist Video Four' }, ]; for (const def of videoDefs) { const created = await createContentItem(db, { channelId: channel.id, title: def.title, platformContentId: def.platformContentId, url: `https://youtube.com/watch?v=${def.platformContentId}`, contentType: 'video', duration: 300, }); if (created) contentItemsList.push(created); } expect(contentItemsList.length).toBe(4); }); afterAll(async () => { await server.close(); closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Temp dir cleanup is best-effort on Windows } }); // ── GET /api/v1/channel/:id/playlists ── describe('GET /api/v1/channel/:id/playlists', () => { it('returns empty playlists array for channel with no playlists', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/playlists`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.playlists).toEqual([]); expect(body.data.mappings).toEqual({}); }); it('returns 404 for non-existent channel', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/channel/99999/playlists', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(404); const body = res.json(); expect(body.error).toBe('Not Found'); }); it('returns 401 without API key', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/playlists`, }); expect(res.statusCode).toBe(401); }); }); // ── POST /api/v1/channel/:id/playlists/refresh ── describe('POST /api/v1/channel/:id/playlists/refresh', () => { it('refreshes playlists for a YouTube channel', async () => { // Mock execYtDlp: first call = playlist list, subsequent calls = playlist contents mockedExecYtDlp // Call 1: Enumerate playlists from /playlists tab .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'PL_playlist_A', title: 'Best Videos' }), JSON.stringify({ id: 'PL_playlist_B', title: 'Tutorial Series' }), ].join('\n'), stderr: '', exitCode: 0, }) // Call 2: Fetch video IDs for playlist A .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'vid_pl_1' }), JSON.stringify({ id: 'vid_pl_2' }), JSON.stringify({ id: 'vid_pl_nonexistent' }), // Not in our DB — should be skipped ].join('\n'), stderr: '', exitCode: 0, }) // Call 3: Fetch video IDs for playlist B .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'vid_pl_3' }), JSON.stringify({ id: 'vid_pl_4' }), ].join('\n'), stderr: '', exitCode: 0, }); const res = await server.inject({ method: 'POST', url: `/api/v1/channel/${channel.id}/playlists/refresh`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.playlists).toHaveLength(2); expect(body.data.playlists[0].title).toBe('Best Videos'); expect(body.data.playlists[0].platformPlaylistId).toBe('PL_playlist_A'); expect(body.data.playlists[1].title).toBe('Tutorial Series'); expect(body.data.playlists[1].platformPlaylistId).toBe('PL_playlist_B'); // Verify mappings — playlist A should have 2 items (nonexistent skipped) const playlistA = body.data.playlists[0]; const playlistB = body.data.playlists[1]; const mappingsA = body.data.mappings[String(playlistA.id)]; const mappingsB = body.data.mappings[String(playlistB.id)]; expect(mappingsA).toHaveLength(2); expect(mappingsB).toHaveLength(2); }); it('GET returns populated playlists after refresh', async () => { const res = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/playlists`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.playlists).toHaveLength(2); expect(body.data.playlists[0].title).toBe('Best Videos'); expect(body.data.playlists[1].title).toBe('Tutorial Series'); // Verify mappings exist const keys = Object.keys(body.data.mappings); expect(keys.length).toBe(2); }); it('refreshing again updates existing playlists (idempotent upsert)', async () => { // Mock with updated playlist titles and slightly different video membership mockedExecYtDlp .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'PL_playlist_A', title: 'Best Videos (Updated)' }), JSON.stringify({ id: 'PL_playlist_B', title: 'Tutorial Series (Updated)' }), ].join('\n'), stderr: '', exitCode: 0, }) .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'vid_pl_1' }), ].join('\n'), stderr: '', exitCode: 0, }) .mockResolvedValueOnce({ stdout: [ JSON.stringify({ id: 'vid_pl_2' }), JSON.stringify({ id: 'vid_pl_3' }), JSON.stringify({ id: 'vid_pl_4' }), ].join('\n'), stderr: '', exitCode: 0, }); const res = await server.inject({ method: 'POST', url: `/api/v1/channel/${channel.id}/playlists/refresh`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.playlists).toHaveLength(2); expect(body.data.playlists[0].title).toBe('Best Videos (Updated)'); expect(body.data.playlists[1].title).toBe('Tutorial Series (Updated)'); // Verify updated mappings const playlistA = body.data.playlists[0]; const playlistB = body.data.playlists[1]; expect(body.data.mappings[String(playlistA.id)]).toHaveLength(1); expect(body.data.mappings[String(playlistB.id)]).toHaveLength(3); }); it('returns 404 for non-existent channel', async () => { const res = await server.inject({ method: 'POST', url: '/api/v1/channel/99999/playlists/refresh', headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(404); const body = res.json(); expect(body.error).toBe('Not Found'); }); it('returns 500 when yt-dlp fails', async () => { mockedExecYtDlp.mockRejectedValueOnce(new Error('yt-dlp process crashed')); const res = await server.inject({ method: 'POST', url: `/api/v1/channel/${channel.id}/playlists/refresh`, headers: { 'x-api-key': apiKey }, }); expect(res.statusCode).toBe(500); const body = res.json(); expect(body.error).toBe('Internal Server Error'); expect(body.message).toContain('Failed to refresh playlists'); }); it('returns 401 without API key', async () => { const res = await server.inject({ method: 'POST', url: `/api/v1/channel/${channel.id}/playlists/refresh`, }); expect(res.statusCode).toBe(401); }); }); });