feat: add count_posts aggregation tool to AI SDK and MCP server

This commit is contained in:
2026-03-01 20:46:44 +01:00
parent 3074fe461c
commit db84129a17
8 changed files with 398 additions and 2 deletions

View File

@@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies {
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
}),
getMediaEngine: () => ({

View File

@@ -25,6 +25,7 @@ function createMockPostEngine() {
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: 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);
});
it('registers count_posts tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'count_posts')).toBe(true);
});
it('registers accept_proposal tool', () => {
const mcpServer = server.createMcpServer();
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 () => {
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
mockPostEngine.createPost.mockResolvedValue(createdPost);

View File

@@ -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', () => {
it('should return empty array when no posts have tags', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {

View File

@@ -54,6 +54,7 @@ function createMockBlogToolDeps(): BlogToolDeps {
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),

View File

@@ -17,6 +17,7 @@ function createMockDeps(): BlogToolDeps {
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),
@@ -71,9 +72,9 @@ describe('Blog Tools — createBlogTools', () => {
tools = createBlogTools(deps);
});
it('returns all 16 tools', () => {
it('returns all 17 tools', () => {
const names = Object.keys(tools);
expect(names).toHaveLength(16);
expect(names).toHaveLength(17);
expect(names).toContain('check_term');
expect(names).toContain('search_posts');
expect(names).toContain('read_post');
@@ -84,6 +85,7 @@ describe('Blog Tools — createBlogTools', () => {
expect(names).toContain('update_media_metadata');
expect(names).toContain('list_tags');
expect(names).toContain('list_categories');
expect(names).toContain('count_posts');
expect(names).toContain('get_blog_stats');
expect(names).toContain('view_image');
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
// ---------------------------------------------------------------------------