initial commit
This commit is contained in:
46
tests/conftest.py
Normal file
46
tests/conftest.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from glitchup_bot.config import clear_settings_cache
|
||||
|
||||
DEFAULT_ENV = {
|
||||
"TELEGRAM_BOT_TOKEN": "token",
|
||||
"TELEGRAM_GROUP_CHAT_ID": "-1001234567890",
|
||||
"TELEGRAM_BACKEND_TOPIC_ID": "11",
|
||||
"TELEGRAM_FRONTEND_TOPIC_ID": "22",
|
||||
"TELEGRAM_DIGEST_TOPIC_ID": "33",
|
||||
"BACKEND_PROJECTS": "backend-production,backend-staging",
|
||||
"FRONTEND_PROJECTS": "frontend-production,frontend-staging",
|
||||
"BACKEND_SUBSCRIBERS": "",
|
||||
"FRONTEND_SUBSCRIBERS": "",
|
||||
"TELEGRAM_ADMIN_IDS": "",
|
||||
"GLITCHTIP_URL": "https://glitchtip.example.com",
|
||||
"GLITCHTIP_API_TOKEN": "secret",
|
||||
"GLITCHTIP_ORG_SLUG": "org",
|
||||
"DATABASE_URL": "postgresql+asyncpg://glitchup:glitchup@db:5432/glitchup",
|
||||
"API_PORT": "8080",
|
||||
"WEBHOOK_SECRET": "",
|
||||
"DIGEST_CRON_DAY": "mon",
|
||||
"DIGEST_CRON_HOUR": "10",
|
||||
"DIGEST_CRON_MINUTE": "0",
|
||||
"DIGEST_TIMEZONE": "Asia/Krasnoyarsk",
|
||||
"SYNC_INTERVAL_MINUTES": "30",
|
||||
"ALERT_ENVIRONMENTS": "production",
|
||||
"DEDUP_WINDOW_HOURS": "6",
|
||||
"ALERT_RATE_LIMIT_COUNT": "10",
|
||||
"ALERT_RATE_LIMIT_WINDOW_MINUTES": "15",
|
||||
}
|
||||
|
||||
for key, value in DEFAULT_ENV.items():
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key, value in DEFAULT_ENV.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
clear_settings_cache()
|
||||
yield
|
||||
clear_settings_cache()
|
||||
165
tests/test_alert_processor.py
Normal file
165
tests/test_alert_processor.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import pytest
|
||||
|
||||
from glitchup_bot.services import alert_processor
|
||||
|
||||
|
||||
def make_issue_payload(project: str = "backend-production", color: str = "#e52b50") -> dict:
|
||||
return {
|
||||
"text": "GlitchTip Alert",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "ValueError: boom",
|
||||
"title_link": "https://glitchtip.example.com/issues/1",
|
||||
"text": "app.views.index",
|
||||
"color": color,
|
||||
"fields": [
|
||||
{"title": "Project", "value": project, "short": True},
|
||||
{"title": "Environment", "value": "production", "short": True},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_sends_and_records_notification(monkeypatch):
|
||||
sent_calls = []
|
||||
recorded = []
|
||||
|
||||
async def fake_send_alert(attachment, project_slug, priority, **kwargs):
|
||||
sent_calls.append((attachment.title, project_slug, priority))
|
||||
return "backend"
|
||||
|
||||
async def fake_is_duplicate(fingerprint: str) -> bool:
|
||||
assert fingerprint == "backend-production:ValueError: boom"
|
||||
return False
|
||||
|
||||
async def fake_record(issue_id: int, fingerprint: str, **kwargs) -> None:
|
||||
recorded.append((issue_id, fingerprint, kwargs["delivery_status"]))
|
||||
|
||||
monkeypatch.setattr(alert_processor, "send_alert", fake_send_alert)
|
||||
monkeypatch.setattr(alert_processor, "_is_duplicate", fake_is_duplicate)
|
||||
monkeypatch.setattr(alert_processor, "_record_notification", fake_record)
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
monkeypatch.setattr(
|
||||
alert_processor, "resolve_group", lambda project_slug: _async_value("backend")
|
||||
)
|
||||
monkeypatch.setattr(alert_processor, "find_matching_rule", lambda *args: _async_value(None))
|
||||
monkeypatch.setattr(alert_processor, "_is_rate_limited", lambda *args: _async_value(False))
|
||||
|
||||
await alert_processor.process_webhook_payload(make_issue_payload())
|
||||
|
||||
assert sent_calls == [("ValueError: boom", "backend-production", "P1")]
|
||||
assert recorded == [(0, "backend-production:ValueError: boom", "sent")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_skips_duplicate(monkeypatch):
|
||||
async def fake_send_alert(*args, **kwargs):
|
||||
raise AssertionError("duplicate alert should not be sent")
|
||||
|
||||
monkeypatch.setattr(alert_processor, "send_alert", fake_send_alert)
|
||||
monkeypatch.setattr(alert_processor, "_is_duplicate", lambda fingerprint: _async_value(True))
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
|
||||
await alert_processor.process_webhook_payload(make_issue_payload())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_skips_non_alert_environment(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def fake_send_alert(*args, **kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(alert_processor, "send_alert", fake_send_alert)
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
|
||||
await alert_processor.process_webhook_payload(make_issue_payload(project="backend-staging"))
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_handles_uptime(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send_alert(attachment, project_slug, priority, is_uptime=False, **kwargs):
|
||||
calls.append((attachment.title, project_slug, priority, is_uptime))
|
||||
return "backend"
|
||||
|
||||
monkeypatch.setattr(alert_processor, "send_alert", fake_send_alert)
|
||||
monkeypatch.setattr(alert_processor, "_record_notification", _noop)
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
|
||||
await alert_processor.process_webhook_payload(
|
||||
{
|
||||
"text": "GlitchTip Uptime Alert",
|
||||
"attachments": [
|
||||
{
|
||||
"title": "Example Monitor",
|
||||
"text": "The monitored site has gone down.",
|
||||
"title_link": "https://glitchtip.example.com/uptime/1",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert calls == [("Example Monitor", None, "P1", True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_skips_muted(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
class FakeRule:
|
||||
pattern = "ValueError"
|
||||
|
||||
async def fake_record(issue_id: int, fingerprint: str, **kwargs) -> None:
|
||||
recorded.append(kwargs["delivery_status"])
|
||||
|
||||
monkeypatch.setattr(alert_processor, "_is_duplicate", lambda fingerprint: _async_value(False))
|
||||
monkeypatch.setattr(
|
||||
alert_processor, "find_matching_rule", lambda *args: _async_value(FakeRule())
|
||||
)
|
||||
monkeypatch.setattr(alert_processor, "_record_notification", fake_record)
|
||||
monkeypatch.setattr(
|
||||
alert_processor, "resolve_group", lambda project_slug: _async_value("backend")
|
||||
)
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
|
||||
await alert_processor.process_webhook_payload(make_issue_payload())
|
||||
|
||||
assert recorded == ["muted"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_webhook_skips_rate_limited(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
async def fake_record(issue_id: int, fingerprint: str, **kwargs) -> None:
|
||||
recorded.append(kwargs["delivery_status"])
|
||||
|
||||
monkeypatch.setattr(alert_processor, "_is_duplicate", lambda fingerprint: _async_value(False))
|
||||
monkeypatch.setattr(alert_processor, "find_matching_rule", lambda *args: _async_value(None))
|
||||
monkeypatch.setattr(alert_processor, "_record_notification", fake_record)
|
||||
monkeypatch.setattr(
|
||||
alert_processor, "resolve_group", lambda project_slug: _async_value("backend")
|
||||
)
|
||||
monkeypatch.setattr(alert_processor, "_is_rate_limited", lambda *args: _async_value(True))
|
||||
monkeypatch.setattr(alert_processor, "mark_sync_success", _noop)
|
||||
|
||||
await alert_processor.process_webhook_payload(
|
||||
make_issue_payload(project="backend-production", color="#e9b949")
|
||||
)
|
||||
|
||||
assert recorded == ["rate_limited"]
|
||||
|
||||
|
||||
async def _async_value(value):
|
||||
return value
|
||||
42
tests/test_api_webhook.py
Normal file
42
tests/test_api_webhook.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from glitchup_bot.api.app import app
|
||||
from glitchup_bot.config import clear_settings_cache
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_rejects_invalid_secret(monkeypatch):
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "expected-secret")
|
||||
clear_settings_cache()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
response = await client.post(
|
||||
"/webhooks/glitchtip", json={"text": "GlitchTip Alert", "attachments": []}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_accepts_valid_secret(monkeypatch):
|
||||
received = []
|
||||
|
||||
async def fake_process(payload: dict) -> None:
|
||||
received.append(payload)
|
||||
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "expected-secret")
|
||||
clear_settings_cache()
|
||||
monkeypatch.setattr("glitchup_bot.api.webhook.process_webhook_payload", fake_process)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
response = await client.post(
|
||||
"/webhooks/glitchtip",
|
||||
headers={"X-Webhook-Secret": "expected-secret"},
|
||||
json={"text": "GlitchTip Alert", "attachments": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert received == [{"text": "GlitchTip Alert", "attachments": []}]
|
||||
30
tests/test_config.py
Normal file
30
tests/test_config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from glitchup_bot.config import get_settings
|
||||
|
||||
|
||||
def test_settings_parse_lists_and_groups(monkeypatch):
|
||||
monkeypatch.setenv("BACKEND_PROJECTS", "api-production,worker-production")
|
||||
monkeypatch.setenv("FRONTEND_PROJECTS", "web-production")
|
||||
monkeypatch.setenv("BACKEND_SUBSCRIBERS", "1,2")
|
||||
monkeypatch.setenv("FRONTEND_SUBSCRIBERS", "7,8")
|
||||
monkeypatch.setenv("TELEGRAM_ADMIN_IDS", "5,6")
|
||||
monkeypatch.setenv("ALERT_ENVIRONMENTS", "production,hotfix")
|
||||
monkeypatch.setenv("SYNC_INTERVAL_MINUTES", "45")
|
||||
monkeypatch.setenv("ALERT_RATE_LIMIT_COUNT", "7")
|
||||
monkeypatch.setenv("ALERT_RATE_LIMIT_WINDOW_MINUTES", "20")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.backend_projects == ["api-production", "worker-production"]
|
||||
assert settings.frontend_projects == ["web-production"]
|
||||
assert settings.backend_subscribers == [1, 2]
|
||||
assert settings.frontend_subscribers == [7, 8]
|
||||
assert settings.telegram_admin_ids == [5, 6]
|
||||
assert settings.alert_environments == ["production", "hotfix"]
|
||||
assert settings.sync_interval_minutes == 45
|
||||
assert settings.alert_rate_limit_count == 7
|
||||
assert settings.alert_rate_limit_window_minutes == 20
|
||||
assert settings.get_environment("api-production") == "production"
|
||||
assert settings.get_group("web-production") == "frontend"
|
||||
assert settings.get_group("unknown-project") == "backend"
|
||||
assert settings.is_alert_environment("api-production") is True
|
||||
assert settings.is_admin(5) is True
|
||||
227
tests/test_digest_builder.py
Normal file
227
tests/test_digest_builder.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from glitchup_bot.services import digest_builder
|
||||
from glitchup_bot.services.sync_service import IssueSnapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_digest_aggregates_projects(monkeypatch):
|
||||
now = datetime.now(UTC)
|
||||
issues = [
|
||||
IssueSnapshot(
|
||||
1,
|
||||
"backend-production",
|
||||
"New backend issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(days=1),
|
||||
now,
|
||||
12,
|
||||
True,
|
||||
None,
|
||||
"2026.03.20",
|
||||
),
|
||||
IssueSnapshot(
|
||||
2,
|
||||
"frontend-production",
|
||||
"Old frontend issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(days=10),
|
||||
now,
|
||||
3,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
|
||||
)
|
||||
|
||||
text = await digest_builder.build_digest()
|
||||
|
||||
assert "новых issues: 1" in text
|
||||
assert "regressions: 1" in text
|
||||
assert "unresolved > 7 дней: 1" in text
|
||||
assert "backend-production" in text
|
||||
assert "Old frontend issue" in text
|
||||
assert "2026.03.20" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_today_summary_limits_to_today(monkeypatch):
|
||||
now = datetime.now(UTC)
|
||||
issues = [
|
||||
IssueSnapshot(
|
||||
1,
|
||||
"backend-production",
|
||||
"Today issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(hours=2),
|
||||
now,
|
||||
2,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
IssueSnapshot(
|
||||
2,
|
||||
"backend-production",
|
||||
"Old issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(days=2),
|
||||
now,
|
||||
1,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
|
||||
)
|
||||
|
||||
text = await digest_builder.build_today_summary()
|
||||
|
||||
assert "Сегодня: 1 новых issues" in text
|
||||
assert "Today issue" in text
|
||||
assert "Old issue" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_project_summary(monkeypatch):
|
||||
now = datetime.now(UTC)
|
||||
issues = [
|
||||
IssueSnapshot(
|
||||
1,
|
||||
"backend-production",
|
||||
"Project issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now,
|
||||
now,
|
||||
5,
|
||||
False,
|
||||
"https://glitchtip.example.com/issues/1",
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
|
||||
)
|
||||
|
||||
text = await digest_builder.build_project_summary("backend-production")
|
||||
|
||||
assert "backend-production" in text
|
||||
assert "Project issue" in text
|
||||
assert "5 событий" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_top_and_stale(monkeypatch):
|
||||
now = datetime.now(UTC)
|
||||
issues = [
|
||||
IssueSnapshot(
|
||||
1,
|
||||
"backend-production",
|
||||
"Loud issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(days=9),
|
||||
now,
|
||||
99,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
IssueSnapshot(
|
||||
2,
|
||||
"frontend-production",
|
||||
"Quiet issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now - timedelta(days=1),
|
||||
now,
|
||||
3,
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
|
||||
)
|
||||
|
||||
top_text = await digest_builder.build_top_issues()
|
||||
stale_text = await digest_builder.build_stale_issues()
|
||||
|
||||
assert "Loud issue" in top_text
|
||||
assert "99" in top_text
|
||||
assert "Loud issue" in stale_text
|
||||
assert "9 дн." in stale_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_release_summary_and_detail(monkeypatch):
|
||||
now = datetime.now(UTC)
|
||||
issues = [
|
||||
IssueSnapshot(
|
||||
1,
|
||||
"backend-production",
|
||||
"Release issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now,
|
||||
now,
|
||||
15,
|
||||
True,
|
||||
None,
|
||||
"2026.03.27",
|
||||
),
|
||||
IssueSnapshot(
|
||||
2,
|
||||
"frontend-production",
|
||||
"Another issue",
|
||||
None,
|
||||
"error",
|
||||
"unresolved",
|
||||
now,
|
||||
now,
|
||||
5,
|
||||
False,
|
||||
None,
|
||||
"2026.03.27",
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
|
||||
)
|
||||
|
||||
summary = await digest_builder.build_release_summary()
|
||||
detail = await digest_builder.build_release_detail("2026.03.27")
|
||||
|
||||
assert "2026.03.27" in summary
|
||||
assert "2 issues" in summary
|
||||
assert "Release issue" in detail
|
||||
|
||||
|
||||
async def _async_value(value):
|
||||
return value
|
||||
70
tests/test_telegram_sender.py
Normal file
70
tests/test_telegram_sender.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
|
||||
from glitchup_bot.api.schemas import WebhookAttachment
|
||||
from glitchup_bot.services import telegram_sender
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def send_message(self, **kwargs) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_alert_routes_to_topic_and_subscribers(monkeypatch):
|
||||
fake_bot = FakeBot()
|
||||
monkeypatch.setattr(telegram_sender, "get_bot", lambda: fake_bot)
|
||||
monkeypatch.setattr(
|
||||
telegram_sender, "resolve_group", lambda project_slug: _async_value("backend")
|
||||
)
|
||||
monkeypatch.setattr(telegram_sender, "resolve_topic_id", lambda group_name: _async_value(11))
|
||||
monkeypatch.setattr(
|
||||
telegram_sender,
|
||||
"resolve_subscribers",
|
||||
lambda group_name: _async_value([101, 202]),
|
||||
)
|
||||
|
||||
await telegram_sender.send_alert(
|
||||
WebhookAttachment(
|
||||
title="<boom>",
|
||||
text="service <api>",
|
||||
title_link="https://glitchtip.example.com/issues/1?x=<x>",
|
||||
color="#e52b50",
|
||||
),
|
||||
project_slug="backend-production",
|
||||
priority="P1",
|
||||
release_name="2026.03.27",
|
||||
)
|
||||
|
||||
assert len(fake_bot.calls) == 3
|
||||
assert fake_bot.calls[0]["chat_id"] == -1001234567890
|
||||
assert fake_bot.calls[0]["message_thread_id"] == 11
|
||||
assert "<boom>" in fake_bot.calls[0]["text"]
|
||||
assert "service <api>" in fake_bot.calls[0]["text"]
|
||||
assert "2026.03.27" in fake_bot.calls[0]["text"]
|
||||
assert fake_bot.calls[1]["chat_id"] == 101
|
||||
assert fake_bot.calls[2]["chat_id"] == 202
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_digest_message_uses_digest_topic(monkeypatch):
|
||||
fake_bot = FakeBot()
|
||||
monkeypatch.setattr(telegram_sender, "get_bot", lambda: fake_bot)
|
||||
monkeypatch.setattr(telegram_sender, "resolve_topic_id", lambda group_name: _async_value(33))
|
||||
|
||||
await telegram_sender.send_digest_message("<b>digest</b>")
|
||||
|
||||
assert fake_bot.calls == [
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"message_thread_id": 33,
|
||||
"text": "<b>digest</b>",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def _async_value(value):
|
||||
return value
|
||||
Reference in New Issue
Block a user