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 type { Channel, ContentItem } from '../types/index'; /** * Integration tests for monitoring API endpoints: * PATCH /api/v1/content/:id/monitored — single item toggle * PATCH /api/v1/content/bulk/monitored — bulk toggle * PUT /api/v1/channel/:id/monitoring-mode — channel monitoring mode with cascade */ describe('monitoring-api', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; let channel: Channel; const items: ContentItem[] = []; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-monitoring-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 channel = await createChannel(db, { name: 'Monitoring Test Channel', platform: 'youtube', platformId: 'UC_MONITORING_TEST', url: 'https://www.youtube.com/channel/UC_MONITORING_TEST', monitoringEnabled: true, checkInterval: 360, imageUrl: null, metadata: null, formatProfileId: null, }); // Create 4 content items with mixed monitored states const itemDefs = [ { platformContentId: 'mon_v1', title: 'Monitored Item One', monitored: true }, { platformContentId: 'mon_v2', title: 'Monitored Item Two', monitored: true }, { platformContentId: 'mon_v3', title: 'Unmonitored Item Three', monitored: false }, { platformContentId: 'mon_v4', title: 'Unmonitored Item Four', monitored: false }, ]; for (const def of itemDefs) { 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: 600, monitored: def.monitored, }); if (created) items.push(created); } expect(items.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 (K004) } }); // ── PATCH /api/v1/content/:id/monitored ── describe('PATCH /api/v1/content/:id/monitored', () => { it('toggles monitored from true to false', async () => { const item = items[0]; // monitored: true const res = await server.inject({ method: 'PATCH', url: `/api/v1/content/${item.id}/monitored`, headers: { 'x-api-key': apiKey }, payload: { monitored: false }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.id).toBe(item.id); expect(body.data.monitored).toBe(false); }); it('toggles monitored from false to true', async () => { const item = items[2]; // monitored: false const res = await server.inject({ method: 'PATCH', url: `/api/v1/content/${item.id}/monitored`, headers: { 'x-api-key': apiKey }, payload: { monitored: true }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.id).toBe(item.id); expect(body.data.monitored).toBe(true); }); it('returns 404 for non-existent content item ID', async () => { const res = await server.inject({ method: 'PATCH', url: '/api/v1/content/99999/monitored', headers: { 'x-api-key': apiKey }, payload: { monitored: true }, }); expect(res.statusCode).toBe(404); const body = res.json(); expect(body.error).toBe('Not Found'); }); it('returns 400 for invalid body (missing monitored field)', async () => { const res = await server.inject({ method: 'PATCH', url: `/api/v1/content/${items[0].id}/monitored`, headers: { 'x-api-key': apiKey }, payload: {}, }); expect(res.statusCode).toBe(400); }); it('returns 401 without API key', async () => { const res = await server.inject({ method: 'PATCH', url: `/api/v1/content/${items[0].id}/monitored`, payload: { monitored: true }, }); expect(res.statusCode).toBe(401); }); }); // ── PATCH /api/v1/content/bulk/monitored ── describe('PATCH /api/v1/content/bulk/monitored', () => { it('bulk sets multiple items to false', async () => { const ids = items.map((i) => i.id); const res = await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', headers: { 'x-api-key': apiKey }, payload: { ids, monitored: false }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.updated).toBe(ids.length); }); it('returns count of only existing items when some IDs are invalid', async () => { const ids = [items[0].id, items[1].id, 99998, 99999]; const res = await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', headers: { 'x-api-key': apiKey }, payload: { ids, monitored: true }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.updated).toBe(2); // Only the 2 existing items }); it('verifies items actually changed by fetching them via GET', async () => { // First set all to false const ids = items.map((i) => i.id); await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', headers: { 'x-api-key': apiKey }, payload: { ids, monitored: false }, }); // Then set all to true await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', headers: { 'x-api-key': apiKey }, payload: { ids, monitored: true }, }); // Fetch via GET and verify const getRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); expect(getRes.statusCode).toBe(200); const body = getRes.json(); expect(body.data.length).toBe(4); for (const item of body.data) { expect(item.monitored).toBe(true); } }); it('returns 400 for invalid body (missing ids)', async () => { const res = await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', headers: { 'x-api-key': apiKey }, payload: { monitored: true }, }); expect(res.statusCode).toBe(400); }); it('returns 401 without API key', async () => { const res = await server.inject({ method: 'PATCH', url: '/api/v1/content/bulk/monitored', payload: { ids: [1], monitored: true }, }); expect(res.statusCode).toBe(401); }); }); // ── PUT /api/v1/channel/:id/monitoring-mode ── describe('PUT /api/v1/channel/:id/monitoring-mode', () => { it("set mode to 'all': channel has monitoringMode 'all', all items monitored", async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'all' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(body.data.monitoringMode).toBe('all'); expect(body.data.monitoringEnabled).toBe(true); // Verify all content items are monitored const contentRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); const contentBody = contentRes.json(); expect(contentBody.data.length).toBe(4); for (const item of contentBody.data) { expect(item.monitored).toBe(true); } }); it("set mode to 'future': all existing items become unmonitored", async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'future' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.monitoringMode).toBe('future'); expect(body.data.monitoringEnabled).toBe(true); // Verify all content items are unmonitored (existing content, mode is 'future') const contentRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); const contentBody = contentRes.json(); for (const item of contentBody.data) { expect(item.monitored).toBe(false); } }); it("set mode to 'existing': all existing items become monitored", async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'existing' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.monitoringMode).toBe('existing'); expect(body.data.monitoringEnabled).toBe(true); // Verify all content items are monitored const contentRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); const contentBody = contentRes.json(); for (const item of contentBody.data) { expect(item.monitored).toBe(true); } }); it("set mode to 'none': all items unmonitored AND monitoringEnabled is false", async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'none' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.monitoringMode).toBe('none'); expect(body.data.monitoringEnabled).toBe(false); // Verify all content items are unmonitored const contentRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); const contentBody = contentRes.json(); for (const item of contentBody.data) { expect(item.monitored).toBe(false); } }); it("set mode back to 'all' from 'none': monitoringEnabled restored to true", async () => { // Precondition: mode is 'none' from previous test const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'all' }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.monitoringMode).toBe('all'); expect(body.data.monitoringEnabled).toBe(true); // Verify all items are monitored again const contentRes = await server.inject({ method: 'GET', url: `/api/v1/channel/${channel.id}/content`, headers: { 'x-api-key': apiKey }, }); const contentBody = contentRes.json(); for (const item of contentBody.data) { expect(item.monitored).toBe(true); } }); it('returns 404 for non-existent channel ID', async () => { const res = await server.inject({ method: 'PUT', url: '/api/v1/channel/99999/monitoring-mode', headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'all' }, }); expect(res.statusCode).toBe(404); const body = res.json(); expect(body.error).toBe('Not Found'); }); it('returns 400 for invalid mode value', async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, headers: { 'x-api-key': apiKey }, payload: { monitoringMode: 'invalid_mode' }, }); expect(res.statusCode).toBe(400); }); it('returns 401 without API key', async () => { const res = await server.inject({ method: 'PUT', url: `/api/v1/channel/${channel.id}/monitoring-mode`, payload: { monitoringMode: 'all' }, }); expect(res.statusCode).toBe(401); }); }); });