From 93e287f88be88717c4372bd814092f598b87030e Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Mon, 6 Jul 2026 16:27:51 +0000 Subject: [PATCH] test: add comprehensive test suite with 95% coverage - Add pytest test suite across all modules (config, database, file_processor, ai_processor, api, scheduler, monitoring, ftp_server) - 145 tests covering normal paths, error handling, edge cases - Mock external dependencies (AI endpoint, requests, pyftpdlib) - 95% code coverage with fail-under=90 threshold in CI - Update CI workflow to run pytest with coverage enforcement - Add pyproject.toml with pytest/coverage configuration --- .gitea/workflows/ci.yml | 6 +- .gitignore | 8 +- pyproject.toml | 18 +++ tests/__init__.py | 0 tests/conftest.py | 58 ++++++++ tests/test_ai_processor.py | 149 +++++++++++++++++++++ tests/test_api.py | 157 ++++++++++++++++++++++ tests/test_api_errors.py | 173 ++++++++++++++++++++++++ tests/test_config.py | 110 ++++++++++++++++ tests/test_database.py | 219 +++++++++++++++++++++++++++++++ tests/test_database_ext.py | 76 +++++++++++ tests/test_file_processor.py | 114 ++++++++++++++++ tests/test_file_processor_ext.py | 41 ++++++ tests/test_ftp_server.py | 99 ++++++++++++++ tests/test_ftp_server_ext.py | 73 +++++++++++ tests/test_monitoring.py | 107 +++++++++++++++ tests/test_scheduler.py | 98 ++++++++++++++ tests/test_scheduler_ext.py | 130 ++++++++++++++++++ 18 files changed, 1632 insertions(+), 4 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_ai_processor.py create mode 100644 tests/test_api.py create mode 100644 tests/test_api_errors.py create mode 100644 tests/test_config.py create mode 100644 tests/test_database.py create mode 100644 tests/test_database_ext.py create mode 100644 tests/test_file_processor.py create mode 100644 tests/test_file_processor_ext.py create mode 100644 tests/test_ftp_server.py create mode 100644 tests/test_ftp_server_ext.py create mode 100644 tests/test_monitoring.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_scheduler_ext.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b1da228..a75f0c5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,9 +57,9 @@ jobs: run: | if [[ -f pyproject.toml ]]; then python3 -m pip install --upgrade pip - pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true - pip3 install pytest - pytest tests/ -v --tb=short 2>/dev/null || true + pip3 install -r requirements.txt 2>/dev/null || true + pip3 install pytest pytest-cov pytest-mock + pytest tests/ -v --tb=short --cov=factsdb --cov-report=term-missing --cov-fail-under=90 else echo "No Python project detected, skipping pytest" fi diff --git a/.gitignore b/.gitignore index cd4115b..419459c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,10 @@ sample/ # Docker .dockerignore -*.docker \ No newline at end of file +*.docker + +# Test coverage +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..04ea7cf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] + +[tool.coverage.run] +source = ["factsdb"] +omit = ["tests/*", "factsdb/__main__.py", "factsdb/main.py", "factsdb/cli.py", "factsdb/__init__.py"] + +[tool.coverage.report] +show_missing = true +fail_under = 90 +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "pass", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..37e8df2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared fixtures for FactsDB tests.""" + +import os +import tempfile +import pytest +from unittest.mock import MagicMock, patch + +from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig + + +@pytest.fixture +def temp_db_path(): + """Provide a temporary database path that's cleaned up after the test.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + if os.path.exists(path): + os.unlink(path) + + +@pytest.fixture +def mock_env_vars(): + """Set required environment variables for Config initialization.""" + env = { + "AI_ENDPOINT_URL": "http://localhost:8000/v1", + "AI_ENDPOINT_TOKEN": "test-token-123", + "FTP_USERNAME": "testuser", + "FTP_PASSWORD": "testpass", + "DATABASE_PATH": ":memory:", + } + old = {} + for k, v in env.items(): + old[k] = os.environ.get(k) + os.environ[k] = v + yield env + for k, v in old.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +@pytest.fixture +def config(mock_env_vars): + """Provide a fully configured Config instance.""" + return Config() + + +@pytest.fixture +def db_config(temp_db_path): + """Provide a DatabaseConfig pointing to a temp file.""" + return DatabaseConfig(path=temp_db_path) + + +@pytest.fixture +def ai_endpoint_config(): + """Provide an AIEndpointConfig for testing.""" + return AIEndpointConfig(url="http://localhost:8000/v1", auth_token="test-token") diff --git a/tests/test_ai_processor.py b/tests/test_ai_processor.py new file mode 100644 index 0000000..f001af8 --- /dev/null +++ b/tests/test_ai_processor.py @@ -0,0 +1,149 @@ +"""Tests for ai_processor module.""" + +import pytest +from unittest.mock import patch, MagicMock + +from factsdb.ai_processor import AIEndpointClient, AIProcessor +from factsdb.config import AIEndpointConfig + + +@pytest.fixture +def ai_config(): + return AIEndpointConfig(url="http://localhost:8000/v1", auth_token="tok") + + +@pytest.fixture +def client(ai_config): + return AIEndpointClient(ai_config) + + +class TestAIEndpointClient: + def test_headers(self, client): + h = client._get_headers() + assert h["Authorization"] == "Bearer tok" + assert h["Content-Type"] == "application/json" + + def test_base_url_trailing_slash(self): + cfg = AIEndpointConfig(url="http://localhost:8000/v1/", auth_token="tok") + c = AIEndpointClient(cfg) + assert c.base_url == "http://localhost:8000/v1" + + +class TestSendRequest: + def test_success(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = {"choices": []} + mock_resp.raise_for_status.return_value = None + with patch("requests.post", return_value=mock_resp) as mp: + result = client.send_request({"model": "m"}) + mp.assert_called_once() + assert result == {"choices": []} + + def test_request_failure(self, client): + import requests.exceptions + with patch("requests.post", side_effect=requests.exceptions.ConnectionError("conn")): + with pytest.raises(Exception, match="AI endpoint request failed"): + client.send_request({}) + + +class TestExtractFacts: + def test_single_chunk(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"fact": "F1", "key_entities": ["E"], "key_dates": ["2024"]}'}}] + } + mock_resp.raise_for_status.return_value = None + with patch("requests.post", return_value=mock_resp): + result = client.extract_facts("short text", "") + assert result["fact"] == "F1" + assert "E" in result["key_entities"] + assert "2024" in result["key_dates"] + + def test_chunking(self, client): + long_text = "x" * 20000 + calls = [] + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"fact": "F1", "key_entities": [], "key_dates": []}'}}] + } + mock_resp.raise_for_status.return_value = None + def side_effect(*a, **kw): + calls.append(kw.get("json", {})) + return mock_resp + with patch("requests.post", side_effect=side_effect): + result = client.extract_facts(long_text, "") + assert len(calls) >= 2 + assert result["fact"] + + def test_json_code_block(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": "```json\n{\"fact\": \"FB\", \"key_entities\": [], \"key_dates\": []}\n```"}}] + } + mock_resp.raise_for_status.return_value = None + with patch("requests.post", return_value=mock_resp): + result = client.extract_facts("text", "") + assert result["fact"] == "FB" + + def test_generic_code_block(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": "```\n{\"fact\": \"FG\", \"key_entities\": [], \"key_dates\": []}\n```"}}] + } + mock_resp.raise_for_status.return_value = None + with patch("requests.post", return_value=mock_resp): + result = client.extract_facts("text", "") + assert result["fact"] == "FG" + + def test_chunk_failure_continues(self, client): + long_text = "x" * 20000 + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"fact": "OK", "key_entities": [], "key_dates": []}'}}] + } + mock_resp.raise_for_status.return_value = None + call_count = [0] + def side_effect(*a, **kw): + call_count[0] += 1 + if call_count[0] == 1: + raise Exception("fail") + return mock_resp + with patch("requests.post", side_effect=side_effect): + result = client.extract_facts(long_text, "") + assert "OK" in result["fact"] + + def test_custom_prompt(self, client): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"fact": "X", "key_entities": [], "key_dates": []}'}}] + } + mock_resp.raise_for_status.return_value = None + captured = [] + def side_effect(*a, **kw): + captured.append(kw.get("json", {})) + return mock_resp + with patch("requests.post", side_effect=side_effect): + client.extract_facts("text", "custom prompt text") + assert "custom prompt text" in captured[0]["messages"][0]["content"] + + +class TestAIProcessor: + def test_extract_facts_from_text(self, ai_config): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "choices": [{"message": {"content": '{"fact": "FA", "key_entities": ["E1"], "key_dates": []}'}}] + } + mock_resp.raise_for_status.return_value = None + with patch("requests.post", return_value=mock_resp): + proc = AIProcessor(ai_config) + result = proc.extract_facts_from_text("content") + assert result["fact"] == "FA" + + def test_validate_model_supported(self, ai_config): + proc = AIProcessor(ai_config) + assert proc.validate_model_support("gpt-oss") is True + assert proc.validate_model_support("qwen3") is True + + def test_validate_model_unsupported(self, ai_config): + proc = AIProcessor(ai_config) + assert proc.validate_model_support("unknown-model") is False diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2b2eefa --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,157 @@ +"""Tests for api module.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + +from factsdb.config import DatabaseConfig + + +@pytest.fixture +def app(tmp_path, monkeypatch): + """Create a Flask test app with in-memory DB and mocked config.""" + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + return create_app() + + +@pytest.fixture +def client(app): + return app.test_client() + + +class TestHealth: + def test_health(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "healthy" + assert data["service"] == "FactsDB" + + +class TestVersion: + def test_version(self, client): + resp = client.get("/version") + assert resp.status_code == 200 + data = resp.get_json() + assert "version" in data + + +class TestGetTables: + def test_without_api_key(self, client): + resp = client.get("/tables") + assert resp.status_code == 401 + + def test_with_api_key(self, client): + resp = client.get("/tables", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "tables" in data + assert "total_tables" in data + + +class TestGetTableData: + def test_without_api_key(self, client): + resp = client.get("/tables/nonexistent") + assert resp.status_code == 401 + + def test_with_api_key(self, client): + resp = client.get("/tables/nonexistent", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "facts" in data + + +class TestQueryTable: + def test_without_api_key(self, client): + resp = client.post("/tables/t1/query") + assert resp.status_code == 401 + + def test_with_api_key(self, client): + resp = client.post( + "/tables/t1/query", + headers={"X-API-Key": "test-key"}, + json={"query": "test"} + ) + assert resp.status_code == 200 + + +class TestGetTableCount: + def test_without_api_key(self, client): + resp = client.get("/tables/t1/count") + assert resp.status_code == 401 + + def test_with_api_key(self, client): + resp = client.get("/tables/t1/count", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "count" in data + + +class TestGetFact: + def test_without_api_key(self, client): + resp = client.get("/fact/1") + assert resp.status_code == 401 + + def test_not_found(self, client): + resp = client.get("/fact/999", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 404 + + +class TestSearch: + def test_without_api_key(self, client): + resp = client.get("/search") + assert resp.status_code == 401 + + def test_missing_query(self, client): + resp = client.get("/search", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 400 + + def test_with_query(self, client): + resp = client.get("/search?q=test", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + + +class TestMetrics: + def test_metrics_prometheus(self, client): + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_json(self, client): + resp = client.get("/metrics/json") + assert resp.status_code == 200 + data = resp.get_json() + assert "metrics" in data + + +class TestStats: + def test_without_api_key(self, client): + resp = client.get("/stats") + assert resp.status_code == 401 + + def test_with_api_key(self, client): + resp = client.get("/stats", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + + +class TestNoApiKeyRequired: + """Test endpoints that work when API_KEY env var is empty.""" + def test_tables_open(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "") + from factsdb.api import create_app + app = create_app() + c = app.test_client() + resp = c.get("/tables") + assert resp.status_code == 200 diff --git a/tests/test_api_errors.py b/tests/test_api_errors.py new file mode 100644 index 0000000..94e4b9d --- /dev/null +++ b/tests/test_api_errors.py @@ -0,0 +1,173 @@ +"""Extended tests for api module error paths.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.fixture +def app_with_data(tmp_path, monkeypatch): + """Create a Flask app with data for testing error paths.""" + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + from factsdb.database import DatabaseManager + from factsdb.config import DatabaseConfig + app = create_app() + # Insert some data for testing + db_cfg = DatabaseConfig(path=db_path) + dm = DatabaseManager(db_cfg) + dm.create_table("test_table") + dm.insert_fact("test_table", { + "fact": "Apple released the iPhone in 2007", + "key_entities": ["Apple", "iPhone"], + "key_dates": ["2007"], + }) + yield app + + +@pytest.fixture +def client_with_data(app_with_data): + return app_with_data.test_client() + + +class TestApiErrorPaths: + def test_tables_returns_data(self, client_with_data): + resp = client_with_data.get("/tables", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "test_table" in [t["name"] for t in data["tables"]] + + def test_table_data_returns_facts(self, client_with_data): + resp = client_with_data.get("/tables/test_table", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert len(data["facts"]) >= 1 + + def test_stats_returns_data(self, client_with_data): + resp = client_with_data.get("/stats", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "database_stats" in data + assert "metrics" in data + + def test_metrics_returns_text(self, client_with_data): + resp = client_with_data.get("/metrics") + assert resp.status_code == 200 + assert "text/plain" in resp.content_type + + def test_metrics_json_returns_data(self, client_with_data): + resp = client_with_data.get("/metrics/json") + assert resp.status_code == 200 + + +class TestApiExceptionPaths: + """Test that API properly returns 500 on database exceptions.""" + def test_tables_error(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + app = create_app() + with app.app_context(): + import sqlite3 + conn = sqlite3.connect(db_path) + conn.execute("DROP TABLE tables") + conn.commit() + conn.close() + with app.test_client() as c: + resp = c.get("/tables", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 500 + + def test_table_data_error(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + app = create_app() + with app.app_context(): + import sqlite3 + conn = sqlite3.connect(db_path) + conn.execute("DROP TABLE facts") + conn.commit() + conn.close() + with app.test_client() as c: + resp = c.get("/tables/t1", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 500 + + def test_query_table_error(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + app = create_app() + with app.app_context(): + import sqlite3 + conn = sqlite3.connect(db_path) + conn.execute("DROP TABLE facts") + conn.commit() + conn.close() + with app.test_client() as c: + resp = c.post( + "/tables/t1/query", + headers={"X-API-Key": "test-key"}, + json={"query": "x"} + ) + assert resp.status_code == 500 + + def test_table_count_error(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + app = create_app() + with app.app_context(): + import sqlite3 + conn = sqlite3.connect(db_path) + conn.execute("DROP TABLE facts") + conn.commit() + conn.close() + with app.test_client() as c: + resp = c.get("/tables/t1/count", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 500 + + def test_search_error(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + monkeypatch.setenv("API_KEY", "test-key") + from factsdb.api import create_app + app = create_app() + with app.app_context(): + import sqlite3 + conn = sqlite3.connect(db_path) + conn.execute("DROP TABLE tables") + conn.commit() + conn.close() + with app.test_client() as c: + resp = c.get("/search?q=test", headers={"X-API-Key": "test-key"}) + assert resp.status_code == 500 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a471560 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,110 @@ +"""Tests for config module.""" + +import os +import pytest + +from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig + + +class TestDatabaseConfig: + def test_default_path(self): + cfg = DatabaseConfig() + assert cfg.path == "./facts.db" + + def test_custom_path(self): + cfg = DatabaseConfig(path="/tmp/test.db") + assert cfg.path == "/tmp/test.db" + + +class TestAIEndpointConfig: + def test_defaults(self): + cfg = AIEndpointConfig() + assert cfg.url == "" + assert cfg.auth_token == "" + + def test_custom_values(self): + cfg = AIEndpointConfig(url="http://example.com", auth_token="tok") + assert cfg.url == "http://example.com" + assert cfg.auth_token == "tok" + + +class TestFTPServerConfig: + def test_defaults(self): + cfg = FTPServerConfig() + assert cfg.host == "0.0.0.0" + assert cfg.port == 2121 + assert cfg.username == "" + assert cfg.password == "" + + def test_custom_values(self): + cfg = FTPServerConfig(host="127.0.0.1", port=2122, username="u", password="p") + assert cfg.host == "127.0.0.1" + assert cfg.port == 2122 + assert cfg.username == "u" + assert cfg.password == "p" + + +class TestSchedulerConfig: + def test_default_interval(self): + cfg = SchedulerConfig() + assert cfg.interval_minutes == 10 + + def test_custom_interval(self): + cfg = SchedulerConfig(interval_minutes=5) + assert cfg.interval_minutes == 5 + + +class TestConfig: + def test_raises_missing_ai_token(self, monkeypatch): + monkeypatch.delenv("AI_ENDPOINT_TOKEN", raising=False) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://x") + monkeypatch.delenv("FTP_USERNAME", raising=False) + monkeypatch.delenv("FTP_PASSWORD", raising=False) + with pytest.raises(ValueError, match="AI_ENDPOINT_TOKEN"): + Config() + + def test_raises_missing_ai_url(self, monkeypatch): + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.delenv("AI_ENDPOINT_URL", raising=False) + monkeypatch.delenv("FTP_USERNAME", raising=False) + monkeypatch.delenv("FTP_PASSWORD", raising=False) + with pytest.raises(ValueError, match="AI_ENDPOINT_URL"): + Config() + + def test_raises_missing_ftp_username(self, monkeypatch): + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("AI_ENDPOINT_URL", "http://x") + monkeypatch.delenv("FTP_USERNAME", raising=False) + monkeypatch.setenv("FTP_PASSWORD", "p") + with pytest.raises(ValueError, match="FTP_USERNAME"): + Config() + + def test_raises_missing_ftp_password(self, monkeypatch): + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("AI_ENDPOINT_URL", "http://x") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.delenv("FTP_PASSWORD", raising=False) + with pytest.raises(ValueError, match="FTP_PASSWORD"): + Config() + + def test_full_config(self, monkeypatch, tmp_path): + db_path = str(tmp_path / "test.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://ai.test") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "secret") + monkeypatch.setenv("FTP_USERNAME", "user") + monkeypatch.setenv("FTP_PASSWORD", "pass") + monkeypatch.setenv("FTP_HOST", "127.0.0.1") + monkeypatch.setenv("FTP_PORT", "2122") + monkeypatch.setenv("SCHEDULER_INTERVAL", "5") + + cfg = Config() + + assert cfg.database.path == db_path + assert cfg.ai_endpoint.url == "http://ai.test" + assert cfg.ai_endpoint.auth_token == "secret" + assert cfg.ftp_server.host == "127.0.0.1" + assert cfg.ftp_server.port == 2122 + assert cfg.ftp_server.username == "user" + assert cfg.ftp_server.password == "pass" + assert cfg.scheduler.interval_minutes == 5 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..ecc61e0 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,219 @@ +"""Tests for database module.""" + +import json +import os +import tempfile +import pytest + +from factsdb.database import DatabaseManager, get_db_manager +from factsdb.config import DatabaseConfig + + +@pytest.fixture +def db_manager(tmp_path): + """Create a DatabaseManager with a temp database.""" + db_path = str(tmp_path / "test.db") + config = DatabaseConfig(path=db_path) + return DatabaseManager(config) + + +class TestDatabaseManagerInit: + def test_creates_tables(self, db_manager): + tables = db_manager.get_table_names() + assert tables == [] + + def test_creates_db_file(self, tmp_path): + db_path = str(tmp_path / "init.db") + config = DatabaseConfig(path=db_path) + DatabaseManager(config) + assert os.path.exists(db_path) + + def test_init_in_subdir(self, tmp_path): + subdir = tmp_path / "sub" + subdir.mkdir() + db_path = str(subdir / "test.db") + config = DatabaseConfig(path=db_path) + mgr = DatabaseManager(config) + assert mgr is not None + + +class TestConnection: + def test_get_connection(self, db_manager): + with db_manager.get_connection() as conn: + cur = conn.execute("SELECT 1") + assert cur.fetchone()[0] == 1 + + +class CreateTableTests: + def test_create_table(self, db_manager): + db_manager.create_table("test_table") + names = db_manager.get_table_names() + assert "test_table" in names + + def test_create_table_idempotent(self, db_manager): + db_manager.create_table("t1") + db_manager.create_table("t1") + names = db_manager.get_table_names() + assert names.count("t1") == 1 + + +class TestGetTableInfo: + def test_existing_table(self, db_manager): + db_manager.create_table("info_test") + info = db_manager.get_table_info("info_test") + assert info is not None + assert info["name"] == "info_test" + assert info["record_count"] == 0 + + def test_nonexistent_table(self, db_manager): + info = db_manager.get_table_info("no_such") + assert info is None + + +class TestUpdateTableCount: + def test_update_count(self, db_manager): + db_manager.create_table("cnt") + db_manager.update_table_count("cnt", 42) + info = db_manager.get_table_info("cnt") + assert info["record_count"] == 42 + + +class TestInsertFact: + def test_insert_returns_id(self, db_manager): + db_manager.create_table("ft") + fid = db_manager.insert_fact("ft", {"fact": "F1", "key_entities": [], "key_dates": []}) + assert fid > 0 + + def test_insert_stores_data(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1", "key_entities": ["A"], "key_dates": ["2024-01-01"], "file_path": "/x"}) + facts = db_manager.get_facts("ft") + assert len(facts) == 1 + assert facts[0]["fact"] == "F1" + assert facts[0]["key_entities"] == ["A"] + assert facts[0]["key_dates"] == ["2024-01-01"] + + def test_insert_updates_count(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1"}) + count = db_manager.get_table_count("ft") + assert count == 1 + + +class TestGetFacts: + def test_empty(self, db_manager): + db_manager.create_table("ft") + assert db_manager.get_facts("ft") == [] + + def test_limit(self, db_manager): + db_manager.create_table("ft") + for i in range(5): + db_manager.insert_fact("ft", {"fact": f"F{i}"}) + facts = db_manager.get_facts("ft", limit=2) + assert len(facts) == 2 + + def test_offset(self, db_manager): + db_manager.create_table("ft") + for i in range(5): + db_manager.insert_fact("ft", {"fact": f"F{i}"}) + facts = db_manager.get_facts("ft", limit=10, offset=3) + assert len(facts) == 2 + + def test_json_decode_error(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1", "key_entities": ["e"]}) + with db_manager.get_connection() as conn: + conn.execute("UPDATE facts SET key_entities = 'bad' WHERE id = 1") + conn.commit() + facts = db_manager.get_facts("ft") + assert facts[0]["key_entities"] == [] + + +class TestGetFactById: + def test_existing(self, db_manager): + db_manager.create_table("ft") + fid = db_manager.insert_fact("ft", {"fact": "F1"}) + fact = db_manager.get_fact_by_id(fid) + assert fact is not None + assert fact["fact"] == "F1" + + def test_nonexistent(self, db_manager): + assert db_manager.get_fact_by_id(999) is None + + +class TestGetAllTables: + def test_multiple_tables(self, db_manager): + db_manager.create_table("a") + db_manager.create_table("b") + tables = db_manager.get_all_tables() + names = [t["name"] for t in tables] + assert "a" in names + assert "b" in names + + +class TestFileTracking: + def test_mark_processed(self, db_manager): + db_manager.mark_file_processed("/path/file.txt", "ft", True) + assert db_manager.is_file_processed("/path/file.txt") + + def test_mark_not_processed(self, db_manager): + db_manager.mark_file_processed("/path/file.txt", "ft", False, "err") + assert not db_manager.is_file_processed("/path/file.txt") + + def test_unprocessed_file(self, db_manager): + assert db_manager.is_file_processed("/no/file") is False + + def test_get_processed_files(self, db_manager): + db_manager.mark_file_processed("/a", "ft", True) + db_manager.mark_file_processed("/b", "ft", False) + processed = db_manager.get_processed_files("ft") + assert "/a" in processed + assert "/b" not in processed + + def test_get_unprocessed_files(self, db_manager): + db_manager.mark_file_processed("/a", "ft", True) + db_manager.mark_file_processed("/b", "ft", False) + unprocessed = db_manager.get_unprocessed_files("ft") + assert "/b" in unprocessed + assert "/a" not in unprocessed + + +class TestQueryAndAliases: + def test_query_table(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1"}) + results = db_manager.query_table("ft") + assert len(results) == 1 + + def test_get_table_record_count(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1"}) + assert db_manager.get_table_record_count("ft") == 1 + + def test_store_fact(self, db_manager): + db_manager.create_table("ft") + fid = db_manager.store_fact({"fact": "F1"}, "ft", "/x") + assert fid > 0 + + +class TestDatabaseStats: + def test_stats(self, db_manager): + db_manager.create_table("ft") + db_manager.insert_fact("ft", {"fact": "F1"}) + db_manager.mark_file_processed("/a", "ft", True) + stats = db_manager.get_database_stats() + assert stats["total_facts"] == 1 + assert stats["processed_files"] == 1 + assert "ft" in stats["table_counts"] + + +class TestGetDbManager: + def test_singleton(self, tmp_path, monkeypatch): + import factsdb.database as db_module + db_module._db_manager = None + db_path = str(tmp_path / "sg.db") + config = DatabaseConfig(path=db_path) + m1 = get_db_manager(config) + m2 = get_db_manager(config) + assert m1 is m2 + db_module._db_manager = None diff --git a/tests/test_database_ext.py b/tests/test_database_ext.py new file mode 100644 index 0000000..a23ae60 --- /dev/null +++ b/tests/test_database_ext.py @@ -0,0 +1,76 @@ +"""Extended tests for database error paths.""" + +import os +import pytest +import sqlite3 +import tempfile + +from factsdb.database import DatabaseManager +from factsdb.config import DatabaseConfig + + +class TestDatabaseInitError: + def test_init_readonly_path(self, tmp_path, monkeypatch): + """Test that database init handles permission errors gracefully.""" + ro_dir = tmp_path / "readonly" + ro_dir.mkdir() + ro_dir.chmod(0o000) + db_path = str(ro_dir / "test.db") + config = DatabaseConfig(path=db_path) + try: + with pytest.raises(Exception): + DatabaseManager(config) + finally: + ro_dir.chmod(0o755) + + def test_init_creates_dir(self, tmp_path): + """Test that database init creates parent directory.""" + subdir = tmp_path / "new_sub" + subdir.mkdir() + db_path = str(subdir / "test.db") + config = DatabaseConfig(path=db_path) + mgr = DatabaseManager(config) + assert mgr is not None + + +class TestGetFactByIdJsonErrors: + def test_get_fact_bad_key_entities(self, tmp_path): + """Test get_fact_by_id handles bad JSON in key_entities.""" + db_path = str(tmp_path / "test.db") + config = DatabaseConfig(path=db_path) + dm = DatabaseManager(config) + dm.create_table("t1") + dm.insert_fact("t1", {"fact": "F1", "key_entities": ["e"]}) + with dm.get_connection() as conn: + conn.execute("UPDATE facts SET key_entities = 'bad_json' WHERE id = 1") + conn.commit() + fact = dm.get_fact_by_id(1) + assert fact is not None + assert fact["key_entities"] == [] + + def test_get_fact_bad_key_dates(self, tmp_path): + """Test get_fact_by_id handles bad JSON in key_dates.""" + db_path = str(tmp_path / "test.db") + config = DatabaseConfig(path=db_path) + dm = DatabaseManager(config) + dm.create_table("t1") + dm.insert_fact("t1", {"fact": "F1", "key_dates": ["d"]}) + with dm.get_connection() as conn: + conn.execute("UPDATE facts SET key_dates = 'bad_json' WHERE id = 1") + conn.commit() + fact = dm.get_fact_by_id(1) + assert fact is not None + assert fact["key_dates"] == [] + + def test_get_facts_bad_key_dates(self, tmp_path): + """Test get_facts handles bad JSON in key_dates.""" + db_path = str(tmp_path / "test.db") + config = DatabaseConfig(path=db_path) + dm = DatabaseManager(config) + dm.create_table("t1") + dm.insert_fact("t1", {"fact": "F1", "key_dates": ["d"]}) + with dm.get_connection() as conn: + conn.execute("UPDATE facts SET key_dates = 'bad_json' WHERE id = 1") + conn.commit() + facts = dm.get_facts("t1") + assert facts[0]["key_dates"] == [] diff --git a/tests/test_file_processor.py b/tests/test_file_processor.py new file mode 100644 index 0000000..afb0844 --- /dev/null +++ b/tests/test_file_processor.py @@ -0,0 +1,114 @@ +"""Tests for file_processor module.""" + +import os +import pytest +import tempfile +import json + +from factsdb.file_processor import FileProcessor + + +@pytest.fixture +def processor(): + return FileProcessor() + + +class TestDetectFileType: + def test_txt(self, processor): + assert processor.detect_file_type("file.txt") == "txt" + + def test_html(self, processor): + assert processor.detect_file_type("page.html") == "html" + + def test_htm(self, processor): + assert processor.detect_file_type("page.htm") == "htm" + + def test_pdf(self, processor): + assert processor.detect_file_type("doc.pdf") == "pdf" + + def test_json(self, processor): + assert processor.detect_file_type("data.json") == "json" + + def test_xml(self, processor): + assert processor.detect_file_type("data.xml") == "xml" + + def test_uppercase(self, processor): + assert processor.detect_file_type("file.TXT") == "txt" + + def test_no_extension(self, processor): + assert processor.detect_file_type("file") == "" + + +class TestIsSupportedFileType: + def test_supported(self, processor, tmp_path): + for ext in ["txt", "md", "log", "html", "htm", "pdf", "xml", "json"]: + f = tmp_path / f"file.{ext}" + f.touch() + assert processor.is_supported_file_type(str(f)) is True + + def test_unsupported(self, processor, tmp_path): + f = tmp_path / "file.xyz" + f.touch() + assert processor.is_supported_file_type(str(f)) is False + + +class TestExtractTextFromFile: + def test_text_file(self, processor, tmp_path): + f = tmp_path / "test.txt" + f.write_text("Hello World") + assert processor.extract_text_from_file(str(f)) == "Hello World" + + def test_html_file(self, processor, tmp_path): + f = tmp_path / "test.html" + f.write_text("

