feat(backend): prepare MAX linking, webhook API and durable notifications #86

Merged
Soyavo merged 2 commits from feat/82-max-backend into dev 2026-10-04 16:09:14 +03:00
2 changed files with 17 additions and 1 deletions
Showing only changes of commit 461c3d02ea - Show all commits
+5
View File
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings from app.config import get_settings
from app.core import clock from app.core import clock
@@ -106,5 +107,9 @@ async def webhook(request: Request) -> dict[str, bool]:
raise HTTPException(422, "event must be an object") raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update from app.channels.max_bot import handle_update
try:
await handle_update(event) await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True} return {"success": True}
+11
View File
@@ -1,9 +1,13 @@
"""MAX endpoints authenticate before reading bindings or accepting events.""" """MAX endpoints authenticate before reading bindings or accepting events."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy.exc import OperationalError
from app.config import get_settings from app.config import get_settings
from app.core import clock from app.core import clock
@@ -90,3 +94,10 @@ async def test_webhook_secret_checked_before_body_or_adapter(client):
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400 assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422 assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413 assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
async def test_webhook_storage_failure_is_retryable_without_identity_details(client, monkeypatch):
handler = AsyncMock(side_effect=OperationalError("private SQL", {"token": TOKEN}, Exception("private")))
monkeypatch.setitem(sys.modules, "app.channels.max_bot", SimpleNamespace(handle_update=handler))
response = await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": SECRET}, json={})
assert response.status_code == 503 and TOKEN not in response.text and "private" not in response.text