test: Built media server CRUD routes, connection test/sections endpoint…

- "src/server/routes/media-server.ts"
- "src/__tests__/media-server-api.test.ts"
- "src/index.ts"
- "src/server/index.ts"

GSD-Task: S04/T03
This commit is contained in:
jlightner 2026-04-04 05:57:39 +00:00
parent 73c232a845
commit 9ef0323480
4 changed files with 593 additions and 0 deletions

View file

@ -0,0 +1,332 @@
import { describe, it, expect, vi, 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';
/**
* Integration tests for media-server CRUD + action API endpoints.
* Uses Fastify inject no real HTTP ports.
*/
describe('Media Server API', () => {
let server: FastifyInstance;
let db: LibSQLDatabase<typeof schema>;
let apiKey: string;
let tmpDir: string;
beforeAll(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-media-server-api-'));
const dbPath = join(tmpDir, 'test.db');
db = await initDatabaseAsync(dbPath);
await runMigrations(dbPath);
server = await buildServer({ db });
await server.ready();
// Read API key from database (generated by auth plugin)
const rows = await db
.select()
.from(systemConfig)
.where(eq(systemConfig.key, 'api_key'))
.limit(1);
apiKey = rows[0]?.value ?? '';
expect(apiKey).toBeTruthy();
});
afterAll(async () => {
await server.close();
closeDatabase();
try {
if (tmpDir && existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// best-effort cleanup
}
});
// ── Helpers ──
function authed(opts: Record<string, unknown>) {
return {
...opts,
headers: { 'x-api-key': apiKey, ...(opts.headers as Record<string, string> | undefined) },
};
}
const plexBody = {
name: 'My Plex',
type: 'plex' as const,
url: 'http://plex.local:32400',
token: 'abc123secret',
librarySection: '1',
enabled: true,
};
const jellyfinBody = {
name: 'My Jellyfin',
type: 'jellyfin' as const,
url: 'http://jellyfin.local:8096',
token: 'jf-token-secret',
enabled: true,
};
// ── CRUD ──
describe('CRUD', () => {
it('POST /api/v1/media-servers creates a server and redacts token', async () => {
const res = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: plexBody,
})
);
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.name).toBe('My Plex');
expect(body.type).toBe('plex');
expect(body.url).toBe('http://plex.local:32400');
// Token should be redacted
expect(body.token).not.toBe('abc123secret');
expect(body.token).toContain('****');
expect(body.id).toBeTypeOf('number');
});
it('GET /api/v1/media-servers lists all servers', async () => {
// Create a second server
await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: jellyfinBody,
})
);
const res = await server.inject(
authed({ method: 'GET', url: '/api/v1/media-servers' })
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.length).toBeGreaterThanOrEqual(2);
// All tokens should be redacted
for (const s of body) {
expect(s.token).toContain('****');
}
});
it('GET /api/v1/media-servers/:id returns a single server', async () => {
const createRes = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: { ...plexBody, name: 'Get-By-Id Test' },
})
);
const created = createRes.json();
const res = await server.inject(
authed({ method: 'GET', url: `/api/v1/media-servers/${created.id}` })
);
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Get-By-Id Test');
});
it('GET /api/v1/media-servers/:id returns 404 for missing', async () => {
const res = await server.inject(
authed({ method: 'GET', url: '/api/v1/media-servers/99999' })
);
expect(res.statusCode).toBe(404);
});
it('PUT /api/v1/media-servers/:id updates fields', async () => {
const createRes = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: plexBody,
})
);
const id = createRes.json().id;
const res = await server.inject(
authed({
method: 'PUT',
url: `/api/v1/media-servers/${id}`,
payload: { name: 'Updated Plex', enabled: false },
})
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.name).toBe('Updated Plex');
expect(body.enabled).toBe(false);
});
it('PUT /api/v1/media-servers/:id returns 404 for missing', async () => {
const res = await server.inject(
authed({
method: 'PUT',
url: '/api/v1/media-servers/99999',
payload: { name: 'Nope' },
})
);
expect(res.statusCode).toBe(404);
});
it('DELETE /api/v1/media-servers/:id deletes and returns 204', async () => {
const createRes = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: jellyfinBody,
})
);
const id = createRes.json().id;
const res = await server.inject(
authed({ method: 'DELETE', url: `/api/v1/media-servers/${id}` })
);
expect(res.statusCode).toBe(204);
// Confirm gone
const getRes = await server.inject(
authed({ method: 'GET', url: `/api/v1/media-servers/${id}` })
);
expect(getRes.statusCode).toBe(404);
});
it('DELETE /api/v1/media-servers/:id returns 404 for missing', async () => {
const res = await server.inject(
authed({ method: 'DELETE', url: '/api/v1/media-servers/99999' })
);
expect(res.statusCode).toBe(404);
});
});
// ── Validation ──
describe('validation', () => {
it('rejects POST with missing required fields', async () => {
const res = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: { name: 'Missing fields' },
})
);
expect(res.statusCode).toBe(400);
});
it('rejects POST with invalid type', async () => {
const res = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: { ...plexBody, type: 'emby' },
})
);
expect(res.statusCode).toBe(400);
});
it('rejects non-numeric ID param', async () => {
const res = await server.inject(
authed({ method: 'GET', url: '/api/v1/media-servers/abc' })
);
expect(res.statusCode).toBe(400);
});
});
// ── Action endpoints ──
describe('actions', () => {
it('POST /api/v1/media-servers/:id/test returns 404 for missing server', async () => {
const res = await server.inject(
authed({ method: 'POST', url: '/api/v1/media-servers/99999/test' })
);
expect(res.statusCode).toBe(404);
});
it('GET /api/v1/media-servers/:id/sections returns 404 for missing server', async () => {
const res = await server.inject(
authed({ method: 'GET', url: '/api/v1/media-servers/99999/sections' })
);
expect(res.statusCode).toBe(404);
});
it('POST /api/v1/media-servers/:id/test calls testConnection on a real server record', async () => {
// Create a server (the test will fail to actually connect, but verifies the route works)
const createRes = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: { ...plexBody, name: 'Test-Connection Target', url: 'http://127.0.0.1:1' },
})
);
const id = createRes.json().id;
const res = await server.inject(
authed({ method: 'POST', url: `/api/v1/media-servers/${id}/test` })
);
expect(res.statusCode).toBe(200);
const body = res.json();
// Should return a structured result (will fail since no server is running)
expect(body).toHaveProperty('success');
expect(body).toHaveProperty('message');
expect(body.success).toBe(false);
});
it('GET /api/v1/media-servers/:id/sections returns sections array', async () => {
// Create a jellyfin server (listLibrarySections returns [] for jellyfin)
const createRes = await server.inject(
authed({
method: 'POST',
url: '/api/v1/media-servers',
payload: { ...jellyfinBody, name: 'Sections-Test' },
})
);
const id = createRes.json().id;
const res = await server.inject(
authed({ method: 'GET', url: `/api/v1/media-servers/${id}/sections` })
);
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
});
});
// ── Auth ──
describe('auth', () => {
it('rejects requests without API key', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/v1/media-servers',
});
// Should be 401 or 403 depending on auth plugin behavior
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
});
});

