Merge pull request 'test: add comprehensive test suite with 95% coverage' (#31) from feat/add-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

This commit is contained in:
Jarian Cottingham 2026-07-06 11:29:17 -05:00
commit 4a3118a480
18 changed files with 1632 additions and 4 deletions

View File

@ -57,9 +57,9 @@ jobs:
run: | run: |
if [[ -f pyproject.toml ]]; then if [[ -f pyproject.toml ]]; then
python3 -m pip install --upgrade pip python3 -m pip install --upgrade pip
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true pip3 install -r requirements.txt 2>/dev/null || true
pip3 install pytest pip3 install pytest pytest-cov pytest-mock
pytest tests/ -v --tb=short 2>/dev/null || true pytest tests/ -v --tb=short --cov=factsdb --cov-report=term-missing --cov-fail-under=90
else else
echo "No Python project detected, skipping pytest" echo "No Python project detected, skipping pytest"
fi fi

8
.gitignore vendored
View File

@ -62,4 +62,10 @@ sample/
# Docker # Docker
.dockerignore .dockerignore
*.docker *.docker
# Test coverage
.coverage
coverage.xml
htmlcov/
.pytest_cache/

18
pyproject.toml Normal file
View File

@ -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",
]

0
tests/__init__.py Normal file
View File

58
tests/conftest.py Normal file
View File

@ -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")

149
tests/test_ai_processor.py Normal file
View File

@ -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

157
tests/test_api.py Normal file
View File

@ -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

173
tests/test_api_errors.py Normal file
View File

@ -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

110
tests/test_config.py Normal file
View File

@ -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

219
tests/test_database.py Normal file
View File

@ -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

View File

@ -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"] == []

View File

@ -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("<html><body><p>Hello</p></body></html>")
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("<html><script>alert(1)</script><p>Content</p></html>")
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("<html><style>body{}</style><p>Content</p></html>")
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

View File

@ -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("<root><item>Hello</item></root>")
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

99
tests/test_ftp_server.py Normal file
View File

@ -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

View File

@ -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

107
tests/test_monitoring.py Normal file
View File

@ -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()

98
tests/test_scheduler.py Normal file
View File

@ -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

130
tests/test_scheduler_ext.py Normal file
View File

@ -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