mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
- Remove obsolete instruction documents (PROMPT_*.md, SECURITY_FIXES.md) - Update CLAUDE.md with security features, test suite, audit logs - Merge Techstack_Dokumentation into INFRASTRUCTURE.md - Update SECURITY.md with custom login route documentation - Add changelog to TODO.md - Update email service and data masking for SMTP error handling - Extend test coverage for CSRF and data masking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
8.8 KiB
TypeScript
334 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',
|
|
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)
|
|
})
|
|
})
|