feat: agents get access to backlinks
This commit is contained in:
@@ -582,14 +582,33 @@ describe('MCPServer', () => {
|
||||
expect(JSON.parse(result.contents[0].text)).toEqual(stats);
|
||||
});
|
||||
|
||||
it('bds://posts/{id} calls getPost with correct id', async () => {
|
||||
it('bds://posts/{id} calls getPost with correct id and includes backlinks', async () => {
|
||||
const post = { id: 'post-1', title: 'Test' };
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'p2', title: 'Referring Post', slug: 'referring-post' },
|
||||
]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tpl = getResourceTemplate(mcpServer, 'post');
|
||||
const result = await tpl.readCallback(new URL('bds://posts/post-1'), { id: 'post-1' }, {}) as { contents: Array<{ text: string }> };
|
||||
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
||||
expect(JSON.parse(result.contents[0].text)).toEqual(post);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('post-1');
|
||||
const parsed = JSON.parse(result.contents[0].text);
|
||||
expect(parsed.id).toBe('post-1');
|
||||
expect(parsed.backlinks).toEqual([
|
||||
{ id: 'p2', title: 'Referring Post', slug: 'referring-post' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('bds://posts/{id} returns empty backlinks when none exist', async () => {
|
||||
const post = { id: 'post-1', title: 'Test' };
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tpl = getResourceTemplate(mcpServer, 'post');
|
||||
const result = await tpl.readCallback(new URL('bds://posts/post-1'), { id: 'post-1' }, {}) as { contents: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.contents[0].text);
|
||||
expect(parsed.backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('posts-page template decodes cursor and passes offset to getAllPosts', async () => {
|
||||
@@ -694,14 +713,16 @@ describe('MCPServer', () => {
|
||||
return (mcpServer as Record<string, Record<string, { handler: (...args: unknown[]) => Promise<unknown> }>>)._registeredTools[name];
|
||||
}
|
||||
|
||||
it('search_posts with query only calls searchPosts', async () => {
|
||||
it('search_posts with query only calls searchPosts and includes backlinks', async () => {
|
||||
const searchResults = [{ id: 'p1', title: 'Found', slug: 'found' }];
|
||||
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'px', title: 'Ref', slug: 'ref' }]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
|
||||
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test');
|
||||
expect(JSON.parse(result.content[0].text)).toEqual(searchResults);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed[0].backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]);
|
||||
});
|
||||
|
||||
it('search_posts with query applies offset and limit', async () => {
|
||||
@@ -726,14 +747,17 @@ describe('MCPServer', () => {
|
||||
expect(parsed).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('search_posts with filters only calls getPostsFiltered', async () => {
|
||||
it('search_posts with filters only calls getPostsFiltered and includes backlinks', async () => {
|
||||
const filtered = [{ id: 'p2', title: 'Filtered' }];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
|
||||
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' });
|
||||
expect(JSON.parse(result.content[0].text)).toEqual(filtered);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed[0].backlinks).toEqual([]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p2');
|
||||
});
|
||||
|
||||
it('search_posts with filters applies offset and limit', async () => {
|
||||
@@ -747,11 +771,12 @@ describe('MCPServer', () => {
|
||||
expect(parsed[0].id).toBe('p3');
|
||||
});
|
||||
|
||||
it('search_posts with query + filters calls searchPostsFiltered', async () => {
|
||||
it('search_posts with query + filters calls searchPostsFiltered and includes backlinks', async () => {
|
||||
const combined = [
|
||||
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
|
||||
];
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> };
|
||||
@@ -766,6 +791,7 @@ describe('MCPServer', () => {
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].id).toBe('p1');
|
||||
expect(parsed[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
|
||||
});
|
||||
|
||||
it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => {
|
||||
|
||||
179
tests/engine/OpenCodeManagerTools.test.ts
Normal file
179
tests/engine/OpenCodeManagerTools.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* OpenCodeManager Tool Execution Tests
|
||||
*
|
||||
* Tests the executeTool method for post-related tools,
|
||||
* specifically that backlinks are included in results.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the class
|
||||
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
||||
ChatEngine: class {
|
||||
getSetting = vi.fn();
|
||||
setSetting = vi.fn();
|
||||
getSelectedModel = vi.fn();
|
||||
getDefaultSystemPrompt = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
||||
getMediaEngine: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
|
||||
|
||||
function createMockPostEngine() {
|
||||
return {
|
||||
getPost: vi.fn(),
|
||||
searchPosts: vi.fn(),
|
||||
searchPostsFiltered: vi.fn(),
|
||||
getAllPosts: vi.fn(),
|
||||
getPostsFiltered: vi.fn(),
|
||||
getDashboardStats: vi.fn().mockResolvedValue({ totalPosts: 0 }),
|
||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getTagsWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
|
||||
getBlogStats: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMediaEngine() {
|
||||
return {
|
||||
getAllMedia: vi.fn(),
|
||||
getMedia: vi.fn(),
|
||||
getThumbnailDataUrl: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPostMediaEngine() {
|
||||
return {
|
||||
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
|
||||
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(postEngine: ReturnType<typeof createMockPostEngine>, mediaEngine?: ReturnType<typeof createMockMediaEngine>, postMediaEngine?: ReturnType<typeof createMockPostMediaEngine>) {
|
||||
const manager = new OpenCodeManager(
|
||||
{ getSetting: vi.fn(), setSetting: vi.fn() } as never,
|
||||
postEngine as never,
|
||||
(mediaEngine ?? createMockMediaEngine()) as never,
|
||||
(postMediaEngine ?? createMockPostMediaEngine()) as never,
|
||||
() => null,
|
||||
);
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe('OpenCodeManager tool execution – backlinks', () => {
|
||||
let mockPostEngine: ReturnType<typeof createMockPostEngine>;
|
||||
let manager: OpenCodeManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPostEngine = createMockPostEngine();
|
||||
manager = createManager(mockPostEngine);
|
||||
});
|
||||
|
||||
describe('read_post', () => {
|
||||
it('includes backlinks in the response', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Target Post', slug: 'target-post',
|
||||
content: '# Hello', excerpt: 'Hello', status: 'published',
|
||||
author: 'Test', categories: ['article'], tags: ['test'],
|
||||
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
|
||||
publishedAt: new Date('2025-01-01'),
|
||||
};
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.post.backlinks).toEqual([
|
||||
{ id: 'p2', title: 'Linking Post A', slug: 'linking-a' },
|
||||
{ id: 'p3', title: 'Linking Post B', slug: 'linking-b' },
|
||||
]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('returns empty backlinks array when no backlinks exist', async () => {
|
||||
const post = {
|
||||
id: 'p1', title: 'Lonely Post', slug: 'lonely-post',
|
||||
content: '# Alone', excerpt: '', status: 'draft',
|
||||
categories: [], tags: [],
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
mockPostEngine.getPost.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('read_post', { postId: 'p1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.post.backlinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_posts', () => {
|
||||
it('includes backlinks for each post in search results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(posts);
|
||||
mockPostEngine.getLinkedBy
|
||||
.mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await (manager as any).executeTool('search_posts', { query: 'test' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]);
|
||||
expect(result.posts[1].backlinks).toEqual([]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_posts', () => {
|
||||
it('includes backlinks for each post in listed results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getAllPosts.mockResolvedValue({ items: posts, total: 1 });
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||
{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' },
|
||||
]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('includes backlinks for filtered list results', async () => {
|
||||
const posts = [
|
||||
{ id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(posts);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
|
||||
const result = await (manager as any).executeTool('list_posts', { tags: ['js'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.posts[0].backlinks).toEqual([]);
|
||||
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user