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
477 lines
18 KiB
Python
477 lines
18 KiB
Python
"""Release-gate integration test for the journal-quality data pipeline.
|
||
|
||
Why this test exists
|
||
====================
|
||
|
||
The journal-quality system depends on five external data sources:
|
||
|
||
- OpenAlex Sources (S3 bulk dump) ~280K journals/conferences
|
||
- DOAJ public CSV dump ~22K open-access journals
|
||
- Stop Predatory Journals ~2.5K predatory entries
|
||
- JabRef abbreviation list ~66K abbreviations
|
||
- OpenAlex Institutions (S3 bulk dump) ~120K institutions
|
||
|
||
If any of these upstreams change their schema (rename a field, drop a
|
||
column, restructure the JSON layout), the bundled-data tier silently
|
||
breaks: every academic search result starts coming back unscored. This
|
||
test catches that BEFORE we cut a release.
|
||
|
||
Structure
|
||
---------
|
||
|
||
A session-scoped fixture downloads ALL FIVE sources **in parallel** via
|
||
``ThreadPoolExecutor``. Each per-source test then asserts file presence
|
||
and record-shape against the already-downloaded data (fast). A separate
|
||
test runs ``build_db()`` against the freshly-downloaded files and a
|
||
final test verifies the runtime accessor can score a real journal.
|
||
|
||
Parallelism is essential: the OpenAlex Institutions API alone takes
|
||
~10 minutes (550 paginated requests). Sequentially, all five would
|
||
exceed 25 minutes; in parallel the wall-clock is bounded by the
|
||
slowest single source (~10 min for institutions).
|
||
|
||
This test is intentionally **not** part of the regular suite — it pulls
|
||
~30 MB from third-party APIs and runs ~10–15 minutes wall-clock. It's
|
||
marked with ``@pytest.mark.integration`` and ``@pytest.mark.slow`` so
|
||
it's skipped by default; the dedicated workflow opts in via ``-m``
|
||
selection.
|
||
|
||
Run locally with::
|
||
|
||
pytest tests/integration/test_journal_quality_release_gate.py \\
|
||
-m "integration and slow" --no-header -v --timeout=2700
|
||
|
||
Or via the dedicated CI workflow::
|
||
|
||
.github/workflows/journal-data-integration.yml
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import gzip
|
||
import json
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import requests.exceptions
|
||
from loguru import logger
|
||
|
||
|
||
pytestmark = [
|
||
pytest.mark.integration,
|
||
pytest.mark.slow,
|
||
]
|
||
|
||
|
||
# Lower bounds — deliberately loose so a small upstream fluctuation
|
||
# doesn't break the gate, but tight enough to catch a catastrophic
|
||
# regression (an API returning an empty result set).
|
||
MIN_OPENALEX_SOURCES = 100_000 # actual ~217K
|
||
MIN_DOAJ_JOURNALS = 5_000 # actual ~35K
|
||
MIN_PREDATORY_JOURNALS = 500 # actual ~1.3K
|
||
MIN_INSTITUTIONS = 50_000 # actual ~110K
|
||
MIN_ABBREVIATIONS = 10_000 # actual ~66K
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def downloaded_data_dir(tmp_path_factory) -> Path:
|
||
"""Download every external data source in parallel into a tmp dir.
|
||
|
||
Uses ``ThreadPoolExecutor`` so the slowest source (institutions)
|
||
sets the wall-clock floor instead of the sum of all five. Each
|
||
source is fetched directly via its ``DataSource.fetch()`` method —
|
||
we deliberately bypass ``download_journal_data()`` here so a single
|
||
source's failure doesn't abort the others. We want the test to
|
||
report ALL broken sources, not just the first one we hit.
|
||
|
||
Module-scoped so the per-source tests + the build test + the
|
||
lookup test all share one download.
|
||
"""
|
||
from local_deep_research.journal_quality.data_sources import (
|
||
ALL_SOURCES,
|
||
)
|
||
|
||
tmp_dir: Path = tmp_path_factory.mktemp("journal_quality_release_gate")
|
||
|
||
errors: dict[str, str] = {}
|
||
counts: dict[str, int] = {}
|
||
|
||
# Transient network errors that warrant a full-source retry. The
|
||
# per-partition download in ``iter_partitions`` already retries
|
||
# individual partitions with a 2-5-10-20-40 s backoff (5 attempts).
|
||
# If even that budget is exhausted (e.g. a sustained S3 outage >
|
||
# ~75 s), we retry the *entire* source from scratch — fresh TCP
|
||
# connections, fresh manifest, fresh partition list — up to 3 times.
|
||
_TRANSIENT_ERRORS = (
|
||
requests.exceptions.ChunkedEncodingError,
|
||
requests.exceptions.ConnectionError,
|
||
requests.exceptions.Timeout,
|
||
ConnectionResetError,
|
||
ConnectionAbortedError,
|
||
BrokenPipeError,
|
||
)
|
||
_FETCH_MAX_RETRIES = 3
|
||
_FETCH_BACKOFF_SECONDS = (30, 60, 120)
|
||
assert len(_FETCH_BACKOFF_SECONDS) == _FETCH_MAX_RETRIES, (
|
||
"backoff tuple length must match retry count"
|
||
)
|
||
|
||
def _fetch_one(src):
|
||
for attempt in range(1 + _FETCH_MAX_RETRIES):
|
||
try:
|
||
n = src.fetch(tmp_dir)
|
||
return src.key, n, None
|
||
except _TRANSIENT_ERRORS as e:
|
||
if attempt < _FETCH_MAX_RETRIES:
|
||
wait = _FETCH_BACKOFF_SECONDS[attempt]
|
||
logger.warning(
|
||
f"Source {src.key} failed (attempt {attempt + 1}/"
|
||
f"{1 + _FETCH_MAX_RETRIES}): {e!r} — retrying in {wait}s"
|
||
)
|
||
time.sleep(wait)
|
||
continue
|
||
logger.exception(
|
||
f"Source {src.key} exhausted all "
|
||
f"{1 + _FETCH_MAX_RETRIES} retries"
|
||
)
|
||
return src.key, 0, repr(e)
|
||
except Exception as e: # noqa: BLE001 — surface every failure
|
||
return src.key, 0, repr(e)
|
||
|
||
# All five in parallel. max_workers=5 lets each source own a thread
|
||
# and run end-to-end without blocking on its peers.
|
||
with ThreadPoolExecutor(max_workers=len(ALL_SOURCES)) as pool:
|
||
futures = [pool.submit(_fetch_one, src) for src in ALL_SOURCES]
|
||
for fut in as_completed(futures):
|
||
key, n, err = fut.result()
|
||
counts[key] = n
|
||
if err:
|
||
errors[key] = err
|
||
|
||
# If the REQUIRED source (OpenAlex) failed, abort the whole module
|
||
# — there's nothing meaningful to assert against. Optional sources
|
||
# are reported as test failures by their own per-source tests so
|
||
# the report still tells us which one broke.
|
||
if "openalex" in errors:
|
||
pytest.fail(
|
||
f"Required OpenAlex source failed to download: {errors['openalex']}"
|
||
)
|
||
|
||
# Stash the count + error info on the dir for the per-source tests
|
||
# to assert against. tmp_path is otherwise a plain Path so we use a
|
||
# sidecar JSON file.
|
||
(tmp_dir / "_release_gate_meta.json").write_text(
|
||
json.dumps({"counts": counts, "errors": errors})
|
||
)
|
||
|
||
return tmp_dir
|
||
|
||
|
||
def _meta(data_dir: Path) -> dict:
|
||
return json.loads((data_dir / "_release_gate_meta.json").read_text())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-source download tests
|
||
# Each one asserts that ONE source downloaded successfully, the file is
|
||
# present, and the field names we depend on at build time are still
|
||
# there. They run instantly because the download already happened in
|
||
# the parallel fixture.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_openalex_sources(downloaded_data_dir: Path):
|
||
"""OpenAlex sources file is gzipped JSON with the compact-record
|
||
field names we read in db.py::_populate_sources."""
|
||
meta = _meta(downloaded_data_dir)
|
||
assert "openalex" not in meta["errors"], (
|
||
f"OpenAlex fetch failed: {meta['errors'].get('openalex')}"
|
||
)
|
||
assert meta["counts"]["openalex"] >= MIN_OPENALEX_SOURCES
|
||
|
||
f = downloaded_data_dir / "openalex_sources.json.gz"
|
||
assert f.exists()
|
||
assert f.stat().st_size > 1_000_000
|
||
|
||
with gzip.open(f, "rt", encoding="utf-8") as fh:
|
||
payload = json.load(fh)
|
||
assert "s" in payload
|
||
sources = payload["s"]
|
||
assert len(sources) >= MIN_OPENALEX_SOURCES
|
||
|
||
# Spot-check a sample — the field names are the wire contract
|
||
# between OpenAlex's API and our build pipeline. ``cb`` is the new
|
||
# cited_by_count field added for quartile derivation; if OpenAlex
|
||
# ever drops that field this assertion fires.
|
||
sample = next(iter(sources.values()))
|
||
expected = {"n", "t", "h", "if", "cb", "p", "i"}
|
||
missing = expected - set(sample.keys())
|
||
assert len(missing) < len(expected) / 2, (
|
||
f"OpenAlex compact record missing too many expected keys: "
|
||
f"{missing} (sample={sample!r})"
|
||
)
|
||
|
||
|
||
def test_doaj_journals(downloaded_data_dir: Path):
|
||
"""DOAJ dump downloaded with the field names we read at build time."""
|
||
meta = _meta(downloaded_data_dir)
|
||
assert "doaj" not in meta["errors"], (
|
||
f"DOAJ fetch failed: {meta['errors'].get('doaj')}"
|
||
)
|
||
assert meta["counts"]["doaj"] >= MIN_DOAJ_JOURNALS
|
||
|
||
f = downloaded_data_dir / "doaj_journals.json"
|
||
assert f.exists()
|
||
data = json.loads(f.read_text())
|
||
assert isinstance(data, dict)
|
||
assert len(data) >= MIN_DOAJ_JOURNALS
|
||
|
||
sample = next(iter(data.values()))
|
||
# Field names consumed by the DOAJ pass in db.py::_populate_sources.
|
||
assert "name" in sample
|
||
assert "publisher" in sample
|
||
|
||
|
||
def test_predatory_list(downloaded_data_dir: Path):
|
||
"""Stop-predatory-journals lists downloaded and shaped correctly."""
|
||
meta = _meta(downloaded_data_dir)
|
||
assert "predatory" not in meta["errors"], (
|
||
f"Predatory fetch failed: {meta['errors'].get('predatory')}"
|
||
)
|
||
assert meta["counts"]["predatory"] >= MIN_PREDATORY_JOURNALS
|
||
|
||
f = downloaded_data_dir / "predatory.json"
|
||
assert f.exists()
|
||
data = json.loads(f.read_text())
|
||
assert "journals" in data
|
||
assert "publishers" in data
|
||
assert "hijacked" in data
|
||
assert len(data["journals"]) >= MIN_PREDATORY_JOURNALS
|
||
|
||
|
||
def test_jabref_abbreviations(downloaded_data_dir: Path):
|
||
"""JabRef abbreviation list downloaded with sane row counts."""
|
||
meta = _meta(downloaded_data_dir)
|
||
assert "jabref" not in meta["errors"], (
|
||
f"JabRef fetch failed: {meta['errors'].get('jabref')}"
|
||
)
|
||
assert meta["counts"]["jabref"] >= MIN_ABBREVIATIONS
|
||
|
||
f = downloaded_data_dir / "jabref_abbreviations.json.gz"
|
||
assert f.exists()
|
||
with gzip.open(f, "rt", encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
assert len(data) >= MIN_ABBREVIATIONS
|
||
|
||
|
||
def test_openalex_institutions(downloaded_data_dir: Path):
|
||
"""OpenAlex institutions API still returns compact records.
|
||
|
||
This is the slowest source (~10 min for ~110K institutions via
|
||
cursor pagination). It's not strictly required for the journal
|
||
scoring tier — it powers the Tier 3.5 affiliation salvage path —
|
||
but a regression here means arxiv preprints lose their
|
||
institution-tier scoring fallback, which is a real quality drop.
|
||
"""
|
||
meta = _meta(downloaded_data_dir)
|
||
assert "institutions" not in meta["errors"], (
|
||
f"Institutions fetch failed: {meta['errors'].get('institutions')}"
|
||
)
|
||
assert meta["counts"]["institutions"] >= MIN_INSTITUTIONS
|
||
|
||
f = downloaded_data_dir / "openalex_institutions.json.gz"
|
||
assert f.exists()
|
||
with gzip.open(f, "rt", encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
# Same wrapper convention as the sources file.
|
||
institutions = data.get("i") or data.get("institutions") or data
|
||
if isinstance(institutions, dict):
|
||
assert len(institutions) >= MIN_INSTITUTIONS
|
||
else:
|
||
assert len(institutions) >= MIN_INSTITUTIONS
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Build + lookup tests — these run AFTER all five downloads have
|
||
# completed (the fixture is module-scoped so the build test sees a
|
||
# fully-populated data directory).
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_build_journal_quality_db(downloaded_data_dir: Path):
|
||
"""``build_db()`` runs end-to-end against the freshly-downloaded
|
||
files and produces a queryable database with all the columns from
|
||
this PR (cited_by_count + quartile + the existing schema).
|
||
"""
|
||
from sqlalchemy import create_engine, func, select
|
||
|
||
from local_deep_research.journal_quality.db import build_db
|
||
from local_deep_research.journal_quality.models import (
|
||
Institution,
|
||
PredatoryJournal,
|
||
Source,
|
||
)
|
||
|
||
db_file = downloaded_data_dir / "journal_quality.db"
|
||
if db_file.exists():
|
||
# Build is the only writer; the file is chmod 0o444 by default.
|
||
db_file.chmod(0o644)
|
||
db_file.unlink()
|
||
|
||
build_db(data_dir=downloaded_data_dir, output_path=db_file)
|
||
|
||
assert db_file.exists()
|
||
|
||
engine = create_engine(f"sqlite:///{db_file}")
|
||
try:
|
||
with engine.connect() as conn:
|
||
# 1. Source table populated.
|
||
n_sources = conn.execute(
|
||
select(func.count()).select_from(Source)
|
||
).scalar()
|
||
assert n_sources >= MIN_OPENALEX_SOURCES, (
|
||
f"Source row count below minimum: {n_sources}"
|
||
)
|
||
|
||
# 2. cited_by_count populated for at least some rows (it's
|
||
# NULL on DOAJ-only entries by design).
|
||
n_with_citations = conn.execute(
|
||
select(func.count())
|
||
.select_from(Source)
|
||
.where(Source.cited_by_count.is_not(None))
|
||
).scalar()
|
||
assert n_with_citations > 0, (
|
||
"cited_by_count is NULL for every source — either the "
|
||
"OpenAlex API stopped exposing the field or the openalex.py "
|
||
"data source loader regressed."
|
||
)
|
||
|
||
# 3. Quartile post-pass ran and assigned every bucket.
|
||
quartiles = {
|
||
row[0]
|
||
for row in conn.execute(
|
||
select(Source.quartile)
|
||
.where(Source.quartile.is_not(None))
|
||
.distinct()
|
||
).all()
|
||
}
|
||
assert quartiles == {"Q1", "Q2", "Q3", "Q4"}, (
|
||
f"Quartile buckets not all populated: got {quartiles}"
|
||
)
|
||
|
||
# 4. Predatory list loaded into its dedicated table.
|
||
n_pred = conn.execute(
|
||
select(func.count()).select_from(PredatoryJournal)
|
||
).scalar()
|
||
assert n_pred >= MIN_PREDATORY_JOURNALS
|
||
|
||
# 5. Institutions loaded (used by Tier 3.5 affiliation salvage).
|
||
n_inst = conn.execute(
|
||
select(func.count()).select_from(Institution)
|
||
).scalar()
|
||
assert n_inst >= MIN_INSTITUTIONS
|
||
finally:
|
||
engine.dispose()
|
||
|
||
|
||
def test_runtime_accessor_can_score_real_journal(
|
||
downloaded_data_dir: Path,
|
||
):
|
||
"""End-to-end smoke: bind the runtime ``JournalQualityDB`` to the
|
||
freshly-built file and score a real journal. This is the same code
|
||
path the ``JournalReputationFilter`` uses in production.
|
||
"""
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from local_deep_research.journal_quality.db import JournalQualityDB
|
||
|
||
db_file = downloaded_data_dir / "journal_quality.db"
|
||
assert db_file.exists(), (
|
||
"test_build_journal_quality_db must run before this test"
|
||
)
|
||
|
||
db = JournalQualityDB()
|
||
db._engine = create_engine(f"sqlite:///{db_file}")
|
||
db._SessionLocal = sessionmaker(bind=db._engine, expire_on_commit=False)
|
||
|
||
try:
|
||
nature = db.lookup_openalex(name="Nature")
|
||
assert nature is not None, "Nature not found in built DB"
|
||
assert nature["h_index"] is not None and nature["h_index"] > 1000
|
||
# Field shape contract used by the filter (`is_in_doaj`,
|
||
# `publisher`, `issn_l`, `openalex_source_id`).
|
||
assert "is_in_doaj" in nature
|
||
assert "publisher" in nature
|
||
assert "issn_l" in nature
|
||
assert "openalex_source_id" in nature
|
||
finally:
|
||
db.reset()
|
||
|
||
|
||
def test_dashboard_queries_against_real_db(downloaded_data_dir: Path):
|
||
"""Exercise the dashboard query methods against the freshly built
|
||
DB. Same code path that ``/api/journals`` (the journal-quality
|
||
dashboard) hits in production — if the schema or query helpers
|
||
regress, the dashboard goes blank.
|
||
"""
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from local_deep_research.journal_quality.db import JournalQualityDB
|
||
|
||
db_file = downloaded_data_dir / "journal_quality.db"
|
||
assert db_file.exists(), (
|
||
"test_build_journal_quality_db must run before this test"
|
||
)
|
||
|
||
db = JournalQualityDB()
|
||
db._engine = create_engine(f"sqlite:///{db_file}")
|
||
db._SessionLocal = sessionmaker(bind=db._engine, expire_on_commit=False)
|
||
|
||
try:
|
||
# 1. Summary card on the dashboard top.
|
||
summary = db.get_summary()
|
||
assert summary["total"] >= MIN_OPENALEX_SOURCES
|
||
assert summary["avg_quality"] is not None
|
||
assert summary["doaj_count"] >= MIN_DOAJ_JOURNALS // 2
|
||
assert summary["predatory_count"] >= MIN_PREDATORY_JOURNALS
|
||
|
||
# 2. Quality histogram (powers the bar chart).
|
||
qdist = db.get_quality_distribution()
|
||
assert qdist, "quality distribution is empty"
|
||
assert all(int(k) >= 1 and int(k) <= 10 for k in qdist.keys())
|
||
assert sum(qdist.values()) >= MIN_OPENALEX_SOURCES // 2
|
||
|
||
# 3. Source breakdown (openalex / doaj / predatory / llm).
|
||
sdist = db.get_source_distribution()
|
||
assert "openalex" in sdist
|
||
assert sdist["openalex"] >= MIN_OPENALEX_SOURCES // 2
|
||
|
||
# 4. Default first page of the journals table.
|
||
journals, total = db.get_journals_page(page=1, per_page=50)
|
||
assert total >= MIN_OPENALEX_SOURCES
|
||
assert len(journals) == 50
|
||
# Default sort=quality desc — first page should be Q1 / elite.
|
||
assert journals[0]["quality"] >= journals[-1]["quality"]
|
||
# Field shape consumed by the dashboard JS.
|
||
j0 = journals[0]
|
||
for key in ("name", "quality", "h_index", "score_source"):
|
||
assert key in j0, f"dashboard row missing field: {key}"
|
||
|
||
# 5. Search filter — "nature" should always match a real journal.
|
||
journals, total = db.get_journals_page(
|
||
page=1, per_page=10, search="nature"
|
||
)
|
||
assert total > 0
|
||
assert any("nature" in (j["name"] or "").lower() for j in journals)
|
||
|
||
# 6. Tier filter — elite tier should always have entries given the
|
||
# ~280K-row corpus.
|
||
_, total_elite = db.get_journals_page(page=1, per_page=10, tier="elite")
|
||
assert total_elite > 0
|
||
finally:
|
||
db.reset()
|