steward_mirror/tests/test_proposals.py

107 lines
3.3 KiB
Python

"""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 ****** 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 "https://example.com" in text
assert "All good." in text
assert "2026-01-01" in text