329 lines
12 KiB
Python
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 < 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"]
|