From 7fcf4abed9d395cf62f895f18f902225b649d03f Mon Sep 17 00:00:00 2001 From: markov Date: Sat, 14 Mar 2026 10:28:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20isolated=20test=20infra=20=E2=80=94=20d?= =?UTF-8?q?ocker-compose.test.yml=20+=20test.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Отдельная PostgreSQL в tmpfs (RAM) - Отдельный Tracker на порту 8101 - Полная изоляция от прода - ./test.sh для запуска --- docker-compose.test.yml | 45 +++++++++ test.sh | 44 +++++++++ .../conftest.cpython-312-pytest-8.2.2.pyc | Bin 13448 -> 11319 bytes tests/conftest.py | 91 ++++-------------- tests/test_websocket.py | 6 +- 5 files changed, 110 insertions(+), 76 deletions(-) create mode 100644 docker-compose.test.yml create mode 100755 test.sh 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 dac673305305b5556b3c3081a7873004eb6601ee..299009e83273b6009d2578fe6d201d4119bfecde 100644 GIT binary patch delta 3546 zcmb_fYitzP6`q;ho!#|5?Dei6*v1}X8+IY~VqOM=Ab{Nz*BER}LQIm?vNJYg@2hua zvArVof>9|dQjBh-SjwaBKM+-l;yREfko>4Cs;JUzS`|8|Z7Q`=ky;6qAEcS*WFnS0JX_nvdVbMM_B$XAbeKlONu7B5>GpC#^2cm_NP z?|_$OB>QkJ?dAP!J^gdYAF_(J%N+J$m;s;2OTIT5(f+n&pj33A{5Ch>=NQQ`97O9n z=TC|*v!=D^I>THxMP{IE$!2Atjl006TymOTsy$ML=#?r4tzz*R>mwhPi=+5T3VN-0 zte4N|Ra(%i>M_0F&<6_i)r)#tnN&2q6{D#4N-zV-JIJko`NJ&A3+z`e!@i=xzV@s3 z_67g6gsZSD#Jn_mctzppkcYy#@M2YgZ{e%Hw?A%g6{`#E>%|&~pA%P}u@0#hmp`4K6t=dmZxw?1v*08L23G}DQq&gZ zc&Mf5E|R1t#Oc-!Io6@-mfgv5-PzxHs6TS>KwrOZ#lBhJaaB#Xhr{tyEE+$aQq=aW zJseHT;S;Ud;!))>4gI^Pz48*<6JmA8p52E!BZvFCFEL->I1ks+w6%H@46cM@spOzB zjr6$gI2Ki;NQT5oFkrQ8TziC!XhrIRdAL%*oyR1fgTk7Zw#rHYYNxxfL(=EDAa>Ll~i3jWK zQC+#SpcOqQd6~`f4D+g8MVcCq>RgnDt7typrxPWs*p>9)>MBn3(ifb^r4ot2i4*iJuxy@Lhu z7dze1I$y+gmh64C=o@st8zk0 zWz!b0r@)mr-3vY+xekw*hwKe!V< z$sT}3pQ!(@KHX+W3r?Q{(^fR^K z5H=zpV|8AX;*u(boCI?~x)F?_Hlwr!p$%ax!Zw<%u8tafc4G*Ug{hP0L5mj3?*abC zd5g0ehP=OFX3tEo=3k?g1~peG*KxZ{xK-AaFKe0^xm{W@ZJjzX z<-KUVHgMmSXL@w1O*9zjKs%e zrPD|&Mol6NvyVaQDDMG0Wm1O=l6qz6ld;)X1&?JFZ}8L1CpiLMv;1x>Ldd0|Ap`XN zhLzDR92TI8YS=9IjQ{a*q2nox_3P|a~izyw!9`6a-vg;@Qscf8Sc zp^J9k3N+>ejWc~SQ7sVE{2|S?wva#&Yvpucnx87Z7`QTe(?LILoME4zRhss3z60Pd z>x8;eh5o2ALb~X$*ECM-hgKstoj3P*-H|48ipc8tazhKbfFJ)0J>ZP!GD;0=<{L^4 zv|p1nlOHOEqJYJ~?9YQEmI*!{JtoD?b%oo5@ug(#4TFQ5+cs_taYkk6wpc2jBFhah z>sas;CYVHW5MXhaFbJ}=OH4euO@OIxGiQMZx54ivj++Z_sL3d(MR=880X*fp%h%MK za(>GZZ8q3^mSubCm6q03W9STT>WkaFxmiXs8CfJbXiv{t!rNIkOLwjR_Rf>gK)#0n zrOrU2usfLqQ1HLOVpWzbIKi_92$M*Y?}K)f&aWR~8)<86fPIF3t99c9?z}peQVf^6 zH$84r9B&rDF5`T{Jx1qYdyfaUtM^O}7uqV`_#- z=Bb8PFx{f4#5~`2pqW9a=P(B205tYv+&~zDm1R)HNVSmxWAaa2@E$sLBvN7~F6|@~ zdSC*TBani77M5l2a~9V6z`?NW8D`RXhwx?bx!*V8n; z{UZMxcR1&~?Wvs}pB}kbp}AUfj@z!P>21^NH0SCZe~+hcZCF*6D?eL%s&-2LEi2sT XShn;F3&YjlLuvMd4WF`ByqEq5LL7|& delta 5398 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 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: