"""Tests for steward.proposals.generator.""" from datetime import UTC from unittest.mock import AsyncMock, MagicMock import httpx import pytest import respx from steward.config import Settings from steward.llm.client import LLMClient from steward.proposals.generator import Proposal, ProposalGenerator def _make_settings(**kwargs) -> Settings: defaults = dict( openai_api_key="test-key", analysis_target_url="https://example.com/api/status", analysis_target_api_key="secret", ) defaults.update(kwargs) return Settings(**defaults) @pytest.fixture def settings(): return _make_settings() @pytest.fixture def mock_llm(): llm = MagicMock(spec=LLMClient) llm.chat = AsyncMock( return_value="**Summary:** Everything is healthy.\n**Why:** No issues detected." ) return llm @pytest.mark.asyncio async def test_run_generates_proposal(settings, mock_llm): """run() should fetch the URL and return a Proposal.""" with respx.mock: respx.get("https://example.com/api/status").mock( return_value=httpx.Response(200, text='{"status": "ok"}') ) generator = ProposalGenerator(settings, mock_llm) proposal = await generator.run() assert proposal is not None assert isinstance(proposal, Proposal) assert proposal.source_url == "https://example.com/api/status" assert proposal.raw_data == '{"status": "ok"}' mock_llm.chat.assert_awaited_once() @pytest.mark.asyncio async def test_run_no_url_returns_none(mock_llm): """run() should return None when analysis_target_url is not configured.""" settings = _make_settings(analysis_target_url="") generator = ProposalGenerator(settings, mock_llm) result = await generator.run() assert result is None mock_llm.chat.assert_not_called() @pytest.mark.asyncio async def test_run_http_error_returns_none(settings, mock_llm): """run() should return None when the HTTP request fails.""" with respx.mock: respx.get("https://example.com/api/status").mock( return_value=httpx.Response(500, text="Internal Server Error") ) generator = ProposalGenerator(settings, mock_llm) proposal = await generator.run() assert proposal is None mock_llm.chat.assert_not_called() @pytest.mark.asyncio async def test_fetch_sends_bearer_token(settings, mock_llm): """_fetch() should include an Authorization header when api_key is configured.""" with respx.mock: route = respx.get("https://example.com/api/status").mock( return_value=httpx.Response(200, text="data") ) generator = ProposalGenerator(settings, mock_llm) await generator._fetch("https://example.com/api/status") request = route.calls.last.request assert request.headers["Authorization"].startswith("Bearer ") def test_proposal_format_for_telegram(): """format_for_telegram() should contain the source URL and body.""" from datetime import datetime p = Proposal( title="Test", body="**Summary:** All good.", source_url="https://example.com", generated_at=datetime(2026, 1, 1, 8, 0, 0, tzinfo=UTC), ) text = p.format_for_telegram() assert p.source_url in text assert "All good." in text assert "2026-01-01" in text