import { describe, it, expect, beforeAll, afterAll } 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 { createHistoryEvent } from '../db/repositories/history-repository'; import type { Channel, ContentItem } from '../types/index'; /** * Integration tests for history and activity API endpoints. */ describe('History API', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; let testChannel: Channel; let testContent: ContentItem; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-history-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 test data testChannel = await createChannel(db, { name: 'History API Test Channel', platform: 'youtube', platformId: 'UC_history_api_test', url: 'https://www.youtube.com/channel/UC_history_api_test', monitoringEnabled: true, checkInterval: 360, imageUrl: null, metadata: null, formatProfileId: null, }); testContent = (await createContentItem(db, { channelId: testChannel.id, title: 'History API Test Video', platformContentId: 'vid_hist_api_1', url: 'https://www.youtube.com/watch?v=hist_test', contentType: 'video', duration: 300, status: 'monitored', }))!; // Seed some history events await createHistoryEvent(db, { contentItemId: testContent.id, channelId: testChannel.id, eventType: 'grabbed', status: 'pending', details: { title: testContent.title }, }); await createHistoryEvent(db, { contentItemId: testContent.id, channelId: testChannel.id, eventType: 'downloaded', status: 'completed', details: { title: testContent.title }, }); await createHistoryEvent(db, { contentItemId: testContent.id, channelId: testChannel.id, eventType: 'failed', status: 'failed', details: { error: 'test error' }, }); }); 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 } }); // ── Helpers ── function authed(opts: Record) { return { ...opts, headers: { 'x-api-key': apiKey, ...(opts.headers as Record | undefined), }, }; } // ── Auth gating ── describe('Authentication', () => { it('GET /api/v1/history returns 401 without API key', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/history' }); expect(res.statusCode).toBe(401); }); it('GET /api/v1/activity returns 401 without API key', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/activity' }); expect(res.statusCode).toBe(401); }); }); // ── GET /api/v1/history ── describe('GET /api/v1/history', () => { it('returns paginated history events', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/history' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(Array.isArray(body.data)).toBe(true); expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); expect(body.pagination.pageSize).toBe(20); expect(body.pagination.totalItems).toBeGreaterThanOrEqual(3); expect(body.pagination.totalPages).toBeGreaterThanOrEqual(1); }); it('respects page and pageSize parameters', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/history?page=1&pageSize=2' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeLessThanOrEqual(2); expect(body.pagination.pageSize).toBe(2); }); it('filters by eventType', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/history?eventType=grabbed' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.every((e: { eventType: string }) => e.eventType === 'grabbed')).toBe( true ); }); it('filters by channelId', async () => { const res = await server.inject( authed({ method: 'GET', url: `/api/v1/history?channelId=${testChannel.id}`, }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeGreaterThanOrEqual(3); expect( body.data.every((e: { channelId: number }) => e.channelId === testChannel.id) ).toBe(true); }); it('filters by contentItemId', async () => { const res = await server.inject( authed({ method: 'GET', url: `/api/v1/history?contentItemId=${testContent.id}`, }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeGreaterThanOrEqual(3); }); it('returns empty data for unmatched filters', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/history?eventType=nonexistent' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data).toHaveLength(0); expect(body.pagination.totalItems).toBe(0); }); }); // ── GET /api/v1/activity ── describe('GET /api/v1/activity', () => { it('returns recent activity feed', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/activity' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(Array.isArray(body.data)).toBe(true); expect(body.data.length).toBeGreaterThanOrEqual(3); }); it('respects limit parameter', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/activity?limit=2' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeLessThanOrEqual(2); }); it('returns events in newest-first order', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/activity' }) ); const body = res.json(); const dates = body.data.map((e: { createdAt: string; id: number }) => ({ createdAt: e.createdAt, id: e.id, })); // Events should be ordered by createdAt DESC, then ID DESC for (let i = 1; i < dates.length; i++) { const prev = dates[i - 1]; const curr = dates[i]; const prevTime = new Date(prev.createdAt).getTime(); const currTime = new Date(curr.createdAt).getTime(); expect(prevTime).toBeGreaterThanOrEqual(currTime); if (prevTime === currTime) { expect(prev.id).toBeGreaterThan(curr.id); } } }); }); });