From 98063595f8ba93f82a1709550f993e6bc88f017c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:31:26 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-006=20=D0=BD=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82:=20{'detail':'Not=20Found'}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_baton_006.py | 235 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/test_baton_006.py diff --git a/tests/test_baton_006.py b/tests/test_baton_006.py new file mode 100644 index 0000000..72ec197 --- /dev/null +++ b/tests/test_baton_006.py @@ -0,0 +1,235 @@ +""" +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 с корректным секретом. +""" +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") + +import pytest + +from tests.conftest import make_app_client + +PROJECT_ROOT = Path(__file__).parent.parent +NGINX_CONF = PROJECT_ROOT / "nginx" / "baton.conf" + +# --------------------------------------------------------------------------- +# 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 и proxy_pass внутри + 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") + # Ищем последний блок location / (не /api/, не /health) + 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": "baton-006-uuid-001", "name": "TestUser"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["user_id"] > 0 + assert data["uuid"] == "baton-006-uuid-001" + + +# --------------------------------------------------------------------------- +# 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: + # Сначала регистрируем пользователя + await client.post( + "/api/register", + json={"uuid": "baton-006-uuid-002", "name": "SignalUser"}, + ) + # Отправляем сигнал + response = await client.post( + "/api/signal", + json={ + "user_id": "baton-006-uuid-002", + "timestamp": 1700000000000, + "geo": None, + }, + ) + + 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}