feat: add count_posts aggregation tool to AI SDK and MCP server
This commit is contained in:
@@ -74,6 +74,7 @@ interface PostEngineContract {
|
|||||||
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||||
|
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaEngineContract {
|
interface MediaEngineContract {
|
||||||
@@ -567,6 +568,44 @@ export class MCPServer {
|
|||||||
}
|
}
|
||||||
return { content };
|
return { content };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── count_posts ──
|
||||||
|
server.registerTool('count_posts', {
|
||||||
|
title: 'Count Posts',
|
||||||
|
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without full post data — ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
|
||||||
|
inputSchema: {
|
||||||
|
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
|
||||||
|
year: z.number().optional().describe('Filter to posts in this year'),
|
||||||
|
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
|
||||||
|
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
|
||||||
|
category: z.string().optional().describe('Filter by category'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
|
}, async (args) => {
|
||||||
|
if (args.month && !args.year) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
|
||||||
|
if (args.year !== undefined) filter.year = args.year;
|
||||||
|
if (args.month !== undefined) filter.month = args.month;
|
||||||
|
if (args.status) filter.status = args.status;
|
||||||
|
if (args.category) filter.category = args.category;
|
||||||
|
if (args.tags) filter.tags = args.tags;
|
||||||
|
|
||||||
|
const result = await this.deps.postEngine.getPostCounts(
|
||||||
|
args.groupBy,
|
||||||
|
Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerProposalTools(server: McpServer): void {
|
private registerProposalTools(server: McpServer): void {
|
||||||
|
|||||||
@@ -984,6 +984,127 @@ export class PostEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side aggregation: count posts grouped by one or more dimensions.
|
||||||
|
* Returns flat groups with counts — avoids transferring full post data for analytics.
|
||||||
|
*/
|
||||||
|
async getPostCounts(
|
||||||
|
groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>,
|
||||||
|
filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] },
|
||||||
|
): Promise<{ groups: Record<string, string | number>[]; totalPosts: number }> {
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (!client) return { groups: [], totalPosts: 0 };
|
||||||
|
|
||||||
|
// Build SELECT expressions and GROUP BY columns
|
||||||
|
const selectExprs: string[] = [];
|
||||||
|
const groupByCols: string[] = [];
|
||||||
|
const joins: string[] = [];
|
||||||
|
|
||||||
|
for (const dim of groupBy) {
|
||||||
|
switch (dim) {
|
||||||
|
case 'year':
|
||||||
|
selectExprs.push("CAST(strftime('%Y', posts.created_at) AS INTEGER) AS g_year");
|
||||||
|
groupByCols.push('g_year');
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
selectExprs.push("CAST(strftime('%m', posts.created_at) AS INTEGER) AS g_month");
|
||||||
|
groupByCols.push('g_month');
|
||||||
|
break;
|
||||||
|
case 'tag':
|
||||||
|
selectExprs.push('t.value AS g_tag');
|
||||||
|
joins.push('JOIN json_each(posts.tags) AS t');
|
||||||
|
groupByCols.push('g_tag');
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
selectExprs.push('c.value AS g_category');
|
||||||
|
joins.push('JOIN json_each(posts.categories) AS c');
|
||||||
|
groupByCols.push('g_category');
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
selectExprs.push('posts.status AS g_status');
|
||||||
|
groupByCols.push('g_status');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectExprs.push('COUNT(*) AS cnt');
|
||||||
|
|
||||||
|
// Build WHERE conditions
|
||||||
|
const conditions: string[] = ['posts.project_id = ?'];
|
||||||
|
const args: (string | number)[] = [this.currentProjectId];
|
||||||
|
|
||||||
|
if (filter?.year !== undefined) {
|
||||||
|
const start = `${filter.year}-01-01`;
|
||||||
|
const end = `${filter.year + 1}-01-01`;
|
||||||
|
conditions.push('posts.created_at >= ?');
|
||||||
|
args.push(start);
|
||||||
|
conditions.push('posts.created_at < ?');
|
||||||
|
args.push(end);
|
||||||
|
}
|
||||||
|
if (filter?.month !== undefined && filter?.year !== undefined) {
|
||||||
|
const start = `${filter.year}-${String(filter.month).padStart(2, '0')}-01`;
|
||||||
|
const endMonth = filter.month === 12 ? 1 : filter.month + 1;
|
||||||
|
const endYear = filter.month === 12 ? filter.year + 1 : filter.year;
|
||||||
|
const end = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
|
||||||
|
conditions.push('posts.created_at >= ?');
|
||||||
|
args.push(start);
|
||||||
|
conditions.push('posts.created_at < ?');
|
||||||
|
args.push(end);
|
||||||
|
}
|
||||||
|
if (filter?.status) {
|
||||||
|
conditions.push('posts.status = ?');
|
||||||
|
args.push(filter.status);
|
||||||
|
}
|
||||||
|
if (filter?.category) {
|
||||||
|
conditions.push(
|
||||||
|
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)`,
|
||||||
|
);
|
||||||
|
args.push(filter.category);
|
||||||
|
}
|
||||||
|
if (filter?.tags && filter.tags.length > 0) {
|
||||||
|
for (const tag of filter.tags) {
|
||||||
|
conditions.push(
|
||||||
|
`EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)`,
|
||||||
|
);
|
||||||
|
args.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT ${selectExprs.join(', ')}
|
||||||
|
FROM posts
|
||||||
|
${joins.join(' ')}
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
GROUP BY ${groupByCols.join(', ')}
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.execute({ sql, args });
|
||||||
|
|
||||||
|
// Map dimension aliases back to clean names
|
||||||
|
const dimMap: Record<string, string> = {
|
||||||
|
g_year: 'year', g_month: 'month', g_tag: 'tag',
|
||||||
|
g_category: 'category', g_status: 'status',
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: Record<string, string | number>[] = result.rows.map((row: any) => {
|
||||||
|
const group: Record<string, string | number> = {};
|
||||||
|
for (const col of groupByCols) {
|
||||||
|
group[dimMap[col]] = row[col];
|
||||||
|
}
|
||||||
|
group.count = Number(row.cnt);
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPosts = groups.reduce((sum, g) => sum + (g.count as number), 0);
|
||||||
|
return { groups, totalPosts };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getPostCounts failed:', error);
|
||||||
|
return { groups: [], totalPosts: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAvailableTags(): Promise<string[]> {
|
async getAvailableTags(): Promise<string[]> {
|
||||||
const allPosts = await this.getAllPostsUnpaginated();
|
const allPosts = await this.getAllPostsUnpaginated();
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface BlogToolDeps {
|
|||||||
categoryCount: number;
|
categoryCount: number;
|
||||||
}>;
|
}>;
|
||||||
getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>;
|
getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>;
|
||||||
|
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
|
||||||
};
|
};
|
||||||
mediaEngine: {
|
mediaEngine: {
|
||||||
getMedia: (id: string) => Promise<MediaData | null>;
|
getMedia: (id: string) => Promise<MediaData | null>;
|
||||||
@@ -444,6 +445,37 @@ export function createBlogTools(deps: BlogToolDeps) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
count_posts: tool({
|
||||||
|
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without transferring full post data. Ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
|
||||||
|
year: z.number().optional().describe('Filter to posts in this year'),
|
||||||
|
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
|
||||||
|
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
|
||||||
|
category: z.string().optional().describe('Filter by category'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
|
||||||
|
}),
|
||||||
|
execute: async ({ groupBy, year, month, status, category, tags }) => {
|
||||||
|
if (month !== undefined && year === undefined) {
|
||||||
|
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||||
|
}
|
||||||
|
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
|
||||||
|
if (year !== undefined) filter.year = year;
|
||||||
|
if (month !== undefined) filter.month = month;
|
||||||
|
if (status) filter.status = status;
|
||||||
|
if (category) filter.category = category;
|
||||||
|
if (tags && tags.length > 0) filter.tags = tags;
|
||||||
|
|
||||||
|
const result = await postEngine.getPostCounts(groupBy, Object.keys(filter).length > 0 ? filter : undefined);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
groupCount: result.groups.length,
|
||||||
|
totalPosts: result.totalPosts,
|
||||||
|
groups: result.groups,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
list_categories: tool({
|
list_categories: tool({
|
||||||
description: 'List all categories used across blog posts, with the count of posts in each category.',
|
description: 'List all categories used across blog posts, with the count of posts in each category.',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies {
|
|||||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||||
|
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
|
||||||
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
|
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
|
||||||
}),
|
}),
|
||||||
getMediaEngine: () => ({
|
getMediaEngine: () => ({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function createMockPostEngine() {
|
|||||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||||
|
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +191,11 @@ describe('MCPServer', () => {
|
|||||||
expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true);
|
expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers count_posts tool', () => {
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
expect(hasRegistered(mcpServer, '_registeredTools', 'count_posts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('registers accept_proposal tool', () => {
|
it('registers accept_proposal tool', () => {
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true);
|
expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true);
|
||||||
@@ -840,6 +846,35 @@ describe('MCPServer', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── count_posts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('count_posts calls getPostCounts with correct args', async () => {
|
||||||
|
mockPostEngine.getPostCounts.mockResolvedValue({
|
||||||
|
groups: [
|
||||||
|
{ month: 1, tag: 'Politik', count: 12 },
|
||||||
|
{ month: 2, tag: 'Politik', count: 5 },
|
||||||
|
],
|
||||||
|
totalPosts: 300,
|
||||||
|
});
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'count_posts');
|
||||||
|
const result = await tool.handler({ groupBy: ['month', 'tag'], year: 2004 }, {}) as { content: Array<{ text: string }> };
|
||||||
|
expect(mockPostEngine.getPostCounts).toHaveBeenCalledWith(
|
||||||
|
['month', 'tag'],
|
||||||
|
{ year: 2004 },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
|
expect(parsed.totalPosts).toBe(300);
|
||||||
|
expect(parsed.groups).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count_posts returns error when month without year', async () => {
|
||||||
|
const mcpServer = server.createMcpServer();
|
||||||
|
const tool = getTool(mcpServer, 'count_posts');
|
||||||
|
const result = await tool.handler({ groupBy: ['tag'], month: 6 }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('draft_post creates a draft and stores proposal', async () => {
|
it('draft_post creates a draft and stores proposal', async () => {
|
||||||
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
|
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
|
||||||
mockPostEngine.createPost.mockResolvedValue(createdPost);
|
mockPostEngine.createPost.mockResolvedValue(createdPost);
|
||||||
|
|||||||
@@ -2495,6 +2495,108 @@ Published snapshot content`);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getPostCounts', () => {
|
||||||
|
it('should return empty groups when no posts exist', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const result = await postEngine.getPostCounts(['year']);
|
||||||
|
expect(result).toEqual({ groups: [], totalPosts: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group by year', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ g_year: 2024, cnt: 15 },
|
||||||
|
{ g_year: 2023, cnt: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getPostCounts(['year']);
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ year: 2024, count: 15 },
|
||||||
|
{ year: 2023, count: 10 },
|
||||||
|
]);
|
||||||
|
expect(result.totalPosts).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group by month and tag with year filter', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ g_month: 1, g_tag: 'Politik', cnt: 12 },
|
||||||
|
{ g_month: 1, g_tag: 'Medien', cnt: 8 },
|
||||||
|
{ g_month: 2, g_tag: 'Politik', cnt: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getPostCounts(['month', 'tag'], { year: 2004 });
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ month: 1, tag: 'Politik', count: 12 },
|
||||||
|
{ month: 1, tag: 'Medien', count: 8 },
|
||||||
|
{ month: 2, tag: 'Politik', count: 5 },
|
||||||
|
]);
|
||||||
|
expect(result.totalPosts).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should group by category and status', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ g_category: 'article', g_status: 'published', cnt: 20 },
|
||||||
|
{ g_category: 'wiki', g_status: 'draft', cnt: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await postEngine.getPostCounts(['category', 'status']);
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ category: 'article', status: 'published', count: 20 },
|
||||||
|
{ category: 'wiki', status: 'draft', count: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include year and month filters in SQL WHERE', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await postEngine.getPostCounts(['tag'], { year: 2004, month: 6 });
|
||||||
|
|
||||||
|
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
|
||||||
|
const sql = call?.sql?.toLowerCase() ?? '';
|
||||||
|
expect(sql).toContain('json_each');
|
||||||
|
expect(sql).toContain('group by');
|
||||||
|
expect(sql).toContain('created_at >=');
|
||||||
|
expect(sql).toContain('created_at <');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include status filter in SQL WHERE', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await postEngine.getPostCounts(['year'], { status: 'published' });
|
||||||
|
|
||||||
|
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
|
||||||
|
const sql = call?.sql?.toLowerCase() ?? '';
|
||||||
|
expect(sql).toContain("status = ?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include category filter in SQL WHERE', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await postEngine.getPostCounts(['month'], { year: 2024, category: 'tech' });
|
||||||
|
|
||||||
|
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
|
||||||
|
const sql = call?.sql?.toLowerCase() ?? '';
|
||||||
|
expect(sql).toContain('json_each');
|
||||||
|
expect(sql).toContain('categories');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tags filter in SQL WHERE', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await postEngine.getPostCounts(['year'], { tags: ['js', 'react'] });
|
||||||
|
|
||||||
|
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
|
||||||
|
const sql = call?.sql?.toLowerCase() ?? '';
|
||||||
|
expect(sql).toContain('json_each');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getTagsWithCounts', () => {
|
describe('getTagsWithCounts', () => {
|
||||||
it('should return empty array when no posts have tags', async () => {
|
it('should return empty array when no posts have tags', async () => {
|
||||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function createMockBlogToolDeps(): BlogToolDeps {
|
|||||||
getAllPosts: vi.fn(),
|
getAllPosts: vi.fn(),
|
||||||
getPostsFiltered: vi.fn(),
|
getPostsFiltered: vi.fn(),
|
||||||
searchPostsFiltered: vi.fn(),
|
searchPostsFiltered: vi.fn(),
|
||||||
|
getPostCounts: vi.fn(),
|
||||||
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
||||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createMockDeps(): BlogToolDeps {
|
|||||||
getAllPosts: vi.fn(),
|
getAllPosts: vi.fn(),
|
||||||
getPostsFiltered: vi.fn(),
|
getPostsFiltered: vi.fn(),
|
||||||
searchPostsFiltered: vi.fn(),
|
searchPostsFiltered: vi.fn(),
|
||||||
|
getPostCounts: vi.fn(),
|
||||||
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
||||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||||
@@ -71,9 +72,9 @@ describe('Blog Tools — createBlogTools', () => {
|
|||||||
tools = createBlogTools(deps);
|
tools = createBlogTools(deps);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all 16 tools', () => {
|
it('returns all 17 tools', () => {
|
||||||
const names = Object.keys(tools);
|
const names = Object.keys(tools);
|
||||||
expect(names).toHaveLength(16);
|
expect(names).toHaveLength(17);
|
||||||
expect(names).toContain('check_term');
|
expect(names).toContain('check_term');
|
||||||
expect(names).toContain('search_posts');
|
expect(names).toContain('search_posts');
|
||||||
expect(names).toContain('read_post');
|
expect(names).toContain('read_post');
|
||||||
@@ -84,6 +85,7 @@ describe('Blog Tools — createBlogTools', () => {
|
|||||||
expect(names).toContain('update_media_metadata');
|
expect(names).toContain('update_media_metadata');
|
||||||
expect(names).toContain('list_tags');
|
expect(names).toContain('list_tags');
|
||||||
expect(names).toContain('list_categories');
|
expect(names).toContain('list_categories');
|
||||||
|
expect(names).toContain('count_posts');
|
||||||
expect(names).toContain('get_blog_stats');
|
expect(names).toContain('get_blog_stats');
|
||||||
expect(names).toContain('view_image');
|
expect(names).toContain('view_image');
|
||||||
expect(names).toContain('get_post_backlinks');
|
expect(names).toContain('get_post_backlinks');
|
||||||
@@ -429,6 +431,69 @@ describe('Blog Tools — update_media_metadata', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// count_posts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Blog Tools — count_posts', () => {
|
||||||
|
let deps: BlogToolDeps;
|
||||||
|
let tools: ReturnType<typeof createBlogTools>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deps = createMockDeps();
|
||||||
|
tools = createBlogTools(deps);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls getPostCounts with groupBy and filters', async () => {
|
||||||
|
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
|
||||||
|
groups: [
|
||||||
|
{ month: 1, tag: 'Politik', count: 12 },
|
||||||
|
{ month: 2, tag: 'Politik', count: 5 },
|
||||||
|
],
|
||||||
|
totalPosts: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tools.count_posts.execute!(
|
||||||
|
{ groupBy: ['month', 'tag'], year: 2004 },
|
||||||
|
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
|
||||||
|
);
|
||||||
|
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
|
||||||
|
['month', 'tag'],
|
||||||
|
{ year: 2004 },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
totalPosts: 200,
|
||||||
|
groupCount: 2,
|
||||||
|
});
|
||||||
|
expect((result as any).groups).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes all optional filters', async () => {
|
||||||
|
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
|
||||||
|
groups: [],
|
||||||
|
totalPosts: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tools.count_posts.execute!(
|
||||||
|
{ groupBy: ['status'], year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
|
||||||
|
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
|
||||||
|
);
|
||||||
|
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
|
||||||
|
['status'],
|
||||||
|
{ year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when month without year', async () => {
|
||||||
|
const result = await tools.count_posts.execute!(
|
||||||
|
{ groupBy: ['tag'], month: 3 },
|
||||||
|
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// list_tags / list_categories
|
// list_tags / list_categories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user