"""Tests for us2kr.deliver (file archive, Telegram, factory). No network.""" from __future__ import annotations import json import logging import sys import time import types from collections.abc import Callable from datetime import date, datetime from pathlib import Path from zoneinfo import ZoneInfo import httpx import pytest from us2kr.config import Settings from us2kr.deliver import build_deliverers from us2kr.deliver.base import DeliveryError from us2kr.deliver.file import FileDeliverer from us2kr.deliver.telegram import TelegramDeliverer from us2kr.models import BriefingResult, MarketPayload, Quote, Report TOKEN = "123456789:AAHsecretTOKENvalueXYZ" CHAT = "-1001234567890" # ------------------------------------------------------------------ fixtures @pytest.fixture def result() -> BriefingResult: payload = MarketPayload( session_date=date(2026, 8, 24), us_close_time=datetime(2026, 8, 24, 16, 0, tzinfo=ZoneInfo("America/New_York")), generated_at=datetime(2026, 8, 25, 6, 15, tzinfo=ZoneInfo("Asia/Seoul")), krx_date=date(2026, 8, 25), krx_open_today=True, quotes=[Quote(symbol="^GSPC", name="S&P 500", group="index", close=5500.0, change_pct=0.5)], data_gaps=["news: timeout"], ) report = Report( headline="미 증시 소폭 상승", market_overview="S&P 500이 0.5% 올랐다.", key_drivers=["기술주 강세"], rates_fx_commodities="10년물 금리 보합.", korea_implications=["반도체 긍정적"], watchlist_today=["삼성전자"], risk_flags=["환율 변동성"], data_gaps=["news: timeout"], ) return BriefingResult( payload=payload, report=report, markdown="# 브리핑 2026-08-25\n\n## 시장\nS&P 500 +0.5%\n", model_used="MiniMax-M3", ) @pytest.fixture def fake_render(monkeypatch: pytest.MonkeyPatch) -> Callable[[list[str]], None]: """Install a stub us2kr.render module returning fixed chunks (render may not exist yet).""" def install(chunks: list[str]) -> None: mod = types.ModuleType("us2kr.render") mod.render_telegram_chunks = lambda markdown, limit=4000: list(chunks) # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "us2kr.render", mod) return install @pytest.fixture def no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: calls: list[float] = [] monkeypatch.setattr(time, "sleep", lambda s: calls.append(s)) return calls def _ok(message_id: int) -> httpx.Response: return httpx.Response(200, json={"ok": True, "result": {"message_id": message_id}}) def _err(status: int, description: str, **params) -> httpx.Response: body = {"ok": False, "error_code": status, "description": description} if params: body["parameters"] = params return httpx.Response(status, json=body) class Scripted: """MockTransport handler that records requests and replays scripted responses.""" def __init__(self, responses: list[httpx.Response | Exception]) -> None: self.responses = list(responses) self.requests: list[httpx.Request] = [] def __call__(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) if not self.responses: raise AssertionError("unexpected extra request") item = self.responses.pop(0) if isinstance(item, Exception): raise item return item def bodies(self) -> list[dict]: return [json.loads(r.content) for r in self.requests] def make_deliverer(responses: list[httpx.Response | Exception]) -> tuple[TelegramDeliverer, Scripted]: handler = Scripted(responses) client = httpx.Client(transport=httpx.MockTransport(handler)) return TelegramDeliverer(TOKEN, CHAT, client=client), handler # ------------------------------------------------------------ FileDeliverer def test_file_deliverer_writes_md_and_json(tmp_path: Path, result: BriefingResult) -> None: reports_dir = tmp_path / "nested" / "reports" # does not exist yet d = FileDeliverer(reports_dir) assert d.name == "file" receipt = d.deliver(result) md_path = reports_dir / "2026-08-25.md" assert receipt == str(md_path) assert md_path.read_text(encoding="utf-8") == result.markdown json_path = reports_dir / "2026-08-25.json" restored = BriefingResult.model_validate_json(json_path.read_text(encoding="utf-8")) assert restored == result assert "\n " in json_path.read_text(encoding="utf-8") # indent=2 def test_file_deliverer_overwrites_on_redeliver(tmp_path: Path, result: BriefingResult) -> None: d = FileDeliverer(tmp_path) d.deliver(result) updated = result.model_copy(update={"markdown": "# v2\n"}) d.deliver(updated) assert (tmp_path / "2026-08-25.md").read_text(encoding="utf-8") == "# v2\n" def test_file_deliverer_notify_error_appends(tmp_path: Path) -> None: d = FileDeliverer(tmp_path / "r") d.notify_error("LLM 실패", "LLMError: all models failed\nsecond line") d.notify_error("수집 실패", "yfinance down") lines = (tmp_path / "r" / "errors.log").read_text(encoding="utf-8").splitlines() assert len(lines) == 2 assert "LLM 실패" in lines[0] and "all models failed | second line" in lines[0] assert lines[0].startswith("20") # ISO timestamp assert "yfinance down" in lines[1] def test_file_deliverer_notify_error_never_raises(tmp_path: Path) -> None: blocker = tmp_path / "file_not_dir" blocker.write_text("x") d = FileDeliverer(blocker / "sub") # mkdir will fail: parent is a regular file d.notify_error("s", "d") # must not raise def test_file_deliverer_deliver_raises_delivery_error(tmp_path: Path, result: BriefingResult) -> None: blocker = tmp_path / "file_not_dir" blocker.write_text("x") with pytest.raises(DeliveryError): FileDeliverer(blocker / "sub").deliver(result) # -------------------------------------------------------- TelegramDeliverer def test_telegram_sends_each_chunk_and_returns_first_id(result: BriefingResult, fake_render) -> None: fake_render(["chunk 1", "chunk 2", "chunk 3"]) d, handler = make_deliverer([_ok(101), _ok(102), _ok(103)]) assert d.name == "telegram" receipt = d.deliver(result) assert receipt == "101" assert len(handler.requests) == 3 for req in handler.requests: assert req.method == "POST" assert str(req.url) == f"https://api.telegram.org/bot{TOKEN}/sendMessage" bodies = handler.bodies() assert [b["text"] for b in bodies] == ["chunk 1", "chunk 2", "chunk 3"] for body in bodies: assert body["chat_id"] == CHAT assert body["parse_mode"] == "HTML" assert body["disable_web_page_preview"] is True def test_telegram_no_chunks_raises(result: BriefingResult, fake_render) -> None: fake_render([]) d, handler = make_deliverer([]) with pytest.raises(DeliveryError): d.deliver(result) assert handler.requests == [] def test_telegram_retries_on_429_honouring_retry_after(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(["only"]) d, handler = make_deliverer([_err(429, "Too Many Requests: retry after 7", retry_after=7), _ok(5)]) assert d.deliver(result) == "5" assert len(handler.requests) == 2 assert len(no_sleep) == 1 and no_sleep[0] >= 7 def test_telegram_retries_5xx_then_succeeds(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(["only"]) d, handler = make_deliverer([_err(502, "Bad Gateway"), _err(503, "Service Unavailable"), _ok(9)]) assert d.deliver(result) == "9" assert len(handler.requests) == 3 assert len(no_sleep) == 2 assert no_sleep[1] > no_sleep[0] # exponential backoff def test_telegram_gives_up_after_three_5xx(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(["only"]) d, handler = make_deliverer([_err(500, "boom"), _err(500, "boom"), _err(500, "boom")]) with pytest.raises(DeliveryError, match="3 attempts"): d.deliver(result) assert len(handler.requests) == 3 def test_telegram_transport_error_is_retried(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(["only"]) d, handler = make_deliverer([httpx.ConnectError("connection refused"), _ok(1)]) assert d.deliver(result) == "1" assert len(handler.requests) == 2 def test_telegram_other_4xx_not_retried(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(["only"]) d, handler = make_deliverer([_err(403, "Forbidden: bot was blocked by the user")]) with pytest.raises(DeliveryError, match="403"): d.deliver(result) assert len(handler.requests) == 1 assert no_sleep == [] def test_telegram_html_parse_error_falls_back_to_plain(result: BriefingResult, fake_render, no_sleep) -> None: fake_render(['제목 5 < 6 링크', "second"]) d, handler = make_deliverer( [ _err(400, 'Bad Request: can\'t parse entities: Unsupported start tag "x" at byte offset 3'), _ok(21), _ok(22), ] ) assert d.deliver(result) == "21" bodies = handler.bodies() assert len(bodies) == 3 assert bodies[0]["parse_mode"] == "HTML" assert "parse_mode" not in bodies[1] assert bodies[1]["text"] == "제목 5 < 6 링크" assert bodies[2]["parse_mode"] == "HTML" and bodies[2]["text"] == "second" assert no_sleep == [] def test_telegram_notify_error_sends_plain_text() -> None: d, handler = make_deliverer([_ok(77)]) d.notify_error("LLM 실패", "x" * 2000) body = handler.bodies()[0] assert "parse_mode" not in body assert body["text"].startswith("⚠️ US2KR 오류: LLM 실패\n") assert body["text"] == "⚠️ US2KR 오류: LLM 실패\n" + "x" * 1500 assert body["chat_id"] == CHAT def test_telegram_notify_error_never_raises(no_sleep, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) d, _ = make_deliverer([httpx.ConnectError(f"boom https://api.telegram.org/bot{TOKEN}/sendMessage")] * 3) d.notify_error("subject", "detail") # must swallow d2, _ = make_deliverer([_err(401, "Unauthorized")]) d2.notify_error("subject", "detail") # must swallow assert TOKEN not in caplog.text assert "notify_error failed" in caplog.text def test_telegram_token_never_logged( result: BriefingResult, fake_render, no_sleep, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) fake_render(["a", "b"]) d, _ = make_deliverer([_err(429, "slow down", retry_after=1), _ok(1), _err(500, "x"), _ok(2)]) d.deliver(result) d3, _ = make_deliverer([_err(500, "x")] * 3) with pytest.raises(DeliveryError) as excinfo: d3.deliver(result) assert TOKEN not in str(excinfo.value) assert TOKEN not in caplog.text assert caplog.records, "expected some log output" # httpx's own request log (which contains the URL) must be suppressed assert not [r for r in caplog.records if r.name == "httpx" and r.levelno < logging.WARNING] def test_telegram_requires_token_and_chat() -> None: with pytest.raises(ValueError): TelegramDeliverer("", CHAT) with pytest.raises(ValueError): TelegramDeliverer(TOKEN, "") # ---------------------------------------------------------- build_deliverers def _settings(tmp_path: Path, **overrides) -> Settings: return Settings(_env_file=None, US2KR_REPORTS_DIR=str(tmp_path / "reports"), **overrides) def test_build_deliverers_file_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False) monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) ds = build_deliverers(_settings(tmp_path)) assert [d.name for d in ds] == ["file"] assert isinstance(ds[0], FileDeliverer) assert ds[0].reports_dir == tmp_path / "reports" def test_build_deliverers_with_telegram(tmp_path: Path) -> None: ds = build_deliverers(_settings(tmp_path, TELEGRAM_BOT_TOKEN=TOKEN, TELEGRAM_CHAT_ID=CHAT)) assert [d.name for d in ds] == ["file", "telegram"] assert isinstance(ds[1], TelegramDeliverer) assert ds[1].chat_id == CHAT def test_build_deliverers_token_without_chat_is_file_only(tmp_path: Path, monkeypatch) -> None: monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False) ds = build_deliverers(_settings(tmp_path, TELEGRAM_BOT_TOKEN=TOKEN)) assert [d.name for d in ds] == ["file"]