from __future__ import annotations """R3 사용자 시나리오 RED: API 키 없음은 조용한 실패가 아니라 복구 가능한 BLOCKED 여야 한다.""" from pathlib import Path from typing import Callable from dmf_crawler.config import AgyCfg, BackupCfg, Config, LoggingCfg, ReportCfg, SourceCfg, StorageCfg from dmf_crawler.models import DmfRecord, FetchResult, RunStatus def test_run_without_service_key_uses_existing_baseline_instead_of_blocking( tmp_path: Path, monkeypatch, record_factory: Callable[..., DmfRecord] ) -> None: """공식 API 모드에서도 인증키 없음은 BLOCKED 가 아니라 기존 기준선 리포트로 폴백한다.""" from dmf_crawler import alerts, pipeline, paths, secrets_dpapi from dmf_crawler.storage import db, repo monkeypatch.setattr(secrets_dpapi, "load_service_key", lambda: None) monkeypatch.setattr(paths, "RUN_LOCK_PATH", tmp_path / "state" / "run.lock") monkeypatch.setattr(paths, "HEARTBEAT_PATH", tmp_path / "state" / "heartbeat.json") monkeypatch.setattr(paths, "ALERTS_MIRROR", tmp_path / "state" / "alerts.json") cfg = Config().with_overrides( storage=StorageCfg(sqlite_path=str(tmp_path / "data" / "dmf.sqlite3")), report=ReportCfg(output_dir=str(tmp_path / "reports")), backup=BackupCfg(dir=str(tmp_path / "backup")), logging=LoggingCfg(dir=str(tmp_path / "logs"), console=False), source=SourceCfg(mode="api"), agy=AgyCfg(enabled=False), ) baseline_records = (record_factory("20200101-1-A-1-1", ingredient_name="기준성분"),) conn = db.connect(cfg.db_path()) try: db.apply_migrations(conn, paths.MIGRATIONS_DIR, cfg.backup_dir()) repo.start_run(conn, "baseline_no_key", "2026-09-02", "manual") repo.record_fetch_stats( conn, "baseline_no_key", FetchResult( records=(), total_count_reported=len(baseline_records), pages_fetched=1, pages_expected=1, http_calls=0, elapsed_seconds=0.0, archive_dir=None, body_signature_ok=True, payload_sha256="baseline", ), ) repo.insert_snapshot(conn, "baseline_no_key", baseline_records) repo.upsert_current_records(conn, "baseline_no_key", baseline_records) repo.finish_run( conn, "baseline_no_key", "SUCCESS", 0, "기준선", is_baseline=True, integrity_ok=True, diff_performed=False, ) finally: conn.close() run_id = "run_no_key_uses_baseline" result = pipeline.run_once(cfg, trigger="manual", force=True, skip_agy=True, run_id=run_id) assert result.status == RunStatus.PARTIAL assert result.exit_code == 0 assert result.report_path is not None and result.report_path.exists() assert any(stage.stage == "preflight" and stage.status == "SUCCESS" for stage in result.stages) assert any(stage.stage == "fetch" and stage.status == "SKIPPED" for stage in result.stages) assert any(stage.stage == "report" and stage.status == "SUCCESS" for stage in result.stages) conn = db.connect(cfg.db_path()) try: pending = alerts.pending(conn) finally: conn.close() assert all(row.code != "API_KEY_INVALID" for row in pending) assert paths.HEARTBEAT_PATH.exists(), "PARTIAL 도 운영 기준 heartbeat 를 갱신해야 한다"