feat: add count_posts aggregation tool to AI SDK and MCP server
This commit is contained in:
@@ -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: () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user