Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

413 lines
15 KiB
Python

"""
Tests for journal_quality/db.py accessor methods and utility functions
that lack direct coverage in test_db.py.
Uses the real bundled journal_quality.db where available (via the shared
``ref_db`` fixture in conftest.py, same skip-if-missing pattern as
test_db.py) and temp-file fixtures for filesystem utilities.
"""
import os
import sqlite3
import time
import pytest
from local_deep_research.journal_quality.db import (
JOURNAL_QUALITY_SCHEMA_VERSION,
JournalQualityDB,
_sweep_stale_tmp_files,
)
# PLoS ONE — open access, in DOAJ since the registry's early days, so a
# stable positive example. Nature is a subscription journal and therefore
# absent from DOAJ — a stable negative example that exists in the sources
# table (unlike a made-up ISSN, it exercises the is_in_doaj filter).
DOAJ_ISSN = "1932-6203" # PLoS ONE
NON_DOAJ_ISSN = "0028-0836" # Nature
# _sweep_stale_tmp_files removes tmp files older than 1 hour; backdate
# twice that so the test is comfortably past the cutoff.
SWEEP_CUTOFF_SECONDS = 3600
STALE_MTIME_AGE_SECONDS = 2 * SWEEP_CUTOFF_SECONDS
def _uninitialized_db() -> JournalQualityDB:
"""Instance that skips ``__init__`` (no engine, no lock).
Valid for ``_validate_existing_db`` and the early-return guard of
``expand_abbreviation`` because neither reads instance state before
the code under test runs. If those methods ever grow instance-state
dependencies, switch this to a properly constructed instance.
"""
return JournalQualityDB.__new__(JournalQualityDB)
def _openalex_id_for_ror(ref_db, ror_id):
"""Fetch an institution's OpenAlex ID straight from the SQLite file.
The public ``lookup_institution()`` dict deliberately omits
``openalex_id`` (see ``_institution_to_dict``), so tests that
exercise the openalex_id lookup path have to read it from the
table directly.
"""
conn = sqlite3.connect(
f"file:{ref_db._resolve_db_path()}?mode=ro", uri=True
)
try:
row = conn.execute(
"SELECT openalex_id FROM institutions WHERE ror_id = ?",
(ror_id,),
).fetchone()
finally:
conn.close()
return row[0] if row else None
# ---------------------------------------------------------------------------
# lookup_doaj / is_in_doaj
# ---------------------------------------------------------------------------
@pytest.fixture()
def doaj_db():
"""A ``JournalQualityDB`` backed by an in-memory DB with controlled rows.
The DOAJ accessor tests must not use the shared ``ref_db`` (real bundled
file): that fixture *skips* in CI where the file is absent, and worse it
makes these tests *fail* (not skip) on a machine whose local
``journal_quality.db`` was built without ``doaj_journals.json`` — in that
case ``is_in_doaj`` is 0 for every row, so the positive assertions can
never hold. Wiring a small fixed dataset makes ``lookup_doaj`` /
``is_in_doaj`` deterministic and lets them actually run in CI.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from local_deep_research.journal_quality.models import (
JournalQualityBase,
Source,
)
from local_deep_research.utilities.citation_normalizer import (
normalize_issn,
)
# StaticPool + a single shared connection keeps the in-memory DB alive for
# the whole fixture (a plain sqlite:// would get a fresh empty DB per
# connection).
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
JournalQualityBase.metadata.create_all(engine)
session_local = sessionmaker(bind=engine, expire_on_commit=False)
with session_local() as s:
s.add(
Source(
name="PLoS ONE",
name_lower="plos one",
issn=normalize_issn(DOAJ_ISSN),
publisher="PLOS",
is_in_doaj=True,
score_source="doaj",
)
)
# Nature: present in sources but NOT in DOAJ — the negative example
# that exercises the is_in_doaj filter rather than a missing row.
s.add(
Source(
name="Nature",
name_lower="nature",
issn=normalize_issn(NON_DOAJ_ISSN),
publisher="Springer Nature",
is_in_doaj=False,
score_source="openalex",
)
)
s.commit()
db = JournalQualityDB()
# Pre-wire the engine/session so _ensure_engine() short-circuits and the
# accessor logic runs against our dataset, never the real DB path.
db._engine = engine
db._SessionLocal = session_local
try:
yield db
finally:
engine.dispose()
class TestLookupDoaj:
"""DOAJ lookup by ISSN (query against is_in_doaj flag on Source rows)."""
def test_lookup_doaj_journal_returns_dict(self, doaj_db):
result = doaj_db.lookup_doaj(issn=DOAJ_ISSN)
assert result is not None
assert result["name"] == "PLoS ONE"
assert result["publisher"] == "PLOS"
def test_lookup_non_doaj_journal_returns_none(self, doaj_db):
# Nature exists in the sources table but is not in DOAJ, so the
# DOAJ-only lookup must filter it out.
assert doaj_db.lookup_doaj(issn=NON_DOAJ_ISSN) is None
def test_lookup_nonexistent_issn_returns_none(self, doaj_db):
result = doaj_db.lookup_doaj(issn="0000-0000")
assert result is None
def test_lookup_none_issn_returns_none(self, doaj_db):
result = doaj_db.lookup_doaj(issn=None)
assert result is None
def test_lookup_empty_issn_returns_none(self, doaj_db):
result = doaj_db.lookup_doaj(issn="")
assert result is None
class TestIsInDoaj:
def test_doaj_journal_is_true(self, doaj_db):
assert doaj_db.is_in_doaj(DOAJ_ISSN) is True
def test_non_doaj_journal_is_false(self, doaj_db):
assert doaj_db.is_in_doaj(NON_DOAJ_ISSN) is False
def test_nonexistent_issn(self, doaj_db):
assert doaj_db.is_in_doaj("0000-0000") is False
# ---------------------------------------------------------------------------
# count_predatory_by_names
# ---------------------------------------------------------------------------
class TestCountPredatoryByNames:
def test_empty_iterable_returns_zero(self, ref_db):
assert ref_db.count_predatory_by_names([]) == 0
def test_none_and_blanks_filtered(self, ref_db):
assert ref_db.count_predatory_by_names([None, "", " "]) == 0
def test_legitimate_journals_count_zero(self, ref_db):
count = ref_db.count_predatory_by_names(["Nature", "Science"])
assert count == 0
def test_returns_int(self, ref_db):
result = ref_db.count_predatory_by_names(["Nature"])
assert isinstance(result, int)
# ---------------------------------------------------------------------------
# lookup_institution
# ---------------------------------------------------------------------------
class TestLookupInstitution:
def test_by_name_returns_dict(self, ref_db):
result = ref_db.lookup_institution(name="Harvard University")
assert result is not None
assert result["name"] == "Harvard University"
assert result["h_index"] is not None
def test_nonexistent_returns_none(self, ref_db):
result = ref_db.lookup_institution(name="ZZZ Nonexistent 99999")
assert result is None
def test_no_args_returns_none(self, ref_db):
result = ref_db.lookup_institution()
assert result is None
def test_by_openalex_id(self, ref_db):
# The accessor dict has no openalex_id key, so resolve it from
# the table via the ror_id and re-lookup through the public API.
by_name = ref_db.lookup_institution(name="Harvard University")
assert by_name is not None
openalex_id = _openalex_id_for_ror(ref_db, by_name["ror_id"])
assert openalex_id is not None
by_id = ref_db.lookup_institution(openalex_id=openalex_id)
assert by_id is not None
assert by_id["name"] == "Harvard University"
def test_by_ror_id(self, ref_db):
by_name = ref_db.lookup_institution(name="Harvard University")
assert by_name is not None
assert by_name["ror_id"]
by_ror = ref_db.lookup_institution(ror_id=by_name["ror_id"])
assert by_ror is not None
assert by_ror["name"] == "Harvard University"
# ---------------------------------------------------------------------------
# score_from_affiliations
# ---------------------------------------------------------------------------
class TestScoreFromAffiliations:
def test_empty_list_returns_none(self, ref_db):
assert ref_db.score_from_affiliations([]) is None
def test_string_affiliation(self, ref_db):
result = ref_db.score_from_affiliations(["Harvard University"])
assert isinstance(result, int)
def test_dict_affiliation_with_name(self, ref_db):
result = ref_db.score_from_affiliations(
[{"name": "Harvard University"}]
)
assert isinstance(result, int)
def test_dict_affiliation_with_openalex_id(self, ref_db):
by_name = ref_db.lookup_institution(name="Harvard University")
assert by_name is not None
openalex_id = _openalex_id_for_ror(ref_db, by_name["ror_id"])
assert openalex_id is not None
result = ref_db.score_from_affiliations([{"openalex_id": openalex_id}])
assert isinstance(result, int)
def test_nonexistent_returns_none(self, ref_db):
result = ref_db.score_from_affiliations(
["ZZZ Nonexistent Institution 99999"]
)
assert result is None
def test_dict_with_no_relevant_keys_returns_none(self, ref_db):
result = ref_db.score_from_affiliations([{"foo": "bar"}])
assert result is None
# ---------------------------------------------------------------------------
# expand_abbreviation
# ---------------------------------------------------------------------------
class TestExpandAbbreviation:
def test_empty_string_returns_none(self, ref_db):
assert ref_db.expand_abbreviation("") is None
def test_none_returns_none(self):
assert _uninitialized_db().expand_abbreviation(None) is None
def test_nonexistent_returns_none(self, ref_db):
result = ref_db.expand_abbreviation("ZZZNOTANABBREVIATION12345")
assert result is None
def test_known_abbreviation(self, ref_db):
# JACS comes from the bundled JabRef abbreviation list.
result = ref_db.expand_abbreviation("JACS")
assert result is not None
assert "american chemical society" in result.lower()
# ---------------------------------------------------------------------------
# is_predatory expanded branches
# ---------------------------------------------------------------------------
class TestIsPredatoryExpanded:
"""Expand TestIsPredatory with publisher and hijacked branches."""
def test_publisher_name_arg(self, ref_db):
# A legitimate publisher should not be flagged
is_pred, source = ref_db.is_predatory(publisher_name="Elsevier")
assert is_pred is False
def test_both_journal_and_publisher(self, ref_db):
is_pred, source = ref_db.is_predatory(
journal_name="Nature", publisher_name="Springer Nature"
)
assert is_pred is False
def test_long_publisher_name_handled(self, ref_db):
# The implementation checks substrings for long publisher names
is_pred, source = ref_db.is_predatory(publisher_name="A" * 500)
assert is_pred is False
# ---------------------------------------------------------------------------
# _validate_existing_db (temp-file tests, no real DB needed)
# ---------------------------------------------------------------------------
class TestValidateExistingDb:
def test_valid_db_with_matching_schema(self, tmp_path):
db_path = tmp_path / "test.db"
conn = sqlite3.connect(str(db_path))
conn.execute(
"PRAGMA user_version = %d" % JOURNAL_QUALITY_SCHEMA_VERSION
)
conn.execute("CREATE TABLE t (x)")
conn.commit()
conn.close()
assert _uninitialized_db()._validate_existing_db(db_path) is True
def test_stale_schema_triggers_rebuild(self, tmp_path):
db_path = tmp_path / "test.db"
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA user_version = 999")
conn.execute("CREATE TABLE t (x)")
conn.commit()
conn.close()
result = _uninitialized_db()._validate_existing_db(db_path)
assert result is False
# File should have been removed by _unlink_unusable_db
assert not db_path.exists()
def test_grandfathered_zero_version(self, tmp_path):
db_path = tmp_path / "test.db"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE t (x)")
conn.commit()
conn.close()
assert _uninitialized_db()._validate_existing_db(db_path) is True
def test_corrupted_file_returns_false(self, tmp_path):
db_path = tmp_path / "test.db"
db_path.write_text("this is not a database")
assert _uninitialized_db()._validate_existing_db(db_path) is False
def test_missing_file_returns_false(self, tmp_path):
db_path = tmp_path / "nonexistent.db"
assert _uninitialized_db()._validate_existing_db(db_path) is False
# ---------------------------------------------------------------------------
# _sweep_stale_tmp_files (temp-file tests)
# ---------------------------------------------------------------------------
class TestSweepStaleTmpFiles:
def test_removes_old_tmp_files(self, tmp_path):
stale = tmp_path / "journal_quality.db.tmp-old"
stale.write_text("stale")
old_mtime = time.time() - STALE_MTIME_AGE_SECONDS
os.utime(str(stale), (old_mtime, old_mtime))
fresh = tmp_path / "journal_quality.db.tmp-recent"
fresh.write_text("fresh")
_sweep_stale_tmp_files(tmp_path, "journal_quality.db")
assert not stale.exists()
assert fresh.exists()
def test_nonexistent_directory_is_noop(self, tmp_path):
missing = tmp_path / "does_not_exist"
_sweep_stale_tmp_files(missing, "journal_quality.db")
# No exception raised
def test_no_matching_files_is_noop(self, tmp_path):
other = tmp_path / "other.txt"
other.write_text("unrelated")
_sweep_stale_tmp_files(tmp_path, "journal_quality.db")
assert other.exists()
# _escape_like was consolidated into the shared ``sql_utils.escape_like``
# (see tests/utilities/test_sql_utils.py::TestEscapeLike for its coverage).