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)
227 lines
7.2 KiB
TypeScript
227 lines
7.2 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, updateContentItem } from '../db/repositories/content-repository';
|
|
import { QueueService } from '../services/queue';
|
|
import type { DownloadService } from '../services/download';
|
|
import type { ContentItem, Channel } from '../types/index';
|
|
|
|
/**
|
|
* Integration tests for the download trigger API endpoint.
|
|
*
|
|
* The download route now enqueues via QueueService instead of calling
|
|
* DownloadService directly. It returns 202 Accepted with the queue item.
|
|
*/
|
|
|
|
describe('Download API', () => {
|
|
let server: FastifyInstance;
|
|
let db: LibSQLDatabase<typeof schema>;
|
|
let apiKey: string;
|
|
let tmpDir: string;
|
|
let testChannel: Channel;
|
|
let queueService: QueueService;
|
|
let mockDownloadService: {
|
|
downloadItem: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-dl-api-'));
|
|
const dbPath = join(tmpDir, 'test.db');
|
|
db = await initDatabaseAsync(dbPath);
|
|
await runMigrations(dbPath);
|
|
server = await buildServer({ db });
|
|
|
|
// Create mock download service and queue service
|
|
mockDownloadService = {
|
|
downloadItem: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
queueService = new QueueService(
|
|
db,
|
|
mockDownloadService as unknown as DownloadService,
|
|
2
|
|
);
|
|
// Stop auto-processing so tests stay deterministic
|
|
queueService.stop();
|
|
|
|
(server as { downloadService: DownloadService | null }).downloadService =
|
|
mockDownloadService as unknown as DownloadService;
|
|
(server as { queueService: QueueService | null }).queueService = queueService;
|
|
|
|
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
|
|
testChannel = await createChannel(db, {
|
|
name: 'Download Test Channel',
|
|
platform: 'youtube',
|
|
platformId: 'UC_dl_test',
|
|
url: 'https://www.youtube.com/channel/UC_dl_test',
|
|
monitoringEnabled: true,
|
|
checkInterval: 360,
|
|
imageUrl: null,
|
|
metadata: null,
|
|
formatProfileId: null,
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
queueService.stop();
|
|
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<string, unknown>) {
|
|
return {
|
|
...opts,
|
|
headers: { 'x-api-key': apiKey, ...(opts.headers as Record<string, string> | undefined) },
|
|
};
|
|
}
|
|
|
|
let contentCounter = 0;
|
|
async function createTestContentItem(
|
|
overrides: { status?: string; platformContentId?: string } = {}
|
|
): Promise<ContentItem> {
|
|
contentCounter++;
|
|
const item = await createContentItem(db, {
|
|
channelId: testChannel.id,
|
|
title: 'Test Download Video',
|
|
platformContentId: overrides.platformContentId ?? `vid_dl_${Date.now()}_${contentCounter}`,
|
|
url: 'https://www.youtube.com/watch?v=test123',
|
|
contentType: 'video',
|
|
duration: 300,
|
|
status: (overrides.status ?? 'monitored') as 'monitored',
|
|
});
|
|
return item!;
|
|
}
|
|
|
|
// ── Auth gating ──
|
|
|
|
describe('Authentication', () => {
|
|
it('returns 401 when no API key is provided', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/download/1',
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── 404 handling ──
|
|
|
|
describe('Not found', () => {
|
|
it('returns 404 for non-existent content item', async () => {
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: '/api/v1/download/99999' })
|
|
);
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.json().message).toContain('99999');
|
|
});
|
|
|
|
it('returns 400 for non-numeric content item ID', async () => {
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: '/api/v1/download/abc' })
|
|
);
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ── 409 Conflict ──
|
|
|
|
describe('Conflict handling', () => {
|
|
it('returns 409 when content item is already downloading', async () => {
|
|
const item = await createTestContentItem();
|
|
await updateContentItem(db, item.id, { status: 'downloading' });
|
|
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: `/api/v1/download/${item.id}` })
|
|
);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json().message).toContain('downloading');
|
|
});
|
|
|
|
it('returns 409 when content item is already downloaded', async () => {
|
|
const item = await createTestContentItem();
|
|
await updateContentItem(db, item.id, { status: 'downloaded' });
|
|
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: `/api/v1/download/${item.id}` })
|
|
);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json().message).toContain('downloaded');
|
|
});
|
|
|
|
it('returns 409 when content item is already queued', async () => {
|
|
const item = await createTestContentItem();
|
|
// Enqueue once
|
|
await queueService.enqueue(item.id);
|
|
|
|
// Try to enqueue again via the download endpoint
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: `/api/v1/download/${item.id}` })
|
|
);
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json().message).toContain('already in the queue');
|
|
});
|
|
});
|
|
|
|
// ── Successful enqueue ──
|
|
|
|
describe('Successful enqueue via download endpoint', () => {
|
|
it('returns 202 Accepted with queue item', async () => {
|
|
const item = await createTestContentItem();
|
|
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: `/api/v1/download/${item.id}` })
|
|
);
|
|
|
|
expect(res.statusCode).toBe(202);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.contentItemId).toBe(item.id);
|
|
expect(body.data.status).toBe('pending');
|
|
expect(body.data.id).toBeDefined();
|
|
});
|
|
|
|
it('re-allows enqueue of failed items', async () => {
|
|
const item = await createTestContentItem();
|
|
await updateContentItem(db, item.id, { status: 'failed' });
|
|
|
|
const res = await server.inject(
|
|
authed({ method: 'POST', url: `/api/v1/download/${item.id}` })
|
|
);
|
|
|
|
// Failed items can be re-enqueued
|
|
expect(res.statusCode).toBe(202);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.status).toBe('pending');
|
|
});
|
|
});
|
|
});
|