Files
sebrief/tests/test_pipeline.py
jsahnandClaude Fable 5 cdd070749b Backfill guard: a past week's digest only takes papers published by its end
Backfilling an old week used to sweep in undigested papers published later
(stamping them into the wrong week and removing them from the next Monday's
digest); regular weekly runs are unaffected since the collection window
already ends at week_end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-20 14:06:25 +09:00

259 lines
12 KiB
Python

"""Pipeline orchestration with fakes: happy path, idempotency, degrade and delivery failures."""
from __future__ import annotations
import pytest
from _fakes import NOW, WEEK, FakeDeliverer, FakeProvider, FakeStore, make_digest, make_paper
from sebrief import pipeline
from sebrief.config import Settings
from sebrief.models import Verification, VerificationIssue
from sebrief.pipeline import SKIP_ALREADY_SENT, build_payload, delivery_failed, run_digest
@pytest.fixture
def env(monkeypatch, tmp_path):
"""Patch every pipeline collaborator; return the mutable test context."""
ctx = {
"store": FakeStore(pending=[make_paper()]),
"provider": FakeProvider(),
"deliverers": [FakeDeliverer("file")],
"openalex": [make_paper()],
"rss": [],
"settings": Settings(_env_file=None, SEBRIEF_DATA_DIR=str(tmp_path / "data")),
}
ctx["crossref"] = []
monkeypatch.setattr(pipeline, "_now", lambda: NOW)
monkeypatch.setattr(pipeline, "Store", lambda path: ctx["store"])
monkeypatch.setattr(pipeline, "fetch_openalex_papers", lambda start, end, s: ctx["openalex"])
monkeypatch.setattr(pipeline, "fetch_crossref_papers", lambda start, end, s: ctx["crossref"])
monkeypatch.setattr(pipeline, "fetch_rss_papers", lambda feeds, start, end, bl: ctx["rss"])
monkeypatch.setattr(pipeline, "build_provider", lambda s: ctx["provider"])
monkeypatch.setattr(pipeline, "build_deliverers", lambda s: ctx["deliverers"])
return ctx
# ------------------------------------------------------------------------------ build_payload
def test_build_payload_collects_upserts_and_reads_pending(env):
payload = build_payload(WEEK, env["settings"], env["store"])
assert payload.week == WEEK
assert env["store"].upserted == [[make_paper()]]
assert [p.doi for p in payload.papers] == ["10.1145/1234567"]
assert payload.data_gaps == []
# collection window reaches back by backfill_days
assert payload.week_start.isoformat() == "2026-08-31"
def test_build_payload_collector_failure_becomes_gap(env, monkeypatch):
def boom(start, end, s):
raise RuntimeError("openalex down")
monkeypatch.setattr(pipeline, "fetch_openalex_papers", boom)
payload = build_payload(WEEK, env["settings"], env["store"])
assert payload.data_gaps == ["openalex: RuntimeError: openalex down"]
assert env["store"].upserted == [] # nothing collected -> nothing upserted
assert [p.doi for p in payload.papers] == ["10.1145/1234567"] # pending leftovers still flow in
def test_build_payload_merges_rss_dedup_by_doi(env):
env["settings"] = env["settings"].model_copy(
update={"rss_feeds": ["e-Inf|https://example.org/rss"]}
)
dup = make_paper(source="rss:e-Inf", venue="e-Inf") # same DOI as openalex
fresh = make_paper("10.5555/rss-only", source="rss:e-Inf")
env["rss"] = [dup, fresh]
build_payload(WEEK, env["settings"], env["store"])
[upserted] = env["store"].upserted
assert [p.doi for p in upserted] == ["10.1145/1234567", "10.5555/rss-only"]
assert upserted[0].source == "openalex" # openalex metadata wins on the duplicate
def test_build_payload_merges_crossref_openalex_wins_duplicates(env):
dup = make_paper(source="crossref:tse", venue="TSE", abstract="") # same DOI as openalex
fresh = make_paper("10.1109/tse.2026.1", source="crossref:tse", abstract="")
env["crossref"] = [dup, fresh]
build_payload(WEEK, env["settings"], env["store"])
[upserted] = env["store"].upserted
assert [p.doi for p in upserted] == ["10.1145/1234567", "10.1109/tse.2026.1"]
assert upserted[0].source == "openalex" # richer OpenAlex metadata wins the duplicate
def test_build_payload_invalid_feed_spec_is_a_gap(env):
env["settings"] = env["settings"].model_copy(update={"rss_feeds": ["broken-spec"]})
payload = build_payload(WEEK, env["settings"], env["store"])
assert any("invalid feed spec" in g for g in payload.data_gaps)
def test_backfill_excludes_papers_published_after_the_week(env):
# 과거 주 소급 생성: 그 주 이후에 발행된 미소화 논문은 과거 다이제스트로 끌려가면 안 된다
from datetime import date
in_week = make_paper("10.1/inweek", published_date=date(2026, 9, 2))
later = make_paper("10.1/later", published_date=date(2026, 9, 20)) # W36 이후 발행
undated = make_paper("10.1/undated", published_date=None)
env["store"].pending = [in_week, later, undated]
payload = build_payload(WEEK, env["settings"], env["store"]) # WEEK = 2026-W35 (…~09-06)
assert [p.doi for p in payload.papers] == ["10.1/inweek", "10.1/undated"]
def test_build_payload_caps_papers_and_notes_rollover(env):
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(5)]
env["settings"] = env["settings"].model_copy(update={"max_papers": 3})
payload = build_payload(WEEK, env["settings"], env["store"])
assert len(payload.papers) == 3
assert [p.doi for p in payload.papers] == ["10.1/p0", "10.1/p1", "10.1/p2"]
assert any("이월" in g for g in payload.data_gaps)
def test_cap_never_drops_papers_already_in_this_weeks_digest(env):
# --force re-run: p3/p4 were published in this week's digest; the cap must drop rollover
# candidates (p0..p2) instead, even though they sort first.
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(5)]
env["store"].published_dois = {"10.1/p3", "10.1/p4"}
env["settings"] = env["settings"].model_copy(update={"max_papers": 3})
payload = build_payload(WEEK, env["settings"], env["store"])
dois = [p.doi for p in payload.papers]
assert "10.1/p3" in dois and "10.1/p4" in dois
assert len(dois) == 3 and dois == ["10.1/p0", "10.1/p3", "10.1/p4"]
def test_cap_keeps_all_published_even_beyond_max(env):
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(4)]
env["store"].published_dois = {f"10.1/p{i}" for i in range(4)}
env["settings"] = env["settings"].model_copy(update={"max_papers": 2})
payload = build_payload(WEEK, env["settings"], env["store"])
assert len(payload.papers) == 4 # a published paper is never dropped
assert not any("이월" in g for g in payload.data_gaps)
# -------------------------------------------------------------------------------- run_digest
def test_run_digest_happy_path_saves_marks_and_delivers(env):
result = run_digest(WEEK, env["settings"])
assert result.error is None and not result.degraded
assert result.model_used == "fake-model"
assert result.verification is not None and result.verification.ok
assert WEEK in env["store"].digests
assert env["store"].digested == [(["10.1145/1234567"], WEEK)]
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
assert env["store"].closed is True
assert "SE 주간 브리핑" in result.markdown
# per-paper summaries were generated, attached, persisted and rendered
assert result.payload.papers[0].summary is not None
assert env["store"].saved_summaries == [{"10.1145/1234567": result.payload.papers[0].summary}]
assert "## 논문별 요약" in result.markdown
def test_run_digest_paper_summaries_disabled(env):
env["settings"] = env["settings"].model_copy(update={"paper_summary_enabled": False})
result = run_digest(WEEK, env["settings"])
assert result.payload.papers[0].summary is None
assert env["store"].saved_summaries == []
assert not any(c.startswith("summarize_papers") for c in env["provider"].calls)
def test_run_digest_paper_summary_failure_is_a_gap_not_a_blocker(env):
env["provider"] = FakeProvider(fail_paper_summaries=True)
result = run_digest(WEEK, env["settings"])
assert result.error is None and not result.degraded # digest itself still produced
assert any("paper-summaries" in g for g in result.payload.data_gaps)
assert env["store"].saved_summaries == []
def test_run_digest_defaults_to_latest_completed_week(env):
result = run_digest(None, env["settings"])
assert result.payload.week == WEEK # NOW is Monday 2026-09-07 -> last completed week is W36
def test_run_digest_skips_when_already_sent(env):
env["store"].digests[WEEK] = object()
result = run_digest(WEEK, env["settings"])
assert result.error == SKIP_ALREADY_SENT
assert env["store"].sent == []
def test_run_digest_force_resends(env):
env["store"].digests[WEEK] = object()
result = run_digest(WEEK, env["settings"], force=True)
assert result.error is None
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
def test_run_digest_refuses_unfinished_week(env):
with pytest.raises(ValueError, match="has not ended yet"):
run_digest("2026-W37", env["settings"]) # NOW (Mon 09-07) is inside W37
def test_run_digest_dry_run_previews_without_side_effects(env, capsys):
result = run_digest("2026-W37", env["settings"], dry_run=True) # even an unfinished week
assert "SE 주간 브리핑" in capsys.readouterr().out
assert env["store"].digests == {} and env["store"].sent == [] and env["store"].digested == []
assert result.markdown
def test_run_digest_llm_failure_degrades_but_still_delivers(env):
env["provider"] = FakeProvider(fail_summarize=True)
result = run_digest(WEEK, env["settings"])
assert result.degraded and result.digest is None
assert "all models failed" in (result.error or "")
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
# degraded delivery raises an operator alert
assert any("축소 모드" in s for s, _ in env["deliverers"][0].errors)
def test_run_digest_no_llm_flag(env):
result = run_digest(WEEK, env["settings"], no_llm=True)
assert result.degraded and result.digest is None and result.error is None
assert env["provider"].calls == []
def test_run_digest_empty_week_is_not_degraded(env):
env["openalex"] = []
env["store"].pending = []
result = run_digest(WEEK, env["settings"])
assert not result.degraded and result.digest is None and result.error is None
assert "이번 주 수집된 OA 논문이 없습니다." in result.markdown
assert env["provider"].calls == []
assert env["store"].digested == [([], WEEK)]
def test_run_digest_verification_disabled(env):
env["settings"] = env["settings"].model_copy(update={"verify_enabled": False})
result = run_digest(WEEK, env["settings"])
assert result.verification is None
assert env["provider"].calls == ["summarize_papers:1", "summarize"]
def test_run_digest_verification_revises_major_issue(env):
major = Verification(ok=False, issues=[VerificationIssue(claim="c", problem="p", severity="major")])
clean = Verification(ok=True, issues=[])
env["provider"] = FakeProvider(
verifications=[major, clean], revised=make_digest(overview="수정본입니다.")
)
result = run_digest(WEEK, env["settings"])
assert result.digest is not None and result.digest.overview == "수정본입니다."
assert result.verification is not None and result.verification.ok
def test_run_digest_delivery_failure_is_annotated_and_alerted(env):
failing = FakeDeliverer("file", fail=True)
env["deliverers"] = [failing]
result = run_digest(WEEK, env["settings"])
assert delivery_failed(result)
assert "delivery failed" in (result.error or "")
assert WEEK in env["store"].digests # saved before delivering
assert any("발송 실패" in s for s, _ in failing.errors)
assert any(f"--week {WEEK}" in d for _, d in failing.errors)
def test_run_digest_one_channel_failing_does_not_block_the_other(env):
ok = FakeDeliverer("ok")
env["deliverers"] = [FakeDeliverer("bad", fail=True), ok]
result = run_digest(WEEK, env["settings"])
assert ok.delivered and env["store"].sent == [(WEEK, "ok", "ok-receipt-1")]
assert delivery_failed(result)
def test_run_digest_rejects_malformed_week(env):
with pytest.raises(ValueError):
run_digest("2026-36", env["settings"])