import json import sqlite3 from types import SimpleNamespace import numpy as np import pytest from fastapi.testclient import TestClient import app.main as main import app.routes as routes import tools.tool_health as health import tools.rag.build_sqlite_index as indexer import tools.rag.search_core as search_core from tools.index_state import durable_json, PENDING_FILE, VERSION_FILE from tools.publication import PUBLICATION_POLICY_VERSION, effective_published from tools.rag.search_utils import search_database from tools.rag.rag_utils import build_rag_context @pytest.fixture def corpus(tmp_path, monkeypatch): docs = []; chunks = [] paths = ['pages/students/2020/eva/README.md', 'pages/topics/missing/README.md', 'pages/topics/hidden/README.md', 'pages/students/2020/eva/private/README.md'] for index, (path, published) in enumerate(zip(paths, [True, None, False, False])): title = ['Verejný profil', 'Chýbajúca značka', 'Skrytý dokument', 'Skrytá poznámka'][index] text = 'spoločný overovací výraz ' + ('SKRYTY_OVEROVACI_OBSAH' if published is False else title) docs.append({'path': path, 'title': title, 'published': published}) chunks.append({'chunk_id': path+'::0', 'document_path': path, 'title': title, 'published': published, 'chunk_index': 0, 'heading_paths': [['Úvod']], 'text': text, 'text_length': len(text), 'token_count': 10, 'content_hash': str(index), 'tags': [], 'categories': []}) data = tmp_path / 'data'; data.mkdir() for name, rows in [('documents.json', docs), ('chunks.json', chunks)]: (data/name).write_text(json.dumps(rows)) monkeypatch.setattr(indexer, 'embed_passages', lambda texts: np.ones((len(texts), 4), dtype=np.float32)) monkeypatch.setattr(search_core, 'embed_query', lambda *args, **kwargs: np.ones(4, dtype=np.float32)) db = data / 'zp_index.sqlite' indexer.build_database(documents_file=data/'documents.json', chunks_file=data/'chunks.json', db_file=db) version = {'version': 'test-generation', 'publication_policy': PUBLICATION_POLICY_VERSION} with sqlite3.connect(db) as conn: conn.execute('CREATE TABLE index_state (id INTEGER PRIMARY KEY, metadata_json TEXT)') conn.execute('INSERT INTO index_state VALUES (1,?)', (json.dumps(version),)) durable_json(data/VERSION_FILE, version) monkeypatch.setattr(routes, 'DB_FILE', db) monkeypatch.setattr(main, 'embed_query', lambda *args: None) return db, version, paths @pytest.mark.parametrize('value,visible', [(True, True), (None, True), (False, False)]) def test_publication_policy(value, visible): assert effective_published(value) is visible def test_rag_default_filters_both_fts_and_vectors_but_keeps_null(corpus): db, _, paths = corpus result = search_database(db, 'spoločný overovací výraz', limit=10) assert {r['document_path'] for r in result['results']} == set(paths[:2]) assert any(r['published'] is None for r in result['results']) all_rows = search_database(db, 'spoločný overovací výraz', limit=10, published_only=False) assert {r['document_path'] for r in all_rows['results']} == set(paths) @pytest.mark.parametrize('query', ['spoločný overovací výraz', 'Skrytý dokument', 'Skrytá poznámka']) def test_rag_expansions_do_not_reintroduce_hidden_text(corpus, query): db, _, paths = corpus result = build_rag_context(db, query, limit=10) assert all(s['document_path'] not in paths[2:] for s in result['sources']) assert 'SKRYTY_OVEROVACI_OBSAH' not in result['context'] all_rows = build_rag_context(db, query, limit=10, published_only=False) assert 'SKRYTY_OVEROVACI_OBSAH' in all_rows['context'] def healthy_graph(version): return health.readiness(True), health.readiness(True, index_version=version, nodes=2) def test_neo4j_outage_does_not_disable_rag(corpus, monkeypatch): _, _, paths = corpus monkeypatch.setattr(health, 'neo4j_readiness', lambda v: ( health.readiness(False, 'unreachable'), health.readiness(False, 'neo4j_unavailable'))) with TestClient(main.app) as client: payload = client.get('/health').json() assert payload['tools']['rag']['ready'] is True assert payload['tools']['neo4j']['ready'] is False assert payload['tools']['graphrag']['ready'] is False assert payload['ready'] is False response = client.post('/rag', json={'query': 'spoločný overovací výraz'}, headers={'Authorization': 'Bearer '+'a'*64}) assert response.status_code == 200 assert {s['document_path'] for s in response.json()['sources']} == set(paths[:2]) def test_rag_index_outage_does_not_disable_graph(corpus, monkeypatch): db, version, _ = corpus monkeypatch.setattr(health, 'neo4j_readiness', healthy_graph) db.unlink() with TestClient(main.app) as client: payload = client.get('/health').json() assert payload['tools']['rag']['ready'] is False assert payload['tools']['neo4j']['ready'] is True assert payload['tools']['graphrag']['ready'] is True assert payload['ready'] is False def test_pending_sync_blocks_both_tools_but_reports_neo4j_service(corpus, monkeypatch): db, _, _ = corpus durable_json(db.parent/PENDING_FILE, {'target_version': 'next'}) monkeypatch.setattr(health, 'neo4j_readiness', healthy_graph) with TestClient(main.app) as client: tools = client.get('/health').json()['tools'] assert tools['rag']['reason'] == tools['graphrag']['reason'] == 'snapshot_unavailable' assert tools['neo4j']['ready'] is True def test_embedding_startup_failure_and_successful_query_recovery(corpus, monkeypatch): monkeypatch.setattr(health, 'neo4j_readiness', healthy_graph) def broken(*args): raise RuntimeError('transient warmup failure') monkeypatch.setattr(main, 'embed_query', broken) with TestClient(main.app) as client: tools = client.get('/health').json()['tools'] assert tools['rag']['reason'] == 'embedding_not_ready' assert tools['graphrag']['ready'] is True response = client.post('/rag', json={'query': 'spoločný overovací výraz'}, headers={'Authorization': 'Bearer '+'a'*64}) assert response.status_code == 200 assert client.get('/health').json()['tools']['rag']['ready'] is True def test_neo4j_probe_is_bounded_and_does_not_expose_errors(monkeypatch): monkeypatch.setattr(health, 'get_neo4j_settings', lambda: SimpleNamespace(database='neo4j')) captured = {} def fail(settings, **options): captured.update(options) raise RuntimeError('password=never-return-this') monkeypatch.setattr(health, 'create_neo4j_driver', fail) result = health.neo4j_readiness(None) assert not result[0]['ready'] and not result[1]['ready'] assert captured['connection_timeout'] == captured['connection_acquisition_timeout'] == 2.0 assert captured['max_transaction_retry_time'] == 0 assert 'never-return-this' not in json.dumps(result) @pytest.mark.parametrize('actual,nodes,reason', [ ({'version': 'different'}, 2, 'version_mismatch'), (None, 0, 'graph_empty'), ({'version': 'old'}, 2, 'publication_sync_required'), ]) def test_connection_readiness_is_distinct_from_graph_readiness(monkeypatch, actual, nodes, reason): class Result: def single(self): return {'nodes': nodes, 'metadata': json.dumps(actual) if actual else None} class Connection: def __enter__(self): return self def __exit__(self, *args): pass def verify_connectivity(self): pass def session(self, **kwargs): return self def run(self, query, **params): assert query.timeout == 2.0 return Result() monkeypatch.setattr(health, 'get_neo4j_settings', lambda: SimpleNamespace(database='neo4j')) monkeypatch.setattr(health, 'create_neo4j_driver', lambda *args, **kwargs: Connection()) expected = {'version': 'expected'} if reason=='version_mismatch' else actual neo, graph = health.neo4j_readiness(expected) assert neo['ready'] is True and graph['ready'] is False and graph['reason']==reason