fix: resolve mypy errors, fix arxiv test session isolation, and add p… #43

Merged
JustinZeus merged 1 commit from bugfix/broken-import into main 2026-03-02 12:46:35 +01:00
17 changed files with 129 additions and 28 deletions

2
.gitignore vendored
View file

@ -18,3 +18,5 @@ frontend/dist/
frontend/.vite/ frontend/.vite/
docs/website/node_modules/ docs/website/node_modules/
docs/website/build/ docs/website/build/
docs/website/.docusaurus/
.claude/settings.json

View file

@ -136,7 +136,7 @@ class DataImportRequest(BaseModel):
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def unwrap_export_envelope(cls, values: dict) -> dict: # type: ignore[override] def unwrap_export_envelope(cls, values: dict) -> dict:
"""Accept the full export envelope format (with data/meta wrapper).""" """Accept the full export envelope format (with data/meta wrapper)."""
if isinstance(values, dict) and "data" in values and isinstance(values["data"], dict): if isinstance(values, dict) and "data" in values and isinstance(values["data"], dict):
return values["data"] return values["data"]

58
scripts/premerge.sh Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
DC="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
passed=0
failed=0
failures=()
step() {
local label="$1"
shift
printf '\n\033[1;34m── %s\033[0m\n' "$label"
if "$@"; then
printf '\033[1;32m PASS\033[0m %s\n' "$label"
((passed++)) || true
else
printf '\033[1;31m FAIL\033[0m %s\n' "$label"
((failed++)) || true
failures+=("$label")
fi
}
# ── repo hygiene (host-side, no runtime deps) ────────────────────────────────
step "No generated artifacts" ./scripts/check_no_generated_artifacts.sh
step "Env contract parity" python3 scripts/check_env_contract.py
step "API contract drift" python3 scripts/check_frontend_api_contract.py
# ── backend lint + typecheck ─────────────────────────────────────────────────
step "Ruff check" $DC run --rm app ruff check .
step "Ruff format" $DC run --rm app ruff format --check .
step "Mypy" $DC run --rm app mypy app/ --ignore-missing-imports
# ── backend tests ────────────────────────────────────────────────────────────
step "Unit tests" $DC run --rm app python -m pytest tests/unit
step "Integration tests" $DC run --rm app python -m pytest -m integration
# ── frontend ─────────────────────────────────────────────────────────────────
step "Frontend theme tokens" $DC run --rm frontend npm run check:theme-tokens
step "Frontend typecheck" $DC run --rm frontend npm run typecheck
step "Frontend unit tests" $DC run --rm frontend npm run test:run
step "Frontend build" $DC run --rm frontend npm run build
# ── docs ─────────────────────────────────────────────────────────────────────
step "Docs build" npm --prefix docs/website run build
# ── summary ──────────────────────────────────────────────────────────────────
printf '\n\033[1;34m── Summary ─────────────────────────────────────────────\033[0m\n'
printf ' %s passed, %s failed\n' "$passed" "$failed"
if ((failed > 0)); then
printf '\n\033[1;31m Failed steps:\033[0m\n'
for f in "${failures[@]}"; do
printf ' - %s\n' "$f"
done
exit 1
else
printf '\n\033[1;32m All checks passed.\033[0m\n'
fi

