Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/. Previous history preserved in Tubearr-full-backup.bundle at parent directory. Completed milestones: M001 through M005 Active: M006/S02 (Add Channel UX)
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
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<string, unknown>;
|
|
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<typeof schema>;
|
|
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);
|
|
});
|
|
});
|
|
});
|