import { getPayload, Payload } from 'payload' import config from '@/payload.config' import { describe, it, beforeAll, afterAll, expect } from 'vitest' import { searchPosts, getSearchSuggestions, getPostsByCategory, searchCache, suggestionCache, extractTextFromLexical, } from '@/lib/search' import { searchLimiter } from '@/lib/security' let payload: Payload // Test data IDs for cleanup const testIds: { posts: number[]; categories: number[]; tenants: number[] } = { posts: [], categories: [], tenants: [], } describe('Search Library', () => { beforeAll(async () => { const payloadConfig = await config payload = await getPayload({ config: payloadConfig }) // Clear caches before tests searchCache.clear() suggestionCache.clear() }) afterAll(async () => { // Cleanup test data for (const postId of testIds.posts) { try { await payload.delete({ collection: 'posts', id: postId }) } catch { // Ignore cleanup errors } } for (const catId of testIds.categories) { try { await payload.delete({ collection: 'categories', id: catId }) } catch { // Ignore cleanup errors } } }) describe('extractTextFromLexical', () => { it('extracts text from Lexical JSON structure', () => { const lexicalContent = { root: { children: [ { type: 'paragraph', children: [{ type: 'text', text: 'Hello' }, { type: 'text', text: ' World' }], }, { type: 'paragraph', children: [{ type: 'text', text: 'Another paragraph' }], }, ], }, } const result = extractTextFromLexical(lexicalContent) expect(result).toContain('Hello') expect(result).toContain('World') expect(result).toContain('Another paragraph') }) it('returns empty string for null/undefined content', () => { expect(extractTextFromLexical(null)).toBe('') expect(extractTextFromLexical(undefined)).toBe('') expect(extractTextFromLexical({})).toBe('') }) }) describe('searchLimiter (central rate limiter)', () => { it('allows requests within limit', async () => { const testIp = `test-${Date.now()}-1` const result = await searchLimiter.check(testIp) expect(result.allowed).toBe(true) expect(result.remaining).toBe(29) // 30 - 1 }) it('blocks requests exceeding limit', async () => { const testIp = `test-${Date.now()}-2` // Use up all requests for (let i = 0; i < 30; i++) { await searchLimiter.check(testIp) } const result = await searchLimiter.check(testIp) expect(result.allowed).toBe(false) expect(result.remaining).toBe(0) expect(result.retryAfter).toBeGreaterThan(0) }) }) // Skip searchPosts tests until localization migration is complete describe.skip('searchPosts (requires migration)', () => { it('returns empty results for non-matching query', async () => { const result = await searchPosts(payload, { query: 'xyznonexistent12345', limit: 10, offset: 0, }) expect(result.results).toEqual([]) expect(result.total).toBe(0) expect(result.query).toBe('xyznonexistent12345') }) it('respects limit parameter', async () => { const result = await searchPosts(payload, { query: '', limit: 5, offset: 0, }) expect(result.pagination.limit).toBe(5) expect(result.results.length).toBeLessThanOrEqual(5) }) it('caches search results', async () => { // Clear cache first searchCache.clear() const params = { query: 'test-cache', limit: 10, offset: 0 } // First call const result1 = await searchPosts(payload, params) // Second call should use cache const result2 = await searchPosts(payload, params) expect(result1).toEqual(result2) }) }) describe('getSearchSuggestions', () => { it('returns empty array for short query', async () => { const result = await getSearchSuggestions(payload, { query: 'a', limit: 5, }) expect(result).toEqual([]) }) // Skip tests that require localization migration it.skip('respects limit parameter (requires migration)', async () => { const result = await getSearchSuggestions(payload, { query: 'test', limit: 3, }) expect(result.length).toBeLessThanOrEqual(3) }) }) // Skip tests that require localization migration describe.skip('getPostsByCategory (requires migration)', () => { it('returns paginated results', async () => { const result = await getPostsByCategory(payload, { page: 1, limit: 10, }) expect(result).toHaveProperty('docs') expect(result).toHaveProperty('totalDocs') expect(result).toHaveProperty('page') expect(result).toHaveProperty('totalPages') expect(result).toHaveProperty('hasNextPage') expect(result).toHaveProperty('hasPrevPage') }) it('filters by type', async () => { const result = await getPostsByCategory(payload, { type: 'blog', page: 1, limit: 10, }) // All returned posts should be of type 'blog' (or empty if none exist) for (const post of result.docs) { const postWithType = post as typeof post & { type?: string } if (postWithType.type) { expect(postWithType.type).toBe('blog') } } }) }) }) // Skip Search API Integration tests until localization migration is complete describe.skip('Search API Integration (requires migration)', () => { let testCategoryId: number | null = null let testPostId: number | null = null let testTenantId: number | null = null beforeAll(async () => { const payloadConfig = await config payload = await getPayload({ config: payloadConfig }) // Get existing tenant (required by multi-tenant plugin) try { const tenants = await payload.find({ collection: 'tenants', limit: 1, }) if (tenants.docs.length > 0) { testTenantId = tenants.docs[0].id } } catch { // No tenants available } if (!testTenantId) { console.warn('No tenant available for integration tests') return } // Create test category try { const category = await payload.create({ collection: 'categories', data: { name: 'Test Search Category', slug: `test-search-category-${Date.now()}`, tenant: testTenantId, }, }) testCategoryId = category.id testIds.categories.push(category.id) } catch { // Category might already exist } // Create test post try { const post = await payload.create({ collection: 'posts', data: { title: 'Searchable Test Post Title', slug: `searchable-test-post-${Date.now()}`, excerpt: 'This is a searchable excerpt for testing', status: 'published', publishedAt: new Date().toISOString(), tenant: testTenantId, content: { root: { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', text: 'Test content for search' }], direction: null, format: '', indent: 0, version: 1, }, ], direction: null, format: '', indent: 0, version: 1, }, }, ...(testCategoryId ? { category: testCategoryId } : {}), }, }) testPostId = post.id testIds.posts.push(post.id) } catch (error) { console.error('Failed to create test post:', error) } }) it('finds posts by title search', async () => { if (!testPostId) { console.warn('Test post not created, skipping test') return } // Clear cache to ensure fresh search searchCache.clear() const result = await searchPosts(payload, { query: 'Searchable Test Post', limit: 10, offset: 0, }) expect(result.results.length).toBeGreaterThan(0) expect(result.results.some((r) => r.id === testPostId)).toBe(true) }) it('finds posts by excerpt search', async () => { if (!testPostId) { console.warn('Test post not created, skipping test') return } searchCache.clear() const result = await searchPosts(payload, { query: 'searchable excerpt', limit: 10, offset: 0, }) expect(result.results.length).toBeGreaterThan(0) expect(result.results.some((r) => r.id === testPostId)).toBe(true) }) it('returns suggestions for valid prefix', async () => { if (!testPostId) { console.warn('Test post not created, skipping test') return } suggestionCache.clear() const suggestions = await getSearchSuggestions(payload, { query: 'Searchable', limit: 5, }) expect(suggestions.length).toBeGreaterThan(0) expect(suggestions.some((s) => s.title.includes('Searchable'))).toBe(true) }) })