View file

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runtime_deps import get_scholar_source from app.api.runtime_deps import get_scholar_source
from app.main import app from app.main import app
from app.services.scholar.rate_limit import reset_scholar_rate_limit_state_for_tests
from app.services.scholar.source import FetchResult from app.services.scholar.source import FetchResult
from app.settings import settings from app.settings import settings
from tests.integration.helpers import ( from tests.integration.helpers import (
@ -141,6 +142,7 @@ async def test_api_scholars_search_and_profile_image_management(
assert candidate["scholar_id"] == "abcDEF123456" assert candidate["scholar_id"] == "abcDEF123456"
assert candidate["profile_image_url"] == "https://scholar.google.com/citations/images/avatar_scholar_256.png" assert candidate["profile_image_url"] == "https://scholar.google.com/citations/images/avatar_scholar_256.png"
reset_scholar_rate_limit_state_for_tests()
create_response = client.post( create_response = client.post(
"/api/v1/scholars", "/api/v1/scholars",
json={ json={

View file

@ -76,6 +76,9 @@ class FixtureScholarSource:
async def fetch_profile_html(self, scholar_id: str) -> FetchResult: async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
return await self.fetch_profile_page_html(scholar_id, cstart=0, pagesize=100) return await self.fetch_profile_page_html(scholar_id, cstart=0, pagesize=100)
async def fetch_author_search_html(self, query: str, *, start: int = 0) -> FetchResult:
raise NotImplementedError
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.db @pytest.mark.db

View file

@ -33,6 +33,9 @@ class _PassthroughSource:
async def fetch_profile_page_html(self, scholar_id: str, *, cstart: int, pagesize: int) -> FetchResult: async def fetch_profile_page_html(self, scholar_id: str, *, cstart: int, pagesize: int) -> FetchResult:
return await self.fetch_profile_html(scholar_id) return await self.fetch_profile_html(scholar_id)
async def fetch_author_search_html(self, query: str, *, start: int = 0) -> FetchResult:
raise NotImplementedError
def _csrf_headers(client: TestClient) -> dict[str, str]: def _csrf_headers(client: TestClient) -> dict[str, str]:
response = client.get("/api/v1/auth/me") response = client.get("/api/v1/auth/me")

View file

@ -0,0 +1,17 @@
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@pytest.fixture
def patch_session_factory(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch):
"""Patch get_session_factory in arxiv modules to use the test's DB engine.
Without this, the implementation creates its own engine via get_session_factory()
which may not share transaction visibility with the test's db_session fixture.
"""
factory = async_sessionmaker(db_session.bind, expire_on_commit=False)
monkeypatch.setattr("app.services.arxiv.rate_limit.get_session_factory", lambda: factory)
monkeypatch.setattr("app.services.arxiv.cache.get_session_factory", lambda: factory)
return factory

View file

@ -147,8 +147,8 @@ async def test_client_coalesces_concurrent_identical_search_requests() -> None:
async def test_client_logs_cache_hit_and_miss( async def test_client_logs_cache_hit_and_miss(
db_session: AsyncSession, db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
patch_session_factory,
) -> None: ) -> None:
_ = db_session
calls = {"count": 0} calls = {"count": 0}
logged: list[dict[str, object]] = [] logged: list[dict[str, object]] = []

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from typing import Any
import pytest import pytest
@ -10,7 +10,9 @@ from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta
from app.settings import settings from app.settings import settings
def _item(*, title: str = "A Test Paper", scholar_label: str = "Ada Lovelace") -> SimpleNamespace: def _item(*, title: str = "A Test Paper", scholar_label: str = "Ada Lovelace") -> Any:
from types import SimpleNamespace
return SimpleNamespace(title=title, scholar_label=scholar_label) return SimpleNamespace(title=title, scholar_label=scholar_label)
@ -71,7 +73,7 @@ async def test_http_gateway_returns_none_when_disabled(monkeypatch: pytest.Monke
fake_client = FakeClient() fake_client = FakeClient()
object.__setattr__(settings, "arxiv_enabled", False) object.__setattr__(settings, "arxiv_enabled", False)
try: try:
gateway = arxiv_gateway.HttpArxivGateway(client=fake_client) gateway = arxiv_gateway.HttpArxivGateway(client=fake_client) # type: ignore[arg-type]
result = await gateway.discover_arxiv_id_for_publication(item=_item()) result = await gateway.discover_arxiv_id_for_publication(item=_item())
assert result is None assert result is None
assert fake_client.calls == 0 assert fake_client.calls == 0
@ -102,7 +104,7 @@ async def test_http_gateway_uses_client_search_for_discovery() -> None:
) )
fake_client = FakeClient() fake_client = FakeClient()
gateway = arxiv_gateway.HttpArxivGateway(client=fake_client) gateway = arxiv_gateway.HttpArxivGateway(client=fake_client) # type: ignore[arg-type]
result = await gateway.discover_arxiv_id_for_publication( result = await gateway.discover_arxiv_id_for_publication(
item=_item(title="My Paper", scholar_label="Ada Lovelace"), item=_item(title="My Paper", scholar_label="Ada Lovelace"),
request_email="user@example.com", request_email="user@example.com",

View file

@ -16,7 +16,7 @@ from app.settings import settings
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arxiv_rate_limit_respects_cooldown(db_session: AsyncSession) -> None: async def test_arxiv_rate_limit_respects_cooldown(db_session: AsyncSession, patch_session_factory) -> None:
db_session.add( db_session.add(
ArxivRuntimeState( ArxivRuntimeState(
state_key=ARXIV_RUNTIME_STATE_KEY, state_key=ARXIV_RUNTIME_STATE_KEY,
@ -37,7 +37,7 @@ async def test_arxiv_rate_limit_respects_cooldown(db_session: AsyncSession) -> N
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arxiv_rate_limit_persists_cooldown_after_429(db_session: AsyncSession) -> None: async def test_arxiv_rate_limit_persists_cooldown_after_429(db_session: AsyncSession, patch_session_factory) -> None:
previous_interval = settings.arxiv_min_interval_seconds previous_interval = settings.arxiv_min_interval_seconds
previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds
object.__setattr__(settings, "arxiv_min_interval_seconds", 0.0) object.__setattr__(settings, "arxiv_min_interval_seconds", 0.0)
@ -62,7 +62,7 @@ async def test_arxiv_rate_limit_persists_cooldown_after_429(db_session: AsyncSes
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arxiv_rate_limit_serializes_concurrent_calls(db_session: AsyncSession) -> None: async def test_arxiv_rate_limit_serializes_concurrent_calls(db_session: AsyncSession, patch_session_factory) -> None:
previous_interval = settings.arxiv_min_interval_seconds previous_interval = settings.arxiv_min_interval_seconds
previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds
object.__setattr__(settings, "arxiv_min_interval_seconds", 0.2) object.__setattr__(settings, "arxiv_min_interval_seconds", 0.2)
@ -90,6 +90,7 @@ async def test_arxiv_rate_limit_serializes_concurrent_calls(db_session: AsyncSes
async def test_arxiv_rate_limit_logs_request_scheduled_and_completed( async def test_arxiv_rate_limit_logs_request_scheduled_and_completed(
db_session: AsyncSession, db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
patch_session_factory,
) -> None: ) -> None:
logged: list[dict[str, object]] = [] logged: list[dict[str, object]] = []
@ -107,8 +108,8 @@ async def test_arxiv_rate_limit_logs_request_scheduled_and_completed(
assert scheduled assert scheduled
assert completed assert completed
assert float(scheduled[0]["wait_seconds"]) >= 0.0 assert float(str(scheduled[0]["wait_seconds"])) >= 0.0
assert int(completed[0]["status_code"]) == 200 assert int(str(completed[0]["status_code"])) == 200
assert completed[0]["source_path"] == "search" assert completed[0]["source_path"] == "search"
@ -116,6 +117,7 @@ async def test_arxiv_rate_limit_logs_request_scheduled_and_completed(
async def test_arxiv_rate_limit_logs_cooldown_activation( async def test_arxiv_rate_limit_logs_cooldown_activation(
db_session: AsyncSession, db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
patch_session_factory,
) -> None: ) -> None:
logged_warning: list[dict[str, object]] = [] logged_warning: list[dict[str, object]] = []
previous_interval = settings.arxiv_min_interval_seconds previous_interval = settings.arxiv_min_interval_seconds
@ -140,11 +142,11 @@ async def test_arxiv_rate_limit_logs_cooldown_activation(
cooldown_events = [entry for entry in logged_warning if entry.get("event") == "arxiv.cooldown_activated"] cooldown_events = [entry for entry in logged_warning if entry.get("event") == "arxiv.cooldown_activated"]
assert cooldown_events assert cooldown_events
assert cooldown_events[0]["source_path"] == "lookup_ids" assert cooldown_events[0]["source_path"] == "lookup_ids"
assert float(cooldown_events[0]["cooldown_remaining_seconds"]) > 0.0 assert float(str(cooldown_events[0]["cooldown_remaining_seconds"])) > 0.0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_arxiv_cooldown_status_reads_active_cooldown(db_session: AsyncSession) -> None: async def test_get_arxiv_cooldown_status_reads_active_cooldown(db_session: AsyncSession, patch_session_factory) -> None:
now_utc = datetime(2026, 2, 26, 13, 0, tzinfo=UTC) now_utc = datetime(2026, 2, 26, 13, 0, tzinfo=UTC)
existing = await db_session.get(ArxivRuntimeState, ARXIV_RUNTIME_STATE_KEY) existing = await db_session.get(ArxivRuntimeState, ARXIV_RUNTIME_STATE_KEY)
if existing is None: if existing is None:

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
import pytest import pytest
@ -38,8 +39,8 @@ async def test_discover_identifiers_for_enrichment_disables_arxiv_on_rate_limit(
monkeypatch.setattr(runner, "_publish_identifier_update_event", _publish_noop) monkeypatch.setattr(runner, "_publish_identifier_update_event", _publish_noop)
result = await runner._discover_identifiers_for_enrichment( result = await runner._discover_identifiers_for_enrichment(
object(), cast(Any, object()),
publication=publication, publication=cast(Any, publication),
run_id=321, run_id=321,
allow_arxiv_lookup=True, allow_arxiv_lookup=True,
) )

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app.services.portability.publication_import import ( from app.services.portability.publication_import import (
@ -9,14 +10,14 @@ from app.services.portability.publication_import import (
) )
def _mock_profile(scholar_id: str = "ABC123DEF456") -> MagicMock: def _mock_profile(scholar_id: str = "ABC123DEF456") -> Any:
profile = MagicMock() profile = MagicMock()
profile.id = 1 profile.id = 1
profile.scholar_id = scholar_id profile.scholar_id = scholar_id
return profile return profile
def _scholar_map(scholar_id: str = "ABC123DEF456") -> dict[str, MagicMock]: def _scholar_map(scholar_id: str = "ABC123DEF456") -> dict[str, Any]:
return {scholar_id: _mock_profile(scholar_id)} return {scholar_id: _mock_profile(scholar_id)}

View file

@ -111,8 +111,9 @@ class TestBuildFingerprint:
assert all(c in "0123456789abcdef" for c in fp) assert all(c in "0123456789abcdef" for c in fp)
def test_deterministic(self) -> None: def test_deterministic(self) -> None:
kwargs = {"title": "Test Title", "year": 2024, "author_text": "Smith", "venue_text": "ICML"} fp_a = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
assert _build_fingerprint(**kwargs) == _build_fingerprint(**kwargs) fp_b = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
assert fp_a == fp_b
def test_different_titles_produce_different_fingerprints(self) -> None: def test_different_titles_produce_different_fingerprints(self) -> None:
fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None) fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
import pytest import pytest
@ -27,7 +28,7 @@ def _job(
def _row( def _row(
*, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz" *, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz"
) -> SimpleNamespace: ) -> Any:
return SimpleNamespace( return SimpleNamespace(
publication_id=1, publication_id=1,
scholar_profile_id=1, scholar_profile_id=1,
@ -235,7 +236,7 @@ async def test_run_resolution_task_disables_arxiv_for_remaining_batch(
) -> None: ) -> None:
calls: list[tuple[int, bool]] = [] calls: list[tuple[int, bool]] = []
first = _row() first = _row()
second = SimpleNamespace(**{**first.__dict__, "publication_id": 2}) second: Any = SimpleNamespace(**{**first.__dict__, "publication_id": 2})
def _raise_session_factory_error(): def _raise_session_factory_error():
raise RuntimeError("skip user settings lookup in test") raise RuntimeError("skip user settings lookup in test")

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
import pytest import pytest
@ -11,7 +12,7 @@ from app.services.publications import pdf_resolution_pipeline as pipeline
from app.services.unpaywall.application import OaResolutionOutcome from app.services.unpaywall.application import OaResolutionOutcome
def _row(*, display_identifier: DisplayIdentifier | None = None) -> SimpleNamespace: def _row(*, display_identifier: DisplayIdentifier | None = None) -> Any:
return SimpleNamespace( return SimpleNamespace(
publication_id=1, publication_id=1,
scholar_profile_id=1, scholar_profile_id=1,

View file

@ -23,6 +23,12 @@ class StubScholarSource:
index = min(self.calls - 1, len(self._fetch_results) - 1) index = min(self.calls - 1, len(self._fetch_results) - 1)
return self._fetch_results[index] return self._fetch_results[index]
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
raise NotImplementedError
async def fetch_profile_page_html(self, scholar_id: str, *, cstart: int, pagesize: int) -> FetchResult:
raise NotImplementedError
def _ok_author_search_fetch() -> FetchResult: def _ok_author_search_fetch() -> FetchResult:
body = ( body = (

View file

@ -1,4 +1,5 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
import pytest import pytest
@ -42,9 +43,9 @@ async def test_create_hydration_skips_when_global_throttle_active(
) )
result = await scholar_helpers.hydrate_scholar_metadata_if_needed( result = await scholar_helpers.hydrate_scholar_metadata_if_needed(
db_session=None, db_session=cast(Any, None),
profile=profile, profile=profile,
source=object(), source=cast(Any, object()),
user_id=7, user_id=7,
) )
@ -77,9 +78,9 @@ async def test_create_hydration_runs_when_throttle_is_clear(
) )
result = await scholar_helpers.hydrate_scholar_metadata_if_needed( result = await scholar_helpers.hydrate_scholar_metadata_if_needed(
db_session=None, db_session=cast(Any, None),
profile=profile, profile=profile,
source=object(), source=cast(Any, object()),
user_id=8, user_id=8,
) )
@ -109,7 +110,7 @@ async def test_initial_scrape_job_enqueued_on_create(
) )
queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar( queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar(
session, cast(Any, session),
profile=profile, profile=profile,
user_id=9, user_id=9,
) )
@ -134,7 +135,7 @@ async def test_initial_scrape_job_not_enqueued_when_disabled(
) )
queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar( queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar(
session, cast(Any, session),
profile=profile, profile=profile,
user_id=11, user_id=11,
) )