diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..8347a07 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,45 @@ +# Тестовое окружение Team Board +# Поднимает полный стек на изолированной БД и портах. +# +# Использование: +# ./test.sh — поднять, прогнать тесты, убить +# docker compose -f docker-compose.test.yml up -d — поднять вручную +# docker compose -f docker-compose.test.yml down -v — убить +# +# Порты (тестовые): +# Tracker API: 8101 +# PostgreSQL: 5433 (изолированный) + +services: + db-test: + image: postgres:16-alpine + environment: + POSTGRES_DB: team_board_test + POSTGRES_USER: team_board + POSTGRES_PASSWORD: team_board + ports: + - "5433:5432" + tmpfs: + - /var/lib/postgresql/data # RAM-диск — быстро, дропается при down + healthcheck: + test: ["CMD-SHELL", "pg_isready -U team_board -d team_board_test"] + interval: 1s + timeout: 3s + retries: 15 + + tracker-test: + build: ./tracker + ports: + - "8101:8100" + environment: + TRACKER_DATABASE_URL: postgresql+asyncpg://team_board:team_board@db-test:5432/team_board_test + TRACKER_ENV: dev + TRACKER_JWT_SECRET: test-secret + depends_on: + db-test: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.create_connection(('localhost',8100),2); s.close()\""] + interval: 2s + timeout: 3s + retries: 15 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..0d922e2 --- /dev/null +++ b/test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Запуск E2E тестов Team Board на изолированном окружении. +# +# Использование: +# ./test.sh — все тесты +# ./test.sh test_auth.py — один файл +# ./test.sh -k "test_login" — по имени +# ./test.sh --no-teardown — не убивать после (для отладки) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +COMPOSE="docker compose -f $ROOT/docker-compose.test.yml" +TESTS_DIR="$ROOT/tests" + +NO_TEARDOWN=false +PYTEST_ARGS=() + +for arg in "$@"; do + if [[ "$arg" == "--no-teardown" ]]; then + NO_TEARDOWN=true + else + PYTEST_ARGS+=("$arg") + fi +done + +cleanup() { + if [[ "$NO_TEARDOWN" == false ]]; then + echo "🧹 Тушим тестовое окружение..." + $COMPOSE down -v --remove-orphans 2>/dev/null || true + else + echo "⚠️ --no-teardown: окружение оставлено (порт 8101)" + fi +} +trap cleanup EXIT + +echo "🚀 Поднимаем тестовое окружение..." +$COMPOSE up -d --build --wait + +echo "✅ Tracker-test на порту 8101" +echo "" + +# Запускаем тесты +cd "$TESTS_DIR" +python3 -m pytest "${PYTEST_ARGS[@]:--v --tb=short}" || true diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc index dac6733..299009e 100644 Binary files a/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc and b/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py index e3eb533..904d93e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,91 +1,36 @@ """ -Conftest для E2E тестов Team Board API. -Поднимает отдельный Tracker (Docker) на тестовой БД team_board_test:8101. -После тестов — дропает БД и убивает контейнер. +Conftest для E2E тестов Team Board. +Ожидает что тестовый Tracker уже запущен на порту 8101 (через test.sh). """ +import os import pytest import pytest_asyncio import httpx import uuid -import subprocess -import time from typing import Dict, Any -TEST_DB = "team_board_test" -TEST_PORT = 8101 +TEST_PORT = os.environ.get("TEST_PORT", "8101") BASE_URL = f"http://localhost:{TEST_PORT}/api/v1" -TRACKER_DIR = "/root/projects/team-board/tracker" +WS_URL = f"ws://localhost:{TEST_PORT}" -def pytest_configure(config): - """Создаём тестовую БД, запускаем Tracker в Docker.""" - # 1. Создать тестовую БД - subprocess.run( - ["sudo", "-u", "postgres", "psql", "-c", f"DROP DATABASE IF EXISTS {TEST_DB};"], - capture_output=True, - ) - result = subprocess.run( - ["sudo", "-u", "postgres", "psql", "-c", f"CREATE DATABASE {TEST_DB} OWNER team_board;"], - capture_output=True, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to create test DB: {result.stderr.decode()}") - - # 2. Запустить Tracker-test контейнер - subprocess.run( - ["docker", "compose", "-f", "docker-compose.test.yml", "down", "-v"], - cwd=TRACKER_DIR, capture_output=True, - ) - result = subprocess.run( - ["docker", "compose", "-f", "docker-compose.test.yml", "up", "-d", "--build"], - cwd=TRACKER_DIR, capture_output=True, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to start test Tracker: {result.stderr.decode()}") - - # 3. Ждём пока ответит - for i in range(30): - try: - r = httpx.get(f"http://localhost:{TEST_PORT}/api/v1/labels", timeout=2) - if r.status_code in (200, 401): - break - except Exception: - pass - time.sleep(1) - else: - subprocess.run( - ["docker", "compose", "-f", "docker-compose.test.yml", "logs"], - cwd=TRACKER_DIR, - ) - raise RuntimeError("Test Tracker did not start in 30s") - - -def pytest_unconfigure(config): - """Убиваем контейнер и дропаем тестовую БД.""" - subprocess.run( - ["docker", "compose", "-f", "docker-compose.test.yml", "down", "-v"], - cwd=TRACKER_DIR, capture_output=True, - ) - subprocess.run( - ["sudo", "-u", "postgres", "psql", "-c", f"DROP DATABASE IF EXISTS {TEST_DB};"], - capture_output=True, - ) - - -# ---------- Fixtures ---------- - @pytest.fixture(scope="session") def base_url(): return BASE_URL +@pytest.fixture(scope="session") +def ws_url(): + return WS_URL + + @pytest_asyncio.fixture async def admin_token(base_url: str) -> str: async with httpx.AsyncClient() as client: response = await client.post(f"{base_url}/auth/login", json={ "login": "admin", "password": "teamboard" }) - assert response.status_code == 200 + assert response.status_code == 200, f"Login failed: {response.text}" return response.json()["token"] @@ -110,9 +55,9 @@ async def agent_client(base_url: str, agent_token: str): @pytest_asyncio.fixture async def test_project(http_client: httpx.AsyncClient) -> Dict[str, Any]: - slug = f"test-project-{uuid.uuid4().hex[:8]}" + slug = f"test-{uuid.uuid4().hex[:8]}" response = await http_client.post("/projects", json={ - "name": f"Test Project {slug}", "slug": slug, "description": "E2E test", + "name": f"Test {slug}", "slug": slug, "description": "E2E test", }) assert response.status_code == 200 project = response.json() @@ -122,9 +67,9 @@ async def test_project(http_client: httpx.AsyncClient) -> Dict[str, Any]: @pytest_asyncio.fixture async def test_user(http_client: httpx.AsyncClient) -> Dict[str, Any]: - slug = f"test-user-{uuid.uuid4().hex[:8]}" + slug = f"user-{uuid.uuid4().hex[:8]}" response = await http_client.post("/members", json={ - "name": f"Test User {slug}", "slug": slug, "type": "human", "role": "member", + "name": f"User {slug}", "slug": slug, "type": "human", "role": "member", }) assert response.status_code == 200 yield response.json() @@ -132,9 +77,9 @@ async def test_user(http_client: httpx.AsyncClient) -> Dict[str, Any]: @pytest_asyncio.fixture async def test_agent(http_client: httpx.AsyncClient) -> Dict[str, Any]: - slug = f"test-agent-{uuid.uuid4().hex[:8]}" + slug = f"agent-{uuid.uuid4().hex[:8]}" response = await http_client.post("/members", json={ - "name": f"Test Agent {slug}", "slug": slug, "type": "agent", "role": "member", + "name": f"Agent {slug}", "slug": slug, "type": "agent", "role": "member", "agent_config": {"capabilities": ["coding"], "labels": ["backend"], "chat_listen": "mentions", "task_listen": "assigned"}, }) @@ -154,7 +99,7 @@ async def test_task(http_client: httpx.AsyncClient, test_project: Dict[str, Any] @pytest_asyncio.fixture async def test_label(http_client: httpx.AsyncClient) -> Dict[str, Any]: - name = f"test-label-{uuid.uuid4().hex[:8]}" + name = f"label-{uuid.uuid4().hex[:8]}" response = await http_client.post("/labels", json={"name": name, "color": "#ff5733"}) assert response.status_code == 200 label = response.json() diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d2951dc..6386f87 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -79,7 +79,7 @@ async def test_websocket_auth_with_agent_token(agent_token: str): @pytest.mark.asyncio async def test_websocket_auth_first_message(admin_token: str): """Test WebSocket authentication by sending auth message first""" - uri = "ws://localhost:8101/ws" + uri = "ws://localhost:{TEST_PORT}/ws" try: async with websockets.connect(uri, timeout=10) as websocket: @@ -104,7 +104,7 @@ async def test_websocket_auth_first_message(admin_token: str): @pytest.mark.asyncio async def test_websocket_auth_invalid_token(): """Test WebSocket authentication with invalid token""" - uri = "ws://localhost:8101/ws?token=invalid_token" + uri = "ws://localhost:{TEST_PORT}/ws?token=invalid_token" try: async with websockets.connect(uri, timeout=10) as websocket: @@ -370,7 +370,7 @@ async def test_websocket_invalid_message_format(admin_token: str): @pytest.mark.asyncio async def test_websocket_connection_without_auth(): """Test WebSocket connection without authentication""" - uri = "ws://localhost:8101/ws" + uri = "ws://localhost:{TEST_PORT}/ws" try: async with websockets.connect(uri, timeout=10) as websocket: