harden domain architecture and add lazy Crossref+Unpaywall PDF enrichment with publications workspace refactor
This commit is contained in:
parent
a527e7a535
commit
01454162bb
62 changed files with 2562 additions and 688 deletions
75
tests/unit/test_crossref_lookup.py
Normal file
75
tests/unit/test_crossref_lookup.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.domains.crossref import application as crossref_app
|
||||
|
||||
|
||||
def _item(*, title: str, year: int | None, scholar_label: str = "Shinya Yamanaka"):
|
||||
return SimpleNamespace(
|
||||
publication_id=1,
|
||||
scholar_label=scholar_label,
|
||||
title=title,
|
||||
year=year,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
responses = [
|
||||
[],
|
||||
[
|
||||
{
|
||||
"DOI": "10.1000/noisy",
|
||||
"title": ["Completely unrelated paper"],
|
||||
"issued": {"date-parts": [[2014]]},
|
||||
"author": [{"family": "Other"}],
|
||||
},
|
||||
{
|
||||
"DOI": "10.1016/j.cell.2007.11.019",
|
||||
"title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
|
||||
"issued": {"date-parts": [[2007]]},
|
||||
"author": [{"family": "Yamanaka"}],
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
async def _fake_fetch_items(**_kwargs):
|
||||
return responses.pop(0) if responses else []
|
||||
|
||||
monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
|
||||
doi = await crossref_app.discover_doi_for_publication(
|
||||
item=_item(
|
||||
title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
|
||||
year=2007,
|
||||
),
|
||||
max_rows=10,
|
||||
email="user@example.com",
|
||||
)
|
||||
assert doi == "10.1016/j.cell.2007.11.019"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_fetch_items(**_kwargs):
|
||||
return [
|
||||
{
|
||||
"DOI": "10.1000/wrong-year",
|
||||
"title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
|
||||
"issued": {"date-parts": [[2014]]},
|
||||
"author": [{"family": "Yamanaka"}],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
|
||||
doi = await crossref_app.discover_doi_for_publication(
|
||||
item=_item(
|
||||
title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
|
||||
year=2007,
|
||||
),
|
||||
max_rows=10,
|
||||
email=None,
|
||||
)
|
||||
assert doi is None
|
||||
17
tests/unit/test_doi_normalize.py
Normal file
17
tests/unit/test_doi_normalize.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.services.domains.doi.normalize import first_doi_from_texts, normalize_doi
|
||||
|
||||
|
||||
def test_normalize_doi_extracts_and_lowercases() -> None:
|
||||
value = "https://doi.org/10.48550/ARXIV.1412.6980"
|
||||
assert normalize_doi(value) == "10.48550/arxiv.1412.6980"
|
||||
|
||||
|
||||
def test_first_doi_from_texts_prefers_first_match() -> None:
|
||||
result = first_doi_from_texts(
|
||||
"no doi here",
|
||||
"venue text 10.1000/ABC-123",
|
||||
"title with 10.9999/ignored",
|
||||
)
|
||||
assert result == "10.1000/abc-123"
|
||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.db.models import UserSetting
|
||||
from app.services import run_safety
|
||||
from app.services.domains.ingestion import safety as run_safety
|
||||
|
||||
|
||||
def test_apply_run_safety_outcome_triggers_blocked_cooldown() -> None:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.services.runs import extract_run_summary
|
||||
from app.services.domains.runs.application import extract_run_summary
|
||||
|
||||
|
||||
def test_extract_run_summary_includes_extended_metrics() -> None:
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.scholar_parser import (
|
||||
import pytest
|
||||
|
||||
from app.services.domains.scholar.parser import (
|
||||
ParseState,
|
||||
ScholarDomInvariantError,
|
||||
parse_author_search_page,
|
||||
parse_profile_page,
|
||||
)
|
||||
from app.services.scholar_source import FetchResult
|
||||
from app.services.domains.scholar.source import FetchResult
|
||||
|
||||
|
||||
def _fixture(name: str) -> str:
|
||||
|
|
@ -98,7 +101,7 @@ def test_parse_profile_page_handles_missing_optional_metadata() -> None:
|
|||
assert publication.venue_text is None
|
||||
|
||||
|
||||
def test_parse_profile_page_extracts_direct_pdf_link() -> None:
|
||||
def test_parse_profile_page_ignores_direct_pdf_link_markup() -> None:
|
||||
html = """
|
||||
<html>
|
||||
<div id="gsc_prf_in">Direct PDF Test</div>
|
||||
|
|
@ -131,7 +134,7 @@ def test_parse_profile_page_extracts_direct_pdf_link() -> None:
|
|||
|
||||
assert parsed.state == ParseState.OK
|
||||
assert len(parsed.publications) == 1
|
||||
assert parsed.publications[0].pdf_url == "https://example.org/paper.pdf"
|
||||
assert parsed.publications[0].pdf_url is None
|
||||
|
||||
|
||||
def test_parse_profile_page_fails_fast_when_citation_markup_is_unparseable() -> None:
|
||||
|
|
@ -160,10 +163,9 @@ def test_parse_profile_page_fails_fast_when_citation_markup_is_unparseable() ->
|
|||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.LAYOUT_CHANGED
|
||||
assert parsed.state_reason == "layout_row_citation_unparseable"
|
||||
with pytest.raises(ScholarDomInvariantError) as exc:
|
||||
parse_profile_page(fetch_result)
|
||||
assert exc.value.code == "layout_row_citation_unparseable"
|
||||
|
||||
|
||||
def test_parse_profile_page_detects_layout_change_when_markers_absent() -> None:
|
||||
|
|
@ -175,11 +177,9 @@ def test_parse_profile_page_detects_layout_change_when_markers_absent() -> None:
|
|||
error=None,
|
||||
)
|
||||
|
||||
parsed = parse_profile_page(fetch_result)
|
||||
|
||||
assert parsed.state == ParseState.LAYOUT_CHANGED
|
||||
assert parsed.state_reason == "layout_markers_missing"
|
||||
assert "no_rows_detected" in parsed.warnings
|
||||
with pytest.raises(ScholarDomInvariantError) as exc:
|
||||
parse_profile_page(fetch_result)
|
||||
assert exc.value.code == "layout_markers_missing"
|
||||
|
||||
|
||||
def test_parse_profile_page_reports_network_reason_when_status_missing() -> None:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services import scholars as scholar_service
|
||||
from app.services.scholar_parser import ParseState
|
||||
from app.services.scholar_source import FetchResult
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.services.domains.scholar.parser import ParseState
|
||||
from app.services.domains.scholar.source import FetchResult
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.db]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from app.services.scholar_source import _build_profile_url
|
||||
from app.services.domains.scholar.source import _build_profile_url
|
||||
|
||||
|
||||
def test_build_profile_url_includes_pagesize_for_initial_page() -> None:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from app.services.user_settings import (
|
||||
from app.services.domains.settings.application import (
|
||||
DEFAULT_NAV_VISIBLE_PAGES,
|
||||
HARD_MIN_REQUEST_DELAY_SECONDS,
|
||||
HARD_MIN_RUN_INTERVAL_MINUTES,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue