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);
|
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 () => {
|
it('returns empty array for channel with no content', async () => {
|
||||||
const noContentChannel = await createChannel(db, {
|
const noContentChannel = await createChannel(db, {
|
||||||
name: 'Empty Channel',
|
name: 'Empty Channel',
|
||||||
|
|
@ -306,4 +320,89 @@ describe('content-api', () => {
|
||||||
expect(res.statusCode).toBe(401);
|
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 type * as schema from '../schema/index';
|
||||||
import { contentItems } from '../schema/index';
|
import { contentItems } from '../schema/index';
|
||||||
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index';
|
||||||
import type { ContentCounts } from '../../types/api';
|
import type { ContentCounts, ContentTypeCounts } from '../../types/api';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|
@ -373,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) {
|
||||||
return conditions;
|
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 ──
|
// ── Content Counts by Channel ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ import {
|
||||||
getAllContentItems,
|
getAllContentItems,
|
||||||
getContentByChannelId,
|
getContentByChannelId,
|
||||||
getChannelContentPaginated,
|
getChannelContentPaginated,
|
||||||
|
getContentCountsByType,
|
||||||
setMonitored,
|
setMonitored,
|
||||||
bulkSetMonitored,
|
bulkSetMonitored,
|
||||||
updateContentItem,
|
updateContentItem,
|
||||||
} from '../../db/repositories/content-repository';
|
} 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';
|
import type { ContentItem, ContentStatus, ContentType } from '../../types/index';
|
||||||
|
|
||||||
// ── JSON Schemas for Fastify Validation ──
|
// ── 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) ──
|
// ── GET /api/v1/channel/:id/content (paginated) ──
|
||||||
|
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ export interface ContentCounts {
|
||||||
downloaded: number;
|
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). */
|
/** App-wide settings (check interval, concurrent downloads). */
|
||||||
export interface AppSettingsResponse {
|
export interface AppSettingsResponse {
|
||||||
checkInterval: number;
|
checkInterval: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue