cms.c2sgmbh/tests/int/search.int.spec.ts

336 lines
8.8 KiB
TypeScript

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)
})
})
describe('searchPosts', () => {
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([])
})
it('respects limit parameter', async () => {
const result = await getSearchSuggestions(payload, {
query: 'test',
limit: 3,
})
expect(result.length).toBeLessThanOrEqual(3)
})
})
describe('getPostsByCategory', () => {
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')
}
}
})
})
})
describe('Search API Integration', () => {
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',
draft: false,
data: {
title: 'Searchable Test Post Title',
slug: `searchable-test-post-${Date.now()}`,
excerpt: 'This is a searchable excerpt for testing',
type: 'blog',
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 ? { categories: [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)
})
})