"""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"])