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:
parent
e6711e91a5
commit
69ec5841e7
4 changed files with 166 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue