- 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>
194 lines
6.4 KiB
Python
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,
|
|
)
|