From 65918cc80a5d0f55aeaef5c56178566331d1a0ca Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Fri, 5 Jun 2026 07:40:25 +0200 Subject: [PATCH] feat(tests): scaffold + integration smoke tests (config, db, models, pipeline) Co-Authored-By: Claude Sonnet 4.6 --- tests/integration/__init__.py | 0 tests/integration/test_pipeline_smoke.py | 100 +++++++++++++++++++++++ tests/scaffold/__init__.py | 0 tests/scaffold/test_config.py | 35 ++++++++ tests/scaffold/test_db.py | 35 ++++++++ tests/scaffold/test_models.py | 34 ++++++++ 6 files changed, 204 insertions(+) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_pipeline_smoke.py create mode 100644 tests/scaffold/__init__.py create mode 100644 tests/scaffold/test_config.py create mode 100644 tests/scaffold/test_db.py create mode 100644 tests/scaffold/test_models.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_pipeline_smoke.py b/tests/integration/test_pipeline_smoke.py new file mode 100644 index 0000000..e40dc2a --- /dev/null +++ b/tests/integration/test_pipeline_smoke.py @@ -0,0 +1,100 @@ +"""End-to-end smoke test: ingest → discover → provenance flow (all mocked).""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any +from unittest.mock import MagicMock, patch + +import numpy as np + +from codex.discover import discovery_leads +from codex.ingest import ingest_paper +from codex.models import Citation, Paper +from codex.provenance import export_bib + + +def _make_conn_cm(mock_conn: MagicMock) -> Any: + """Return a context-manager factory that yields *mock_conn*.""" + + @contextmanager + def _cm() -> Any: + yield mock_conn + + return _cm + + +def test_full_pipeline_smoke() -> None: + """ingest_paper → discovery_leads → export_bib all execute without errors (all mocked).""" + paper = Paper(id="2301.07041", title="T", openalex_id="W123") + citations = [Citation(citing_id="2301.07041", cited_id="2301.99999")] + + # --- mock connection setup --- + mock_conn = MagicMock() + mock_conn.execute = MagicMock() + mock_conn.commit = MagicMock() + + # discovery_leads: conn.execute(...).fetchall() → empty list (no leads) + discover_execute_result = MagicMock() + discover_execute_result.fetchall.return_value = [] + + # export_bib: conn.execute(...).fetchall() → one paper row with bibkey + bib_row: dict[str, Any] = { + "id": "2301.07041", + "bibkey": "paper2023", + "title": "T", + "authors": ["Author One"], + "year": 2023, + } + bib_execute_result = MagicMock() + bib_execute_result.fetchall.return_value = [bib_row] + + # The ingest path calls conn.execute multiple times (paper upsert, etc.). + # discovery_leads and export_bib each call conn.execute once and use fetchall. + # We use side_effect to route calls: first N calls from ingest return a generic + # mock; then we supply discover and bib results in order. + _call_count: list[int] = [0] + + def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: + _call_count[0] += 1 + sql: str = str(args[0]) if args else "" + if "cited_id NOT IN" in sql: + return discover_execute_result + if "FROM papers" in sql and "bibkey IS NOT NULL" in sql: + return bib_execute_result + return MagicMock() + + mock_conn.execute.side_effect = _execute_side_effect + + # cursor() context manager for executemany calls inside ingest + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + + embedder = MagicMock() + embedder.dim = 1024 + embedder.encode_dense.return_value = np.zeros((1, 1024), dtype=np.float32) + + conn_cm = _make_conn_cm(mock_conn) + + with ( + patch("codex.ingest.openalex.fetch_paper", return_value=paper), + patch("codex.ingest.openalex.fetch_citations", return_value=citations), + patch("codex.ingest.get_embedder", return_value=embedder), + patch("codex.ingest.get_conn", side_effect=conn_cm), + patch("codex.discover.get_conn", side_effect=conn_cm), + patch("codex.provenance.get_conn", side_effect=conn_cm), + ): + # Step 1: ingest + result = ingest_paper("2301.07041") + + # Step 2: discover + leads = discovery_leads() + + # Step 3: export bib + bib = export_bib(["2301.07041"]) + + # --- assertions --- + assert result.paper_id == "2301.07041" + assert isinstance(leads, list) + assert "@article" in bib diff --git a/tests/scaffold/__init__.py b/tests/scaffold/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scaffold/test_config.py b/tests/scaffold/test_config.py new file mode 100644 index 0000000..f8bde0c --- /dev/null +++ b/tests/scaffold/test_config.py @@ -0,0 +1,35 @@ +"""Smoke tests for codex.config.Settings.""" + +from __future__ import annotations + +from codex.config import Settings, get_settings + + +def test_settings_defaults() -> None: + """Settings() is constructable without env vars; expected defaults are set.""" + s = Settings() + assert s.database_url == "postgresql://researcher:change_me@localhost:5432/papers" + assert s.embedding_model == "BAAI/bge-m3" + assert s.embedding_dim == 1024 + + +def test_settings_env_override(monkeypatch: object) -> None: + """DATABASE_URL env var overrides the default database_url.""" + import pytest + + mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment] + mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db") + s = Settings() + assert s.database_url == "postgresql://x:y@h:5432/db" + + +def test_get_settings_is_singleton() -> None: + """get_settings() returns the same object on repeated calls; new object after cache_clear.""" + get_settings.cache_clear() + first = get_settings() + second = get_settings() + assert first is second + + get_settings.cache_clear() + third = get_settings() + assert third is not first diff --git a/tests/scaffold/test_db.py b/tests/scaffold/test_db.py new file mode 100644 index 0000000..a91e09d --- /dev/null +++ b/tests/scaffold/test_db.py @@ -0,0 +1,35 @@ +"""Smoke tests for codex.db — get_conn and apply_schema.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +def test_get_conn_context_manager() -> None: + """get_conn() yields a context manager that calls psycopg.connect and cleans up.""" + from codex.db import get_conn + + mock_conn = MagicMock() + mock_connect = MagicMock() + # psycopg.connect is itself used as a context manager inside get_conn + mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_connect.return_value.__exit__ = MagicMock(return_value=False) + + with patch("codex.db.psycopg.connect", mock_connect), get_conn() as conn: + assert conn is mock_conn + + mock_connect.assert_called_once() + + +def test_apply_schema_executes_sql() -> None: + """apply_schema(conn) calls conn.execute and conn.commit.""" + from codex.db import apply_schema + + mock_conn = MagicMock() + + # Provide a fake schema.sql so Path.read_text succeeds + with patch("codex.db.Path.read_text", return_value="CREATE TABLE IF NOT EXISTS test ();"): + apply_schema(mock_conn) + + mock_conn.execute.assert_called_once() + mock_conn.commit.assert_called_once() diff --git a/tests/scaffold/test_models.py b/tests/scaffold/test_models.py new file mode 100644 index 0000000..485964b --- /dev/null +++ b/tests/scaffold/test_models.py @@ -0,0 +1,34 @@ +"""Smoke tests for codex.models dataclasses.""" + +from __future__ import annotations + +from codex.models import Chunk, Citation, CodeLink, Paper + + +def test_paper_defaults() -> None: + """Paper(id=..., title=...) has correct optional-field defaults.""" + p = Paper(id="2301.00001", title="T") + assert p.openalex_id is None + assert p.authors == [] + assert p.year is None + + +def test_chunk_required_fields() -> None: + """Chunk(paper_id=..., ord=..., content=...) has id=None and embedding=None.""" + c = Chunk(paper_id="x", ord=0, content="c") + assert c.id is None + assert c.embedding is None + + +def test_citation_fields() -> None: + """Citation(citing_id=..., cited_id=...) has context=None.""" + cit = Citation(citing_id="a", cited_id="b") + assert cit.context is None + + +def test_codelink_fields() -> None: + """CodeLink(symbol=...) has paper_id=None, role=None, and id=None.""" + cl = CodeLink(symbol="foo") + assert cl.paper_id is None + assert cl.role is None + assert cl.id is None