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)
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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<typeof schema>;
|
|
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);
|
|
});
|
|
});
|
|
});
|