learningcircuit--local-deep-research
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
406 行
15 KiB
Python
406 行
15 KiB
Python
"""Tests for journal_data_downloader — fetch from OpenAlex/DOAJ APIs."""
|
|
|
|
import gzip
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.journal_quality.downloader import (
|
|
JOURNAL_DATA_VERSION,
|
|
download_journal_data,
|
|
ensure_journal_data,
|
|
get_journal_data_status,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_data_dir(tmp_path):
|
|
"""Override journal data directory to a temp path."""
|
|
with patch(
|
|
"local_deep_research.journal_quality.downloader._get_data_dir",
|
|
return_value=tmp_path,
|
|
):
|
|
yield tmp_path
|
|
|
|
|
|
def _write_openalex(data_dir):
|
|
"""Create a minimal openalex file."""
|
|
with gzip.open(data_dir / "openalex_sources.json.gz", "wt") as f:
|
|
json.dump({"s": {"S1": {"n": "Nature", "t": "j", "h": 1000}}}, f)
|
|
|
|
|
|
class TestGetJournalDataStatus:
|
|
def test_no_files(self, tmp_data_dir):
|
|
status = get_journal_data_status()
|
|
assert status["available"] is False
|
|
assert status["version"] is None
|
|
assert status["needs_update"] is True
|
|
|
|
def test_files_present(self, tmp_data_dir):
|
|
_write_openalex(tmp_data_dir)
|
|
(tmp_data_dir / "doaj_journals.json").write_text("{}")
|
|
(tmp_data_dir / "version.json").write_text(
|
|
json.dumps({"version": JOURNAL_DATA_VERSION})
|
|
)
|
|
status = get_journal_data_status()
|
|
assert status["available"] is True
|
|
assert status["needs_update"] is False
|
|
|
|
def test_outdated_version(self, tmp_data_dir):
|
|
_write_openalex(tmp_data_dir)
|
|
(tmp_data_dir / "version.json").write_text(
|
|
json.dumps({"version": "v0"})
|
|
)
|
|
status = get_journal_data_status()
|
|
assert status["needs_update"] is True
|
|
|
|
|
|
class TestDownloadJournalData:
|
|
def test_already_up_to_date(self, tmp_data_dir):
|
|
_write_openalex(tmp_data_dir)
|
|
(tmp_data_dir / "doaj_journals.json").write_text("{}")
|
|
(tmp_data_dir / "version.json").write_text(
|
|
json.dumps({"version": JOURNAL_DATA_VERSION})
|
|
)
|
|
success, msg = download_journal_data()
|
|
assert success is True
|
|
assert "up to date" in msg
|
|
|
|
def test_concurrent_download_blocked(self, tmp_data_dir):
|
|
# Simulate an in-flight download by writing the *current* PID
|
|
# into the sentinel. The runtime liveness check sees our own
|
|
# PID → returns alive → download_journal_data bows out without
|
|
# reclaiming (the "don't nuke self" guard).
|
|
#
|
|
# An empty sentinel (old behavior) would be treated as orphan
|
|
# debris, reclaimed, and the test would fall through to real
|
|
# network calls — which is exactly what the PID stamp prevents.
|
|
import os
|
|
|
|
sentinel = tmp_data_dir / ".downloading"
|
|
sentinel.write_text(str(os.getpid()))
|
|
success, msg = download_journal_data()
|
|
assert success is False
|
|
assert "already in progress" in msg
|
|
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_institutions")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader"
|
|
"._fetch_jabref_abbreviations"
|
|
)
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_predatory")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_openalex_sources"
|
|
)
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_doaj_journals"
|
|
)
|
|
@patch("local_deep_research.journal_quality.db.build_db")
|
|
def test_successful_fetch(
|
|
self,
|
|
mock_build_db,
|
|
mock_doaj,
|
|
mock_openalex,
|
|
mock_pred,
|
|
mock_jabref,
|
|
mock_inst,
|
|
tmp_data_dir,
|
|
):
|
|
mock_openalex.return_value = 100
|
|
mock_doaj.return_value = 50
|
|
mock_pred.return_value = 0
|
|
mock_jabref.return_value = 0
|
|
mock_inst.return_value = 0
|
|
# build_db runs after all fetches succeed; mock it so the happy-path
|
|
# test doesn't depend on a real build against the minimal fixture.
|
|
mock_build_db.return_value = None
|
|
# Need the openalex file to exist after mock runs
|
|
_write_openalex(tmp_data_dir)
|
|
|
|
success, msg = download_journal_data(force=True)
|
|
assert success is True
|
|
assert "100 OpenAlex" in msg
|
|
assert "50 DOAJ" in msg
|
|
mock_openalex.assert_called_once()
|
|
mock_doaj.assert_called_once()
|
|
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_institutions")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader"
|
|
"._fetch_jabref_abbreviations"
|
|
)
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_predatory")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_openalex_sources"
|
|
)
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_doaj_journals"
|
|
)
|
|
def test_openalex_failure(
|
|
self,
|
|
mock_doaj,
|
|
mock_openalex,
|
|
mock_pred,
|
|
mock_jabref,
|
|
mock_inst,
|
|
tmp_data_dir,
|
|
):
|
|
"""If the required OpenAlex source fails, the whole download
|
|
returns failure — even though the other four sources now run
|
|
in parallel and may succeed.
|
|
"""
|
|
mock_openalex.side_effect = Exception("Network error")
|
|
# Keep optional sources cheap so the parallel pool finishes
|
|
# quickly; we're only exercising the required-source-failure
|
|
# path.
|
|
mock_doaj.return_value = 0
|
|
mock_pred.return_value = 0
|
|
mock_jabref.return_value = 0
|
|
mock_inst.return_value = 0
|
|
success, msg = download_journal_data(force=True)
|
|
assert success is False
|
|
assert "Failed" in msg
|
|
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_institutions")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader"
|
|
"._fetch_jabref_abbreviations"
|
|
)
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_predatory")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_openalex_sources"
|
|
)
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_doaj_journals"
|
|
)
|
|
@patch("local_deep_research.journal_quality.db.build_db")
|
|
def test_build_db_failure_returns_false(
|
|
self,
|
|
mock_build_db,
|
|
mock_doaj,
|
|
mock_openalex,
|
|
mock_pred,
|
|
mock_jabref,
|
|
mock_inst,
|
|
tmp_data_dir,
|
|
):
|
|
"""If fetches succeed but build_db raises, caller must see success=False.
|
|
|
|
Previously this case silently returned (True, 'Fetched ...') while the
|
|
DB was never built, leading the dashboard to show a green toast with
|
|
no DB on disk.
|
|
"""
|
|
mock_openalex.return_value = 100
|
|
mock_doaj.return_value = 50
|
|
mock_pred.return_value = 0
|
|
mock_jabref.return_value = 0
|
|
mock_inst.return_value = 0
|
|
mock_build_db.side_effect = RuntimeError("synthetic build failure")
|
|
_write_openalex(tmp_data_dir)
|
|
|
|
success, msg = download_journal_data(force=True)
|
|
assert success is False
|
|
assert "DB build failed" in msg
|
|
# Exception message is not surfaced to callers (info-disclosure hardening
|
|
# in commit da803376d); only the exception class name is included.
|
|
assert "RuntimeError" in msg
|
|
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_institutions")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader"
|
|
"._fetch_jabref_abbreviations"
|
|
)
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_predatory")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_openalex_sources"
|
|
)
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_doaj_journals"
|
|
)
|
|
@patch("local_deep_research.journal_quality.db.build_db")
|
|
def test_build_db_schema_drift_error_surfaced(
|
|
self,
|
|
mock_build_db,
|
|
mock_doaj,
|
|
mock_openalex,
|
|
mock_pred,
|
|
mock_jabref,
|
|
mock_inst,
|
|
tmp_data_dir,
|
|
):
|
|
"""SchemaDriftError messages are developer-authored and safe to
|
|
surface verbatim, so operators can see *which* upstream field
|
|
drifted. Only the generic-exception path gets the class-name
|
|
scrubbing; drift-errors pass through.
|
|
"""
|
|
from local_deep_research.journal_quality.data_sources.openalex import (
|
|
SchemaDriftError,
|
|
)
|
|
|
|
mock_openalex.return_value = 100
|
|
mock_doaj.return_value = 50
|
|
mock_pred.return_value = 0
|
|
mock_jabref.return_value = 0
|
|
mock_inst.return_value = 0
|
|
drift_msg = (
|
|
"OpenAlex snapshot appears to have renamed a required field: "
|
|
"h_index present in journal sample=False"
|
|
)
|
|
mock_build_db.side_effect = SchemaDriftError(drift_msg)
|
|
_write_openalex(tmp_data_dir)
|
|
|
|
success, msg = download_journal_data(force=True)
|
|
assert success is False
|
|
assert "DB build failed" in msg
|
|
# Full drift message is surfaced — operators need to see which
|
|
# field drifted to act on it.
|
|
assert "h_index present in journal sample=False" in msg
|
|
# The class name path is only used for non-SchemaDriftError.
|
|
assert "SchemaDriftError" not in msg
|
|
|
|
|
|
class TestDownloadStateCountsLifecycle:
|
|
"""Verify `_download_state["counts"]` is populated only on success and
|
|
cleared on every other return path, so the `/api/journal-data/download`
|
|
route (and any other reader of `get_download_state()`) cannot observe
|
|
stale counts from a prior run.
|
|
"""
|
|
|
|
def _get_counts(self):
|
|
from local_deep_research.journal_quality.downloader import (
|
|
get_download_state,
|
|
)
|
|
|
|
return get_download_state().get("counts")
|
|
|
|
def test_counts_none_on_already_up_to_date(self, tmp_data_dir):
|
|
_write_openalex(tmp_data_dir)
|
|
(tmp_data_dir / "doaj_journals.json").write_text("{}")
|
|
(tmp_data_dir / "version.json").write_text(
|
|
json.dumps({"version": JOURNAL_DATA_VERSION})
|
|
)
|
|
success, _ = download_journal_data()
|
|
assert success is True
|
|
assert self._get_counts() is None
|
|
|
|
def test_counts_none_on_disk_space_failure(self, tmp_data_dir):
|
|
class _LowDisk:
|
|
total = 10 * 1024**3
|
|
used = total - 500 * 1024**2
|
|
free = 500 * 1024**2
|
|
|
|
with patch("shutil.disk_usage", return_value=_LowDisk()):
|
|
success, _ = download_journal_data(force=True)
|
|
assert success is False
|
|
assert self._get_counts() is None
|
|
|
|
def test_counts_none_on_concurrent_download_block(self, tmp_data_dir):
|
|
import os
|
|
|
|
(tmp_data_dir / ".downloading").write_text(str(os.getpid()))
|
|
success, _ = download_journal_data()
|
|
assert success is False
|
|
assert self._get_counts() is None
|
|
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_institutions")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader"
|
|
"._fetch_jabref_abbreviations"
|
|
)
|
|
@patch("local_deep_research.journal_quality.downloader._fetch_predatory")
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_openalex_sources"
|
|
)
|
|
@patch(
|
|
"local_deep_research.journal_quality.downloader._fetch_doaj_journals"
|
|
)
|
|
@patch("local_deep_research.journal_quality.db.build_db")
|
|
def test_counts_populated_on_success_then_cleared_on_up_to_date(
|
|
self,
|
|
mock_build_db,
|
|
mock_doaj,
|
|
mock_openalex,
|
|
mock_pred,
|
|
mock_jabref,
|
|
mock_inst,
|
|
tmp_data_dir,
|
|
):
|
|
"""The key anti-stale-leak scenario: a successful download
|
|
populates counts, then a subsequent "already up to date" call
|
|
must invalidate them so the route cannot render last-run's
|
|
numbers as if they came from the new (non-existent) fetch.
|
|
"""
|
|
mock_openalex.return_value = 100
|
|
mock_doaj.return_value = 50
|
|
mock_pred.return_value = 0
|
|
mock_jabref.return_value = 0
|
|
mock_inst.return_value = 0
|
|
mock_build_db.return_value = None
|
|
_write_openalex(tmp_data_dir)
|
|
|
|
success, _ = download_journal_data(force=True)
|
|
assert success is True
|
|
counts = self._get_counts()
|
|
assert counts is not None
|
|
assert counts["openalex"] == 100
|
|
assert counts["doaj"] == 50
|
|
|
|
# Stamp the version.json that the real success path would have
|
|
# written, so the next call takes the "already up to date" branch.
|
|
(tmp_data_dir / "doaj_journals.json").write_text("{}")
|
|
(tmp_data_dir / "version.json").write_text(
|
|
json.dumps({"version": JOURNAL_DATA_VERSION})
|
|
)
|
|
success, _ = download_journal_data()
|
|
assert success is True
|
|
assert self._get_counts() is None
|
|
|
|
|
|
class TestEnsureJournalData:
|
|
def test_files_in_user_dir(self, tmp_data_dir):
|
|
_write_openalex(tmp_data_dir)
|
|
data_dir, available = ensure_journal_data(auto_download=False)
|
|
assert available is True
|
|
assert data_dir == tmp_data_dir
|
|
|
|
def test_missing_no_download(self, tmp_data_dir):
|
|
# The user data dir (tmp_data_dir) has no files.
|
|
# Also need to prevent the package dir fallback from finding
|
|
# bundled files in a dev install.
|
|
# Verify status reports unavailable when user dir is empty
|
|
status = get_journal_data_status()
|
|
assert status["available"] is False
|
|
|
|
|
|
class TestEnsureJournalDataCache:
|
|
"""Verify the thundering-herd guard on ensure_journal_data.
|
|
|
|
When 30+ filter workers call this concurrently during a search and
|
|
the data files aren't yet on disk, only one should reach the real
|
|
``download_journal_data`` call; the rest should hit the 30-second
|
|
cooldown cache and return the previous result without adding log
|
|
spam or racing for the sentinel.
|
|
"""
|
|
|
|
def test_second_call_within_ttl_uses_cache(self, tmp_data_dir):
|
|
import local_deep_research.journal_quality.downloader as dl
|
|
|
|
# Clear any stale module-level cache from previous tests.
|
|
dl._ensure_cache = None
|
|
|
|
with patch.object(dl, "download_journal_data") as mock_dl:
|
|
mock_dl.return_value = (False, "network error")
|
|
# First call: reaches download_journal_data, gets the
|
|
# negative result, caches it.
|
|
first = ensure_journal_data(auto_download=True)
|
|
assert first == (None, False)
|
|
assert mock_dl.call_count == 1
|
|
|
|
# Rapid second call (well within 30 s TTL) must NOT
|
|
# re-invoke download_journal_data — that's the whole
|
|
# point of the herd guard.
|
|
second = ensure_journal_data(auto_download=True)
|
|
assert second == (None, False)
|
|
assert mock_dl.call_count == 1
|