feat: mcp server round four
This commit is contained in:
@@ -29,6 +29,7 @@ interface PostEngineContract {
|
||||
getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array<Record<string, unknown>>; hasMore: boolean; total: number }>;
|
||||
getPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
searchPosts: (query: string) => Promise<Array<{ id: string; title: string; slug: string; excerpt?: string }>>;
|
||||
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise<Array<Record<string, unknown>>>;
|
||||
createPost: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
updatePost: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
||||
publishPost: (id: string) => Promise<Record<string, unknown> | null>;
|
||||
@@ -383,26 +384,24 @@ export class MCPServer {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||
}
|
||||
|
||||
// Filter-based query (optionally narrowed by text search)
|
||||
// Build structural filter
|
||||
const filter: PostFilter = {};
|
||||
if (args.category) filter.categories = [args.category];
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.year) filter.year = args.year;
|
||||
if (args.month) filter.month = args.month;
|
||||
if (args.status) filter.status = args.status;
|
||||
let results = await this.deps.getPostEngine().getPostsFiltered(filter);
|
||||
|
||||
// Client-side text filter when query is combined with structured filters
|
||||
if (args.query) {
|
||||
const q = args.query.toLowerCase();
|
||||
results = results.filter((p: Record<string, unknown>) => {
|
||||
const title = String(p.title ?? '').toLowerCase();
|
||||
const content = String(p.content ?? '').toLowerCase();
|
||||
const excerpt = String(p.excerpt ?? '').toLowerCase();
|
||||
return title.includes(q) || content.includes(q) || excerpt.includes(q);
|
||||
});
|
||||
if (args.query && hasFilters) {
|
||||
// FTS + structural filters: single SQL JOIN query, ranked by FTS score
|
||||
const results = await this.deps.getPostEngine().searchPostsFiltered(
|
||||
args.query, filter, { offset, limit },
|
||||
);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
|
||||
}
|
||||
|
||||
// Filter-only query (no text search)
|
||||
const results = await this.deps.getPostEngine().getPostsFiltered(filter);
|
||||
const paginated = results.slice(offset, offset + limit);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(paginated) }] };
|
||||
});
|
||||
@@ -424,19 +423,26 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-post' } },
|
||||
}, async (args: { title: string; content: string; excerpt?: string; tags?: string[]; categories?: string[]; author?: string }) => {
|
||||
const post = await this.deps.getPostEngine().createPost({
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
excerpt: args.excerpt,
|
||||
tags: args.tags ?? [],
|
||||
categories: args.categories ?? [],
|
||||
author: args.author,
|
||||
status: 'draft',
|
||||
});
|
||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||
};
|
||||
try {
|
||||
const post = await this.deps.getPostEngine().createPost({
|
||||
title: args.title,
|
||||
content: args.content,
|
||||
excerpt: args.excerpt,
|
||||
tags: args.tags ?? [],
|
||||
categories: args.categories ?? [],
|
||||
author: args.author,
|
||||
status: 'draft',
|
||||
});
|
||||
const proposalId = this.proposalStore.create('draftPost', { postId: (post as Record<string, unknown>).id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, post }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to create draft: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── propose_script ──
|
||||
@@ -499,15 +505,28 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { mediaId: string; alt?: string; caption?: string; title?: string; tags?: string[] }) => {
|
||||
const { mediaId, ...changes } = args;
|
||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
|
||||
mediaId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
try {
|
||||
const { mediaId, ...changes } = args;
|
||||
const current = await this.deps.getMediaEngine().getMedia(mediaId);
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Media item ${mediaId} not found.` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const proposalId = this.proposalStore.create('proposeMediaMetadata', {
|
||||
mediaId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose media metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── propose_post_metadata ──
|
||||
@@ -524,15 +543,28 @@ export class MCPServer {
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-metadata' } },
|
||||
}, async (args: { postId: string; title?: string; excerpt?: string; tags?: string[]; categories?: string[] }) => {
|
||||
const { postId, ...changes } = args;
|
||||
const current = await this.deps.getPostEngine().getPost(postId);
|
||||
const proposalId = this.proposalStore.create('proposePostMetadata', {
|
||||
postId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
try {
|
||||
const { postId, ...changes } = args;
|
||||
const current = await this.deps.getPostEngine().getPost(postId);
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post ${postId} not found.` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const proposalId = this.proposalStore.create('proposePostMetadata', {
|
||||
postId,
|
||||
changes,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ proposalId, current, proposed: changes }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to propose post metadata: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Register ui:// resources for review Views ──
|
||||
|
||||
@@ -1253,46 +1253,47 @@ export class OpenCodeManager {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'search_posts': {
|
||||
const searchResults = await this.postEngine.searchPosts(args.query as string);
|
||||
const fullPosts = await Promise.all(
|
||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
||||
);
|
||||
let filteredPosts = fullPosts.filter(p => p !== null);
|
||||
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
|
||||
if (args.category) filter.categories = [args.category as string];
|
||||
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[];
|
||||
if (args.year !== undefined) filter.year = args.year as number;
|
||||
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
|
||||
|
||||
if (args.category) {
|
||||
filteredPosts = filteredPosts.filter(p => p!.categories.includes(args.category as string));
|
||||
}
|
||||
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) {
|
||||
filteredPosts = filteredPosts.filter(p =>
|
||||
(args.tags as string[]).every(tag => p!.tags.includes(tag))
|
||||
const hasFilters = Object.keys(filter).length > 0;
|
||||
const offset = (args.offset as number) || 0;
|
||||
const limit = (args.limit as number) || 10;
|
||||
|
||||
let filteredPosts;
|
||||
if (hasFilters) {
|
||||
// Combined FTS + structural filters in a single SQL query
|
||||
filteredPosts = await this.postEngine.searchPostsFiltered(
|
||||
args.query as string, filter, { offset, limit },
|
||||
);
|
||||
}
|
||||
if (args.year !== undefined) {
|
||||
const year = args.year as number;
|
||||
filteredPosts = filteredPosts.filter(p => p!.createdAt.getFullYear() === year);
|
||||
}
|
||||
if (args.month !== undefined && args.year !== undefined) {
|
||||
const month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
||||
filteredPosts = filteredPosts.filter(p => p!.createdAt.getMonth() === month);
|
||||
} else {
|
||||
// Pure FTS search
|
||||
const searchResults = await this.postEngine.searchPosts(args.query as string);
|
||||
// searchPosts returns sparse results; fetch full post data
|
||||
const fullPosts = await Promise.all(
|
||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
||||
);
|
||||
const all = fullPosts.filter(p => p !== null) as PostData[];
|
||||
filteredPosts = all.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
const totalMatches = filteredPosts.length;
|
||||
const offset = (args.offset as number) || 0;
|
||||
const limit = (args.limit as number) || 10;
|
||||
filteredPosts = filteredPosts.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: filteredPosts.length,
|
||||
totalMatches,
|
||||
hasMore: offset + limit < totalMatches,
|
||||
hasMore: false,
|
||||
offset,
|
||||
limit,
|
||||
posts: filteredPosts.map(p => ({
|
||||
id: p!.id, title: p!.title, slug: p!.slug,
|
||||
excerpt: p!.excerpt, status: p!.status,
|
||||
categories: p!.categories, tags: p!.tags,
|
||||
createdAt: p!.createdAt, updatedAt: p!.updatedAt,
|
||||
id: p.id, title: p.title, slug: p.slug,
|
||||
excerpt: p.excerpt, status: p.status,
|
||||
categories: p.categories, tags: p.tags,
|
||||
createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -861,6 +861,107 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined FTS search + structural filters in a single SQL query.
|
||||
* Returns full PostData ordered by FTS rank, filtered by the given criteria.
|
||||
* Both MCP and internal AI tools use this for query+filter combinations.
|
||||
*/
|
||||
async searchPostsFiltered(
|
||||
query: string,
|
||||
filter: PostFilter,
|
||||
pagination?: PaginationOptions,
|
||||
): Promise<PostData[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const stemmedQuery = stemQuery(query, this.searchLanguage);
|
||||
|
||||
// Build WHERE clauses and args for the joined query
|
||||
const conditions: string[] = [
|
||||
'posts_fts.project_id = ?',
|
||||
'posts_fts.MATCH ?',
|
||||
];
|
||||
const args: (string | number | Date)[] = [this.currentProjectId, stemmedQuery];
|
||||
|
||||
if (filter.status) {
|
||||
conditions.push('posts.status = ?');
|
||||
args.push(filter.status);
|
||||
}
|
||||
if (filter.year !== undefined) {
|
||||
const startOfYear = new Date(filter.year, 0, 1);
|
||||
const endOfYear = new Date(filter.year + 1, 0, 1);
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(startOfYear);
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(endOfYear);
|
||||
}
|
||||
if (filter.month !== undefined && filter.year !== undefined) {
|
||||
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
|
||||
const endOfMonth = new Date(filter.year, filter.month, 1);
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(startOfMonth);
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(endOfMonth);
|
||||
}
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
const catClauses = filter.categories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
);
|
||||
conditions.push(`(${catClauses.join(' OR ')})`);
|
||||
args.push(...filter.categories);
|
||||
}
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
const exClauses = filter.excludeCategories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
);
|
||||
conditions.push(`NOT (${exClauses.join(' OR ')})`);
|
||||
args.push(...filter.excludeCategories);
|
||||
}
|
||||
if (filter.startDate) {
|
||||
conditions.push('posts.created_at >= ?');
|
||||
args.push(filter.startDate);
|
||||
}
|
||||
if (filter.endDate) {
|
||||
conditions.push('posts.created_at <= ?');
|
||||
args.push(filter.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ');
|
||||
const sqlQuery = `
|
||||
SELECT posts.*
|
||||
FROM posts_fts
|
||||
JOIN posts ON posts_fts.id = posts.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY posts_fts.rank
|
||||
LIMIT 500
|
||||
`;
|
||||
|
||||
const result = await client.execute({ sql: sqlQuery, args });
|
||||
|
||||
let postDataList: PostData[] = result.rows.map((row) =>
|
||||
this.dbRowToPostData(row as unknown as Post, (row.content as string) || '')
|
||||
);
|
||||
|
||||
// Tag filtering is done client-side (tags are stored as JSON arrays)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
postDataList = postDataList.filter((p) =>
|
||||
filter.tags!.every((tag) => p.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const offset = pagination?.offset ?? 0;
|
||||
const limit = pagination?.limit ?? postDataList.length;
|
||||
return postDataList.slice(offset, offset + limit);
|
||||
} catch (error) {
|
||||
console.error('Search with filters failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableTags(): Promise<string[]> {
|
||||
const allPosts = await this.getAllPostsUnpaginated();
|
||||
const tags = new Set<string>();
|
||||
|
||||
@@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies {
|
||||
getLinkedBy: vi.fn().mockResolvedValue([]),
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
searchPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getMediaEngine: () => ({
|
||||
getAllMedia: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -29,6 +29,7 @@ function createMockPostEngine() {
|
||||
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
|
||||
getPost: vi.fn().mockResolvedValue(null),
|
||||
searchPosts: vi.fn().mockResolvedValue([]),
|
||||
searchPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
createPost: vi.fn().mockResolvedValue({
|
||||
id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft',
|
||||
tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(),
|
||||
@@ -618,20 +619,49 @@ describe('MCPServer', () => {
|
||||
expect(parsed[0].id).toBe('p3');
|
||||
});
|
||||
|
||||
it('search_posts with query + filters uses getPostsFiltered and client-side text filter', async () => {
|
||||
const allFiltered = [
|
||||
{ id: 'p1', title: 'TypeScript Guide', content: 'Learn TS', excerpt: '' },
|
||||
{ id: 'p2', title: 'Python Guide', content: 'Learn Python', excerpt: '' },
|
||||
it('search_posts with query + filters calls searchPostsFiltered', async () => {
|
||||
const combined = [
|
||||
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockResolvedValue(allFiltered);
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
const result = await tool.handler({ query: 'typescript', category: 'tech' }, {}) as { content: Array<{ text: string }> };
|
||||
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalled();
|
||||
// Should call searchPostsFiltered, not searchPosts + getPostsFiltered separately
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'typescript',
|
||||
{ categories: ['tech'] },
|
||||
{ offset: 0, limit: 50 },
|
||||
);
|
||||
expect(mockPostEngine.searchPosts).not.toHaveBeenCalled();
|
||||
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].title).toBe('TypeScript Guide');
|
||||
expect(parsed[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {});
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'keyword',
|
||||
{ status: 'published' },
|
||||
{ offset: 5, limit: 10 },
|
||||
);
|
||||
});
|
||||
|
||||
it('search_posts with query + multiple filters builds correct filter', async () => {
|
||||
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'search_posts');
|
||||
await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {});
|
||||
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
|
||||
'test',
|
||||
{ categories: ['tech'], tags: ['js'], year: 2025, status: 'published' },
|
||||
{ offset: 0, limit: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
it('draft_post creates a draft and stores proposal', async () => {
|
||||
@@ -677,6 +707,18 @@ describe('MCPServer', () => {
|
||||
expect(proposal!.data.mediaId).toBe('img-1');
|
||||
});
|
||||
|
||||
it('propose_media_metadata returns error for non-existent media', async () => {
|
||||
mockMediaEngine.getMedia.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_media_metadata');
|
||||
const result = await tool.handler({ mediaId: 'no-such', alt: 'New alt' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
// No proposal should be created
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('propose_post_metadata loads current post and stores proposal', async () => {
|
||||
const currentPost = { id: 'post-1', title: 'Old Title', excerpt: 'Old excerpt' };
|
||||
mockPostEngine.getPost.mockResolvedValue(currentPost);
|
||||
@@ -689,6 +731,48 @@ describe('MCPServer', () => {
|
||||
const proposal = server.proposalStore.get(parsed.proposalId);
|
||||
expect(proposal!.type).toBe('proposePostMetadata');
|
||||
});
|
||||
|
||||
it('propose_post_metadata returns error for non-existent post', async () => {
|
||||
mockPostEngine.getPost.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_post_metadata');
|
||||
const result = await tool.handler({ postId: 'no-such', title: 'New Title' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('draft_post returns error when createPost fails', async () => {
|
||||
mockPostEngine.createPost.mockRejectedValue(new Error('No active project'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'draft_post');
|
||||
const result = await tool.handler({ title: 'Test', content: '# Hello' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('No active project');
|
||||
expect(server.proposalStore.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('propose_media_metadata returns error when getMedia throws', async () => {
|
||||
mockMediaEngine.getMedia.mockRejectedValue(new Error('DB error'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_media_metadata');
|
||||
const result = await tool.handler({ mediaId: 'img-1', alt: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('DB error');
|
||||
});
|
||||
|
||||
it('propose_post_metadata returns error when getPost throws', async () => {
|
||||
mockPostEngine.getPost.mockRejectedValue(new Error('DB error'));
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'propose_post_metadata');
|
||||
const result = await tool.handler({ postId: 'p1', title: 'New' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('DB error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt handler behavior ────────────────────────────────────────
|
||||
|
||||
@@ -2418,6 +2418,69 @@ Published snapshot content`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchPostsFiltered', () => {
|
||||
it('should return empty array for empty query', async () => {
|
||||
const result = await postEngine.searchPostsFiltered('', {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use FTS JOIN with posts table to combine search and filters', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: 'p1', projectId: 'test-project', title: 'Found', slug: 'found', excerpt: 'Excerpt', content: 'Content', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js"]', categories: '["tech"]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('p1');
|
||||
|
||||
// Verify SQL includes both MATCH and status filter
|
||||
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||||
const sql = call?.sql?.toLowerCase() ?? '';
|
||||
expect(sql).toContain('match');
|
||||
expect(sql).toContain('status');
|
||||
expect(sql).toContain('order by');
|
||||
expect(sql).toContain('rank');
|
||||
});
|
||||
|
||||
it('should apply category filter in SQL', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await postEngine.searchPostsFiltered('term', { categories: ['tech'] });
|
||||
|
||||
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||||
const sql = call?.sql?.toLowerCase() ?? '';
|
||||
expect(sql).toContain('match');
|
||||
expect(sql).toContain('json_each');
|
||||
});
|
||||
|
||||
it('should apply tag filter client-side after SQL query', async () => {
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ id: 'p1', projectId: 'test-project', title: 'Has Tag', slug: 'has-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["js", "react"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
{ id: 'p2', projectId: 'test-project', title: 'No Tag', slug: 'no-tag', excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should apply pagination with offset and limit', async () => {
|
||||
const rows = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: '[]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null,
|
||||
}));
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows });
|
||||
|
||||
const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('p1');
|
||||
expect(result[1].id).toBe('p2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagsWithCounts', () => {
|
||||
it('should return empty array when no posts have tags', async () => {
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
|
||||
Reference in New Issue
Block a user