diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index 838b763..c167e97 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -29,6 +29,7 @@ interface PostEngineContract { getAllPosts: (options?: { limit?: number; offset?: number }) => Promise<{ items: Array>; hasMore: boolean; total: number }>; getPost: (id: string) => Promise | null>; searchPosts: (query: string) => Promise>; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: { offset?: number; limit?: number }) => Promise>>; createPost: (data: Record) => Promise>; updatePost: (id: string, data: Record) => Promise | null>; publishPost: (id: string) => Promise | 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) => { - 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).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).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 ── diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 124301d..a3987a4 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -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, })), }; } diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index cf11f8f..948cc06 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -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 { + 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 { const allPosts = await this.getAllPostsUnpaginated(); const tags = new Set(); diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts index 86db727..aa31804 100644 --- a/tests/engine/MCPServer.integration.test.ts +++ b/tests/engine/MCPServer.integration.test.ts @@ -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([]), diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 9cf4070..909f8f6 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -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 ──────────────────────────────────────── diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index aad10a6..3bc381b 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -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(() => {