`codex search paper` crashed on the live DB with "operator does not exist: vector <-> double precision[]". The pgvector `<->` operator needs its RHS typed as vector; peer call sites (mcp_server.py, wiki.py) cast but the CLI did not. Mocked unit tests never exercise real pgvector, so it went unseen. Add the cast to both `<->` occurrences and a regression guard asserting the SQL contains `::vector`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""Tests for codex.cli — Typer command-line interface.
|
||
|
||
All external calls (ingest, DB, embed, discovery, provenance) are mocked
|
||
so the suite runs offline without real API calls.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import numpy as np
|
||
from typer.testing import CliRunner
|
||
|
||
from codex.cli import app
|
||
from codex.ingest import IngestResult
|
||
from codex.models import CodeLink
|
||
|
||
runner = CliRunner()
|
||
|
||
|
||
class TestIngest:
|
||
"""Tests for `codex ingest` command."""
|
||
|
||
def test_ingest_command(self) -> None:
|
||
"""Test basic ingest command with mocked ingest_paper."""
|
||
with patch("codex.ingest.ingest_paper") as mock_ingest:
|
||
mock_ingest.return_value = IngestResult(
|
||
paper_id="2301.07041",
|
||
chunks_upserted=5,
|
||
citations_upserted=3,
|
||
)
|
||
|
||
result = runner.invoke(app, ["ingest", "2301.07041"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "5 chunks" in result.stdout
|
||
assert "3 citations" in result.stdout
|
||
|
||
def test_ingest_with_rich_flag(self) -> None:
|
||
"""Test `ingest --rich` passes rich=True and reports formulas/figures."""
|
||
with patch("codex.ingest.ingest_paper") as mock_ingest:
|
||
mock_ingest.return_value = IngestResult(
|
||
paper_id="2301.07041",
|
||
chunks_upserted=5,
|
||
citations_upserted=3,
|
||
formulas_upserted=12,
|
||
figures_upserted=4,
|
||
)
|
||
|
||
result = runner.invoke(app, ["ingest", "2301.07041", "--source", "paper.pdf", "--rich"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "12 formulas" in result.stdout
|
||
assert "4 figures" in result.stdout
|
||
# Verify rich=True was passed
|
||
_, kwargs = mock_ingest.call_args
|
||
assert kwargs.get("rich") is True or mock_ingest.call_args[1].get("rich") is True
|
||
|
||
|
||
class TestSearch:
|
||
"""Tests for `codex search` subcommands (paper + formula)."""
|
||
|
||
def test_search_command(self) -> None:
|
||
"""Alias: delegates to test_search_paper_command for backwards compat."""
|
||
self.test_search_paper_command()
|
||
|
||
def test_search_paper_command(self) -> None:
|
||
"""Test `search paper` command with mocked embedder and DB."""
|
||
with (
|
||
patch("codex.embed.get_embedder") as mock_embedder,
|
||
patch("codex.db.get_conn") as mock_get_conn,
|
||
):
|
||
# Mock embedder - return numpy array with one row
|
||
mock_emb_instance = MagicMock()
|
||
mock_emb_instance.encode_dense.return_value = np.array([[0.1, 0.2, 0.3]])
|
||
mock_embedder.return_value = mock_emb_instance
|
||
|
||
# Mock DB connection
|
||
mock_conn = MagicMock()
|
||
mock_conn.__enter__.return_value = mock_conn
|
||
mock_conn.__exit__.return_value = None
|
||
mock_conn.execute.return_value.fetchall.return_value = [
|
||
{
|
||
"id": "2301.07041",
|
||
"title": "Test Paper",
|
||
"year": 2023,
|
||
"distance": 0.123,
|
||
},
|
||
{
|
||
"id": "2301.07042",
|
||
"title": "Another Paper",
|
||
"year": 2023,
|
||
"distance": 0.456,
|
||
},
|
||
]
|
||
mock_get_conn.return_value = mock_conn
|
||
|
||
result = runner.invoke(app, ["search", "paper", "machine learning", "--limit", "2"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "2301.07042" in result.stdout
|
||
assert "Test Paper" in result.stdout
|
||
assert "0.123" in result.stdout
|
||
|
||
# Regression guard (DQ-4): the pgvector `<->` operator requires the
|
||
# query embedding cast to ::vector. Without it, real Postgres raises
|
||
# "operator does not exist: vector <-> double precision[]". Mocked DBs
|
||
# don't catch the type error, so assert the cast is in the SQL.
|
||
executed_sql = mock_conn.execute.call_args_list[0][0][0]
|
||
assert "<->" in executed_sql and "::vector" in executed_sql
|
||
|
||
def test_search_formula_command(self) -> None:
|
||
"""Test `search formula` command with mocked DB."""
|
||
with patch("codex.db.get_conn") as mock_get_conn:
|
||
mock_conn = MagicMock()
|
||
mock_conn.__enter__.return_value = mock_conn
|
||
mock_conn.__exit__.return_value = None
|
||
mock_conn.execute.return_value.fetchall.return_value = [
|
||
{
|
||
"paper_id": "2301.07041",
|
||
"page": 3,
|
||
"raw_latex": r"\sum_{i=1}^{n} x_i",
|
||
"context": "summation formula",
|
||
"rank": 0.856,
|
||
},
|
||
]
|
||
mock_get_conn.return_value = mock_conn
|
||
|
||
result = runner.invoke(app, ["search", "formula", "summation", "--limit", "5"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "p.3" in result.stdout
|
||
|
||
def test_search_formula_no_results(self) -> None:
|
||
"""`search formula` with no DB matches prints a 'no results' message."""
|
||
with patch("codex.db.get_conn") as mock_get_conn:
|
||
mock_conn = MagicMock()
|
||
mock_conn.__enter__.return_value = mock_conn
|
||
mock_conn.__exit__.return_value = None
|
||
mock_conn.execute.return_value.fetchall.return_value = []
|
||
mock_get_conn.return_value = mock_conn
|
||
|
||
result = runner.invoke(app, ["search", "formula", "nonexistent"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "No matching" in result.stdout
|
||
|
||
|
||
class TestDiscoverLeads:
|
||
"""Tests for `codex discover leads` command."""
|
||
|
||
def test_discover_leads_command(self) -> None:
|
||
"""Test discover leads command with mocked discovery_leads."""
|
||
with patch("codex.discover.discovery_leads") as mock_leads:
|
||
mock_leads.return_value = [
|
||
{"cited_id": "2301.07041", "pull": 5},
|
||
{"cited_id": "2301.07042", "pull": 3},
|
||
]
|
||
|
||
result = runner.invoke(app, ["discover", "leads", "--limit", "2"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "2301.07042" in result.stdout
|
||
assert "5×" in result.stdout
|
||
assert "3×" in result.stdout
|
||
|
||
|
||
class TestDiscoverCiting:
|
||
"""Tests for `codex discover citing` command."""
|
||
|
||
def test_discover_citing_command(self) -> None:
|
||
"""Test discover citing command with mocked citing_papers."""
|
||
with patch("codex.discover.citing_papers") as mock_citing:
|
||
mock_citing.return_value = ["2301.07041", "2301.07042"]
|
||
|
||
result = runner.invoke(app, ["discover", "citing", "2301.07043"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "2301.07042" in result.stdout
|
||
|
||
|
||
class TestDiscoverCitedBy:
|
||
"""Tests for `codex discover cited-by` command."""
|
||
|
||
def test_discover_cited_by_command(self) -> None:
|
||
"""Test discover cited-by command with mocked cited_by."""
|
||
with patch("codex.discover.cited_by") as mock_cited:
|
||
mock_cited.return_value = ["2301.07041", "2301.07042"]
|
||
|
||
result = runner.invoke(app, ["discover", "cited-by", "2301.07043"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "2301.07042" in result.stdout
|
||
|
||
|
||
class TestDiscoverCocited:
|
||
"""Tests for `codex discover cocited` command."""
|
||
|
||
def test_discover_cocited_command(self) -> None:
|
||
"""Test discover cocited command with mocked cocited_papers."""
|
||
with patch("codex.discover.cocited_papers") as mock_cocited:
|
||
mock_cocited.return_value = [
|
||
{"paper_id": "2301.07041", "co_citations": 5},
|
||
{"paper_id": "2301.07042", "co_citations": 3},
|
||
]
|
||
|
||
result = runner.invoke(app, ["discover", "cocited", "2301.07043"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "2301.07041" in result.stdout
|
||
assert "2301.07042" in result.stdout
|
||
assert "5×" in result.stdout
|
||
assert "3×" in result.stdout
|
||
|
||
|
||
class TestProvScan:
|
||
"""Tests for `codex provenance scan` command."""
|
||
|
||
def test_prov_scan_command(self) -> None:
|
||
"""Test provenance scan command with mocked scan_cite_tags."""
|
||
with patch("codex.provenance.scan_cite_tags") as mock_scan:
|
||
mock_scan.return_value = [
|
||
{
|
||
"file": "src/main.cpp",
|
||
"line": "42",
|
||
"symbol": "compute_embedding",
|
||
"bibkey": "2301.07041",
|
||
},
|
||
{
|
||
"file": "src/utils.h",
|
||
"line": "10",
|
||
"symbol": "helper_func",
|
||
"bibkey": "2301.07042",
|
||
},
|
||
]
|
||
|
||
result = runner.invoke(app, ["provenance", "scan", "/path/to/src"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "src/main.cpp" in result.stdout
|
||
assert "2301.07041" in result.stdout
|
||
assert "compute_embedding" in result.stdout
|
||
|
||
|
||
class TestProvAddLink:
|
||
"""Tests for `codex provenance add-link` command."""
|
||
|
||
def test_prov_add_link_command(self) -> None:
|
||
"""Test provenance add-link command with mocked add_code_link."""
|
||
with patch("codex.provenance.add_code_link") as mock_add:
|
||
mock_link = CodeLink(
|
||
id=1,
|
||
symbol="my_func",
|
||
paper_id="2301.07041",
|
||
role="core_algorithm",
|
||
note="Primary reference",
|
||
added_at=datetime(2023, 1, 1),
|
||
)
|
||
mock_add.return_value = mock_link
|
||
|
||
result = runner.invoke(
|
||
app,
|
||
[
|
||
"provenance",
|
||
"add-link",
|
||
"my_func",
|
||
"--paper-id",
|
||
"2301.07041",
|
||
"--role",
|
||
"core_algorithm",
|
||
"--note",
|
||
"Primary reference",
|
||
],
|
||
)
|
||
|
||
assert result.exit_code == 0
|
||
assert "Created code_link" in result.stdout
|
||
assert "my_func" in result.stdout
|
||
assert "2301.07041" in result.stdout
|
||
|
||
|
||
class TestProvLinks:
|
||
"""Tests for `codex provenance links` command."""
|
||
|
||
def test_prov_links_command(self) -> None:
|
||
"""Test provenance links command with mocked list_code_links."""
|
||
with patch("codex.provenance.list_code_links") as mock_links:
|
||
mock_links.return_value = [
|
||
CodeLink(
|
||
id=1,
|
||
symbol="my_func",
|
||
paper_id="2301.07041",
|
||
role="core",
|
||
note=None,
|
||
added_at=datetime(2023, 1, 1),
|
||
),
|
||
CodeLink(
|
||
id=2,
|
||
symbol="another_func",
|
||
paper_id="2301.07042",
|
||
role="utility",
|
||
note=None,
|
||
added_at=datetime(2023, 1, 2),
|
||
),
|
||
]
|
||
|
||
result = runner.invoke(app, ["provenance", "links"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "[1]" in result.stdout
|
||
assert "[2]" in result.stdout
|
||
assert "my_func" in result.stdout
|
||
assert "another_func" in result.stdout
|
||
|
||
|
||
class TestProvBib:
|
||
"""Tests for `codex provenance bib` command."""
|
||
|
||
def test_prov_bib_command(self) -> None:
|
||
"""Test provenance bib command with mocked export_bib."""
|
||
with patch("codex.provenance.export_bib") as mock_export:
|
||
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
|
||
|
||
result = runner.invoke(app, ["provenance", "bib"])
|
||
|
||
assert result.exit_code == 0
|
||
assert "@article" in result.stdout
|
||
assert "2301.07041" in result.stdout
|
||
|
||
def test_prov_bib_with_output_file(self, tmp_path: Any) -> None:
|
||
"""Test provenance bib command writing to file."""
|
||
with patch("codex.provenance.export_bib") as mock_export:
|
||
mock_export.return_value = "@article{2301.07041,\n title = {Test},\n}"
|
||
|
||
output_file = tmp_path / "refs.bib"
|
||
result = runner.invoke(app, ["provenance", "bib", "--output", str(output_file)])
|
||
|
||
assert result.exit_code == 0
|
||
assert "Wrote" in result.stdout
|
||
assert output_file.exists()
|
||
assert "@article" in output_file.read_text()
|
||
|
||
|
||
class TestAsk:
|
||
"""Tests for `codex ask` command."""
|
||
|
||
def test_ask_command_exits_1(self) -> None:
|
||
"""Test that ask command exits with code 1 (not yet implemented)."""
|
||
result = runner.invoke(app, ["ask", "what is machine learning?"])
|
||
|
||
assert result.exit_code == 1
|
||
assert "not yet implemented" in result.stdout or "not yet implemented" in result.stderr
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# D-05a/b — Entry-point smoke tests (python -m codex, no uv run)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestEntryPoint:
|
||
"""Verify that `python -m codex` works without `uv run` (D-05a/b)."""
|
||
|
||
def test_python_m_codex_help(self) -> None:
|
||
"""`python -m codex --help` exits 0 and prints usage."""
|
||
import subprocess
|
||
import sys
|
||
|
||
proc = subprocess.run(
|
||
[sys.executable, "-m", "codex", "--help"],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
assert proc.returncode == 0, f"Non-zero exit: {proc.stderr}"
|
||
assert "Usage" in proc.stdout or "usage" in proc.stdout.lower(), (
|
||
f"No usage in output: {proc.stdout[:200]}"
|
||
)
|