Files
US2KR/tests/test_deliver.py
T
2026-08-31 14:33:53 +09:00

329 lines
12 KiB
Python

"""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(["<b>chunk 1</b>", "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] == ["<b>chunk 1</b>", "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(['<b>제목</b> 5 &lt; 6 <a href="http://x">링크</a>', "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"]