View file

@ -19,6 +19,8 @@ import { DownloadEventBus } from './services/event-bus';
import { QueueService } from './services/queue';
import { NotificationService } from './services/notification';
import { HealthService } from './services/health';
import { MediaServerService } from './services/media-server';
import { getEnabledMediaServers } from './db/repositories/media-server-repository';
import { PlatformRegistry } from './sources/platform-source';
import { YouTubeSource } from './sources/youtube';
import { SoundCloudSource } from './sources/soundcloud';
@ -165,6 +167,39 @@ async function main(): Promise<void> {
);
(server as { healthService: HealthService | null }).healthService = healthService;
// 5c. Wire automatic media-server scans on download completion
const mediaServerService = new MediaServerService();
eventBus.onDownload('download:complete', (payload) => {
getEnabledMediaServers(db)
.then(async (servers) => {
if (servers.length === 0) return;
console.log(
`[media-server] download complete contentItemId=${payload.contentItemId} — triggering ${servers.length} server scan(s)`
);
const results = await Promise.allSettled(
servers.map((s) => mediaServerService.triggerScan(s))
);
for (let i = 0; i < results.length; i++) {
const r = results[i];
const s = servers[i];
if (r.status === 'fulfilled') {
if (r.value.success) {
console.log(`[media-server] scan ok server="${s.name}" msg="${r.value.message}"`);
} else {
console.log(`[media-server] scan failed server="${s.name}" msg="${r.value.message}"`);
}
} else {
console.log(`[media-server] scan error server="${s.name}" err="${r.reason}"`);
}
}
})
.catch((err) => {
console.log(
`[media-server] failed to query enabled servers: ${err instanceof Error ? err.message : String(err)}`
);
});
});
// 6. Graceful shutdown handler
const shutdown = async (signal: string) => {
console.log(`[${APP_NAME}] ${signal} received — shutting down gracefully...`);

View file

@ -23,6 +23,7 @@ import { scanRoutes } from './routes/scan';
import { collectRoutes } from './routes/collect';
import { playlistRoutes } from './routes/playlist';
import { adhocDownloadRoutes } from './routes/adhoc-download';
import { mediaServerRoutes } from './routes/media-server';
import { websocketRoutes } from './routes/websocket';
import type { SchedulerService } from '../services/scheduler';
import type { DownloadService } from '../services/download';
@ -111,6 +112,7 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
await server.register(collectRoutes);
await server.register(playlistRoutes);
await server.register(adhocDownloadRoutes);
await server.register(mediaServerRoutes);
// Register WebSocket route (before static file serving so /ws is handled)
if (opts.eventBus) {

View file

@ -0,0 +1,224 @@
import { type FastifyInstance } from 'fastify';
import { parseIdParam } from './helpers';
import {
createMediaServer,
getAllMediaServers,
getMediaServerById,
updateMediaServer,
deleteMediaServer,
} from '../../db/repositories/media-server-repository';
import { MediaServerService } from '../../services/media-server';
// ── JSON Schemas for Fastify Validation ──
const createMediaServerBodySchema = {
type: 'object' as const,
required: ['name', 'type', 'url', 'token'],
properties: {
name: { type: 'string' as const, minLength: 1 },
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
url: { type: 'string' as const, minLength: 1 },
token: { type: 'string' as const, minLength: 1 },
librarySection: { type: 'string' as const, nullable: true },
enabled: { type: 'boolean' as const },
},
additionalProperties: false,
};
const updateMediaServerBodySchema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const, minLength: 1 },
type: { type: 'string' as const, enum: ['plex', 'jellyfin'] },
url: { type: 'string' as const, minLength: 1 },
token: { type: 'string' as const, minLength: 1 },
librarySection: { type: 'string' as const, nullable: true },
enabled: { type: 'boolean' as const },
},
additionalProperties: false,
};
// ── Helpers ──
/** Redact the token for API responses — show first 4 chars + '…'. */
function redactToken(token: string): string {
if (token.length <= 4) return '****';
return token.slice(0, 4) + '****';
}
/** Return a copy of the media server with the token redacted. */
function redactServer<T extends { token: string }>(server: T): T {
return { ...server, token: redactToken(server.token) };
}
// ── Route Plugin ──
/**
* Media server CRUD + action route plugin.
*
* Registers:
* POST /api/v1/media-servers create a media server
* GET /api/v1/media-servers list all media servers (redacted tokens)
* GET /api/v1/media-servers/:id get a single server (redacted token)
* PUT /api/v1/media-servers/:id update server fields
* DELETE /api/v1/media-servers/:id delete a server
* POST /api/v1/media-servers/:id/test test connection
* GET /api/v1/media-servers/:id/sections list library sections
*/
export async function mediaServerRoutes(fastify: FastifyInstance): Promise<void> {
const service = new MediaServerService();
// ── POST /api/v1/media-servers ──
fastify.post<{
Body: {
name: string;
type: 'plex' | 'jellyfin';
url: string;
token: string;
librarySection?: string | null;
enabled?: boolean;
};
}>(
'/api/v1/media-servers',
{ schema: { body: createMediaServerBodySchema } },
async (request, reply) => {
const server = await createMediaServer(fastify.db, request.body);
request.log.info(
{ serverId: server.id, name: server.name, type: server.type },
`[media-server] created server="${server.name}" type=${server.type}`
);
return reply.status(201).send(redactServer(server));
}
);
// ── GET /api/v1/media-servers ──
fastify.get('/api/v1/media-servers', async () => {
const servers = await getAllMediaServers(fastify.db);
return servers.map(redactServer);
});
// ── GET /api/v1/media-servers/:id ──
fastify.get<{ Params: { id: string } }>(
'/api/v1/media-servers/:id',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
return redactServer(server);
}
);
// ── PUT /api/v1/media-servers/:id ──
fastify.put<{
Params: { id: string };
Body: {
name?: string;
type?: 'plex' | 'jellyfin';
url?: string;
token?: string;
librarySection?: string | null;
enabled?: boolean;
};
}>(
'/api/v1/media-servers/:id',
{ schema: { body: updateMediaServerBodySchema } },
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const updated = await updateMediaServer(fastify.db, id, request.body);
if (!updated) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
request.log.info(
{ serverId: id, fields: Object.keys(request.body) },
`[media-server] updated server=${id}`
);
return redactServer(updated);
}
);
// ── DELETE /api/v1/media-servers/:id ──
fastify.delete<{ Params: { id: string } }>(
'/api/v1/media-servers/:id',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const deleted = await deleteMediaServer(fastify.db, id);
if (!deleted) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
request.log.info({ serverId: id }, `[media-server] deleted server=${id}`);
return reply.status(204).send();
}
);
// ── POST /api/v1/media-servers/:id/test ──
fastify.post<{ Params: { id: string } }>(
'/api/v1/media-servers/:id/test',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
const result = await service.testConnection(server);
return reply.send(result);
}
);
// ── GET /api/v1/media-servers/:id/sections ──
fastify.get<{ Params: { id: string } }>(
'/api/v1/media-servers/:id/sections',
async (request, reply) => {
const id = parseIdParam(request.params.id, reply, 'Media server ID');
if (id === null) return;
const server = await getMediaServerById(fastify.db, id);
if (!server) {
return reply.status(404).send({
statusCode: 404,
error: 'Not Found',
message: `Media server with ID ${id} not found`,
});
}
const sections = await service.listLibrarySections(server);
return reply.send(sections);
}
);
}