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)
255 lines
8 KiB
TypeScript
255 lines
8 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 { 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<typeof schema>;
|
|
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<string, unknown>) {
|
|
return {
|
|
...opts,
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
...(opts.headers as Record<string, string> | 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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|