Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user