feat: isolated test infra — docker-compose.test.yml + test.sh
- Отдельная PostgreSQL в tmpfs (RAM) - Отдельный Tracker на порту 8101 - Полная изоляция от прода - ./test.sh для запуска
This commit is contained in:
parent
e6c34321eb
commit
7fcf4abed9
45
docker-compose.test.yml
Normal file
45
docker-compose.test.yml
Normal file
@ -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
|
||||
44
test.sh
Executable file
44
test.sh
Executable file
@ -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
|
||||
Binary file not shown.
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user