""" Tests for BATON-006: не работает фронт — {"detail":"Not Found"} Acceptance criteria: 1. nginx/baton.conf содержит location /api/ (prefix match), проксирует на FastAPI. 2. nginx/baton.conf содержит location /health, проксирует на FastAPI. 3. nginx/baton.conf содержит location / с root и try_files (SPA-поведение). 4. GET / на FastAPI возвращает 404 (маршрут / не зарегистрирован в main.py — статику должен отдавать nginx, а не FastAPI). 5. GET /health возвращает 200 (FastAPI-маршрут работает). 6. POST /api/register возвращает 200 (FastAPI-маршрут не сломан). 7. POST /api/signal возвращает 200 (FastAPI-маршрут не сломан). 8. POST /api/webhook/telegram возвращает 200 с корректным секретом. BATON-SEC-003: POST /api/signal now requires Authorization: Bearer . UUID constants satisfy the UUID v4 pattern. """ from __future__ import annotations import os import re from pathlib import Path 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") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") import pytest from tests.conftest import make_app_client PROJECT_ROOT = Path(__file__).parent.parent NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" # Valid UUID v4 constants _UUID_REG = "e0000001-0000-4000-8000-000000000001" _UUID_SIG = "e0000002-0000-4000-8000-000000000002" # --------------------------------------------------------------------------- # Criterion 1 — location /api/ proxies to FastAPI # --------------------------------------------------------------------------- def test_nginx_conf_exists() -> None: """nginx/baton.conf должен существовать в репозитории.""" assert NGINX_CONF.is_file(), f"nginx/baton.conf не найден: {NGINX_CONF}" def test_nginx_conf_has_api_location_block() -> None: """nginx/baton.conf должен содержать location /api/ (prefix match).""" content = NGINX_CONF.read_text(encoding="utf-8") assert re.search(r"location\s+/api/", content), ( "nginx/baton.conf не содержит блок location /api/" ) def test_nginx_conf_api_location_proxies_to_fastapi() -> None: """Блок location /api/ должен делать proxy_pass на 127.0.0.1:8000.""" content = NGINX_CONF.read_text(encoding="utf-8") api_block = re.search( r"location\s+/api/\s*\{([^}]+)\}", content, re.DOTALL ) assert api_block is not None, "Блок location /api/ { ... } не найден" assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", api_block.group(1)), ( "Блок location /api/ не содержит proxy_pass http://127.0.0.1:8000" ) # --------------------------------------------------------------------------- # Criterion 2 — location /health proxies to FastAPI # --------------------------------------------------------------------------- def test_nginx_conf_has_health_location_block() -> None: """nginx/baton.conf должен содержать отдельный location /health.""" content = NGINX_CONF.read_text(encoding="utf-8") assert re.search(r"location\s+/health\b", content), ( "nginx/baton.conf не содержит блок location /health" ) def test_nginx_conf_health_location_proxies_to_fastapi() -> None: """Блок location /health должен делать proxy_pass на 127.0.0.1:8000.""" content = NGINX_CONF.read_text(encoding="utf-8") health_block = re.search( r"location\s+/health\s*\{([^}]+)\}", content, re.DOTALL ) assert health_block is not None, "Блок location /health { ... } не найден" assert re.search(r"proxy_pass\s+http://127\.0\.0\.1:8000", health_block.group(1)), ( "Блок location /health не содержит proxy_pass http://127.0.0.1:8000" ) # --------------------------------------------------------------------------- # Criterion 3 — location / serves static files (SPA) # --------------------------------------------------------------------------- def test_nginx_conf_root_location_has_root_directive() -> None: """location / в nginx.conf должен содержать директиву root (статика).""" content = NGINX_CONF.read_text(encoding="utf-8") root_block = re.search( r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL ) assert root_block is not None, "Блок location / { ... } не найден" assert re.search(r"root\s+", root_block.group(1)), ( "Блок location / не содержит директиву root — SPA статика не настроена" ) def test_nginx_conf_root_location_has_try_files_for_spa() -> None: """location / должен содержать try_files с fallback на /index.html (SPA).""" content = NGINX_CONF.read_text(encoding="utf-8") root_block = re.search( r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL ) assert root_block is not None, "Блок location / { ... } не найден" assert re.search(r"try_files\s+\$uri\s+/index\.html", root_block.group(1)), ( "Блок location / не содержит try_files $uri /index.html — " "SPA-роутинг не работает" ) def test_nginx_conf_root_location_does_not_proxy_to_fastapi() -> None: """location / НЕ должен делать proxy_pass на FastAPI (только статика).""" content = NGINX_CONF.read_text(encoding="utf-8") root_block = re.search( r"location\s+/\s*\{([^}]+)\}", content, re.DOTALL ) assert root_block is not None, "Блок location / { ... } не найден" assert not re.search(r"proxy_pass", root_block.group(1)), ( "Блок location / содержит proxy_pass — GET / будет проксирован в FastAPI, " "что вернёт 404 {'detail':'Not Found'} (исходная ошибка BATON-006)" ) # --------------------------------------------------------------------------- # Criterion 4 — FastAPI не имеет маршрута GET / # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_fastapi_root_returns_404() -> None: """GET / должен возвращать 404 от FastAPI — маршрут не зарегистрирован. Это ожидаемое поведение: статику отдаёт nginx (location / с root + try_files), а не FastAPI. Регрессия: если когда-нибудь GET / начнёт возвращать 200 от FastAPI, это нарушит архитектуру (FastAPI не должен отдавать статику). """ async with make_app_client() as client: response = await client.get("/") assert response.status_code == 404, ( f"GET / должен возвращать 404 от FastAPI (статику отдаёт nginx). " f"Получено: {response.status_code}" ) # --------------------------------------------------------------------------- # Criterion 5 — GET /health работает # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_health_endpoint_returns_200() -> None: """GET /health должен возвращать 200 после изменений nginx-конфига.""" async with make_app_client() as client: response = await client.get("/health") assert response.status_code == 200 assert response.json().get("status") == "ok" # --------------------------------------------------------------------------- # Criterion 6 — POST /api/register не сломан # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_api_register_not_broken_after_nginx_change() -> None: """POST /api/register должен вернуть 200 — функция не сломана изменением nginx.""" async with make_app_client() as client: response = await client.post( "/api/register", json={"uuid": _UUID_REG, "name": "TestUser"}, ) assert response.status_code == 200 data = response.json() assert data["user_id"] > 0 assert data["uuid"] == _UUID_REG assert "api_key" in data # --------------------------------------------------------------------------- # Criterion 7 — POST /api/signal не сломан # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_api_signal_not_broken_after_nginx_change() -> None: """POST /api/signal должен вернуть 200 — функция не сломана изменением nginx.""" async with make_app_client() as client: reg_resp = await client.post( "/api/register", json={"uuid": _UUID_SIG, "name": "SignalUser"}, ) assert reg_resp.status_code == 200 api_key = reg_resp.json()["api_key"] response = await client.post( "/api/signal", json={ "user_id": _UUID_SIG, "timestamp": 1700000000000, "geo": None, }, headers={"Authorization": f"Bearer {api_key}"}, ) assert response.status_code == 200 assert response.json().get("status") == "ok" # --------------------------------------------------------------------------- # Criterion 8 — POST /api/webhook/telegram не сломан # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_api_webhook_telegram_not_broken_after_nginx_change() -> None: """POST /api/webhook/telegram с корректным секретом должен вернуть 200.""" async with make_app_client() as client: response = await client.post( "/api/webhook/telegram", json={"update_id": 200, "message": {"text": "hello"}}, headers={"X-Telegram-Bot-Api-Secret-Token": "test-webhook-secret"}, ) assert response.status_code == 200 assert response.json() == {"ok": True}