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)
407 lines
13 KiB
TypeScript
407 lines
13 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 { createChannel } from '../db/repositories/channel-repository';
|
|
import { createContentItem } from '../db/repositories/content-repository';
|
|
import { updateQueueItemStatus } from '../db/repositories/queue-repository';
|
|
import { QueueService } from '../services/queue';
|
|
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';
|
|
|
|
/**
|
|
* End-to-end integration test exercising the full application flow:
|
|
* channel CRUD → content listing → download enqueue → queue state →
|
|
* history records → health check → system status.
|
|
*
|
|
* Uses a real SQLite database with migrations and Fastify inject() for
|
|
* fast HTTP testing without binding ports. The download service is mocked
|
|
* so yt-dlp is not required.
|
|
*/
|
|
|
|
describe('End-to-end flow', () => {
|
|
let server: FastifyInstance;
|
|
let db: LibSQLDatabase<typeof schema>;
|
|
let apiKey: string;
|
|
let tmpDir: string;
|
|
|
|
// IDs populated during test flow
|
|
let channelId: number;
|
|
let contentItemId: number;
|
|
let queueItemId: number;
|
|
|
|
// Mock download service — simulates successful downloads
|
|
const mockDownloadService = {
|
|
downloadItem: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
// Create isolated temp database
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-e2e-'));
|
|
const dbPath = join(tmpDir, 'test.db');
|
|
db = await initDatabaseAsync(dbPath);
|
|
await runMigrations(dbPath);
|
|
|
|
// Build server with real database
|
|
server = await buildServer({ db });
|
|
|
|
// Attach a QueueService with mock download service so enqueue works
|
|
const queueService = new QueueService(db, mockDownloadService as any, {
|
|
concurrency: 1,
|
|
});
|
|
// Stop auto-processing so we control when downloads run
|
|
queueService.stop();
|
|
server.queueService = queueService;
|
|
|
|
await server.ready();
|
|
|
|
// Read auto-generated API key from database
|
|
const rows = await db
|
|
.select()
|
|
.from(systemConfig)
|
|
.where(eq(systemConfig.key, 'api_key'))
|
|
.limit(1);
|
|
apiKey = rows[0]?.value ?? '';
|
|
expect(apiKey).toBeTruthy();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
server.queueService?.stop();
|
|
await server.close();
|
|
closeDatabase();
|
|
// Windows: SQLite WAL/SHM files may be locked briefly (K004)
|
|
try {
|
|
if (tmpDir && existsSync(tmpDir)) {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Temp dir cleanup is best-effort on Windows
|
|
}
|
|
});
|
|
|
|
// ── Step 1: Create a channel (via repository — bypasses yt-dlp resolution) ──
|
|
|
|
describe('Step 1: Channel creation and retrieval', () => {
|
|
it('creates a channel in the database', async () => {
|
|
const channel = await createChannel(db, {
|
|
name: 'E2E Test Channel',
|
|
platform: 'youtube',
|
|
platformId: 'UC_e2e_test_channel',
|
|
url: 'https://www.youtube.com/channel/UC_e2e_test_channel',
|
|
imageUrl: 'https://example.com/thumb.jpg',
|
|
formatProfileId: null,
|
|
monitoringEnabled: true,
|
|
checkInterval: 360,
|
|
metadata: null,
|
|
});
|
|
|
|
expect(channel.id).toBeGreaterThan(0);
|
|
channelId = channel.id;
|
|
});
|
|
|
|
it('GET /api/v1/channel/:id returns the channel', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/channel/${channelId}`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.name).toBe('E2E Test Channel');
|
|
expect(body.platform).toBe('youtube');
|
|
expect(body.platformId).toBe('UC_e2e_test_channel');
|
|
});
|
|
|
|
it('GET /api/v1/channel lists channels including ours', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/channel',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const channels = res.json();
|
|
expect(Array.isArray(channels)).toBe(true);
|
|
expect(channels.some((c: { id: number }) => c.id === channelId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Step 2: Create content item (via repository — simulates scheduler detection) ──
|
|
|
|
describe('Step 2: Content creation and listing', () => {
|
|
it('creates a content item for the channel', async () => {
|
|
const item = await createContentItem(db, {
|
|
channelId,
|
|
title: 'E2E Test Video — How to Build a Media Server',
|
|
platformContentId: 'e2e_test_video_001',
|
|
url: 'https://www.youtube.com/watch?v=e2e_test_001',
|
|
contentType: 'video',
|
|
duration: 600,
|
|
status: 'monitored',
|
|
});
|
|
|
|
expect(item).not.toBeNull();
|
|
contentItemId = item!.id;
|
|
});
|
|
|
|
it('GET /api/v1/content?channelId=:id shows the content item', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/content?channelId=${channelId}`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
|
expect(body.data.some((c: { id: number }) => c.id === contentItemId)).toBe(true);
|
|
});
|
|
|
|
it('GET /api/v1/channel/:id/content returns channel-specific content', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/channel/${channelId}/content`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
|
const item = body.data.find((c: { id: number }) => c.id === contentItemId);
|
|
expect(item).toBeDefined();
|
|
expect(item.title).toBe('E2E Test Video — How to Build a Media Server');
|
|
});
|
|
});
|
|
|
|
// ── Step 3: Enqueue download and check queue state ──
|
|
|
|
describe('Step 3: Download enqueue and queue management', () => {
|
|
it('POST /api/v1/download/:contentItemId enqueues the item', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/download/${contentItemId}`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(202);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toHaveProperty('id');
|
|
expect(body.data.contentItemId).toBe(contentItemId);
|
|
expect(body.data.status).toBe('pending');
|
|
queueItemId = body.data.id;
|
|
});
|
|
|
|
it('GET /api/v1/queue shows the queued item', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/queue',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
|
const item = body.data.find((q: { id: number }) => q.id === queueItemId);
|
|
expect(item).toBeDefined();
|
|
expect(item.status).toBe('pending');
|
|
});
|
|
|
|
it('GET /api/v1/queue?status=pending filters correctly', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/queue?status=pending',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.data.every((q: { status: string }) => q.status === 'pending')).toBe(true);
|
|
});
|
|
|
|
it('POST /api/v1/download/:contentItemId rejects duplicate enqueue', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/download/${contentItemId}`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
// Content status is now 'queued', so this should return 409
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
});
|
|
|
|
// ── Step 4: Simulate download completion and verify history ──
|
|
|
|
describe('Step 4: Download completion and history', () => {
|
|
it('simulating download completion creates history records', async () => {
|
|
// Manually transition the queue item to completed to simulate
|
|
// what the QueueService would do after a successful download
|
|
await updateQueueItemStatus(db, queueItemId, 'completed', {
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Verify queue item is now completed
|
|
const queueRes = await server.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/queue/${queueItemId}`,
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(queueRes.statusCode).toBe(200);
|
|
expect(queueRes.json().data.status).toBe('completed');
|
|
});
|
|
|
|
it('GET /api/v1/history shows history events', async () => {
|
|
// The enqueue operation created a 'grabbed' history event
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/history',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// At minimum, we should have a 'grabbed' event from enqueue
|
|
const grabbedEvent = body.data.find(
|
|
(e: { eventType: string; contentItemId: number | null }) =>
|
|
e.eventType === 'grabbed' && e.contentItemId === contentItemId
|
|
);
|
|
expect(grabbedEvent).toBeDefined();
|
|
});
|
|
|
|
it('GET /api/v1/history?eventType=grabbed filters by event type', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/history?eventType=grabbed',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.data.every((e: { eventType: string }) => e.eventType === 'grabbed')).toBe(true);
|
|
});
|
|
|
|
it('GET /api/v1/activity returns recent activity', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/activity',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ── Step 5: Health and System Status ──
|
|
|
|
describe('Step 5: Health and system status', () => {
|
|
it('GET /ping returns ok (unauthenticated)', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/ping',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ status: 'ok' });
|
|
});
|
|
|
|
it('GET /api/v1/health returns healthy status', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/health',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.status).toBeDefined();
|
|
expect(body.components).toBeDefined();
|
|
expect(Array.isArray(body.components)).toBe(true);
|
|
|
|
// Database component should be healthy
|
|
const dbComponent = body.components.find(
|
|
(c: { name: string }) => c.name === 'database'
|
|
);
|
|
expect(dbComponent).toBeDefined();
|
|
expect(dbComponent.status).toBe('healthy');
|
|
});
|
|
|
|
it('GET /api/v1/system/status returns system information', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/system/status',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body).toHaveProperty('appName');
|
|
expect(body.appName).toBe('Tubearr');
|
|
expect(body).toHaveProperty('version');
|
|
expect(body).toHaveProperty('uptime');
|
|
expect(body).toHaveProperty('platform');
|
|
expect(body).toHaveProperty('nodeVersion');
|
|
expect(typeof body.uptime).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ── Step 6: Error handling and edge cases ──
|
|
|
|
describe('Step 6: Error handling', () => {
|
|
it('returns 401 for missing API key on protected routes', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/system/status',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it('returns 404 for unknown API routes', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/nonexistent-route',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for non-existent channel', async () => {
|
|
const res = await server.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/channel/99999',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 404 for non-existent content item download', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/download/99999',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
});
|