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.runtime.tool_health as health import tools.rag.build_sqlite_index as indexer import tools.rag.search_core as search_core from tools.sync.index_state import durable_json, PENDING_FILE, VERSION_FILE from tools.retrieval.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