test: Added getContentCountsByType repository function and GET /api/v1/…

- "src/db/repositories/content-repository.ts"
- "src/server/routes/content.ts"
- "src/types/api.ts"
- "src/__tests__/content-api.test.ts"

GSD-Task: S07/T01
This commit is contained in:
jlightner 2026-04-04 06:44:04 +00:00
parent e6711e91a5
commit 69ec5841e7
4 changed files with 166 additions and 2 deletions

View file

@ -260,6 +260,20 @@ describe('content-api', () => {
expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true);
});
it('filters by contentType when paginated', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content?contentType=video&page=1`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(2);
expect(body.data.every((item: { contentType: string }) => item.contentType === 'video')).toBe(true);
expect(body.pagination.totalItems).toBe(2);
});
it('returns empty array for channel with no content', async () => {
const noContentChannel = await createChannel(db, {
name: 'Empty Channel',
@ -306,4 +320,89 @@ describe('content-api', () => {
expect(res.statusCode).toBe(401);
});
});
// ── GET /api/v1/channel/:id/content-counts ──
describe('GET /api/v1/channel/:id/content-counts', () => {
it('returns per-type counts for a channel with mixed content', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toEqual({
video: 2,
audio: 0,
livestream: 1,
});
});
it('returns per-type counts for audio-only channel', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelB.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toEqual({
video: 0,
audio: 2,
livestream: 0,
});
});
it('returns all zeros for channel with no content', async () => {
// Create a fresh empty channel
const emptyChannel = await createChannel(db, {
name: 'Counts Empty',
platform: 'youtube',
platformId: 'UC_counts_empty',
url: 'https://www.youtube.com/channel/UC_counts_empty',
monitoringEnabled: true,
checkInterval: 360,
imageUrl: null,
metadata: null,
formatProfileId: null,
});
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${emptyChannel.id}/content-counts`,
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toEqual({
video: 0,
audio: 0,
livestream: 0,
});
});
it('returns 400 for invalid channel ID', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/v1/channel/abc/content-counts',
headers: { 'x-api-key': apiKey },
});
expect(res.statusCode).toBe(400);
});
it('returns 401 without API key', async () => {
const res = await server.inject({
method: 'GET',
url: `/api/v1/channel/${channelA.id}/content-counts`,
});
expect(res.statusCode).toBe(401);
});
});
});

View file

@ -3,7 +3,7 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../schema/index';
import { contentItems } from '../schema/index';
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
import type { ContentCounts } from '../../types/api';
import type { ContentCounts, ContentTypeCounts } from '../../types/api';
// ── Types ──
@ -373,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
return conditions;
}
// ── Content Counts by Type ──
/**
* Get content counts grouped by content type for a single channel.
* Returns { video, audio, livestream } with zero-defaults for missing types.
*/
export async function getContentCountsByType(
db: Db,
channelId: number
): Promise<ContentTypeCounts> {
const rows = await db
.select({
contentType: contentItems.contentType,
count: sql<number>`count(*)`,
})
.from(contentItems)
.where(eq(contentItems.channelId, channelId))
.groupBy(contentItems.contentType);
const counts: ContentTypeCounts = { video: 0, audio: 0, livestream: 0 };
for (const row of rows) {
const ct = row.contentType as keyof ContentTypeCounts;
if (ct in counts) {
counts[ct] = Number(row.count);
}
}
return counts;
}
// ── Content Counts by Channel ──
/**

View file

@ -4,11 +4,12 @@ import {
getAllContentItems,
getContentByChannelId,
getChannelContentPaginated,
getContentCountsByType,
setMonitored,
bulkSetMonitored,
updateContentItem,
} from '../../db/repositories/content-repository';
import type { PaginatedResponse, ApiResponse } from '../../types/api';
import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api';
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
// ── JSON Schemas for Fastify Validation ──
@ -254,6 +255,34 @@ export async function contentRoutes(fastify: FastifyInstance): Promise<void> {
}
);
// ── GET /api/v1/channel/:id/content-counts ──
fastify.get<{
Params: { id: string };
}>('/api/v1/channel/:id/content-counts', async (request, reply) => {
const channelId = parseIdParam(request.params.id, reply, 'Channel ID');
if (channelId === null) return;
try {
const counts = await getContentCountsByType(fastify.db, channelId);
const response: ApiResponse<ContentTypeCounts> = {
success: true,
data: counts,
};
return response;
} catch (err) {
request.log.error(
{ err, channelId },
'[content] Failed to fetch content type counts'
);
return reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: `Failed to retrieve content type counts for channel ${channelId}`,
});
}
});
// ── GET /api/v1/channel/:id/content (paginated) ──
fastify.get<{

View file

@ -70,6 +70,13 @@ export interface ContentCounts {
downloaded: number;
}
/** Per-content-type counts for a channel. */
export interface ContentTypeCounts {
video: number;
audio: number;
livestream: number;
}
/** App-wide settings (check interval, concurrent downloads). */
export interface AppSettingsResponse {
checkInterval: number;