feat: agents get access to backlinks
This commit is contained in:
@@ -429,8 +429,11 @@ export class MCPServer {
|
|||||||
|
|
||||||
// ── Entity templates ──
|
// ── Entity templates ──
|
||||||
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => {
|
||||||
const result = await this.deps.postEngine.getPost(id as string);
|
const postId = id as string;
|
||||||
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result) }] };
|
const result = await this.deps.postEngine.getPost(postId);
|
||||||
|
const backlinks = await this.deps.postEngine.getLinkedBy(postId);
|
||||||
|
const enriched = { ...result, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) };
|
||||||
|
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(enriched) }] };
|
||||||
});
|
});
|
||||||
|
|
||||||
server.registerResource('media-item', new ResourceTemplate('bds://media/{id}', { list: undefined }), { description: 'A single media item by ID' }, async (uri, { id }) => {
|
server.registerResource('media-item', new ResourceTemplate('bds://media/{id}', { list: undefined }), { description: 'A single media item by ID' }, async (uri, { id }) => {
|
||||||
@@ -480,7 +483,7 @@ export class MCPServer {
|
|||||||
private registerReadTools(server: McpServer): void {
|
private registerReadTools(server: McpServer): void {
|
||||||
server.registerTool('search_posts', {
|
server.registerTool('search_posts', {
|
||||||
title: 'Search Posts',
|
title: 'Search Posts',
|
||||||
description: 'Search blog posts by query, category, tags, or date range.',
|
description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it).',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
query: z.string().optional().describe('Full-text search query'),
|
query: z.string().optional().describe('Full-text search query'),
|
||||||
category: z.string().optional().describe('Filter by category'),
|
category: z.string().optional().describe('Filter by category'),
|
||||||
@@ -497,11 +500,20 @@ export class MCPServer {
|
|||||||
const offset = args.offset ?? 0;
|
const offset = args.offset ?? 0;
|
||||||
const limit = args.limit ?? 50;
|
const limit = args.limit ?? 50;
|
||||||
|
|
||||||
|
// Helper: enrich posts with backlinks
|
||||||
|
const enrichWithBacklinks = async <T extends { id: string }>(posts: T[]) => {
|
||||||
|
return Promise.all(posts.map(async (p) => {
|
||||||
|
const backlinks = await this.deps.postEngine.getLinkedBy(p.id);
|
||||||
|
return { ...p, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
if (args.query && !hasFilters) {
|
if (args.query && !hasFilters) {
|
||||||
// Pure text search — use FTS
|
// Pure text search — use FTS
|
||||||
const results = await this.deps.postEngine.searchPosts(args.query);
|
const results = await this.deps.postEngine.searchPosts(args.query);
|
||||||
const paginated = results.slice(offset, offset + limit);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
const enriched = await enrichWithBacklinks(paginated);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build structural filter
|
// Build structural filter
|
||||||
@@ -517,13 +529,15 @@ export class MCPServer {
|
|||||||
const results = await this.deps.postEngine.searchPostsFiltered(
|
const results = await this.deps.postEngine.searchPostsFiltered(
|
||||||
args.query, filter, { offset, limit },
|
args.query, filter, { offset, limit },
|
||||||
);
|
);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
const enriched = await enrichWithBacklinks(results);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter-only query (no text search)
|
// Filter-only query (no text search)
|
||||||
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
||||||
const paginated = results.slice(offset, offset + limit);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
const enriched = await enrichWithBacklinks(paginated);
|
||||||
|
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ export class OpenCodeManager {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'read_post',
|
name: 'read_post',
|
||||||
description: 'Read the full content and metadata of a specific blog post by its ID.',
|
description: 'Read the full content and metadata of a specific blog post by its ID. Includes backlinks (posts linking to this post).',
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -897,7 +897,7 @@ export class OpenCodeManager {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'list_posts',
|
name: 'list_posts',
|
||||||
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results.',
|
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results.',
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -1282,11 +1282,15 @@ export class OpenCodeManager {
|
|||||||
hasMore: false,
|
hasMore: false,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
posts: filteredPosts.map(p => ({
|
posts: await Promise.all(filteredPosts.map(async p => {
|
||||||
|
const backlinks = await this.postEngine.getLinkedBy(p.id);
|
||||||
|
return {
|
||||||
id: p.id, title: p.title, slug: p.slug,
|
id: p.id, title: p.title, slug: p.slug,
|
||||||
excerpt: p.excerpt, status: p.status,
|
excerpt: p.excerpt, status: p.status,
|
||||||
categories: p.categories, tags: p.tags,
|
categories: p.categories, tags: p.tags,
|
||||||
createdAt: p.createdAt, updatedAt: p.updatedAt,
|
createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||||
|
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||||
|
};
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1294,6 +1298,7 @@ export class OpenCodeManager {
|
|||||||
case 'read_post': {
|
case 'read_post': {
|
||||||
const post = await this.postEngine.getPost(args.postId as string);
|
const post = await this.postEngine.getPost(args.postId as string);
|
||||||
if (!post) return { success: false, error: 'Post not found' };
|
if (!post) return { success: false, error: 'Post not found' };
|
||||||
|
const backlinks = await this.postEngine.getLinkedBy(post.id);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
post: {
|
post: {
|
||||||
@@ -1303,6 +1308,7 @@ export class OpenCodeManager {
|
|||||||
categories: post.categories, tags: post.tags,
|
categories: post.categories, tags: post.tags,
|
||||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||||
publishedAt: post.publishedAt,
|
publishedAt: post.publishedAt,
|
||||||
|
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1343,10 +1349,14 @@ export class OpenCodeManager {
|
|||||||
hasMore: offset + limit < filteredTotal,
|
hasMore: offset + limit < filteredTotal,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
posts: pageItems.map(p => ({
|
posts: await Promise.all(pageItems.map(async p => {
|
||||||
|
const backlinks = await this.postEngine.getLinkedBy(p.id);
|
||||||
|
return {
|
||||||
id: p.id, title: p.title, slug: p.slug,
|
id: p.id, title: p.title, slug: p.slug,
|
||||||
status: p.status, categories: p.categories,
|
status: p.status, categories: p.categories,
|
||||||
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||||
|
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||||
|
};
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,14 +582,33 @@ describe('MCPServer', () => {
|
|||||||
expect(JSON.parse(result.contents[0].text)).toEqual(stats);
|
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' };
|
const post = { id: 'post-1', title: 'Test' };
|
||||||
mockPostEngine.getPost.mockResolvedValue(post);
|
mockPostEngine.getPost.mockResolvedValue(post);
|
||||||
|
mockPostEngine.getLinkedBy.mockResolvedValue([
|
||||||
|
{ id: 'p2', title: 'Referring Post', slug: 'referring-post' },
|
||||||
|
]);
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
const tpl = getResourceTemplate(mcpServer, 'post');
|
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 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(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 () => {
|
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];
|
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' }];
|
const searchResults = [{ id: 'p1', title: 'Found', slug: 'found' }];
|
||||||
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
mockPostEngine.searchPosts.mockResolvedValue(searchResults);
|
||||||
|
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'px', title: 'Ref', slug: 'ref' }]);
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
const tool = getTool(mcpServer, 'search_posts');
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
|
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
|
||||||
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test');
|
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 () => {
|
it('search_posts with query applies offset and limit', async () => {
|
||||||
@@ -726,14 +747,17 @@ describe('MCPServer', () => {
|
|||||||
expect(parsed).toHaveLength(50);
|
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' }];
|
const filtered = [{ id: 'p2', title: 'Filtered' }];
|
||||||
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
mockPostEngine.getPostsFiltered.mockResolvedValue(filtered);
|
||||||
|
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
const tool = getTool(mcpServer, 'search_posts');
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
|
const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
|
||||||
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' });
|
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 () => {
|
it('search_posts with filters applies offset and limit', async () => {
|
||||||
@@ -747,11 +771,12 @@ describe('MCPServer', () => {
|
|||||||
expect(parsed[0].id).toBe('p3');
|
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 = [
|
const combined = [
|
||||||
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
|
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
|
||||||
];
|
];
|
||||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
|
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
|
||||||
|
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
|
||||||
const mcpServer = server.createMcpServer();
|
const mcpServer = server.createMcpServer();
|
||||||
const tool = getTool(mcpServer, 'search_posts');
|
const tool = getTool(mcpServer, 'search_posts');
|
||||||
const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> };
|
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);
|
const parsed = JSON.parse(result.content[0].text);
|
||||||
expect(parsed).toHaveLength(1);
|
expect(parsed).toHaveLength(1);
|
||||||
expect(parsed[0].id).toBe('p1');
|
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 () => {
|
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