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("