2026-03-20 20:44:00 +02:00
|
|
|
"""
|
|
|
|
|
Integration tests for POST /api/signal.
|
2026-03-21 08:12:01 +02:00
|
|
|
|
|
|
|
|
UUID notes: both RegisterRequest.uuid and SignalRequest.user_id require valid UUID v4.
|
|
|
|
|
All UUID constants below satisfy the pattern.
|
|
|
|
|
|
|
|
|
|
BATON-SEC-003: /api/signal now requires Authorization: Bearer <api_key>.
|
|
|
|
|
The _register() helper returns the api_key from the registration response.
|
2026-03-20 20:44:00 +02:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
|
|
|
|
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
|
|
|
|
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
|
|
|
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
|
|
|
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
2026-03-21 08:12:01 +02:00
|
|
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from httpx import AsyncClient
|
|
|
|
|
|
|
|
|
|
from tests.conftest import make_app_client
|
|
|
|
|
|
2026-03-21 08:12:01 +02:00
|
|
|
# Valid UUID v4 constants for signal tests
|
|
|
|
|
_UUID_1 = "c0000001-0000-4000-8000-000000000001"
|
|
|
|
|
_UUID_2 = "c0000002-0000-4000-8000-000000000002"
|
|
|
|
|
_UUID_3 = "c0000003-0000-4000-8000-000000000003"
|
|
|
|
|
_UUID_4 = "c0000004-0000-4000-8000-000000000004"
|
|
|
|
|
_UUID_5 = "c0000005-0000-4000-8000-000000000005"
|
|
|
|
|
_UUID_6 = "c0000006-0000-4000-8000-000000000006"
|
|
|
|
|
|
2026-03-20 20:44:00 +02:00
|
|
|
|
2026-03-21 08:12:01 +02:00
|
|
|
async def _register(client: AsyncClient, uuid: str, name: str) -> str:
|
|
|
|
|
"""Register user, assert success, return raw api_key."""
|
2026-03-20 20:44:00 +02:00
|
|
|
r = await client.post("/api/register", json={"uuid": uuid, "name": name})
|
2026-03-21 08:12:01 +02:00
|
|
|
assert r.status_code == 200, f"Registration failed: {r.status_code} {r.text}"
|
|
|
|
|
return r.json()["api_key"]
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_with_geo_success():
|
|
|
|
|
"""POST /api/signal with geo returns 200 and signal_id > 0."""
|
|
|
|
|
async with make_app_client() as client:
|
2026-03-21 08:12:01 +02:00
|
|
|
api_key = await _register(client, _UUID_1, "Alice")
|
2026-03-20 20:44:00 +02:00
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
|
|
|
|
json={
|
2026-03-21 08:12:01 +02:00
|
|
|
"user_id": _UUID_1,
|
2026-03-20 20:44:00 +02:00
|
|
|
"timestamp": 1742478000000,
|
|
|
|
|
"geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0},
|
|
|
|
|
},
|
2026-03-21 08:12:01 +02:00
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["status"] == "ok"
|
|
|
|
|
assert data["signal_id"] > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_without_geo_success():
|
|
|
|
|
"""POST /api/signal with geo: null returns 200."""
|
|
|
|
|
async with make_app_client() as client:
|
2026-03-21 08:12:01 +02:00
|
|
|
api_key = await _register(client, _UUID_2, "Bob")
|
2026-03-20 20:44:00 +02:00
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
|
|
|
|
json={
|
2026-03-21 08:12:01 +02:00
|
|
|
"user_id": _UUID_2,
|
2026-03-20 20:44:00 +02:00
|
|
|
"timestamp": 1742478000000,
|
|
|
|
|
"geo": None,
|
|
|
|
|
},
|
2026-03-21 08:12:01 +02:00
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-03-21 14:14:12 +02:00
|
|
|
async def test_signal_missing_auth_returns_401():
|
|
|
|
|
"""Missing Authorization header must return 401."""
|
2026-03-20 20:44:00 +02:00
|
|
|
async with make_app_client() as client:
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
|
|
|
|
json={"timestamp": 1742478000000},
|
|
|
|
|
)
|
2026-03-21 14:14:12 +02:00
|
|
|
assert resp.status_code == 401
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_missing_timestamp_returns_422():
|
|
|
|
|
"""Missing timestamp field must return 422."""
|
|
|
|
|
async with make_app_client() as client:
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
2026-03-21 08:12:01 +02:00
|
|
|
json={"user_id": _UUID_3},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_stored_in_db():
|
|
|
|
|
"""
|
|
|
|
|
Two signals from the same user produce incrementing signal_ids,
|
|
|
|
|
proving both were persisted.
|
|
|
|
|
"""
|
|
|
|
|
async with make_app_client() as client:
|
2026-03-21 08:12:01 +02:00
|
|
|
api_key = await _register(client, _UUID_4, "Charlie")
|
2026-03-20 20:44:00 +02:00
|
|
|
r1 = await client.post(
|
|
|
|
|
"/api/signal",
|
2026-03-21 08:12:01 +02:00
|
|
|
json={"user_id": _UUID_4, "timestamp": 1742478000001},
|
|
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
r2 = await client.post(
|
|
|
|
|
"/api/signal",
|
2026-03-21 08:12:01 +02:00
|
|
|
json={"user_id": _UUID_4, "timestamp": 1742478000002},
|
|
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
assert r1.status_code == 200
|
|
|
|
|
assert r2.status_code == 200
|
|
|
|
|
assert r2.json()["signal_id"] > r1.json()["signal_id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-03-20 20:50:31 +02:00
|
|
|
async def test_signal_sends_telegram_message_directly():
|
|
|
|
|
"""After a signal, send_message is called directly (aggregator disabled, ADR-004)."""
|
|
|
|
|
import respx
|
|
|
|
|
import httpx
|
|
|
|
|
from backend import config as _cfg
|
2026-03-20 20:44:00 +02:00
|
|
|
|
2026-03-20 20:50:31 +02:00
|
|
|
send_url = f"https://api.telegram.org/bot{_cfg.BOT_TOKEN}/sendMessage"
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
async with make_app_client() as client:
|
2026-03-21 08:12:01 +02:00
|
|
|
api_key = await _register(client, _UUID_5, "Dana")
|
2026-03-20 20:50:31 +02:00
|
|
|
# make_app_client already mocks send_url; signal returns 200 proves send was called
|
|
|
|
|
resp = await client.post(
|
2026-03-20 20:44:00 +02:00
|
|
|
"/api/signal",
|
2026-03-21 08:12:01 +02:00
|
|
|
json={"user_id": _UUID_5, "timestamp": 1742478000000},
|
|
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
|
2026-03-20 20:50:31 +02:00
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert resp.json()["signal_id"] > 0
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_returns_signal_id_positive():
|
|
|
|
|
"""signal_id in response is always a positive integer."""
|
|
|
|
|
async with make_app_client() as client:
|
2026-03-21 08:12:01 +02:00
|
|
|
api_key = await _register(client, _UUID_6, "Eve")
|
2026-03-20 20:44:00 +02:00
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
2026-03-21 08:12:01 +02:00
|
|
|
json={"user_id": _UUID_6, "timestamp": 1742478000000},
|
|
|
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
|
|
|
|
assert resp.json()["signal_id"] > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signal_geo_invalid_lat_returns_422():
|
|
|
|
|
"""Geo with lat > 90 must return 422."""
|
|
|
|
|
async with make_app_client() as client:
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/signal",
|
|
|
|
|
json={
|
2026-03-21 08:12:01 +02:00
|
|
|
"user_id": _UUID_1,
|
2026-03-20 20:44:00 +02:00
|
|
|
"timestamp": 1742478000000,
|
|
|
|
|
"geo": {"lat": 200.0, "lon": 0.0, "accuracy": 10.0},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 422
|