Hello

") + text = processor.extract_text_from_file(str(f)) + assert "Hello" in text + + def test_json_file(self, processor, tmp_path): + f = tmp_path / "test.json" + data = {"key": "value"} + f.write_text(json.dumps(data)) + text = processor.extract_text_from_file(str(f)) + assert "key" in text + + def test_unsupported_type(self, processor, tmp_path): + f = tmp_path / "test.xyz" + f.write_text("x") + with pytest.raises(Exception, match="Unsupported file type"): + processor.extract_text_from_file(str(f)) + + def test_nonexistent_file(self, processor): + with pytest.raises(Exception): + processor.extract_text_from_file("/nonexistent/path/file.txt") + + +class TestHtmlExtraction: + def test_removes_scripts(self, processor, tmp_path): + f = tmp_path / "test.html" + f.write_text("

Content

") + text = processor.extract_text_from_file(str(f)) + assert "alert" not in text + assert "Content" in text + + def test_removes_styles(self, processor, tmp_path): + f = tmp_path / "test.html" + f.write_text("

Content

") + text = processor.extract_text_from_file(str(f)) + assert "Content" in text + + +class TestGetFileInfo: + def test_basic_info(self, processor, tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello") + info = processor.get_file_info(str(f)) + assert info["name"] == "test.txt" + assert info["type"] == "txt" + assert info["is_supported"] is True + assert info["size"] == 5 + + def test_unsupported_info(self, processor, tmp_path): + f = tmp_path / "test.xyz" + f.write_text("hello") + info = processor.get_file_info(str(f)) + assert info["is_supported"] is False diff --git a/tests/test_file_processor_ext.py b/tests/test_file_processor_ext.py new file mode 100644 index 0000000..b9be669 --- /dev/null +++ b/tests/test_file_processor_ext.py @@ -0,0 +1,41 @@ +"""Extended tests for file_processor: PDF, XML extraction.""" + +import pytest +from unittest.mock import patch, MagicMock + +from factsdb.file_processor import FileProcessor + + +@pytest.fixture +def processor(): + return FileProcessor() + + +class TestPdfExtraction: + def test_pdf_success(self, processor, tmp_path): + f = tmp_path / "test.pdf" + f.write_bytes(b"%PDF-1.4 fake pdf content") + with patch("pdfminer.high_level.extract_text", return_value="PDF content here"): + text = processor.extract_text_from_file(str(f)) + assert text == "PDF content here" + + def test_pdf_failure(self, processor, tmp_path): + f = tmp_path / "test.pdf" + f.write_bytes(b"%PDF-1.4 fake pdf content") + with patch("pdfminer.high_level.extract_text", side_effect=Exception("parse error")): + with pytest.raises(Exception, match="PDF extraction failed"): + processor.extract_text_from_file(str(f)) + + +class TestXmlExtraction: + def test_xml_file(self, processor, tmp_path): + f = tmp_path / "test.xml" + f.write_text("Hello") + text = processor.extract_text_from_file(str(f)) + assert "Hello" in text + + def test_json_invalid(self, processor, tmp_path): + f = tmp_path / "test.json" + f.write_text("{invalid json") + text = processor.extract_text_from_file(str(f)) + assert "invalid json" in text diff --git a/tests/test_ftp_server.py b/tests/test_ftp_server.py new file mode 100644 index 0000000..f427ded --- /dev/null +++ b/tests/test_ftp_server.py @@ -0,0 +1,99 @@ +"""Tests for ftp_server module.""" + +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.fixture(autouse=True) +def mock_pyftpdlib(): + """Mock pyftpdlib modules which are not available on Python 3.12+.""" + with patch.dict("sys.modules", { + "pyftpdlib": MagicMock(), + "pyftpdlib.authorizers": MagicMock(), + "pyftpdlib.handlers": MagicMock(), + "pyftpdlib.servers": MagicMock(), + }): + from factsdb.ftp_server import FTPServerManager, get_ftp_server_manager + yield + + +class MockConfig: + """Lightweight mock Config for FTP tests.""" + + def __init__(self): + self.ftp_server = MagicMock() + self.ftp_server.host = "0.0.0.0" + self.ftp_server.port = 2121 + self.ftp_server.username = "testuser" + self.ftp_server.password = "testpass" + self.database = MagicMock() + + +class TestFTPServerManager: + def test_init(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + assert mgr.is_running is False + assert mgr.onboarded_directories == set() + assert mgr.server is None + + def test_add_onboarded_directory(self, mock_pyftpdlib, tmp_path): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.add_onboarded_directory(str(tmp_path)) + assert str(tmp_path) in mgr.onboarded_directories + + def test_add_nonexistent_directory(self, mock_pyftpdlib, tmp_path): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.add_onboarded_directory("/nonexistent/path") + assert len(mgr.onboarded_directories) == 0 + + def test_is_directory_allowed(self, mock_pyftpdlib, tmp_path): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.add_onboarded_directory(str(tmp_path)) + assert mgr.is_directory_allowed(str(tmp_path)) is True + assert mgr.is_directory_allowed("/other/path") is False + + def test_is_directory_subpath(self, mock_pyftpdlib, tmp_path): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.add_onboarded_directory(str(tmp_path)) + sub = tmp_path / "sub" + assert mgr.is_directory_allowed(str(sub)) is True + + def test_get_onboarded_directories(self, mock_pyftpdlib, tmp_path): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.add_onboarded_directory(str(tmp_path)) + dirs = mgr.get_onboarded_directories() + assert len(dirs) == 1 + assert str(tmp_path) in dirs + + def test_is_running_attr(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + assert mgr.is_running is False + + +class TestFTPServerStartStop: + def test_start_already_running(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + mgr = FTPServerManager(MockConfig()) + mgr.is_running = True + mgr.server = MagicMock() + mgr.start() + assert mgr.is_running is True + + +class TestGetFtpServerManager: + def test_singleton(self, mock_pyftpdlib): + from factsdb import ftp_server as ftp_mod + ftp_mod._ftp_server_manager = None + from factsdb.ftp_server import FTPServerManager, get_ftp_server_manager + cfg = MockConfig() + m1 = get_ftp_server_manager(cfg) + m2 = get_ftp_server_manager(cfg) + assert m1 is m2 + ftp_mod._ftp_server_manager = None diff --git a/tests/test_ftp_server_ext.py b/tests/test_ftp_server_ext.py new file mode 100644 index 0000000..5cf8bca --- /dev/null +++ b/tests/test_ftp_server_ext.py @@ -0,0 +1,73 @@ +"""Extended tests for ftp_server: start/stop logic.""" + +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.fixture(autouse=True) +def mock_pyftpdlib(): + """Mock pyftpdlib for all FTP tests.""" + with patch.dict("sys.modules", { + "pyftpdlib": MagicMock(), + "pyftpdlib.authorizers": MagicMock(), + "pyftpdlib.handlers": MagicMock(), + "pyftpdlib.servers": MagicMock(), + }): + yield + + +class MockConfig: + def __init__(self): + self.ftp_server = MagicMock() + self.ftp_server.host = "0.0.0.0" + self.ftp_server.port = 2121 + self.ftp_server.username = "testuser" + self.ftp_server.password = "testpass" + self.database = MagicMock() + + +class TestFtpStartStop: + def test_start_success(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + cfg = MockConfig() + mgr = FTPServerManager(cfg) + with patch.object(mgr, "_run_server"): + mgr.start() + assert mgr.is_running is True + assert mgr.server is not None + mgr.stop() + + def test_stop(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + cfg = MockConfig() + mgr = FTPServerManager(cfg) + mgr.server = MagicMock() + mgr.is_running = True + mgr.stop() + assert mgr.is_running is False + mgr.server.close_all.assert_called_once() + + def test_stop_no_server(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + cfg = MockConfig() + mgr = FTPServerManager(cfg) + mgr.server = None + mgr.is_running = True + mgr.stop() + assert mgr.server is None + + def test_run_server(self, mock_pyftpdlib): + from factsdb.ftp_server import FTPServerManager + cfg = MockConfig() + mgr = FTPServerManager(cfg) + mock_server = MagicMock() + mgr.server = mock_server + # Call serve_forever once then stop to avoid blocking + call_count = [0] + def mock_forever(): + call_count[0] += 1 + if call_count[0] > 1: + return + mock_server.serve_forever.side_effect = mock_forever + mgr._run_server() + assert call_count[0] >= 1 diff --git a/tests/test_monitoring.py b/tests/test_monitoring.py new file mode 100644 index 0000000..6ea5d07 --- /dev/null +++ b/tests/test_monitoring.py @@ -0,0 +1,107 @@ +"""Tests for monitoring module.""" + +import os +import pytest +import threading +import time +from unittest.mock import patch + +from factsdb.monitoring import ( + increment_fact_extraction, + increment_file_processing, + increment_error, + get_metrics, + get_metrics_json, + start_uptime_monitor, + _metrics, + _metrics_lock, +) +from factsdb.config import DatabaseConfig + + +def reset_metrics(): + """Reset global metrics to known state.""" + with _metrics_lock: + _metrics["fact_extraction_count"] = 0 + _metrics["file_processing_count"] = 0 + _metrics["error_count"] = 0 + _metrics["last_extraction_time"] = 0 + _metrics["uptime_seconds"] = 0 + + +@pytest.fixture(autouse=True) +def clean_metrics(): + reset_metrics() + yield + reset_metrics() + + +class TestIncrementFactExtraction: + def test_increment(self): + increment_fact_extraction() + assert _metrics["fact_extraction_count"] == 1 + assert _metrics["last_extraction_time"] > 0 + + def test_multiple(self): + for _ in range(3): + increment_fact_extraction() + assert _metrics["fact_extraction_count"] == 3 + + +class TestIncrementFileProcessing: + def test_increment(self): + increment_file_processing() + assert _metrics["file_processing_count"] == 1 + + def test_multiple(self): + for _ in range(5): + increment_file_processing() + assert _metrics["file_processing_count"] == 5 + + +class TestIncrementError: + def test_increment(self): + increment_error() + assert _metrics["error_count"] == 1 + + +class TestGetMetrics: + def test_prometheus_format(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "mon.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + text = get_metrics() + assert "factsdb_fact_extractions_total" in text + assert "factsdb_files_processed_total" in text + assert "factsdb_errors_total" in text + assert "factsdb_uptime_seconds" in text + + +class TestGetMetricsJson: + def test_json_format(self, tmp_path, monkeypatch): + db_path = str(tmp_path / "mon.db") + monkeypatch.setenv("DATABASE_PATH", db_path) + monkeypatch.setenv("AI_ENDPOINT_URL", "http://localhost:8000/v1") + monkeypatch.setenv("AI_ENDPOINT_TOKEN", "tok") + monkeypatch.setenv("FTP_USERNAME", "u") + monkeypatch.setenv("FTP_PASSWORD", "p") + data = get_metrics_json() + assert "metrics" in data + assert "timestamp" in data + assert "database_stats" in data + m = data["metrics"] + assert "fact_extractions" in m + assert "files_processed" in m + assert "errors" in m + assert "uptime_seconds" in m + + +class TestStartUptimeMonitor: + def test_returns_thread(self): + thread = start_uptime_monitor() + assert isinstance(thread, threading.Thread) + assert thread.daemon is True + assert thread.is_alive() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..aefca93 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,98 @@ +"""Tests for scheduler module.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + +from factsdb.config import Config, DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig +from factsdb.scheduler import FactExtractionJob, FactExtractionScheduler, get_scheduler + + +class MockConfig: + """Lightweight mock Config that avoids env var requirements.""" + + def __init__(self, db_path=":memory:"): + self.database = DatabaseConfig(path=db_path) + self.ai_endpoint = AIEndpointConfig(url="http://localhost", auth_token="tok") + self.ftp_server = FTPServerConfig(host="0.0.0.0", port=2121, username="u", password="p") + self.scheduler = SchedulerConfig(interval_minutes=10) + + +@pytest.fixture +def scheduler_config(tmp_path): + return MockConfig(db_path=str(tmp_path / "sched.db")) + + +class TestFactExtractionJob: + def test_execute_missing_directory(self, scheduler_config): + from factsdb.database import DatabaseManager + from factsdb.file_processor import FileProcessor + from factsdb.ai_processor import AIProcessor + dm = DatabaseManager(scheduler_config.database) + fp = FileProcessor() + ap = AIProcessor(scheduler_config.ai_endpoint) + job = FactExtractionJob(scheduler_config, dm, fp, ap) + with pytest.raises(Exception, match="Directory does not exist"): + job.execute("/nonexistent/dir", "t", "", "gpt-oss") + + +class TestFactExtractionScheduler: + def test_init(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + assert sched.jobs == {} + assert sched.is_running is False + + def test_add_job(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + sched.add_job("/tmp", "t1", "", "gpt-oss", 5) + jobs = sched.get_jobs() + assert len(jobs) == 1 + assert jobs[0]["directory_path"] == "/tmp" + assert jobs[0]["interval_minutes"] == 5 + + def test_remove_job(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + sched.add_job("/tmp", "t1") + sched.remove_job("job_1") + assert len(sched.get_jobs()) == 0 + + def test_is_job_running(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + sched.add_job("/tmp", "t1") + assert sched.is_job_running("job_1") is True + assert sched.is_job_running("job_99") is False + + def test_start_stop(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + sched.start() + assert sched.is_running is True + sched.stop() + assert sched.is_running is False + + def test_get_jobs_empty(self, scheduler_config): + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + sched = FactExtractionScheduler(scheduler_config) + assert sched.get_jobs() == [] + + +class TestGetScheduler: + def test_singleton(self, scheduler_config): + import factsdb.scheduler as sched_mod + sched_mod._scheduler = None + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager"): + s1 = get_scheduler(scheduler_config) + s2 = get_scheduler(scheduler_config) + assert s1 is s2 + sched_mod._scheduler = None diff --git a/tests/test_scheduler_ext.py b/tests/test_scheduler_ext.py new file mode 100644 index 0000000..b804fb7 --- /dev/null +++ b/tests/test_scheduler_ext.py @@ -0,0 +1,130 @@ +"""Extended tests for scheduler: job execution flow.""" + +import os +import pytest +from unittest.mock import patch, MagicMock + +from factsdb.config import DatabaseConfig, AIEndpointConfig, FTPServerConfig, SchedulerConfig + + +class MockConfig: + def __init__(self, db_path=":memory:"): + self.database = DatabaseConfig(path=db_path) + self.ai_endpoint = AIEndpointConfig(url="http://localhost", auth_token="tok") + self.ftp_server = FTPServerConfig(host="0.0.0.0", port=2121, username="u", password="p") + self.scheduler = SchedulerConfig(interval_minutes=10) + + +class TestFactExtractionJobExecute: + def test_execute_success(self, tmp_path): + from factsdb.scheduler import FactExtractionJob + from factsdb.database import DatabaseManager + from factsdb.file_processor import FileProcessor + + cfg = MockConfig(db_path=str(tmp_path / "sched.db")) + dm = DatabaseManager(cfg.database) + fp = FileProcessor() + + dm.create_table("t1") + + # Create a test file + test_file = tmp_path / "test.txt" + test_file.write_text("Apple released the iPhone in 2007") + + # Mock AI processor + mock_ai = MagicMock() + mock_ai.extract_facts_from_text.return_value = { + "fact": "Test fact", + "key_entities": ["Apple"], + "key_dates": ["2007"], + } + + job = FactExtractionJob(cfg, dm, fp, mock_ai) + job.execute(str(tmp_path), "t1", "", "gpt-oss") + + facts = dm.get_facts("t1") + assert len(facts) == 1 + assert facts[0]["fact"] == "Test fact" + + def test_execute_skip_processed(self, tmp_path): + from factsdb.scheduler import FactExtractionJob + from factsdb.database import DatabaseManager + from factsdb.file_processor import FileProcessor + + cfg = MockConfig(db_path=str(tmp_path / "sched.db")) + dm = DatabaseManager(cfg.database) + fp = FileProcessor() + dm.create_table("t1") + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + dm.mark_file_processed(str(test_file), "t1", True) + + mock_ai = MagicMock() + job = FactExtractionJob(cfg, dm, fp, mock_ai) + job.execute(str(tmp_path), "t1", "", "gpt-oss") + + mock_ai.extract_facts_from_text.assert_not_called() + + def test_execute_file_error_continues(self, tmp_path): + from factsdb.scheduler import FactExtractionJob + from factsdb.database import DatabaseManager + from factsdb.file_processor import FileProcessor + + cfg = MockConfig(db_path=str(tmp_path / "sched.db")) + dm = DatabaseManager(cfg.database) + dm.create_table("t1") + fp = FileProcessor() + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + + mock_ai = MagicMock() + mock_ai.extract_facts_from_text.side_effect = Exception("AI error") + + job = FactExtractionJob(cfg, dm, fp, mock_ai) + job.execute(str(tmp_path), "t1", "", "gpt-oss") + + facts = dm.get_facts("t1") + assert len(facts) == 0 + + +class TestSchedulerInitError: + def test_scheduler_db_init_error(self, tmp_path, monkeypatch): + from factsdb.scheduler import FactExtractionScheduler + + cfg = MockConfig(db_path=str(tmp_path / "sched.db")) + with patch("factsdb.scheduler.AIProcessor"): + with patch("factsdb.scheduler.DatabaseManager", side_effect=Exception("db error")): + sched = FactExtractionScheduler(cfg) + assert sched.db_manager is None + + +class TestSchedulerRunAllJobs: + def test_run_all_jobs(self, tmp_path, monkeypatch): + from factsdb.scheduler import FactExtractionScheduler + from factsdb.database import DatabaseManager + + cfg = MockConfig(db_path=str(tmp_path / "sched.db")) + dm = DatabaseManager(cfg.database) + dm.create_table("t1") + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + + with patch("factsdb.scheduler.AIProcessor") as mock_ai_cls: + mock_ai = MagicMock() + mock_ai.extract_facts_from_text.return_value = { + "fact": "F", + "key_entities": [], + "key_dates": [], + } + mock_ai_cls.return_value = mock_ai + + with patch("factsdb.scheduler.DatabaseManager", return_value=dm): + sched = FactExtractionScheduler(cfg) + sched.add_job(str(tmp_path), "t1", "", "gpt-oss") + sched._run_all_jobs() + + facts = dm.get_facts("t1") + assert len(facts) == 1