temp commit
This commit is contained in:
parent
8760f27b51
commit
0e9e49df16
193 changed files with 23228 additions and 935 deletions
64
tests/unit/services/domains/openalex/test_client.py
Normal file
64
tests/unit/services/domains/openalex/test_client.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import pytest
|
||||
from app.services.domains.openalex.types import OpenAlexWork
|
||||
|
||||
def test_parse_openalex_work_from_api_dict() -> None:
|
||||
raw_api_response = {
|
||||
"id": "https://openalex.org/W2741809807",
|
||||
"doi": "https://doi.org/10.1038/s41586-020-0315-z",
|
||||
"title": "Machine learning and the physical sciences",
|
||||
"publication_year": 2019,
|
||||
"cited_by_count": 1420,
|
||||
"ids": {
|
||||
"openalex": "https://openalex.org/W2741809807",
|
||||
"doi": "https://doi.org/10.1038/s41586-020-0315-z",
|
||||
"mag": "2741809807",
|
||||
"pmid": "https://pubmed.ncbi.nlm.nih.gov/32040050",
|
||||
"pmcid": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7325852"
|
||||
},
|
||||
"open_access": {
|
||||
"is_oa": True,
|
||||
"oa_url": "https://example.com/pdf"
|
||||
},
|
||||
"authorships": [
|
||||
{
|
||||
"author_position": "first",
|
||||
"author": {
|
||||
"id": "https://openalex.org/A1969205032",
|
||||
"display_name": "Giuseppe Carleo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"author_position": "middle",
|
||||
"author": {
|
||||
"id": "https://openalex.org/A4356881717",
|
||||
"display_name": "Ignacio Cirac"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
work = OpenAlexWork.from_api_dict(raw_api_response)
|
||||
|
||||
assert work.openalex_id == "https://openalex.org/W2741809807"
|
||||
assert work.title == "Machine learning and the physical sciences"
|
||||
assert work.doi == "10.1038/s41586-020-0315-z"
|
||||
assert work.pmid == "32040050"
|
||||
assert work.pmcid == "PMC7325852"
|
||||
assert work.publication_year == 2019
|
||||
assert work.cited_by_count == 1420
|
||||
assert work.is_oa is True
|
||||
assert work.oa_url == "https://example.com/pdf"
|
||||
assert len(work.authors) == 2
|
||||
assert work.authors[0].display_name == "Giuseppe Carleo"
|
||||
assert work.authors[1].display_name == "Ignacio Cirac"
|
||||
|
||||
def test_parse_openalex_work_empty() -> None:
|
||||
work = OpenAlexWork.from_api_dict({"id": "W123"})
|
||||
assert work.openalex_id == "W123"
|
||||
assert work.doi is None
|
||||
assert work.pmid is None
|
||||
assert work.title is None
|
||||
assert work.publication_year is None
|
||||
assert work.cited_by_count == 0
|
||||
assert work.is_oa is False
|
||||
assert len(work.authors) == 0
|
||||
55
tests/unit/services/domains/openalex/test_matching.py
Normal file
55
tests/unit/services/domains/openalex/test_matching.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
from app.services.domains.openalex.types import OpenAlexWork
|
||||
from app.services.domains.openalex.matching import find_best_match
|
||||
|
||||
def test_find_best_match_exact_title():
|
||||
cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Exact Title of the Paper"})
|
||||
cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "Totally Different Paper"})
|
||||
|
||||
match = find_best_match("Exact Title of the Paper", 2020, "Author A", [cand1, cand2])
|
||||
assert match is not None
|
||||
assert match.openalex_id == "W1"
|
||||
|
||||
def test_find_best_match_fuzzy_title():
|
||||
# Only differences are punctuation or minor phrasing (e.g., matching a preprint title vs published)
|
||||
cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Fuzzier Title: A Study on OpenAlex"})
|
||||
cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "Some completely unrelated work"})
|
||||
|
||||
match = find_best_match("Fuzzier Title A Study on OpenAlex", 2021, "Author B", [cand1, cand2])
|
||||
assert match is not None
|
||||
assert match.openalex_id == "W1"
|
||||
|
||||
def test_find_best_match_rejects_low_score():
|
||||
cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Cats in hats"})
|
||||
|
||||
match = find_best_match("Dogs with logs", 2020, "Author A", [cand1])
|
||||
assert match is None
|
||||
|
||||
def test_find_best_match_year_tiebreaker():
|
||||
# Both titles are very similar, one has exact year.
|
||||
cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "The exact same title", "publication_year": 2018})
|
||||
cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "The exact same title", "publication_year": 2020})
|
||||
|
||||
match = find_best_match("The exact same title", 2020, "Author A", [cand1, cand2])
|
||||
assert match is not None
|
||||
assert match.openalex_id == "W2"
|
||||
|
||||
def test_find_best_match_author_tiebreaker():
|
||||
# Titles and years match exactly. Author overlap decides it.
|
||||
cand1 = OpenAlexWork.from_api_dict({
|
||||
"id": "W1",
|
||||
"title": "A popular title",
|
||||
"publication_year": 2020,
|
||||
"authorships": [{"author": {"display_name": "Smith, J"}}]
|
||||
})
|
||||
cand2 = OpenAlexWork.from_api_dict({
|
||||
"id": "W2",
|
||||
"title": "A popular title",
|
||||
"publication_year": 2020,
|
||||
"authorships": [{"author": {"display_name": "Doe, J"}}]
|
||||
})
|
||||
|
||||
# Target authors contains "Doe"
|
||||
match = find_best_match("A popular title", 2020, "A Einstein, J Doe", [cand1, cand2])
|
||||
assert match is not None
|
||||
assert match.openalex_id == "W2"
|
||||
163
tests/unit/services/domains/publications/test_dedup.py
Normal file
163
tests/unit/services/domains/publications/test_dedup.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Unit tests for the identifier-based publication dedup sweep.
|
||||
|
||||
DB operations are mocked via AsyncMock so no database is required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import ScholarPublication
|
||||
from app.services.domains.publications.dedup import (
|
||||
find_identifier_duplicate_pairs,
|
||||
merge_duplicate_publication,
|
||||
sweep_identifier_duplicates,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_result(rows: list) -> MagicMock:
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = rows
|
||||
mock_result.__iter__ = lambda self: iter(rows)
|
||||
return mock_result
|
||||
|
||||
|
||||
def _session_with_execute_sequence(results: list) -> AsyncMock:
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock(side_effect=[_make_result(r) for r in results])
|
||||
session.delete = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_identifier_duplicate_pairs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_identifier_duplicate_pairs_returns_pairs() -> None:
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock(return_value=_make_result([(1, 2)]))
|
||||
|
||||
pairs = await find_identifier_duplicate_pairs(session)
|
||||
|
||||
assert pairs == [(1, 2)]
|
||||
session.execute.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_identifier_duplicate_pairs_returns_empty_when_no_duplicates() -> None:
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock(return_value=_make_result([]))
|
||||
|
||||
pairs = await find_identifier_duplicate_pairs(session)
|
||||
|
||||
assert pairs == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge_duplicate_publication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_duplicate_migrates_orphaned_scholar_links() -> None:
|
||||
"""Scholar link that only the dup has should be migrated to winner."""
|
||||
dup_link = MagicMock(spec=ScholarPublication)
|
||||
dup_link.scholar_profile_id = 99
|
||||
dup_link.publication_id = 2
|
||||
|
||||
session = _session_with_execute_sequence(
|
||||
results=[
|
||||
[dup_link], # dup links
|
||||
[], # winner profile ids (no conflict)
|
||||
[], # execute(delete(Publication)) result
|
||||
]
|
||||
)
|
||||
|
||||
await merge_duplicate_publication(session, winner_id=1, dup_id=2)
|
||||
|
||||
assert dup_link.publication_id == 1
|
||||
session.delete.assert_not_awaited() # not deleted; migrated instead
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_duplicate_drops_conflicting_scholar_link() -> None:
|
||||
"""When winner already has a link for the same scholar, dup's link is deleted."""
|
||||
dup_link = MagicMock(spec=ScholarPublication)
|
||||
dup_link.scholar_profile_id = 88
|
||||
dup_link.publication_id = 2
|
||||
|
||||
session = _session_with_execute_sequence(
|
||||
results=[
|
||||
[dup_link], # dup links
|
||||
[(88,)], # winner profiles (conflict: profile 88 already linked)
|
||||
[], # execute(delete(Publication)) result
|
||||
]
|
||||
)
|
||||
|
||||
await merge_duplicate_publication(session, winner_id=1, dup_id=2)
|
||||
|
||||
session.delete.assert_awaited_once_with(dup_link)
|
||||
assert dup_link.publication_id == 2 # unchanged — link was deleted, not migrated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sweep_identifier_duplicates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_returns_zero_when_no_pairs() -> None:
|
||||
with patch(
|
||||
"app.services.domains.publications.dedup.find_identifier_duplicate_pairs",
|
||||
new=AsyncMock(return_value=[]),
|
||||
):
|
||||
session = AsyncMock()
|
||||
count = await sweep_identifier_duplicates(session)
|
||||
|
||||
assert count == 0
|
||||
session.flush.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_returns_merge_count() -> None:
|
||||
with (
|
||||
patch(
|
||||
"app.services.domains.publications.dedup.find_identifier_duplicate_pairs",
|
||||
new=AsyncMock(return_value=[(1, 2), (3, 4)]),
|
||||
),
|
||||
patch(
|
||||
"app.services.domains.publications.dedup.merge_duplicate_publication",
|
||||
new=AsyncMock(),
|
||||
) as mock_merge,
|
||||
):
|
||||
session = AsyncMock()
|
||||
count = await sweep_identifier_duplicates(session)
|
||||
|
||||
assert count == 2
|
||||
assert mock_merge.await_count == 2
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sweep_merges_each_dup_only_once() -> None:
|
||||
"""A dup sharing two identifiers with the winner appears twice in pairs but merged once."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.domains.publications.dedup.find_identifier_duplicate_pairs",
|
||||
new=AsyncMock(return_value=[(1, 2), (1, 2)]),
|
||||
),
|
||||
patch(
|
||||
"app.services.domains.publications.dedup.merge_duplicate_publication",
|
||||
new=AsyncMock(),
|
||||
) as mock_merge,
|
||||
):
|
||||
session = AsyncMock()
|
||||
count = await sweep_identifier_duplicates(session)
|
||||
|
||||
assert count == 1
|
||||
assert mock_merge.await_count == 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue