"""Unit tests for the PAT Manager alert sender module.""" from __future__ import annotations import pytest import respx from httpx import Response from scripts.pat_manager.alerter import AlertSender from scripts.pat_manager.errors import AlertError from scripts.pat_manager.models import ( AlertChannel, AlertResult, CheckResult, PatEntry, PatStatus, ) @pytest.fixture def expiring_result() -> CheckResult: """A CheckResult for a Jira token expiring in 7 days.""" return CheckResult( entry=PatEntry( service="Jira", token_name="jira-api-access", expiry_date="2025-08-01", renewal_method="manual", ), status=PatStatus.EXPIRING_SOON, days_remaining=7, error_message=None, ) @pytest.fixture def expired_result() -> CheckResult: """A CheckResult for an expired Confluence token.""" return CheckResult( entry=PatEntry( service="Confluence", token_name="confluence-bot", expiry_date="2025-06-01", renewal_method="manual", ), status=PatStatus.EXPIRED, days_remaining=0, error_message=None, ) @pytest.fixture def orgmylife_config() -> dict: """Config for OrgMyLife alert channel.""" return { "orgmylife_url": "https://orgmylife.app", "orgmylife_api_key": "test-api-key-123", } @pytest.fixture def email_config() -> dict: """Config for email alert channel.""" return { "smtp_host": "smtp.example.com", "smtp_port": "587", "smtp_user": "user@example.com", "smtp_pass": "password123", "alert_email_to": "admin@example.com", } class TestAlertSenderInit: """Tests for AlertSender initialization.""" def test_orgmylife_channel(self, orgmylife_config: dict) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) assert sender.channel == AlertChannel.ORGMYLIFE assert sender.config == orgmylife_config def test_email_channel(self, email_config: dict) -> None: sender = AlertSender(AlertChannel.EMAIL, email_config) assert sender.channel == AlertChannel.EMAIL assert sender.config == email_config class TestBuildTaskTitle: """Tests for task title formatting.""" def test_title_format(self, expiring_result: CheckResult, orgmylife_config: dict) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) title = sender._build_task_title(expiring_result) assert title == "[PAT Renewal] Jira - jira-api-access" def test_title_contains_service_and_token( self, expired_result: CheckResult, orgmylife_config: dict ) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) title = sender._build_task_title(expired_result) assert "Confluence" in title assert "confluence-bot" in title assert title == "[PAT Renewal] Confluence - confluence-bot" class TestBuildTaskDescription: """Tests for task description formatting.""" def test_expiring_description( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) desc = sender._build_task_description(expiring_result) assert "7 days" in desc assert "2025-08-01" in desc assert "Manual renewal required" in desc assert "Jira" in desc assert "jira-api-access" in desc def test_expired_description( self, expired_result: CheckResult, orgmylife_config: dict ) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) desc = sender._build_task_description(expired_result) assert "0 days remaining" in desc assert "Token has expired" in desc assert "Manual renewal required" in desc class TestSendAlertOrgMyLife: """Tests for OrgMyLife alert sending.""" @pytest.mark.asyncio @respx.mock async def test_successful_task_creation( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: # Mock: no existing tasks respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response(200, json=[]) ) # Mock: successful task creation respx.post("https://orgmylife.app/api/tasks").mock( return_value=Response(201, json={"id": 1, "title": "[PAT Renewal] Jira - jira-api-access"}) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) result = await sender.send_alert(expiring_result) assert result.sent is True assert result.channel == AlertChannel.ORGMYLIFE assert result.error_message is None @pytest.mark.asyncio @respx.mock async def test_skips_when_existing_task( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: # Mock: existing task found respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response( 200, json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}], ) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) result = await sender.send_alert(expiring_result) assert result.sent is False assert result.channel == AlertChannel.ORGMYLIFE assert result.error_message is None @pytest.mark.asyncio @respx.mock async def test_raises_alert_error_on_failure( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: # Mock: no existing tasks respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response(200, json=[]) ) # Mock: task creation fails respx.post("https://orgmylife.app/api/tasks").mock( return_value=Response(500, json={"error": "Internal Server Error"}) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) with pytest.raises(AlertError) as exc_info: await sender.send_alert(expiring_result) assert "orgmylife" in exc_info.value.channel assert "500" in exc_info.value.reason @pytest.mark.asyncio @respx.mock async def test_api_key_header_sent( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: # Mock: no existing tasks get_route = respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response(200, json=[]) ) # Mock: successful task creation post_route = respx.post("https://orgmylife.app/api/tasks").mock( return_value=Response(201, json={"id": 1}) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) await sender.send_alert(expiring_result) # Verify API key was sent assert get_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123" assert post_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123" class TestCheckExistingTask: """Tests for idempotent alerting via check_existing_task.""" @pytest.mark.asyncio @respx.mock async def test_returns_true_when_task_exists(self, orgmylife_config: dict) -> None: respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response( 200, json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}], ) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01") assert exists is True @pytest.mark.asyncio @respx.mock async def test_returns_false_when_no_task(self, orgmylife_config: dict) -> None: respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response(200, json=[]) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01") assert exists is False @pytest.mark.asyncio @respx.mock async def test_returns_false_on_api_error(self, orgmylife_config: dict) -> None: respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response(500, json={"error": "Server Error"}) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01") assert exists is False @pytest.mark.asyncio @respx.mock async def test_handles_wrapped_response(self, orgmylife_config: dict) -> None: """Test handling of paginated/wrapped API response.""" respx.get("https://orgmylife.app/api/tasks").mock( return_value=Response( 200, json={ "tasks": [ {"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"} ] }, ) ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01") assert exists is True class TestSendAlertEmail: """Tests for email alert sending.""" @pytest.mark.asyncio async def test_raises_on_missing_smtp_config( self, expiring_result: CheckResult ) -> None: sender = AlertSender(AlertChannel.EMAIL, {}) with pytest.raises(AlertError) as exc_info: await sender.send_alert(expiring_result) assert "email" in exc_info.value.channel @pytest.mark.asyncio async def test_raises_on_missing_email_to( self, expiring_result: CheckResult ) -> None: config = {"smtp_host": "smtp.example.com"} sender = AlertSender(AlertChannel.EMAIL, config) with pytest.raises(AlertError) as exc_info: await sender.send_alert(expiring_result) assert "email" in exc_info.value.channel class TestDaysRemainingInAlert: """Tests for days remaining in alert messages.""" def test_expired_shows_zero_days( self, expired_result: CheckResult, orgmylife_config: dict ) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) desc = sender._build_task_description(expired_result) assert "0 days remaining" in desc def test_expiring_shows_correct_days( self, expiring_result: CheckResult, orgmylife_config: dict ) -> None: sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) desc = sender._build_task_description(expiring_result) assert "7 days" in desc def test_none_days_remaining_treated_as_zero(self, orgmylife_config: dict) -> None: result = CheckResult( entry=PatEntry( service="OrgMyLife", token_name="test-token", expiry_date=None, renewal_method="manual", ), status=PatStatus.EXPIRED, days_remaining=None, error_message=None, ) sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config) desc = sender._build_task_description(result) assert "0 days remaining" in desc