scholarr/tests/unit/test_portability_normalize.py
Justin Visser 87aa89b58c fix: resolve mypy errors, fix arxiv test session isolation, and add premerge script
- Remove unused type:ignore comment in scholars.py
- Fix 51 mypy errors across test files (Any return types, cast(), protocol stubs)
- Fix arxiv unit test failures caused by session isolation (patch_session_factory fixture)
- Fix integration test rate-limit bleed between search and create
- Add scripts/premerge.sh for local CI verification
- Add docs/website/.docusaurus/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:46:10 +01:00

194 lines
6.4 KiB
Python

from __future__ import annotations
import pytest
from app.services.portability.constants import MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS
from app.services.portability.normalize import (
_build_fingerprint,
_first_author_last_name,
_first_venue_word,
_normalize_citation_count,
_normalize_optional_text,
_normalize_optional_year,
_resolve_fingerprint,
_validate_import_sizes,
)
from app.services.portability.types import ImportExportError
class TestNormalizeOptionalText:
def test_returns_stripped_string(self) -> None:
assert _normalize_optional_text(" hello ") == "hello"
def test_returns_none_for_none(self) -> None:
assert _normalize_optional_text(None) is None
def test_returns_none_for_empty_string(self) -> None:
assert _normalize_optional_text("") is None
def test_returns_none_for_whitespace_only(self) -> None:
assert _normalize_optional_text(" ") is None
def test_coerces_non_string_to_string(self) -> None:
assert _normalize_optional_text(42) == "42"
class TestNormalizeOptionalYear:
def test_valid_year(self) -> None:
assert _normalize_optional_year(2024) == 2024
def test_string_year(self) -> None:
assert _normalize_optional_year("1999") == 1999
def test_returns_none_for_none(self) -> None:
assert _normalize_optional_year(None) is None
def test_returns_none_for_non_numeric(self) -> None:
assert _normalize_optional_year("abc") is None
def test_returns_none_for_year_below_1500(self) -> None:
assert _normalize_optional_year(1499) is None
def test_returns_none_for_year_above_3000(self) -> None:
assert _normalize_optional_year(3001) is None
def test_boundary_1500_accepted(self) -> None:
assert _normalize_optional_year(1500) == 1500
def test_boundary_3000_accepted(self) -> None:
assert _normalize_optional_year(3000) == 3000
class TestNormalizeCitationCount:
def test_valid_positive(self) -> None:
assert _normalize_citation_count(10) == 10
def test_zero(self) -> None:
assert _normalize_citation_count(0) == 0
def test_negative_clamped_to_zero(self) -> None:
assert _normalize_citation_count(-5) == 0
def test_string_number(self) -> None:
assert _normalize_citation_count("42") == 42
def test_none_returns_zero(self) -> None:
assert _normalize_citation_count(None) == 0
def test_invalid_string_returns_zero(self) -> None:
assert _normalize_citation_count("abc") == 0
class TestFirstAuthorLastName:
def test_single_author(self) -> None:
assert _first_author_last_name("John Smith") == "smith"
def test_multiple_authors(self) -> None:
assert _first_author_last_name("Jane Doe, John Smith") == "doe"
def test_empty_string(self) -> None:
assert _first_author_last_name("") == ""
def test_none(self) -> None:
assert _first_author_last_name(None) == ""
class TestFirstVenueWord:
def test_returns_first_word(self) -> None:
assert _first_venue_word("Nature Communications") == "nature"
def test_empty_string(self) -> None:
assert _first_venue_word("") == ""
def test_none(self) -> None:
assert _first_venue_word(None) == ""
class TestBuildFingerprint:
def test_produces_64_hex_digest(self) -> None:
fp = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
assert len(fp) == 64
assert all(c in "0123456789abcdef" for c in fp)
def test_deterministic(self) -> None:
fp_a = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
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:
fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None)
fp2 = _build_fingerprint(title="Title B", year=2024, author_text=None, venue_text=None)
assert fp1 != fp2
def test_none_year_handled(self) -> None:
fp = _build_fingerprint(title="Test", year=None, author_text=None, venue_text=None)
assert len(fp) == 64
class TestResolveFingerprint:
def test_uses_provided_valid_sha256(self) -> None:
valid_sha = "a" * 64
result = _resolve_fingerprint(
title="Test",
year=2024,
author_text=None,
venue_text=None,
provided_fingerprint=valid_sha,
)
assert result == valid_sha
def test_computes_fingerprint_when_provided_is_none(self) -> None:
result = _resolve_fingerprint(
title="Test",
year=2024,
author_text=None,
venue_text=None,
provided_fingerprint=None,
)
assert len(result) == 64
def test_computes_fingerprint_when_provided_is_invalid(self) -> None:
result = _resolve_fingerprint(
title="Test",
year=2024,
author_text=None,
venue_text=None,
provided_fingerprint="not-a-sha",
)
assert len(result) == 64
def test_normalizes_provided_to_lowercase(self) -> None:
valid_sha = "A" * 64
result = _resolve_fingerprint(
title="Test",
year=2024,
author_text=None,
venue_text=None,
provided_fingerprint=valid_sha,
)
assert result == "a" * 64
class TestValidateImportSizes:
def test_accepts_within_limits(self) -> None:
_validate_import_sizes(scholars=[{}], publications=[{}])
def test_rejects_too_many_scholars(self) -> None:
with pytest.raises(ImportExportError, match="max scholars"):
_validate_import_sizes(
scholars=[{}] * (MAX_IMPORT_SCHOLARS + 1),
publications=[],
)
def test_rejects_too_many_publications(self) -> None:
with pytest.raises(ImportExportError, match="max publications"):
_validate_import_sizes(
scholars=[],
publications=[{}] * (MAX_IMPORT_PUBLICATIONS + 1),
)
def test_accepts_exact_limit(self) -> None:
_validate_import_sizes(
scholars=[{}] * MAX_IMPORT_SCHOLARS,
publications=[{}] * MAX_IMPORT_PUBLICATIONS,
)