284 lines
8.4 KiB
Python
284 lines
8.4 KiB
Python
"""Unit tests for publication dedup operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.db.models import ScholarPublication
|
|
from app.services.domains.publications import dedup as dedup_service
|
|
from app.services.domains.publications.dedup import (
|
|
NearDuplicateCluster,
|
|
NearDuplicateMember,
|
|
find_identifier_duplicate_pairs,
|
|
find_near_duplicate_clusters,
|
|
merge_duplicate_publication,
|
|
merge_near_duplicate_cluster,
|
|
sweep_identifier_duplicates,
|
|
)
|
|
|
|
|
|
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)
|
|
mock_result.all.return_value = 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
|
|
|
|
|
|
@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 == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_duplicate_publication_migrates_links_and_identifiers() -> None:
|
|
session = AsyncMock()
|
|
winner = SimpleNamespace(
|
|
year=2014,
|
|
citation_count=10,
|
|
author_text=None,
|
|
venue_text=None,
|
|
pub_url=None,
|
|
pdf_url=None,
|
|
cluster_id=None,
|
|
canonical_title_hash=None,
|
|
title_raw="winner",
|
|
title_normalized="winner",
|
|
)
|
|
dup = SimpleNamespace(
|
|
year=2015,
|
|
citation_count=11,
|
|
author_text="a",
|
|
venue_text="v",
|
|
pub_url="https://example.org",
|
|
pdf_url="https://example.org/a.pdf",
|
|
cluster_id="x",
|
|
canonical_title_hash="hash",
|
|
title_raw="dup",
|
|
title_normalized="dup",
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"app.services.domains.publications.dedup._load_publication",
|
|
new=AsyncMock(side_effect=[winner, dup]),
|
|
),
|
|
patch(
|
|
"app.services.domains.publications.dedup._migrate_scholar_links",
|
|
new=AsyncMock(),
|
|
) as mock_links,
|
|
patch(
|
|
"app.services.domains.publications.dedup._migrate_identifiers",
|
|
new=AsyncMock(),
|
|
) as mock_identifiers,
|
|
):
|
|
await merge_duplicate_publication(session, winner_id=1, dup_id=2)
|
|
|
|
assert session.execute.await_count == 1
|
|
mock_links.assert_awaited_once_with(session, winner_id=1, dup_id=2)
|
|
mock_identifiers.assert_awaited_once_with(session, winner_id=1, dup_id=2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_duplicate_publication_rejects_missing_publications() -> None:
|
|
session = AsyncMock()
|
|
|
|
with patch(
|
|
"app.services.domains.publications.dedup._load_publication",
|
|
new=AsyncMock(side_effect=[None, None]),
|
|
):
|
|
with pytest.raises(ValueError):
|
|
await merge_duplicate_publication(session, winner_id=1, dup_id=2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_scholar_links_moves_orphans() -> None:
|
|
dup_link = MagicMock(spec=ScholarPublication)
|
|
dup_link.scholar_profile_id = 99
|
|
dup_link.publication_id = 2
|
|
|
|
session = _session_with_execute_sequence(
|
|
results=[
|
|
[dup_link],
|
|
[],
|
|
]
|
|
)
|
|
|
|
await dedup_service._migrate_scholar_links(session, winner_id=1, dup_id=2)
|
|
|
|
assert dup_link.publication_id == 1
|
|
session.delete.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_scholar_links_drops_conflicts() -> None:
|
|
dup_link = MagicMock(spec=ScholarPublication)
|
|
dup_link.scholar_profile_id = 88
|
|
dup_link.publication_id = 2
|
|
|
|
session = _session_with_execute_sequence(
|
|
results=[
|
|
[dup_link],
|
|
[(88,)],
|
|
]
|
|
)
|
|
|
|
await dedup_service._migrate_scholar_links(session, winner_id=1, dup_id=2)
|
|
|
|
session.delete.assert_awaited_once_with(dup_link)
|
|
|
|
|
|
@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:
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_near_duplicate_clusters_groups_similar_titles() -> None:
|
|
first = dedup_service._candidate_from_row(
|
|
publication_id=10,
|
|
title="Adam: A method for stochastic optimization",
|
|
year=2014,
|
|
citation_count=100,
|
|
)
|
|
second = dedup_service._candidate_from_row(
|
|
publication_id=11,
|
|
title="†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent.",
|
|
year=2015,
|
|
citation_count=50,
|
|
)
|
|
assert first is not None
|
|
assert second is not None
|
|
|
|
with patch(
|
|
"app.services.domains.publications.dedup._load_near_duplicate_candidates",
|
|
new=AsyncMock(return_value=[first, second]),
|
|
):
|
|
clusters = await find_near_duplicate_clusters(AsyncMock())
|
|
|
|
assert len(clusters) == 1
|
|
assert len(clusters[0].members) == 2
|
|
assert clusters[0].winner_publication_id == 10
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_near_duplicate_clusters_skips_unrelated_titles() -> None:
|
|
first = dedup_service._candidate_from_row(
|
|
publication_id=21,
|
|
title="Adam optimizer",
|
|
year=2014,
|
|
citation_count=10,
|
|
)
|
|
second = dedup_service._candidate_from_row(
|
|
publication_id=22,
|
|
title="Diffusion models in vision",
|
|
year=2022,
|
|
citation_count=10,
|
|
)
|
|
assert first is not None
|
|
assert second is not None
|
|
|
|
with patch(
|
|
"app.services.domains.publications.dedup._load_near_duplicate_candidates",
|
|
new=AsyncMock(return_value=[first, second]),
|
|
):
|
|
clusters = await find_near_duplicate_clusters(AsyncMock())
|
|
|
|
assert clusters == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_near_duplicate_cluster_merges_non_winner_members() -> None:
|
|
cluster = NearDuplicateCluster(
|
|
cluster_key="abc",
|
|
winner_publication_id=5,
|
|
similarity_score=1.0,
|
|
members=(
|
|
NearDuplicateMember(publication_id=5, title="Winner", year=2014, citation_count=10),
|
|
NearDuplicateMember(publication_id=6, title="Dup", year=2014, citation_count=3),
|
|
NearDuplicateMember(publication_id=7, title="Dup2", year=2014, citation_count=1),
|
|
),
|
|
)
|
|
|
|
with patch(
|
|
"app.services.domains.publications.dedup.merge_duplicate_publication",
|
|
new=AsyncMock(),
|
|
) as mock_merge:
|
|
merged = await merge_near_duplicate_cluster(AsyncMock(), cluster=cluster)
|
|
|
|
assert merged == 2
|
|
assert mock_merge.await_count == 2
|