initial commit
Some checks failed
CI / Run tests (push) Has been cancelled
CI / Docker build test (push) Has been cancelled
CI / Lint (ruff + mypy) (push) Has been cancelled

This commit is contained in:
2026-03-30 16:46:26 +07:00
commit 2a7dfa95c8
67 changed files with 5864 additions and 0 deletions

46
tests/conftest.py Normal file
View 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()

View 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
View 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
View 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

View 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

View 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 "&lt;boom&gt;" in fake_bot.calls[0]["text"]
assert "service &lt;api&gt;" 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