diff --git a/admin-lehrer/app/api/legal-corpus/__tests__/chunk-browser-logic.test.ts b/admin-lehrer/app/api/legal-corpus/__tests__/chunk-browser-logic.test.ts new file mode 100644 index 0000000..bef4a99 --- /dev/null +++ b/admin-lehrer/app/api/legal-corpus/__tests__/chunk-browser-logic.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * Tests for Chunk-Browser logic: + * - Collection dropdown has all 10 collections + * - COLLECTION_TOTALS has expected keys + * - Text search highlighting logic + * - Pagination state management + */ + +// Replicate the COMPLIANCE_COLLECTIONS from the dropdown +const COMPLIANCE_COLLECTIONS = [ + 'bp_compliance_gesetze', + 'bp_compliance_ce', + 'bp_compliance_datenschutz', + 'bp_dsfa_corpus', + 'bp_compliance_recht', + 'bp_legal_templates', + 'bp_compliance_gdpr', + 'bp_compliance_schulrecht', + 'bp_dsfa_templates', + 'bp_dsfa_risks', +] as const + +// Replicate COLLECTION_TOTALS from page.tsx +const COLLECTION_TOTALS: Record = { + bp_compliance_gesetze: 58304, + bp_compliance_ce: 18183, + bp_legal_templates: 7689, + bp_compliance_datenschutz: 2448, + bp_dsfa_corpus: 7867, + bp_compliance_recht: 1425, + bp_nibis_eh: 7996, + total_legal: 76487, + total_all: 103912, +} + +describe('Chunk-Browser Logic', () => { + describe('COMPLIANCE_COLLECTIONS', () => { + it('should have exactly 10 collections', () => { + expect(COMPLIANCE_COLLECTIONS).toHaveLength(10) + }) + + it('should include bp_compliance_ce for IFRS documents', () => { + expect(COMPLIANCE_COLLECTIONS).toContain('bp_compliance_ce') + }) + + it('should include bp_compliance_datenschutz for EFRAG/ENISA', () => { + expect(COMPLIANCE_COLLECTIONS).toContain('bp_compliance_datenschutz') + }) + + it('should include bp_compliance_gesetze as default', () => { + expect(COMPLIANCE_COLLECTIONS[0]).toBe('bp_compliance_gesetze') + }) + + it('should have all collection names starting with bp_', () => { + COMPLIANCE_COLLECTIONS.forEach((col) => { + expect(col).toMatch(/^bp_/) + }) + }) + }) + + describe('COLLECTION_TOTALS', () => { + it('should have bp_compliance_ce key', () => { + expect(COLLECTION_TOTALS).toHaveProperty('bp_compliance_ce') + }) + + it('should have bp_compliance_datenschutz key', () => { + expect(COLLECTION_TOTALS).toHaveProperty('bp_compliance_datenschutz') + }) + + it('should have positive counts for all collections', () => { + Object.values(COLLECTION_TOTALS).forEach((count) => { + expect(count).toBeGreaterThan(0) + }) + }) + + it('total_all should be greater than total_legal', () => { + expect(COLLECTION_TOTALS.total_all).toBeGreaterThan(COLLECTION_TOTALS.total_legal) + }) + }) + + describe('Text search filtering logic', () => { + const mockChunks = [ + { id: '1', text: 'DSGVO Artikel 1 Datenschutz', regulation_code: 'GDPR' }, + { id: '2', text: 'IFRS 16 Leasing Standard', regulation_code: 'EU_IFRS' }, + { id: '3', text: 'Datenschutz Grundverordnung', regulation_code: 'GDPR' }, + { id: '4', text: 'ENISA Supply Chain Security', regulation_code: 'ENISA' }, + ] + + it('should filter chunks by text search (case insensitive)', () => { + const search = 'datenschutz' + const filtered = mockChunks.filter((c) => + c.text.toLowerCase().includes(search.toLowerCase()) + ) + expect(filtered).toHaveLength(2) + }) + + it('should return all chunks when search is empty', () => { + const search = '' + const filtered = search + ? mockChunks.filter((c) => c.text.toLowerCase().includes(search.toLowerCase())) + : mockChunks + expect(filtered).toHaveLength(4) + }) + + it('should return 0 chunks when no match', () => { + const search = 'blockchain' + const filtered = mockChunks.filter((c) => + c.text.toLowerCase().includes(search.toLowerCase()) + ) + expect(filtered).toHaveLength(0) + }) + + it('should match IFRS chunks', () => { + const search = 'IFRS' + const filtered = mockChunks.filter((c) => + c.text.toLowerCase().includes(search.toLowerCase()) + ) + expect(filtered).toHaveLength(1) + expect(filtered[0].regulation_code).toBe('EU_IFRS') + }) + }) + + describe('Pagination state', () => { + it('should start at page 0', () => { + const currentPage = 0 + expect(currentPage).toBe(0) + }) + + it('should increment page on next', () => { + let currentPage = 0 + currentPage += 1 + expect(currentPage).toBe(1) + }) + + it('should maintain offset history for back navigation', () => { + const history: (string | null)[] = [] + history.push(null) // page 0 offset + history.push('uuid-20') // page 1 offset + history.push('uuid-40') // page 2 offset + + // Go back to page 1 + const prevOffset = history[history.length - 2] + expect(prevOffset).toBe('uuid-20') + }) + + it('should reset state on collection change', () => { + let chunkOffset: string | null = 'some-offset' + let chunkHistory: (string | null)[] = [null, 'uuid-1'] + let chunkCurrentPage = 3 + + // Simulate collection change + chunkOffset = null + chunkHistory = [] + chunkCurrentPage = 0 + + expect(chunkOffset).toBeNull() + expect(chunkHistory).toHaveLength(0) + expect(chunkCurrentPage).toBe(0) + }) + }) +}) diff --git a/admin-lehrer/app/api/legal-corpus/__tests__/rag-constants.test.ts b/admin-lehrer/app/api/legal-corpus/__tests__/rag-constants.test.ts new file mode 100644 index 0000000..a8e841a --- /dev/null +++ b/admin-lehrer/app/api/legal-corpus/__tests__/rag-constants.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' + +/** + * Tests for RAG page constants - REGULATIONS_IN_RAG, REGULATION_SOURCES, REGULATION_LICENSES + * + * These are defined inline in page.tsx, so we test the data structures + * by importing a subset of the expected values. + */ + +// Expected IFRS entries in REGULATIONS_IN_RAG +const EXPECTED_IFRS_ENTRIES = { + EU_IFRS_DE: { collection: 'bp_compliance_ce', chunks: 0 }, + EU_IFRS_EN: { collection: 'bp_compliance_ce', chunks: 0 }, + EFRAG_ENDORSEMENT: { collection: 'bp_compliance_datenschutz', chunks: 0 }, +} + +// Expected REGULATION_SOURCES URLs +const EXPECTED_SOURCES = { + GDPR: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32016R0679', + EU_IFRS_DE: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R1803', + EU_IFRS_EN: 'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32023R1803', + EFRAG_ENDORSEMENT: 'https://www.efrag.org/activities/endorsement-status-report', + ENISA_SECURE_DEV: 'https://www.enisa.europa.eu/publications/secure-development-best-practices', + NIST_SSDF: 'https://csrc.nist.gov/pubs/sp/800/218/final', + NIST_CSF: 'https://www.nist.gov/cyberframework', + OECD_AI: 'https://oecd.ai/en/ai-principles', +} + +describe('RAG Page Constants', () => { + describe('IFRS entries in REGULATIONS_IN_RAG', () => { + it('should have EU_IFRS_DE entry with bp_compliance_ce collection', () => { + expect(EXPECTED_IFRS_ENTRIES.EU_IFRS_DE.collection).toBe('bp_compliance_ce') + }) + + it('should have EU_IFRS_EN entry with bp_compliance_ce collection', () => { + expect(EXPECTED_IFRS_ENTRIES.EU_IFRS_EN.collection).toBe('bp_compliance_ce') + }) + + it('should have EFRAG_ENDORSEMENT entry with bp_compliance_datenschutz collection', () => { + expect(EXPECTED_IFRS_ENTRIES.EFRAG_ENDORSEMENT.collection).toBe('bp_compliance_datenschutz') + }) + }) + + describe('REGULATION_SOURCES URLs', () => { + it('should have valid EUR-Lex URLs for EU regulations', () => { + expect(EXPECTED_SOURCES.GDPR).toMatch(/^https:\/\/eur-lex\.europa\.eu/) + expect(EXPECTED_SOURCES.EU_IFRS_DE).toMatch(/^https:\/\/eur-lex\.europa\.eu/) + expect(EXPECTED_SOURCES.EU_IFRS_EN).toMatch(/^https:\/\/eur-lex\.europa\.eu/) + }) + + it('should have correct CELEX for IFRS DE (32023R1803)', () => { + expect(EXPECTED_SOURCES.EU_IFRS_DE).toContain('32023R1803') + }) + + it('should have correct CELEX for IFRS EN (32023R1803)', () => { + expect(EXPECTED_SOURCES.EU_IFRS_EN).toContain('32023R1803') + }) + + it('should have DE language for IFRS DE', () => { + expect(EXPECTED_SOURCES.EU_IFRS_DE).toContain('/DE/') + }) + + it('should have EN language for IFRS EN', () => { + expect(EXPECTED_SOURCES.EU_IFRS_EN).toContain('/EN/') + }) + + it('should have EFRAG URL for endorsement status', () => { + expect(EXPECTED_SOURCES.EFRAG_ENDORSEMENT).toMatch(/^https:\/\/www\.efrag\.org/) + }) + + it('should have ENISA URL for secure development', () => { + expect(EXPECTED_SOURCES.ENISA_SECURE_DEV).toMatch(/^https:\/\/www\.enisa\.europa\.eu/) + }) + + it('should have NIST URLs for SSDF and CSF', () => { + expect(EXPECTED_SOURCES.NIST_SSDF).toMatch(/nist\.gov/) + expect(EXPECTED_SOURCES.NIST_CSF).toMatch(/nist\.gov/) + }) + + it('should have OECD URL for AI principles', () => { + expect(EXPECTED_SOURCES.OECD_AI).toMatch(/oecd\.ai/) + }) + + it('should all be valid HTTPS URLs', () => { + Object.values(EXPECTED_SOURCES).forEach((url) => { + expect(url).toMatch(/^https:\/\//) + }) + }) + }) +}) diff --git a/admin-lehrer/app/api/legal-corpus/__tests__/route.test.ts b/admin-lehrer/app/api/legal-corpus/__tests__/route.test.ts new file mode 100644 index 0000000..420ae06 --- /dev/null +++ b/admin-lehrer/app/api/legal-corpus/__tests__/route.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +// Mock NextRequest and NextResponse +vi.mock('next/server', () => ({ + NextRequest: class MockNextRequest { + url: string + constructor(url: string) { + this.url = url + } + }, + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + data, + status: init?.status || 200, + }), + }, +})) + +describe('Legal Corpus API Proxy', () => { + beforeEach(() => { + mockFetch.mockClear() + }) + + describe('scroll action', () => { + it('should call Qdrant scroll endpoint with correct collection', async () => { + const mockScrollResponse = { + result: { + points: [ + { id: 'uuid-1', payload: { text: 'DSGVO Artikel 1', regulation_code: 'GDPR' } }, + { id: 'uuid-2', payload: { text: 'DSGVO Artikel 2', regulation_code: 'GDPR' } }, + ], + next_page_offset: 'uuid-3', + }, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockScrollResponse), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce&limit=20' } + const response = await GET(request as any) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const calledUrl = mockFetch.mock.calls[0][0] + expect(calledUrl).toContain('/collections/bp_compliance_ce/points/scroll') + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.limit).toBe(20) + expect(body.with_payload).toBe(true) + expect(body.with_vector).toBe(false) + }) + + it('should pass offset parameter to Qdrant', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: { points: [], next_page_offset: null } }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_gesetze&offset=some-uuid' } + await GET(request as any) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.offset).toBe('some-uuid') + }) + + it('should limit chunks to max 100', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: { points: [], next_page_offset: null } }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce&limit=500' } + await GET(request as any) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.limit).toBe(100) + }) + + it('should apply text_search filter client-side', async () => { + const mockScrollResponse = { + result: { + points: [ + { id: 'uuid-1', payload: { text: 'DSGVO Artikel 1 Datenschutz' } }, + { id: 'uuid-2', payload: { text: 'IFRS Standard 16 Leasing' } }, + { id: 'uuid-3', payload: { text: 'Datenschutz Grundverordnung' } }, + ], + next_page_offset: null, + }, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockScrollResponse), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce&text_search=Datenschutz' } + const response = await GET(request as any) + + // Should filter to only chunks containing "Datenschutz" + expect((response as any).data.chunks).toHaveLength(2) + expect((response as any).data.chunks[0].text).toContain('Datenschutz') + }) + + it('should flatten payload into chunk objects', async () => { + const mockScrollResponse = { + result: { + points: [ + { + id: 'uuid-1', + payload: { + text: 'IFRS 16 Leasing', + regulation_code: 'EU_IFRS', + language: 'de', + celex: '32023R1803', + }, + }, + ], + next_page_offset: null, + }, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockScrollResponse), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce' } + const response = await GET(request as any) + + const chunk = (response as any).data.chunks[0] + expect(chunk.id).toBe('uuid-1') + expect(chunk.text).toBe('IFRS 16 Leasing') + expect(chunk.regulation_code).toBe('EU_IFRS') + expect(chunk.language).toBe('de') + }) + + it('should return next_offset from Qdrant response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + result: { points: [], next_page_offset: 'next-uuid' }, + }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce' } + const response = await GET(request as any) + + expect((response as any).data.next_offset).toBe('next-uuid') + }) + + it('should handle Qdrant scroll failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=nonexistent' } + const response = await GET(request as any) + + expect((response as any).status).toBe(404) + }) + + it('should apply filter when filter_key and filter_value provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: { points: [], next_page_offset: null } }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll&collection=bp_compliance_ce&filter_key=language&filter_value=de' } + await GET(request as any) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.filter).toEqual({ + must: [{ key: 'language', match: { value: 'de' } }], + }) + }) + + it('should default collection to bp_compliance_gesetze', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: { points: [], next_page_offset: null } }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=scroll' } + await GET(request as any) + + const calledUrl = mockFetch.mock.calls[0][0] + expect(calledUrl).toContain('/collections/bp_compliance_gesetze/') + }) + }) + + describe('collection-count action', () => { + it('should return points_count from Qdrant collection info', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + result: { points_count: 55053 }, + }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=collection-count&collection=bp_compliance_ce' } + const response = await GET(request as any) + + expect((response as any).data.count).toBe(55053) + }) + + it('should return 0 when Qdrant is unavailable', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=collection-count&collection=bp_compliance_ce' } + const response = await GET(request as any) + + expect((response as any).data.count).toBe(0) + }) + + it('should default to bp_compliance_gesetze collection', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ result: { points_count: 1234 } }), + }) + + const { GET } = await import('../route') + const request = { url: 'http://localhost/api/legal-corpus?action=collection-count' } + await GET(request as any) + + const calledUrl = mockFetch.mock.calls[0][0] + expect(calledUrl).toContain('/collections/bp_compliance_gesetze') + }) + }) +})