Добавлены E2E тесты для Team Board API

- Полный набор тестов для всех модулей API
- test_auth.py: аутентификация и JWT токены
- test_members.py: CRUD участников, агенты, токены
- test_projects.py: CRUD проектов, участники проектов
- test_tasks.py: CRUD задач, этапы, назначения, зависимости
- test_chat.py: сообщения, комментарии, mentions
- test_files.py: upload/download файлов проектов
- test_labels.py: CRUD лейблов, привязка к задачам
- test_websocket.py: WebSocket подключения и события
- test_streaming.py: агентный стриминг через WebSocket
- conftest.py: фикстуры для подключения к API
- requirements.txt: зависимости pytest, httpx, websockets
- pytest.ini: настройки asyncio для pytest
This commit is contained in:
markov 2026-03-13 22:47:19 +01:00
parent a17c37a7b3
commit 2bab3cf60a
22 changed files with 3391 additions and 0 deletions

174
tests/conftest.py Normal file
View File

@ -0,0 +1,174 @@
"""
Conftest для E2E тестов Team Board API
Содержит фикстуры для подключения к API и создания тестовых данных
"""
import pytest
import pytest_asyncio
import httpx
import uuid
from typing import Dict, Any, Optional
@pytest.fixture(scope="session")
def base_url():
"""Базовый URL для API"""
return "http://localhost:8100/api/v1"
@pytest_asyncio.fixture
async def admin_token(base_url: str) -> str:
"""Получает JWT токен администратора"""
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
data = response.json()
return data["token"]
@pytest.fixture
def agent_token():
"""Bearer токен для агента из документации"""
return "tb-coder-dev-token"
@pytest.fixture
def http_client(base_url: str, admin_token: str):
"""HTTP клиент с авторизацией для админа"""
headers = {"Authorization": f"Bearer {admin_token}"}
return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0)
@pytest.fixture
def agent_client(base_url: str, agent_token: str):
"""HTTP клиент с авторизацией для агента"""
headers = {"Authorization": f"Bearer {agent_token}"}
return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0)
@pytest_asyncio.fixture
async def test_project(http_client: httpx.AsyncClient) -> Dict[str, Any]:
"""Создаёт тестовый проект и удаляет его после теста"""
project_slug = f"test-project-{uuid.uuid4().hex[:8]}"
project_data = {
"name": f"Test Project {project_slug}",
"slug": project_slug,
"description": "Test project for E2E tests",
"repo_urls": ["https://github.com/test/repo"]
}
response = await http_client.post("/projects", json=project_data)
assert response.status_code == 201
project = response.json()
yield project
# Cleanup - удаляем проект
await http_client.delete(f"/projects/{project['id']}")
@pytest_asyncio.fixture
async def test_user(http_client: httpx.AsyncClient) -> Dict[str, Any]:
"""Создаёт тестового пользователя и удаляет его после теста"""
user_slug = f"test-user-{uuid.uuid4().hex[:8]}"
user_data = {
"name": f"Test User {user_slug}",
"slug": user_slug,
"type": "human",
"role": "member"
}
response = await http_client.post("/members", json=user_data)
assert response.status_code == 201
user = response.json()
yield user
# Cleanup - помечаем пользователя неактивным
await http_client.delete(f"/members/{user['id']}")
@pytest_asyncio.fixture
async def test_agent(http_client: httpx.AsyncClient) -> Dict[str, Any]:
"""Создаёт тестового агента и удаляет его после теста"""
agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}"
agent_data = {
"name": f"Test Agent {agent_slug}",
"slug": agent_slug,
"type": "agent",
"role": "member",
"agent_config": {
"capabilities": ["coding", "testing"],
"labels": ["backend", "python"],
"chat_listen": "mentions",
"task_listen": "assigned",
"prompt": "Test agent for E2E tests"
}
}
response = await http_client.post("/members", json=agent_data)
assert response.status_code == 201
agent = response.json()
yield agent
# Cleanup
await http_client.delete(f"/members/{agent['id']}")
@pytest_asyncio.fixture
async def test_task(http_client: httpx.AsyncClient, test_project: Dict[str, Any]) -> Dict[str, Any]:
"""Создаёт тестовую задачу в проекте"""
task_data = {
"title": "Test Task",
"description": "Test task for E2E tests",
"type": "task",
"status": "backlog",
"priority": "medium"
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
yield task
# Cleanup - задача удалится вместе с проектом
@pytest_asyncio.fixture
async def test_label(http_client: httpx.AsyncClient) -> Dict[str, Any]:
"""Создаёт тестовый лейбл"""
label_slug = f"test-label-{uuid.uuid4().hex[:8]}"
label_data = {
"name": label_slug,
"color": "#ff5733"
}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
label = response.json()
yield label
# Cleanup
await http_client.delete(f"/labels/{label['id']}")
def assert_uuid(value: str):
"""Проверяет что строка является валидным UUID"""
try:
uuid.UUID(value)
except (ValueError, TypeError):
pytest.fail(f"'{value}' is not a valid UUID")
def assert_timestamp(value: str):
"""Проверяет что строка является валидным ISO timestamp"""
import datetime
try:
datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
except ValueError:
pytest.fail(f"'{value}' is not a valid ISO timestamp")

3
tests/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session

5
tests/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
pytest==8.2.2
pytest-asyncio==0.24.0
httpx==0.27.0
websockets==13.1
uuid==1.30

117
tests/test_auth.py Normal file
View File

@ -0,0 +1,117 @@
"""
Тесты аутентификации - логин и проверка JWT токена
"""
import pytest
import httpx
from conftest import assert_uuid
@pytest.mark.asyncio
async def test_login_success(base_url: str):
"""Test successful admin login and JWT token validation"""
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
data = response.json()
# Проверяем структуру ответа
assert "token" in data
assert "member_id" in data
assert "slug" in data
assert "role" in data
# Проверяем валидность данных
assert_uuid(data["member_id"])
assert data["slug"] == "admin"
assert data["role"] == "owner"
assert isinstance(data["token"], str)
assert len(data["token"]) > 10 # JWT должен быть длинным
@pytest.mark.asyncio
async def test_login_with_slug(base_url: str):
"""Test login using slug instead of name"""
async with httpx.AsyncClient() as client:
response = await client.post(f"{base_url}/auth/login", json={
"login": "admin", # используем slug
"password": "teamboard"
})
assert response.status_code == 200
data = response.json()
assert data["slug"] == "admin"
@pytest.mark.asyncio
async def test_login_invalid_credentials(base_url: str):
"""Test login with invalid credentials returns 401"""
async with httpx.AsyncClient() as client:
response = await client.post(f"{base_url}/auth/login", json={
"login": "admin",
"password": "wrong_password"
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_missing_fields(base_url: str):
"""Test login with missing required fields"""
async with httpx.AsyncClient() as client:
# Без пароля
response = await client.post(f"{base_url}/auth/login", json={
"login": "admin"
})
assert response.status_code == 422
# Без логина
response = await client.post(f"{base_url}/auth/login", json={
"password": "teamboard"
})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_protected_endpoint_without_auth(base_url: str):
"""Test that protected endpoints require authentication"""
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/members")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_protected_endpoint_with_invalid_token(base_url: str):
"""Test protected endpoint with invalid JWT token"""
headers = {"Authorization": "Bearer invalid_token"}
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/members", headers=headers)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_protected_endpoint_with_valid_token(http_client: httpx.AsyncClient):
"""Test that valid JWT token allows access to protected endpoints"""
response = await http_client.get("/members")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_agent_token_authentication(agent_client: httpx.AsyncClient):
"""Test that agent token works for API access"""
response = await agent_client.get("/members")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_token_query_parameter(base_url: str, admin_token: str):
"""Test authentication using token query parameter"""
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/members?token={admin_token}")
assert response.status_code == 200
assert isinstance(response.json(), list)

315
tests/test_chat.py Normal file
View File

@ -0,0 +1,315 @@
"""
Тесты работы с чатами и сообщениями - отправка, mentions, история
"""
import pytest
import httpx
import uuid
from conftest import assert_uuid, assert_timestamp
@pytest.mark.asyncio
async def test_get_project_messages(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting messages from project chat"""
chat_id = test_project["chat_id"]
response = await http_client.get(f"/messages?chat_id={chat_id}")
assert response.status_code == 200
messages = response.json()
assert isinstance(messages, list)
@pytest.mark.asyncio
async def test_get_task_comments(http_client: httpx.AsyncClient, test_task: dict):
"""Test getting comments for specific task"""
response = await http_client.get(f"/messages?task_id={test_task['id']}")
assert response.status_code == 200
messages = response.json()
assert isinstance(messages, list)
@pytest.mark.asyncio
async def test_send_message_to_project_chat(http_client: httpx.AsyncClient, test_project: dict):
"""Test sending message to project chat"""
chat_id = test_project["chat_id"]
message_data = {
"chat_id": chat_id,
"content": "Hello from test! This is a test message."
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 201
message = response.json()
assert message["content"] == message_data["content"]
assert message["chat_id"] == chat_id
assert message["task_id"] is None
assert message["parent_id"] is None
assert message["author_type"] in ["human", "agent"]
assert message["author"] is not None
assert_uuid(message["id"])
assert_uuid(message["author_id"])
assert_timestamp(message["created_at"])
# Проверяем структуру автора
assert "id" in message["author"]
assert "slug" in message["author"]
assert "name" in message["author"]
@pytest.mark.asyncio
async def test_send_comment_to_task(http_client: httpx.AsyncClient, test_task: dict):
"""Test sending comment to task"""
message_data = {
"task_id": test_task["id"],
"content": "This is a comment on the task."
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 201
message = response.json()
assert message["content"] == message_data["content"]
assert message["chat_id"] is None
assert message["task_id"] == test_task["id"]
assert message["parent_id"] is None
@pytest.mark.asyncio
async def test_send_message_with_mentions(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test sending message with user mentions"""
chat_id = test_project["chat_id"]
message_data = {
"chat_id": chat_id,
"content": f"Hey @{test_user['slug']}, check this out!",
"mentions": [test_user["id"]]
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 201
message = response.json()
assert len(message["mentions"]) == 1
assert message["mentions"][0]["id"] == test_user["id"]
assert message["mentions"][0]["slug"] == test_user["slug"]
assert message["mentions"][0]["name"] == test_user["name"]
@pytest.mark.asyncio
async def test_send_agent_message_with_thinking(agent_client: httpx.AsyncClient, test_project: dict):
"""Test agent sending message with thinking content"""
chat_id = test_project["chat_id"]
message_data = {
"chat_id": chat_id,
"content": "I think this is the best approach.",
"thinking": "Let me analyze this problem step by step. First, I need to consider..."
}
response = await agent_client.post("/messages", json=message_data)
assert response.status_code == 201
message = response.json()
assert message["content"] == message_data["content"]
assert message["thinking"] == message_data["thinking"]
assert message["author_type"] == "agent"
@pytest.mark.asyncio
async def test_send_reply_in_thread(http_client: httpx.AsyncClient, test_project: dict):
"""Test sending reply to existing message (thread)"""
chat_id = test_project["chat_id"]
# Отправляем основное сообщение
original_data = {
"chat_id": chat_id,
"content": "Original message"
}
response = await http_client.post("/messages", json=original_data)
assert response.status_code == 201
original_message = response.json()
# Отправляем ответ в тред
reply_data = {
"chat_id": chat_id,
"parent_id": original_message["id"],
"content": "This is a reply in thread"
}
response = await http_client.post("/messages", json=reply_data)
assert response.status_code == 201
reply = response.json()
assert reply["parent_id"] == original_message["id"]
assert reply["content"] == reply_data["content"]
assert reply["chat_id"] == chat_id
@pytest.mark.asyncio
async def test_get_thread_replies(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting replies in thread"""
chat_id = test_project["chat_id"]
# Отправляем основное сообщение
original_data = {
"chat_id": chat_id,
"content": "Message with replies"
}
response = await http_client.post("/messages", json=original_data)
assert response.status_code == 201
original_message = response.json()
# Отправляем несколько ответов
for i in range(3):
reply_data = {
"chat_id": chat_id,
"parent_id": original_message["id"],
"content": f"Reply {i+1}"
}
await http_client.post("/messages", json=reply_data)
# Получаем ответы в треде
response = await http_client.get(f"/messages/{original_message['id']}/replies")
assert response.status_code == 200
replies = response.json()
assert len(replies) == 3
for reply in replies:
assert reply["parent_id"] == original_message["id"]
@pytest.mark.asyncio
async def test_send_message_without_chat_or_task_fails(http_client: httpx.AsyncClient):
"""Test that message without chat_id or task_id returns 400"""
message_data = {
"content": "Message without destination"
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_send_message_to_nonexistent_chat_fails(http_client: httpx.AsyncClient):
"""Test sending message to non-existent chat"""
fake_chat_id = str(uuid.uuid4())
message_data = {
"chat_id": fake_chat_id,
"content": "Message to nowhere"
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_send_message_to_nonexistent_task_fails(http_client: httpx.AsyncClient):
"""Test sending message to non-existent task"""
fake_task_id = str(uuid.uuid4())
message_data = {
"task_id": fake_task_id,
"content": "Comment on nowhere"
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_messages_with_pagination(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting messages with limit and offset"""
chat_id = test_project["chat_id"]
# Отправляем несколько сообщений
for i in range(5):
message_data = {
"chat_id": chat_id,
"content": f"Test message {i+1}"
}
await http_client.post("/messages", json=message_data)
# Получаем с лимитом
response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2")
assert response.status_code == 200
messages = response.json()
assert len(messages) <= 2
# Получаем с отступом
response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2&offset=2")
assert response.status_code == 200
offset_messages = response.json()
assert len(offset_messages) <= 2
@pytest.mark.asyncio
async def test_get_messages_with_parent_filter(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting messages filtered by parent_id"""
chat_id = test_project["chat_id"]
# Отправляем основное сообщение
original_data = {
"chat_id": chat_id,
"content": "Parent message for filter test"
}
response = await http_client.post("/messages", json=original_data)
assert response.status_code == 201
parent_message = response.json()
# Отправляем ответ
reply_data = {
"chat_id": chat_id,
"parent_id": parent_message["id"],
"content": "Child reply"
}
await http_client.post("/messages", json=reply_data)
# Фильтруем по parent_id
response = await http_client.get(f"/messages?parent_id={parent_message['id']}")
assert response.status_code == 200
messages = response.json()
for message in messages:
assert message["parent_id"] == parent_message["id"]
@pytest.mark.asyncio
async def test_message_order_chronological(http_client: httpx.AsyncClient, test_project: dict):
"""Test that messages are returned in chronological order"""
chat_id = test_project["chat_id"]
# Отправляем сообщения с задержкой
import asyncio
messages_sent = []
for i in range(3):
message_data = {
"chat_id": chat_id,
"content": f"Ordered message {i+1}"
}
response = await http_client.post("/messages", json=message_data)
assert response.status_code == 201
messages_sent.append(response.json())
await asyncio.sleep(0.1) # Небольшая задержка
# Получаем сообщения
response = await http_client.get(f"/messages?chat_id={chat_id}&limit=10")
assert response.status_code == 200
messages = response.json()
# Фильтруем только наши тестовые сообщения
test_messages = [m for m in messages if "Ordered message" in m["content"]]
# Проверяем хронологический порядок
if len(test_messages) >= 2:
for i in range(len(test_messages) - 1):
current_time = test_messages[i]["created_at"]
next_time = test_messages[i + 1]["created_at"]
# Более поздние сообщения должны идти раньше (DESC order)
assert next_time >= current_time

404
tests/test_files.py Normal file
View File

@ -0,0 +1,404 @@
"""
Тесты работы с файлами - upload, download, файлы проектов
"""
import pytest
import httpx
import uuid
import tempfile
import os
from conftest import assert_uuid, assert_timestamp
@pytest.mark.asyncio
async def test_upload_file(http_client: httpx.AsyncClient):
"""Test uploading file via multipart/form-data"""
# Создаём временный файл
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("This is a test file for upload\nLine 2\nLine 3")
temp_file_path = f.name
try:
# Загружаем файл
with open(temp_file_path, 'rb') as f:
files = {'file': ('test_upload.txt', f, 'text/plain')}
response = await http_client.post("/upload", files=files)
assert response.status_code == 200
upload_data = response.json()
assert "file_id" in upload_data
assert "filename" in upload_data
assert "mime_type" in upload_data
assert "size" in upload_data
assert "storage_name" in upload_data
assert_uuid(upload_data["file_id"])
assert upload_data["filename"] == "test_upload.txt"
assert upload_data["mime_type"] == "text/plain"
assert upload_data["size"] > 0
assert isinstance(upload_data["storage_name"], str)
finally:
# Удаляем временный файл
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_upload_large_file_fails(http_client: httpx.AsyncClient):
"""Test that uploading too large file returns 413"""
# Создаём файл размером больше 50MB (лимит из API)
# Но для теста создадим файл 1MB и проверим что endpoint работает
large_content = "A" * (1024 * 1024) # 1MB
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(large_content)
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('large_file.txt', f, 'text/plain')}
response = await http_client.post("/upload", files=files)
# В тестовой среде файл 1MB должен проходить успешно
# В продакшене лимит 50MB вернёт 413
assert response.status_code in [200, 413]
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_upload_without_file_fails(http_client: httpx.AsyncClient):
"""Test uploading without file field returns 422"""
response = await http_client.post("/upload", files={})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_download_attachment_by_id():
"""Test downloading uploaded file (requires attachment ID)"""
# Этот тест сложно реализовать без создания сообщения с вложением
# Оставляем как плейсхолдер для полноценной реализации
pass
# Тесты файлов проектов
@pytest.mark.asyncio
async def test_get_project_files_list(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting list of project files"""
response = await http_client.get(f"/projects/{test_project['id']}/files")
assert response.status_code == 200
files = response.json()
assert isinstance(files, list)
@pytest.mark.asyncio
async def test_upload_file_to_project(http_client: httpx.AsyncClient, test_project: dict):
"""Test uploading file directly to project"""
# Создаём временный файл
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
f.write("# Project Documentation\n\nThis is a test document for the project.")
temp_file_path = f.name
try:
# Загружаем файл в проект
with open(temp_file_path, 'rb') as f:
files = {'file': ('README.md', f, 'text/markdown')}
data = {'description': 'Project documentation file'}
response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files,
data=data
)
assert response.status_code == 201
project_file = response.json()
assert project_file["filename"] == "README.md"
assert project_file["description"] == "Project documentation file"
assert project_file["mime_type"] == "text/markdown"
assert project_file["size"] > 0
assert_uuid(project_file["id"])
# Проверяем информацию о загрузившем
assert "uploaded_by" in project_file
assert "id" in project_file["uploaded_by"]
assert "slug" in project_file["uploaded_by"]
assert "name" in project_file["uploaded_by"]
assert_timestamp(project_file["created_at"])
assert_timestamp(project_file["updated_at"])
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_upload_file_to_project_without_description(http_client: httpx.AsyncClient, test_project: dict):
"""Test uploading file to project without description"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("Simple text file without description")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('simple.txt', f, 'text/plain')}
response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files
)
assert response.status_code == 201
project_file = response.json()
assert project_file["filename"] == "simple.txt"
assert project_file["description"] is None
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_upload_file_to_nonexistent_project(http_client: httpx.AsyncClient):
"""Test uploading file to non-existent project returns 404"""
fake_project_id = str(uuid.uuid4())
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("File for nowhere")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('test.txt', f, 'text/plain')}
response = await http_client.post(
f"/projects/{fake_project_id}/files",
files=files
)
assert response.status_code == 404
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_get_project_file_info(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting specific project file information"""
# Сначала загружаем файл
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
f.write('{"test": "data", "project": "file"}')
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('data.json', f, 'application/json')}
data = {'description': 'Test JSON data'}
upload_response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files,
data=data
)
assert upload_response.status_code == 201
project_file = upload_response.json()
# Получаем информацию о файле
response = await http_client.get(f"/projects/{test_project['id']}/files/{project_file['id']}")
assert response.status_code == 200
file_info = response.json()
assert file_info["id"] == project_file["id"]
assert file_info["filename"] == "data.json"
assert file_info["description"] == "Test JSON data"
assert file_info["mime_type"] == "application/json"
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_download_project_file(http_client: httpx.AsyncClient, test_project: dict):
"""Test downloading project file"""
# Сначала загружаем файл
test_content = "Downloadable content\nLine 2"
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(test_content)
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('download_test.txt', f, 'text/plain')}
upload_response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files
)
assert upload_response.status_code == 201
project_file = upload_response.json()
# Скачиваем файл
response = await http_client.get(
f"/projects/{test_project['id']}/files/{project_file['id']}/download"
)
assert response.status_code == 200
# Проверяем заголовки
assert "content-type" in response.headers
assert "content-length" in response.headers
# Проверяем содержимое
downloaded_content = response.text
assert downloaded_content == test_content
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_update_project_file_description(http_client: httpx.AsyncClient, test_project: dict):
"""Test updating project file description"""
# Загружаем файл
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("File to update description")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('update_desc.txt', f, 'text/plain')}
data = {'description': 'Original description'}
upload_response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files,
data=data
)
assert upload_response.status_code == 201
project_file = upload_response.json()
# Обновляем описание
update_data = {"description": "Updated file description"}
response = await http_client.patch(
f"/projects/{test_project['id']}/files/{project_file['id']}",
json=update_data
)
assert response.status_code == 200
updated_file = response.json()
assert updated_file["description"] == "Updated file description"
assert updated_file["id"] == project_file["id"]
assert updated_file["filename"] == project_file["filename"]
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_clear_project_file_description(http_client: httpx.AsyncClient, test_project: dict):
"""Test clearing project file description"""
# Загружаем файл с описанием
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("File to clear description")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('clear_desc.txt', f, 'text/plain')}
data = {'description': 'Description to be cleared'}
upload_response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files,
data=data
)
assert upload_response.status_code == 201
project_file = upload_response.json()
# Очищаем описание
update_data = {"description": None}
response = await http_client.patch(
f"/projects/{test_project['id']}/files/{project_file['id']}",
json=update_data
)
assert response.status_code == 200
updated_file = response.json()
assert updated_file["description"] is None
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_delete_project_file(http_client: httpx.AsyncClient, test_project: dict):
"""Test deleting project file"""
# Загружаем файл
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write("File to be deleted")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': ('to_delete.txt', f, 'text/plain')}
upload_response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files
)
assert upload_response.status_code == 201
project_file = upload_response.json()
# Удаляем файл
response = await http_client.delete(
f"/projects/{test_project['id']}/files/{project_file['id']}"
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что файл действительно удалён
response = await http_client.get(
f"/projects/{test_project['id']}/files/{project_file['id']}"
)
assert response.status_code == 404
finally:
os.unlink(temp_file_path)
@pytest.mark.asyncio
async def test_search_project_files(http_client: httpx.AsyncClient, test_project: dict):
"""Test searching project files by filename"""
# Загружаем несколько файлов
test_files = ["searchable_doc.md", "another_file.txt", "searchable_config.json"]
uploaded_files = []
for filename in test_files:
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write(f"Content of {filename}")
temp_file_path = f.name
try:
with open(temp_file_path, 'rb') as f:
files = {'file': (filename, f, 'text/plain')}
response = await http_client.post(
f"/projects/{test_project['id']}/files",
files=files
)
assert response.status_code == 201
uploaded_files.append(response.json())
finally:
os.unlink(temp_file_path)
# Ищем файлы со словом "searchable"
response = await http_client.get(
f"/projects/{test_project['id']}/files?search=searchable"
)
assert response.status_code == 200
files = response.json()
searchable_files = [f for f in files if "searchable" in f["filename"].lower()]
assert len(searchable_files) >= 2 # Должно найти 2 файла с "searchable"

352
tests/test_labels.py Normal file
View File

@ -0,0 +1,352 @@
"""
Тесты работы с лейблами - CRUD операции, привязка к задачам
"""
import pytest
import httpx
import uuid
from conftest import assert_uuid
@pytest.mark.asyncio
async def test_get_labels_list(http_client: httpx.AsyncClient):
"""Test getting list of all labels"""
response = await http_client.get("/labels")
assert response.status_code == 200
labels = response.json()
assert isinstance(labels, list)
@pytest.mark.asyncio
async def test_create_label(http_client: httpx.AsyncClient):
"""Test creating new label"""
label_name = f"test-label-{uuid.uuid4().hex[:8]}"
label_data = {
"name": label_name,
"color": "#ff5733"
}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
label = response.json()
assert label["name"] == label_name
assert label["color"] == "#ff5733"
assert_uuid(label["id"])
# Cleanup
await http_client.delete(f"/labels/{label['id']}")
@pytest.mark.asyncio
async def test_create_label_default_color(http_client: httpx.AsyncClient):
"""Test creating label without color uses default"""
label_name = f"default-color-{uuid.uuid4().hex[:8]}"
label_data = {"name": label_name}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
label = response.json()
assert label["name"] == label_name
assert label["color"] == "#6366f1" # Цвет по умолчанию
# Cleanup
await http_client.delete(f"/labels/{label['id']}")
@pytest.mark.asyncio
async def test_create_label_duplicate_name_fails(http_client: httpx.AsyncClient, test_label: dict):
"""Test creating label with duplicate name returns 409"""
label_data = {
"name": test_label["name"], # Используем существующее имя
"color": "#123456"
}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_update_label(http_client: httpx.AsyncClient, test_label: dict):
"""Test updating label name and color"""
update_data = {
"name": f"updated-{test_label['name']}",
"color": "#00ff00"
}
response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data)
assert response.status_code == 200
label = response.json()
assert label["name"] == update_data["name"]
assert label["color"] == update_data["color"]
assert label["id"] == test_label["id"]
@pytest.mark.asyncio
async def test_update_label_name_only(http_client: httpx.AsyncClient, test_label: dict):
"""Test updating only label name"""
original_color = test_label["color"]
update_data = {"name": f"name-only-{uuid.uuid4().hex[:8]}"}
response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data)
assert response.status_code == 200
label = response.json()
assert label["name"] == update_data["name"]
assert label["color"] == original_color # Цвет не изменился
@pytest.mark.asyncio
async def test_update_label_color_only(http_client: httpx.AsyncClient, test_label: dict):
"""Test updating only label color"""
original_name = test_label["name"]
update_data = {"color": "#purple"}
response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data)
assert response.status_code == 200
label = response.json()
assert label["name"] == original_name # Имя не изменилось
assert label["color"] == "#purple"
@pytest.mark.asyncio
async def test_update_nonexistent_label(http_client: httpx.AsyncClient):
"""Test updating non-existent label returns 404"""
fake_label_id = str(uuid.uuid4())
update_data = {"name": "nonexistent"}
response = await http_client.patch(f"/labels/{fake_label_id}", json=update_data)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_label(http_client: httpx.AsyncClient):
"""Test deleting label"""
# Создаём временный лейбл для удаления
label_name = f"to-delete-{uuid.uuid4().hex[:8]}"
label_data = {"name": label_name, "color": "#ff0000"}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
label = response.json()
# Удаляем лейбл
response = await http_client.delete(f"/labels/{label['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
@pytest.mark.asyncio
async def test_delete_nonexistent_label(http_client: httpx.AsyncClient):
"""Test deleting non-existent label returns 404"""
fake_label_id = str(uuid.uuid4())
response = await http_client.delete(f"/labels/{fake_label_id}")
assert response.status_code == 404
# Тесты привязки лейблов к задачам
@pytest.mark.asyncio
async def test_add_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict):
"""Test adding label to task"""
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что лейбл добавился к задаче
task_response = await http_client.get(f"/tasks/{test_task['id']}")
assert task_response.status_code == 200
task = task_response.json()
assert test_label["name"] in task["labels"]
@pytest.mark.asyncio
async def test_add_multiple_labels_to_task(http_client: httpx.AsyncClient, test_task: dict):
"""Test adding multiple labels to task"""
# Создаём несколько лейблов
labels_created = []
for i in range(3):
label_data = {
"name": f"multi-label-{i}-{uuid.uuid4().hex[:6]}",
"color": f"#{i:02d}{i:02d}{i:02d}"
}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
labels_created.append(response.json())
# Добавляем все лейблы к задаче
for label in labels_created:
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{label['id']}")
assert response.status_code == 200
# Проверяем что все лейблы добавились
task_response = await http_client.get(f"/tasks/{test_task['id']}")
assert task_response.status_code == 200
task = task_response.json()
for label in labels_created:
assert label["name"] in task["labels"]
# Cleanup
for label in labels_created:
await http_client.delete(f"/labels/{label['id']}")
@pytest.mark.asyncio
async def test_add_nonexistent_label_to_task(http_client: httpx.AsyncClient, test_task: dict):
"""Test adding non-existent label to task returns 404"""
fake_label_id = str(uuid.uuid4())
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{fake_label_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_add_label_to_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict):
"""Test adding label to non-existent task returns 404"""
fake_task_id = str(uuid.uuid4())
response = await http_client.post(f"/tasks/{fake_task_id}/labels/{test_label['id']}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_add_duplicate_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict):
"""Test adding same label twice to task (should be idempotent)"""
# Добавляем лейбл первый раз
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
assert response.status_code == 200
# Добавляем тот же лейбл повторно
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
# В зависимости от реализации может быть 200 (идемпотентность) или 409 (конфликт)
assert response.status_code in [200, 409]
@pytest.mark.asyncio
async def test_remove_label_from_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict):
"""Test removing label from task"""
# Сначала добавляем лейбл
response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
assert response.status_code == 200
# Затем удаляем
response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что лейбл удалился
task_response = await http_client.get(f"/tasks/{test_task['id']}")
assert task_response.status_code == 200
task = task_response.json()
assert test_label["name"] not in task["labels"]
@pytest.mark.asyncio
async def test_remove_nonexistent_label_from_task(http_client: httpx.AsyncClient, test_task: dict):
"""Test removing non-existent label from task returns 404"""
fake_label_id = str(uuid.uuid4())
response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{fake_label_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_remove_label_from_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict):
"""Test removing label from non-existent task returns 404"""
fake_task_id = str(uuid.uuid4())
response = await http_client.delete(f"/tasks/{fake_task_id}/labels/{test_label['id']}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_remove_label_not_attached_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict):
"""Test removing label that's not attached to task"""
# Не добавляем лейбл к задаче, сразу пытаемся удалить
response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}")
# В зависимости от реализации может быть 404 (не найден) или 200 (идемпотентность)
assert response.status_code in [200, 404]
@pytest.mark.asyncio
async def test_filter_tasks_by_label(http_client: httpx.AsyncClient, test_project: dict):
"""Test filtering tasks by label"""
# Создаём лейбл для фильтрации
label_data = {
"name": f"filter-test-{uuid.uuid4().hex[:8]}",
"color": "#123456"
}
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
filter_label = response.json()
# Создаём задачу с лейблом
task_data = {
"title": "Task with specific label",
"labels": [filter_label["name"]]
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
labeled_task = response.json()
# Фильтруем задачи по лейблу
response = await http_client.get(f"/tasks?label={filter_label['name']}")
assert response.status_code == 200
tasks = response.json()
# Все найденные задачи должны содержать указанный лейбл
for task in tasks:
assert filter_label["name"] in task["labels"]
# Наша задача должна быть в результатах
task_ids = [t["id"] for t in tasks]
assert labeled_task["id"] in task_ids
# Cleanup
await http_client.delete(f"/labels/{filter_label['id']}")
@pytest.mark.asyncio
async def test_create_task_with_labels(http_client: httpx.AsyncClient, test_project: dict):
"""Test creating task with labels from the start"""
# Создаём несколько лейблов
labels_data = [
{"name": f"initial-label-1-{uuid.uuid4().hex[:6]}", "color": "#ff0000"},
{"name": f"initial-label-2-{uuid.uuid4().hex[:6]}", "color": "#00ff00"}
]
created_labels = []
for label_data in labels_data:
response = await http_client.post("/labels", json=label_data)
assert response.status_code == 201
created_labels.append(response.json())
# Создаём задачу с лейблами
task_data = {
"title": "Task with initial labels",
"labels": [label["name"] for label in created_labels]
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
for label in created_labels:
assert label["name"] in task["labels"]
# Cleanup
for label in created_labels:
await http_client.delete(f"/labels/{label['id']}")

287
tests/test_members.py Normal file
View File

@ -0,0 +1,287 @@
"""
Тесты работы с участниками - CRUD операции, смена slug, токены агентов
"""
import pytest
import httpx
import uuid
from conftest import assert_uuid, assert_timestamp
@pytest.mark.asyncio
async def test_get_members_list(http_client: httpx.AsyncClient):
"""Test getting list of all members"""
response = await http_client.get("/members")
assert response.status_code == 200
members = response.json()
assert isinstance(members, list)
assert len(members) > 0
# Проверяем структуру первого участника
member = members[0]
assert "id" in member
assert "name" in member
assert "slug" in member
assert "type" in member
assert "role" in member
assert "status" in member
assert "is_active" in member
assert "last_seen_at" in member
assert_uuid(member["id"])
assert member["type"] in ["human", "agent", "bridge"]
assert member["role"] in ["owner", "admin", "member"]
assert member["status"] in ["online", "offline", "busy"]
assert isinstance(member["is_active"], bool)
@pytest.mark.asyncio
async def test_get_member_by_id(http_client: httpx.AsyncClient, test_user: dict):
"""Test getting specific member by ID"""
response = await http_client.get(f"/members/{test_user['id']}")
assert response.status_code == 200
member = response.json()
assert member["id"] == test_user["id"]
assert member["name"] == test_user["name"]
assert member["slug"] == test_user["slug"]
assert member["type"] == "human"
@pytest.mark.asyncio
async def test_get_member_not_found(http_client: httpx.AsyncClient):
"""Test getting non-existent member returns 404"""
fake_id = str(uuid.uuid4())
response = await http_client.get(f"/members/{fake_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_human_member(http_client: httpx.AsyncClient):
"""Test creating new human member"""
member_slug = f"test-human-{uuid.uuid4().hex[:8]}"
member_data = {
"name": f"Test Human {member_slug}",
"slug": member_slug,
"type": "human",
"role": "member"
}
response = await http_client.post("/members", json=member_data)
assert response.status_code == 201
member = response.json()
assert member["name"] == member_data["name"]
assert member["slug"] == member_data["slug"]
assert member["type"] == "human"
assert member["role"] == "member"
assert_uuid(member["id"])
# У человека не должно быть токена
assert "token" not in member
# Cleanup
await http_client.delete(f"/members/{member['id']}")
@pytest.mark.asyncio
async def test_create_agent_member(http_client: httpx.AsyncClient):
"""Test creating new agent member with configuration"""
agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}"
agent_data = {
"name": f"Test Agent {agent_slug}",
"slug": agent_slug,
"type": "agent",
"role": "member",
"agent_config": {
"capabilities": ["coding", "testing", "documentation"],
"labels": ["backend", "python", "api"],
"chat_listen": "mentions",
"task_listen": "assigned",
"prompt": "You are a test automation agent",
"model": "gpt-4"
}
}
response = await http_client.post("/members", json=agent_data)
assert response.status_code == 201
agent = response.json()
assert agent["name"] == agent_data["name"]
assert agent["slug"] == agent_data["slug"]
assert agent["type"] == "agent"
assert agent["role"] == "member"
assert_uuid(agent["id"])
# У агента должен быть токен при создании
assert "token" in agent
assert agent["token"].startswith("tb-")
# Проверяем конфигурацию агента
config = agent["agent_config"]
assert config["capabilities"] == agent_data["agent_config"]["capabilities"]
assert config["labels"] == agent_data["agent_config"]["labels"]
assert config["chat_listen"] == "mentions"
assert config["task_listen"] == "assigned"
# Cleanup
await http_client.delete(f"/members/{agent['id']}")
@pytest.mark.asyncio
async def test_create_member_duplicate_slug(http_client: httpx.AsyncClient, test_user: dict):
"""Test creating member with duplicate slug returns 409"""
member_data = {
"name": "Another User",
"slug": test_user["slug"], # Используем существующий slug
"type": "human",
"role": "member"
}
response = await http_client.post("/members", json=member_data)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_update_member_info(http_client: httpx.AsyncClient, test_user: dict):
"""Test updating member information"""
update_data = {
"name": "Updated Test User",
"role": "admin"
}
response = await http_client.patch(f"/members/{test_user['id']}", json=update_data)
assert response.status_code == 200
member = response.json()
assert member["name"] == "Updated Test User"
assert member["role"] == "admin"
assert member["id"] == test_user["id"]
assert member["slug"] == test_user["slug"] # slug не изменился
@pytest.mark.asyncio
async def test_update_member_slug(http_client: httpx.AsyncClient, test_user: dict):
"""Test updating member slug"""
new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}"
update_data = {"slug": new_slug}
response = await http_client.patch(f"/members/{test_user['id']}", json=update_data)
assert response.status_code == 200
member = response.json()
assert member["slug"] == new_slug
@pytest.mark.asyncio
async def test_update_agent_config(http_client: httpx.AsyncClient, test_agent: dict):
"""Test updating agent configuration"""
new_config = {
"agent_config": {
"capabilities": ["testing", "debugging"],
"labels": ["frontend", "javascript"],
"chat_listen": "all",
"task_listen": "mentions",
"prompt": "Updated test agent",
"model": "claude-3"
}
}
response = await http_client.patch(f"/members/{test_agent['id']}", json=new_config)
assert response.status_code == 200
agent = response.json()
config = agent["agent_config"]
assert config["capabilities"] == new_config["agent_config"]["capabilities"]
assert config["labels"] == new_config["agent_config"]["labels"]
assert config["chat_listen"] == "all"
assert config["task_listen"] == "mentions"
@pytest.mark.asyncio
async def test_regenerate_agent_token(http_client: httpx.AsyncClient, test_agent: dict):
"""Test regenerating agent token"""
response = await http_client.post(f"/members/{test_agent['id']}/regenerate-token")
assert response.status_code == 200
data = response.json()
assert "token" in data
assert data["token"].startswith("tb-")
assert data["token"] != test_agent["token"] # Новый токен должен отличаться
@pytest.mark.asyncio
async def test_regenerate_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict):
"""Test that regenerating token for human returns 400"""
response = await http_client.post(f"/members/{test_user['id']}/regenerate-token")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_revoke_agent_token(http_client: httpx.AsyncClient, test_agent: dict):
"""Test revoking agent token"""
response = await http_client.post(f"/members/{test_agent['id']}/revoke-token")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
@pytest.mark.asyncio
async def test_revoke_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict):
"""Test that revoking token for human returns 400"""
response = await http_client.post(f"/members/{test_user['id']}/revoke-token")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_update_member_status(agent_client: httpx.AsyncClient):
"""Test updating member status (for agents)"""
response = await agent_client.patch("/members/me/status?status=busy")
assert response.status_code == 200
data = response.json()
assert data["status"] == "busy"
@pytest.mark.asyncio
async def test_delete_member(http_client: httpx.AsyncClient):
"""Test soft deleting member (sets is_active=false)"""
# Создаём временного пользователя для удаления
member_slug = f"to-delete-{uuid.uuid4().hex[:8]}"
member_data = {
"name": f"User to Delete {member_slug}",
"slug": member_slug,
"type": "human",
"role": "member"
}
response = await http_client.post("/members", json=member_data)
assert response.status_code == 201
member = response.json()
# Удаляем пользователя
response = await http_client.delete(f"/members/{member['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что пользователь помечен неактивным
response = await http_client.get(f"/members/{member['id']}")
assert response.status_code == 200
deleted_member = response.json()
assert deleted_member["is_active"] is False
@pytest.mark.asyncio
async def test_get_members_include_inactive(http_client: httpx.AsyncClient):
"""Test getting members list with inactive ones"""
response = await http_client.get("/members?include_inactive=true")
assert response.status_code == 200
members = response.json()
# Должен содержать как активных, так и неактивных
has_inactive = any(not m["is_active"] for m in members)
# Может и не быть неактивных, но запрос должен пройти
assert isinstance(members, list)

298
tests/test_projects.py Normal file
View File

@ -0,0 +1,298 @@
"""
Тесты работы с проектами - CRUD операции, участники проектов
"""
import pytest
import httpx
import uuid
from conftest import assert_uuid, assert_timestamp
@pytest.mark.asyncio
async def test_get_projects_list(http_client: httpx.AsyncClient):
"""Test getting list of all projects"""
response = await http_client.get("/projects")
assert response.status_code == 200
projects = response.json()
assert isinstance(projects, list)
if projects: # Если есть проекты
project = projects[0]
assert "id" in project
assert "name" in project
assert "slug" in project
assert "description" in project
assert "repo_urls" in project
assert "status" in project
assert "task_counter" in project
assert "chat_id" in project
assert "auto_assign" in project
assert_uuid(project["id"])
assert project["status"] in ["active", "archived", "paused"]
assert isinstance(project["task_counter"], int)
assert isinstance(project["auto_assign"], bool)
@pytest.mark.asyncio
async def test_get_project_by_id(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting specific project by ID"""
response = await http_client.get(f"/projects/{test_project['id']}")
assert response.status_code == 200
project = response.json()
assert project["id"] == test_project["id"]
assert project["name"] == test_project["name"]
assert project["slug"] == test_project["slug"]
assert project["status"] == "active"
@pytest.mark.asyncio
async def test_get_project_not_found(http_client: httpx.AsyncClient):
"""Test getting non-existent project returns 404"""
fake_id = str(uuid.uuid4())
response = await http_client.get(f"/projects/{fake_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_project(http_client: httpx.AsyncClient):
"""Test creating new project"""
project_slug = f"new-project-{uuid.uuid4().hex[:8]}"
project_data = {
"name": f"New Test Project {project_slug}",
"slug": project_slug,
"description": "A brand new test project",
"repo_urls": [
"https://github.com/test/repo1",
"https://github.com/test/repo2"
]
}
response = await http_client.post("/projects", json=project_data)
assert response.status_code == 201
project = response.json()
assert project["name"] == project_data["name"]
assert project["slug"] == project_data["slug"]
assert project["description"] == project_data["description"]
assert project["repo_urls"] == project_data["repo_urls"]
assert project["status"] == "active"
assert project["task_counter"] == 0
assert project["auto_assign"] is True # По умолчанию
assert_uuid(project["id"])
assert_uuid(project["chat_id"]) # Автоматически создаётся основной чат
# Cleanup
await http_client.delete(f"/projects/{project['id']}")
@pytest.mark.asyncio
async def test_create_project_duplicate_slug(http_client: httpx.AsyncClient, test_project: dict):
"""Test creating project with duplicate slug returns 409"""
project_data = {
"name": "Another Project",
"slug": test_project["slug"], # Используем существующий slug
"description": "Should fail"
}
response = await http_client.post("/projects", json=project_data)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_project_minimal_data(http_client: httpx.AsyncClient):
"""Test creating project with minimal required data"""
project_slug = f"minimal-{uuid.uuid4().hex[:8]}"
project_data = {
"name": f"Minimal Project {project_slug}",
"slug": project_slug
# description и repo_urls опциональны
}
response = await http_client.post("/projects", json=project_data)
assert response.status_code == 201
project = response.json()
assert project["name"] == project_data["name"]
assert project["slug"] == project_data["slug"]
assert project["description"] is None
assert project["repo_urls"] == []
# Cleanup
await http_client.delete(f"/projects/{project['id']}")
@pytest.mark.asyncio
async def test_update_project(http_client: httpx.AsyncClient, test_project: dict):
"""Test updating project information"""
update_data = {
"name": "Updated Project Name",
"description": "Updated description",
"repo_urls": ["https://github.com/updated/repo"],
"auto_assign": False
}
response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data)
assert response.status_code == 200
project = response.json()
assert project["name"] == "Updated Project Name"
assert project["description"] == "Updated description"
assert project["repo_urls"] == ["https://github.com/updated/repo"]
assert project["auto_assign"] is False
assert project["id"] == test_project["id"]
assert project["slug"] == test_project["slug"] # slug не изменился
@pytest.mark.asyncio
async def test_update_project_slug(http_client: httpx.AsyncClient, test_project: dict):
"""Test updating project slug"""
new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}"
update_data = {"slug": new_slug}
response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data)
assert response.status_code == 200
project = response.json()
assert project["slug"] == new_slug
@pytest.mark.asyncio
async def test_update_project_status(http_client: httpx.AsyncClient, test_project: dict):
"""Test updating project status"""
update_data = {"status": "archived"}
response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data)
assert response.status_code == 200
project = response.json()
assert project["status"] == "archived"
@pytest.mark.asyncio
async def test_delete_project(http_client: httpx.AsyncClient):
"""Test deleting project"""
# Создаём временный проект для удаления
project_slug = f"to-delete-{uuid.uuid4().hex[:8]}"
project_data = {
"name": f"Project to Delete {project_slug}",
"slug": project_slug,
"description": "Will be deleted"
}
response = await http_client.post("/projects", json=project_data)
assert response.status_code == 201
project = response.json()
# Удаляем проект
response = await http_client.delete(f"/projects/{project['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что проект действительно удалён
response = await http_client.get(f"/projects/{project['id']}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_project_members(http_client: httpx.AsyncClient, test_project: dict):
"""Test getting project members list"""
response = await http_client.get(f"/projects/{test_project['id']}/members")
assert response.status_code == 200
members = response.json()
assert isinstance(members, list)
if members: # Если есть участники
member = members[0]
assert "id" in member
assert "name" in member
assert "slug" in member
assert "type" in member
assert "role" in member
assert_uuid(member["id"])
assert member["type"] in ["human", "agent", "bridge"]
assert member["role"] in ["owner", "admin", "member"]
@pytest.mark.asyncio
async def test_add_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test adding member to project"""
response = await http_client.post(f"/projects/{test_project['id']}/members", json={
"member_id": test_user["id"]
})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что участник добавлен
response = await http_client.get(f"/projects/{test_project['id']}/members")
assert response.status_code == 200
members = response.json()
member_ids = [m["id"] for m in members]
assert test_user["id"] in member_ids
@pytest.mark.asyncio
async def test_add_nonexistent_member_to_project(http_client: httpx.AsyncClient, test_project: dict):
"""Test adding non-existent member to project returns 404"""
fake_member_id = str(uuid.uuid4())
response = await http_client.post(f"/projects/{test_project['id']}/members", json={
"member_id": fake_member_id
})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_add_duplicate_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test adding already existing member to project returns 409"""
# Добавляем участника первый раз
response = await http_client.post(f"/projects/{test_project['id']}/members", json={
"member_id": test_user["id"]
})
assert response.status_code == 200
# Пытаемся добавить его же повторно
response = await http_client.post(f"/projects/{test_project['id']}/members", json={
"member_id": test_user["id"]
})
assert response.status_code == 409
@pytest.mark.asyncio
async def test_remove_member_from_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test removing member from project"""
# Сначала добавляем участника
response = await http_client.post(f"/projects/{test_project['id']}/members", json={
"member_id": test_user["id"]
})
assert response.status_code == 200
# Затем удаляем
response = await http_client.delete(f"/projects/{test_project['id']}/members/{test_user['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что участник удалён
response = await http_client.get(f"/projects/{test_project['id']}/members")
assert response.status_code == 200
members = response.json()
member_ids = [m["id"] for m in members]
assert test_user["id"] not in member_ids
@pytest.mark.asyncio
async def test_remove_nonexistent_member_from_project(http_client: httpx.AsyncClient, test_project: dict):
"""Test removing non-existent member from project returns 404"""
fake_member_id = str(uuid.uuid4())
response = await http_client.delete(f"/projects/{test_project['id']}/members/{fake_member_id}")
assert response.status_code == 404

538
tests/test_streaming.py Normal file
View File

@ -0,0 +1,538 @@
"""
Тесты агентного стриминга - WebSocket события stream.start/delta/tool/end
"""
import pytest
import asyncio
import websockets
import json
import uuid
from conftest import assert_uuid
@pytest.mark.asyncio
async def test_agent_stream_events_structure(agent_token: str, test_project: dict):
"""Test structure of agent streaming events"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
assert auth_data["type"] == "auth.ok"
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
# Симулируем события стриминга (обычно отправляются сервером, не клиентом)
# Но проверяем структуру событий которые должны прийти
stream_id = str(uuid.uuid4())
# 1. agent.stream.start
start_event = {
"type": "agent.stream.start",
"data": {
"stream_id": stream_id,
"project_id": test_project["id"],
"chat_id": test_project["chat_id"],
"task_id": None,
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
# 2. agent.stream.delta
delta_event = {
"type": "agent.stream.delta",
"data": {
"stream_id": stream_id,
"delta": "Hello, I'm thinking about this task...",
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
# 3. agent.stream.tool
tool_event = {
"type": "agent.stream.tool",
"data": {
"stream_id": stream_id,
"tool_name": "web_search",
"tool_args": {"query": "python testing best practices"},
"tool_result": {"results": ["result1", "result2"]},
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
# 4. agent.stream.end
end_event = {
"type": "agent.stream.end",
"data": {
"stream_id": stream_id,
"final_message": "I've completed the analysis and here are my findings...",
"tool_log": [
{
"name": "web_search",
"args": {"query": "python testing best practices"},
"result": {"results": ["result1", "result2"]},
"error": None
}
],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
# Проверяем структуру событий
assert_stream_start_structure(start_event)
assert_stream_delta_structure(delta_event)
assert_stream_tool_structure(tool_event)
assert_stream_end_structure(end_event)
except Exception as e:
pytest.fail(f"Stream events structure test failed: {e}")
def assert_stream_start_structure(event):
"""Validate agent.stream.start event structure"""
assert event["type"] == "agent.stream.start"
assert "data" in event
data = event["data"]
assert "stream_id" in data
assert "project_id" in data
assert "chat_id" in data
assert "task_id" in data # может быть None
assert "agent_id" in data
assert "agent_slug" in data
assert_uuid(data["stream_id"])
assert_uuid(data["project_id"])
assert_uuid(data["agent_id"])
assert isinstance(data["agent_slug"], str)
if data["chat_id"]:
assert_uuid(data["chat_id"])
if data["task_id"]:
assert_uuid(data["task_id"])
def assert_stream_delta_structure(event):
"""Validate agent.stream.delta event structure"""
assert event["type"] == "agent.stream.delta"
assert "data" in event
data = event["data"]
assert "stream_id" in data
assert "delta" in data
assert "agent_id" in data
assert "agent_slug" in data
assert_uuid(data["stream_id"])
assert_uuid(data["agent_id"])
assert isinstance(data["delta"], str)
assert isinstance(data["agent_slug"], str)
def assert_stream_tool_structure(event):
"""Validate agent.stream.tool event structure"""
assert event["type"] == "agent.stream.tool"
assert "data" in event
data = event["data"]
assert "stream_id" in data
assert "tool_name" in data
assert "tool_args" in data
assert "tool_result" in data
assert "agent_id" in data
assert "agent_slug" in data
assert_uuid(data["stream_id"])
assert_uuid(data["agent_id"])
assert isinstance(data["tool_name"], str)
assert isinstance(data["agent_slug"], str)
# tool_args и tool_result могут быть любыми JSON объектами
assert data["tool_args"] is not None
assert data["tool_result"] is not None
def assert_stream_end_structure(event):
"""Validate agent.stream.end event structure"""
assert event["type"] == "agent.stream.end"
assert "data" in event
data = event["data"]
assert "stream_id" in data
assert "final_message" in data
assert "tool_log" in data
assert "agent_id" in data
assert "agent_slug" in data
assert_uuid(data["stream_id"])
assert_uuid(data["agent_id"])
assert isinstance(data["final_message"], str)
assert isinstance(data["tool_log"], list)
assert isinstance(data["agent_slug"], str)
# Проверяем структуру tool_log
for tool_entry in data["tool_log"]:
assert "name" in tool_entry
assert "args" in tool_entry
assert "result" in tool_entry
# "error" может отсутствовать или быть None
@pytest.mark.asyncio
async def test_agent_stream_task_context(agent_token: str, test_task: dict):
"""Test agent streaming in context of specific task"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
assert auth_data["type"] == "auth.ok"
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
stream_id = str(uuid.uuid4())
# Стриминг в контексте задачи
start_event = {
"type": "agent.stream.start",
"data": {
"stream_id": stream_id,
"project_id": test_task["project"]["id"],
"chat_id": None, # Комментарий к задаче, не чат
"task_id": test_task["id"],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_start_structure(start_event)
# Проверяем что task_id корректно указан
assert start_event["data"]["task_id"] == test_task["id"]
assert start_event["data"]["chat_id"] is None
except Exception as e:
pytest.fail(f"Stream task context test failed: {e}")
@pytest.mark.asyncio
async def test_agent_stream_with_multiple_tools(agent_token: str, test_project: dict):
"""Test agent streaming with multiple tool calls"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
stream_id = str(uuid.uuid4())
# Симулируем последовательность вызовов инструментов
tools_sequence = [
{
"name": "web_search",
"args": {"query": "python best practices"},
"result": {"results": ["doc1", "doc2"]}
},
{
"name": "read_file",
"args": {"filename": "requirements.txt"},
"result": {"content": "pytest==7.0.0\nhttpx==0.24.0"}
},
{
"name": "execute_command",
"args": {"command": "pytest --version"},
"result": {"output": "pytest 7.0.0", "exit_code": 0}
}
]
# Создаём события для каждого инструмента
for i, tool in enumerate(tools_sequence):
tool_event = {
"type": "agent.stream.tool",
"data": {
"stream_id": stream_id,
"tool_name": tool["name"],
"tool_args": tool["args"],
"tool_result": tool["result"],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_tool_structure(tool_event)
# Финальное событие с полным tool_log
end_event = {
"type": "agent.stream.end",
"data": {
"stream_id": stream_id,
"final_message": "I've completed the multi-step analysis.",
"tool_log": [
{
"name": tool["name"],
"args": tool["args"],
"result": tool["result"],
"error": None
} for tool in tools_sequence
],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_end_structure(end_event)
assert len(end_event["data"]["tool_log"]) == 3
except Exception as e:
pytest.fail(f"Multiple tools stream test failed: {e}")
@pytest.mark.asyncio
async def test_agent_stream_with_tool_error(agent_token: str, test_project: dict):
"""Test agent streaming when tool call fails"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
stream_id = str(uuid.uuid4())
# Инструмент с ошибкой
tool_event = {
"type": "agent.stream.tool",
"data": {
"stream_id": stream_id,
"tool_name": "execute_command",
"tool_args": {"command": "nonexistent_command"},
"tool_result": None, # Нет результата при ошибке
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
# Финальное событие с ошибкой в tool_log
end_event = {
"type": "agent.stream.end",
"data": {
"stream_id": stream_id,
"final_message": "I encountered an error while executing the command.",
"tool_log": [
{
"name": "execute_command",
"args": {"command": "nonexistent_command"},
"result": None,
"error": "Command not found: nonexistent_command"
}
],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_tool_structure(tool_event)
assert_stream_end_structure(end_event)
# Проверяем что ошибка корректно записана
error_log = end_event["data"]["tool_log"][0]
assert error_log["error"] is not None
assert error_log["result"] is None
except Exception as e:
pytest.fail(f"Tool error stream test failed: {e}")
@pytest.mark.asyncio
async def test_agent_stream_incremental_delta(agent_token: str, test_project: dict):
"""Test agent streaming with incremental text deltas"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
stream_id = str(uuid.uuid4())
# Симулируем инкрементальное написание текста
text_deltas = [
"I need to ",
"analyze this ",
"problem carefully. ",
"Let me start by ",
"examining the requirements..."
]
full_text = ""
for delta in text_deltas:
full_text += delta
delta_event = {
"type": "agent.stream.delta",
"data": {
"stream_id": stream_id,
"delta": delta,
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_delta_structure(delta_event)
assert delta_event["data"]["delta"] == delta
# Финальное сообщение должно содержать полный текст
end_event = {
"type": "agent.stream.end",
"data": {
"stream_id": stream_id,
"final_message": full_text,
"tool_log": [],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
assert_stream_end_structure(end_event)
assert end_event["data"]["final_message"] == full_text
except Exception as e:
pytest.fail(f"Incremental delta stream test failed: {e}")
@pytest.mark.asyncio
async def test_agent_stream_ids_consistency(agent_token: str, test_project: dict):
"""Test that stream_id is consistent across all events in one stream"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
agent_id = auth_data["data"]["member_id"]
agent_slug = auth_data["data"]["slug"]
stream_id = str(uuid.uuid4())
# Все события должны иметь одинаковый stream_id
events = [
{
"type": "agent.stream.start",
"data": {
"stream_id": stream_id,
"project_id": test_project["id"],
"chat_id": test_project["chat_id"],
"task_id": None,
"agent_id": agent_id,
"agent_slug": agent_slug
}
},
{
"type": "agent.stream.delta",
"data": {
"stream_id": stream_id,
"delta": "Processing...",
"agent_id": agent_id,
"agent_slug": agent_slug
}
},
{
"type": "agent.stream.tool",
"data": {
"stream_id": stream_id,
"tool_name": "test_tool",
"tool_args": {},
"tool_result": {"status": "ok"},
"agent_id": agent_id,
"agent_slug": agent_slug
}
},
{
"type": "agent.stream.end",
"data": {
"stream_id": stream_id,
"final_message": "Complete",
"tool_log": [],
"agent_id": agent_id,
"agent_slug": agent_slug
}
}
]
# Проверяем что все события имеют одинаковый stream_id
for event in events:
assert event["data"]["stream_id"] == stream_id
assert event["data"]["agent_id"] == agent_id
assert event["data"]["agent_slug"] == agent_slug
except Exception as e:
pytest.fail(f"Stream ID consistency test failed: {e}")
@pytest.mark.asyncio
async def test_agent_config_updated_event(agent_token: str):
"""Test config.updated event structure"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
auth_response = await websocket.recv()
auth_data = json.loads(auth_response)
# Симулируем событие обновления конфигурации
config_event = {
"type": "config.updated",
"data": {
"model": "gpt-4",
"provider": "openai",
"prompt": "Updated system prompt",
"chat_listen": "mentions",
"task_listen": "assigned",
"max_concurrent_tasks": 3,
"capabilities": ["coding", "testing", "documentation"],
"labels": ["backend", "python", "api", "database"]
}
}
# Проверяем структуру события
assert config_event["type"] == "config.updated"
assert "data" in config_event
config_data = config_event["data"]
assert "chat_listen" in config_data
assert "task_listen" in config_data
assert "capabilities" in config_data
assert "labels" in config_data
assert "max_concurrent_tasks" in config_data
assert config_data["chat_listen"] in ["all", "mentions", "none"]
assert config_data["task_listen"] in ["all", "mentions", "assigned", "none"]
assert isinstance(config_data["capabilities"], list)
assert isinstance(config_data["labels"], list)
assert isinstance(config_data["max_concurrent_tasks"], int)
assert config_data["max_concurrent_tasks"] > 0
except Exception as e:
pytest.fail(f"Config updated event test failed: {e}")

487
tests/test_tasks.py Normal file
View File

@ -0,0 +1,487 @@
"""
Тесты работы с задачами - CRUD операции, статусы, назначения, этапы, зависимости
"""
import pytest
import httpx
import uuid
from conftest import assert_uuid, assert_timestamp
@pytest.mark.asyncio
async def test_get_tasks_list(http_client: httpx.AsyncClient):
"""Test getting list of all tasks"""
response = await http_client.get("/tasks")
assert response.status_code == 200
tasks = response.json()
assert isinstance(tasks, list)
@pytest.mark.asyncio
async def test_get_tasks_by_project(http_client: httpx.AsyncClient, test_project: dict, test_task: dict):
"""Test filtering tasks by project"""
response = await http_client.get(f"/tasks?project_id={test_project['id']}")
assert response.status_code == 200
tasks = response.json()
assert isinstance(tasks, list)
# Все задачи должны быть из нашего проекта
for task in tasks:
assert task["project"]["id"] == test_project["id"]
# Наша тестовая задача должна быть в списке
task_ids = [t["id"] for t in tasks]
assert test_task["id"] in task_ids
@pytest.mark.asyncio
async def test_get_task_by_id(http_client: httpx.AsyncClient, test_task: dict):
"""Test getting specific task by ID"""
response = await http_client.get(f"/tasks/{test_task['id']}")
assert response.status_code == 200
task = response.json()
assert task["id"] == test_task["id"]
assert task["title"] == test_task["title"]
assert task["description"] == test_task["description"]
assert task["status"] == test_task["status"]
# Проверяем структуру
assert "project" in task
assert "number" in task
assert "key" in task
assert "type" in task
assert "priority" in task
assert "labels" in task
assert "assignee" in task
assert "reviewer" in task
assert "subtasks" in task
assert "steps" in task
assert "watcher_ids" in task
assert "depends_on" in task
assert "position" in task
assert "created_at" in task
assert "updated_at" in task
@pytest.mark.asyncio
async def test_get_task_not_found(http_client: httpx.AsyncClient):
"""Test getting non-existent task returns 404"""
fake_id = str(uuid.uuid4())
response = await http_client.get(f"/tasks/{fake_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_task(http_client: httpx.AsyncClient, test_project: dict):
"""Test creating new task"""
task_data = {
"title": "New Test Task",
"description": "Task created via API test",
"type": "task",
"status": "backlog",
"priority": "high",
"labels": ["backend", "urgent"]
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
assert task["title"] == task_data["title"]
assert task["description"] == task_data["description"]
assert task["type"] == task_data["type"]
assert task["status"] == task_data["status"]
assert task["priority"] == task_data["priority"]
assert task["labels"] == task_data["labels"]
# Проверяем автогенерируемые поля
assert_uuid(task["id"])
assert isinstance(task["number"], int)
assert task["number"] > 0
assert task["key"].startswith(test_project["slug"].upper())
assert f"-{task['number']}" in task["key"]
# Проверяем связанный проект
assert task["project"]["id"] == test_project["id"]
assert task["project"]["slug"] == test_project["slug"]
# По умолчанию assignee и reviewer должны быть null
assert task["assignee"] is None
assert task["reviewer"] is None
# Массивы должны быть пустыми
assert task["subtasks"] == []
assert task["steps"] == []
assert task["watcher_ids"] == []
assert task["depends_on"] == []
@pytest.mark.asyncio
async def test_create_task_minimal(http_client: httpx.AsyncClient, test_project: dict):
"""Test creating task with minimal required data"""
task_data = {"title": "Minimal Task"}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
assert task["title"] == "Minimal Task"
assert task["type"] == "task" # По умолчанию
assert task["status"] == "backlog" # По умолчанию
assert task["priority"] == "medium" # По умолчанию
@pytest.mark.asyncio
async def test_create_task_with_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test creating task with assignee"""
task_data = {
"title": "Assigned Task",
"assignee_id": test_user["id"]
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
assert task["assignee"]["id"] == test_user["id"]
assert task["assignee"]["slug"] == test_user["slug"]
assert task["assignee"]["name"] == test_user["name"]
@pytest.mark.asyncio
async def test_create_task_without_project_fails(http_client: httpx.AsyncClient):
"""Test creating task without project_id fails"""
task_data = {"title": "Task without project"}
response = await http_client.post("/tasks", json=task_data)
assert response.status_code == 422 # Missing query parameter
@pytest.mark.asyncio
async def test_update_task(http_client: httpx.AsyncClient, test_task: dict):
"""Test updating task information"""
update_data = {
"title": "Updated Task Title",
"description": "Updated description",
"status": "in_progress",
"priority": "high",
"labels": ["updated", "test"]
}
response = await http_client.patch(f"/tasks/{test_task['id']}", json=update_data)
assert response.status_code == 200
task = response.json()
assert task["title"] == "Updated Task Title"
assert task["description"] == "Updated description"
assert task["status"] == "in_progress"
assert task["priority"] == "high"
assert task["labels"] == ["updated", "test"]
assert task["id"] == test_task["id"]
@pytest.mark.asyncio
async def test_assign_task(http_client: httpx.AsyncClient, test_task: dict, test_user: dict):
"""Test assigning task to user"""
response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={
"assignee_id": test_user["id"]
})
assert response.status_code == 200
task = response.json()
assert task["assignee"]["id"] == test_user["id"]
assert task["assignee"]["slug"] == test_user["slug"]
@pytest.mark.asyncio
async def test_assign_task_nonexistent_user(http_client: httpx.AsyncClient, test_task: dict):
"""Test assigning task to non-existent user returns 404"""
fake_user_id = str(uuid.uuid4())
response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={
"assignee_id": fake_user_id
})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_take_task_by_agent(agent_client: httpx.AsyncClient, test_project: dict):
"""Test agent taking unassigned task"""
# Создаём неназначенную задачу
task_data = {
"title": "Task for Agent",
"status": "todo" # Подходящий статус для взятия
}
response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
# Берём задачу в работу
response = await agent_client.post(f"/tasks/{task['id']}/take")
assert response.status_code == 200
updated_task = response.json()
assert updated_task["status"] == "in_progress"
# assignee_id должен быть установлен на текущего агента
assert updated_task["assignee"] is not None
@pytest.mark.asyncio
async def test_reject_assigned_task(agent_client: httpx.AsyncClient, test_project: dict):
"""Test agent rejecting assigned task"""
# Создаём задачу и назначаем её на агента
task_data = {
"title": "Task to Reject",
"status": "in_progress"
}
response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
# Отклоняем задачу
response = await agent_client.post(f"/tasks/{task['id']}/reject", json={
"reason": "Too complex for my current capabilities"
})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["reason"] == "Too complex for my current capabilities"
@pytest.mark.asyncio
async def test_add_task_watcher(http_client: httpx.AsyncClient, test_task: dict):
"""Test adding watcher to task"""
response = await http_client.post(f"/tasks/{test_task['id']}/watch")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert isinstance(data["watcher_ids"], list)
@pytest.mark.asyncio
async def test_remove_task_watcher(http_client: httpx.AsyncClient, test_task: dict):
"""Test removing watcher from task"""
# Сначала добавляем себя как наблюдателя
await http_client.post(f"/tasks/{test_task['id']}/watch")
# Затем удаляем
response = await http_client.delete(f"/tasks/{test_task['id']}/watch")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert isinstance(data["watcher_ids"], list)
@pytest.mark.asyncio
async def test_filter_tasks_by_status(http_client: httpx.AsyncClient, test_project: dict):
"""Test filtering tasks by status"""
# Создаём задачи с разными статусами
statuses = ["backlog", "todo", "in_progress", "done"]
created_tasks = []
for status in statuses:
task_data = {
"title": f"Task {status}",
"status": status
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
created_tasks.append(response.json())
# Фильтруем по статусу "in_progress"
response = await http_client.get(f"/tasks?project_id={test_project['id']}&status=in_progress")
assert response.status_code == 200
tasks = response.json()
for task in tasks:
assert task["status"] == "in_progress"
@pytest.mark.asyncio
async def test_filter_tasks_by_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict):
"""Test filtering tasks by assignee"""
# Создаём задачу и назначаем её на пользователя
task_data = {
"title": "Task for specific user",
"assignee_id": test_user["id"]
}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
# Фильтруем по исполнителю
response = await http_client.get(f"/tasks?assignee_id={test_user['id']}")
assert response.status_code == 200
tasks = response.json()
for task in tasks:
assert task["assignee"]["id"] == test_user["id"]
@pytest.mark.asyncio
async def test_search_tasks_by_number(http_client: httpx.AsyncClient, test_task: dict):
"""Test searching tasks by task number"""
task_key = test_task["key"]
response = await http_client.get(f"/tasks?q={task_key}")
assert response.status_code == 200
tasks = response.json()
task_keys = [t["key"] for t in tasks]
assert task_key in task_keys
@pytest.mark.asyncio
async def test_search_tasks_by_title(http_client: httpx.AsyncClient, test_task: dict):
"""Test searching tasks by title"""
search_term = test_task["title"].split()[0] # Первое слово из заголовка
response = await http_client.get(f"/tasks?q={search_term}")
assert response.status_code == 200
tasks = response.json()
# Хотя бы одна задача должна содержать искомое слово
found = any(search_term.lower() in task["title"].lower() for task in tasks)
assert found
@pytest.mark.asyncio
async def test_delete_task(http_client: httpx.AsyncClient, test_project: dict):
"""Test deleting task"""
# Создаём временную задачу для удаления
task_data = {"title": "Task to Delete"}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data)
assert response.status_code == 201
task = response.json()
# Удаляем задачу
response = await http_client.delete(f"/tasks/{task['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Проверяем что задача действительно удалена
response = await http_client.get(f"/tasks/{task['id']}")
assert response.status_code == 404
# Тесты этапов (Steps)
@pytest.mark.asyncio
async def test_get_task_steps(http_client: httpx.AsyncClient, test_task: dict):
"""Test getting task steps list"""
response = await http_client.get(f"/tasks/{test_task['id']}/steps")
assert response.status_code == 200
steps = response.json()
assert isinstance(steps, list)
@pytest.mark.asyncio
async def test_create_task_step(http_client: httpx.AsyncClient, test_task: dict):
"""Test creating new task step"""
step_data = {"title": "Complete step 1"}
response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data)
assert response.status_code == 201
step = response.json()
assert step["title"] == "Complete step 1"
assert step["done"] is False # По умолчанию
assert isinstance(step["position"], int)
assert_uuid(step["id"])
@pytest.mark.asyncio
async def test_update_task_step(http_client: httpx.AsyncClient, test_task: dict):
"""Test updating task step"""
# Создаём этап
step_data = {"title": "Step to update"}
response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data)
assert response.status_code == 201
step = response.json()
# Обновляем этап
update_data = {
"title": "Updated step title",
"done": True
}
response = await http_client.patch(f"/tasks/{test_task['id']}/steps/{step['id']}", json=update_data)
assert response.status_code == 200
updated_step = response.json()
assert updated_step["title"] == "Updated step title"
assert updated_step["done"] is True
assert updated_step["id"] == step["id"]
@pytest.mark.asyncio
async def test_delete_task_step(http_client: httpx.AsyncClient, test_task: dict):
"""Test deleting task step"""
# Создаём этап
step_data = {"title": "Step to delete"}
response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data)
assert response.status_code == 201
step = response.json()
# Удаляем этап
response = await http_client.delete(f"/tasks/{test_task['id']}/steps/{step['id']}")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
# Тесты связей между задачами
@pytest.mark.asyncio
async def test_create_task_link(http_client: httpx.AsyncClient, test_project: dict):
"""Test creating dependency between tasks"""
# Создаём две задачи
task1_data = {"title": "Source Task"}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task1_data)
assert response.status_code == 201
task1 = response.json()
task2_data = {"title": "Target Task"}
response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task2_data)
assert response.status_code == 201
task2 = response.json()
# Создаём связь "task1 зависит от task2"
link_data = {
"target_id": task2["id"],
"link_type": "depends_on"
}
response = await http_client.post(f"/tasks/{task1['id']}/links", json=link_data)
assert response.status_code == 201
link = response.json()
assert link["source_id"] == task1["id"]
assert link["target_id"] == task2["id"]
assert link["link_type"] == "depends_on"
assert link["target_key"] == task2["key"]
assert link["source_key"] == task1["key"]
assert_uuid(link["id"])
@pytest.mark.asyncio
async def test_create_task_link_self_reference_fails(http_client: httpx.AsyncClient, test_task: dict):
"""Test that linking task to itself returns 400"""
link_data = {
"target_id": test_task["id"], # Ссылка на себя
"link_type": "depends_on"
}
response = await http_client.post(f"/tasks/{test_task['id']}/links", json=link_data)
assert response.status_code == 400

411
tests/test_websocket.py Normal file
View File

@ -0,0 +1,411 @@
"""
Тесты WebSocket подключения и получения событий
"""
import pytest
import asyncio
import websockets
import json
import uuid
from conftest import assert_uuid
@pytest.mark.asyncio
async def test_websocket_auth_with_jwt(admin_token: str):
"""Test WebSocket authentication using JWT token"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ожидаем сообщение auth.ok
response = await websocket.recv()
auth_response = json.loads(response)
assert auth_response["type"] == "auth.ok"
assert "data" in auth_response
auth_data = auth_response["data"]
assert "member_id" in auth_data
assert "slug" in auth_data
assert "name" in auth_data
assert "lobby_chat_id" in auth_data
assert "projects" in auth_data
assert "online" in auth_data
assert_uuid(auth_data["member_id"])
assert auth_data["slug"] == "admin"
assert_uuid(auth_data["lobby_chat_id"])
assert isinstance(auth_data["projects"], list)
assert isinstance(auth_data["online"], list)
except Exception as e:
pytest.fail(f"WebSocket connection failed: {e}")
@pytest.mark.asyncio
async def test_websocket_auth_with_agent_token(agent_token: str):
"""Test WebSocket authentication using agent token"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ожидаем сообщение auth.ok
response = await websocket.recv()
auth_response = json.loads(response)
assert auth_response["type"] == "auth.ok"
auth_data = auth_response["data"]
# У агента должна быть конфигурация
assert "agent_config" in auth_data
assert "assigned_tasks" in auth_data
agent_config = auth_data["agent_config"]
assert "chat_listen" in agent_config
assert "task_listen" in agent_config
assert "capabilities" in agent_config
assert "labels" in agent_config
assert isinstance(auth_data["assigned_tasks"], list)
except Exception as e:
pytest.fail(f"WebSocket connection failed: {e}")
@pytest.mark.asyncio
async def test_websocket_auth_first_message(admin_token: str):
"""Test WebSocket authentication by sending auth message first"""
uri = "ws://localhost:8100/ws"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Отправляем сообщение аутентификации
auth_message = {
"type": "auth",
"token": admin_token
}
await websocket.send(json.dumps(auth_message))
# Ожидаем ответ
response = await websocket.recv()
auth_response = json.loads(response)
assert auth_response["type"] == "auth.ok"
assert "data" in auth_response
except Exception as e:
pytest.fail(f"WebSocket auth message failed: {e}")
@pytest.mark.asyncio
async def test_websocket_auth_invalid_token():
"""Test WebSocket authentication with invalid token"""
uri = "ws://localhost:8100/ws?token=invalid_token"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ожидаем сообщение об ошибке
response = await websocket.recv()
auth_response = json.loads(response)
assert auth_response["type"] == "auth.error"
assert "message" in auth_response
except websockets.exceptions.ConnectionClosedError:
# Соединение может быть закрыто сразу при неверном токене
pass
except Exception as e:
pytest.fail(f"Unexpected error: {e}")
@pytest.mark.asyncio
async def test_websocket_heartbeat(admin_token: str):
"""Test WebSocket heartbeat mechanism"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем heartbeat
heartbeat = {"type": "heartbeat"}
await websocket.send(json.dumps(heartbeat))
# Heartbeat может не иметь ответа, но соединение должно остаться живым
await asyncio.sleep(0.5)
# Проверяем что соединение живо отправкой ещё одного heartbeat
heartbeat_with_status = {
"type": "heartbeat",
"status": "online"
}
await websocket.send(json.dumps(heartbeat_with_status))
except Exception as e:
pytest.fail(f"Heartbeat test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_project_subscription(admin_token: str, test_project: dict):
"""Test subscribing to project events via WebSocket"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Подписываемся на проект
subscribe = {
"type": "project.subscribe",
"project_id": test_project["id"]
}
await websocket.send(json.dumps(subscribe))
# ACK может прийти или не прийти, это зависит от реализации
await asyncio.sleep(0.5)
# Отписываемся от проекта
unsubscribe = {
"type": "project.unsubscribe",
"project_id": test_project["id"]
}
await websocket.send(json.dumps(unsubscribe))
except Exception as e:
pytest.fail(f"Project subscription test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_send_chat_message(admin_token: str, test_project: dict):
"""Test sending chat message via WebSocket"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем сообщение в чат проекта
message = {
"type": "chat.send",
"chat_id": test_project["chat_id"],
"content": "Test message via WebSocket"
}
await websocket.send(json.dumps(message))
# Ожидаем получить обратно событие message.new
await asyncio.wait_for(
receive_message_new_event(websocket),
timeout=5.0
)
except asyncio.TimeoutError:
pytest.fail("Timeout waiting for message.new event")
except Exception as e:
pytest.fail(f"Chat message test failed: {e}")
async def receive_message_new_event(websocket):
"""Helper function to receive message.new event"""
while True:
response = await websocket.recv()
event = json.loads(response)
if event["type"] == "message.new":
assert "data" in event
message_data = event["data"]
assert "id" in message_data
assert "content" in message_data
assert "author" in message_data
assert "created_at" in message_data
assert_uuid(message_data["id"])
return event
@pytest.mark.asyncio
async def test_websocket_send_task_comment(admin_token: str, test_task: dict):
"""Test sending task comment via WebSocket"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем комментарий к задаче
message = {
"type": "chat.send",
"task_id": test_task["id"],
"content": "Task comment via WebSocket"
}
await websocket.send(json.dumps(message))
# Ожидаем получить обратно событие message.new
await asyncio.wait_for(
receive_message_new_event(websocket),
timeout=5.0
)
except asyncio.TimeoutError:
pytest.fail("Timeout waiting for task comment event")
except Exception as e:
pytest.fail(f"Task comment test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_agent_with_thinking(agent_token: str, test_project: dict):
"""Test agent sending message with thinking via WebSocket"""
uri = f"ws://localhost:8100/ws?token={agent_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем сообщение с thinking (только агент может)
message = {
"type": "chat.send",
"chat_id": test_project["chat_id"],
"content": "Agent message with thinking",
"thinking": "Let me think about this problem..."
}
await websocket.send(json.dumps(message))
# Ожидаем получить обратно событие message.new
event = await asyncio.wait_for(
receive_message_new_event(websocket),
timeout=5.0
)
# Проверяем что thinking присутствует
message_data = event["data"]
assert "thinking" in message_data
assert message_data["thinking"] == "Let me think about this problem..."
assert message_data["author_type"] == "agent"
except asyncio.TimeoutError:
pytest.fail("Timeout waiting for agent message event")
except Exception as e:
pytest.fail(f"Agent message test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_message_with_mentions(admin_token: str, test_project: dict, test_user: dict):
"""Test sending message with mentions via WebSocket"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем сообщение с упоминанием
message = {
"type": "chat.send",
"chat_id": test_project["chat_id"],
"content": f"Hey @{test_user['slug']}, check this!",
"mentions": [test_user["id"]]
}
await websocket.send(json.dumps(message))
# Ожидаем получить обратно событие message.new
event = await asyncio.wait_for(
receive_message_new_event(websocket),
timeout=5.0
)
# Проверяем что mentions присутствуют
message_data = event["data"]
assert "mentions" in message_data
assert len(message_data["mentions"]) == 1
assert message_data["mentions"][0]["id"] == test_user["id"]
except asyncio.TimeoutError:
pytest.fail("Timeout waiting for message with mentions")
except Exception as e:
pytest.fail(f"Message with mentions test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_invalid_message_format(admin_token: str):
"""Test sending invalid message format via WebSocket"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Ждём аутентификацию
await websocket.recv() # auth.ok
# Отправляем невалидное сообщение
await websocket.send("invalid json")
# Может прийти ошибка или соединение может закрыться
try:
response = await asyncio.wait_for(websocket.recv(), timeout=2.0)
error_event = json.loads(response)
assert error_event["type"] == "error"
except asyncio.TimeoutError:
# Таймаут тоже нормально - сервер может игнорировать невалидные сообщения
pass
except websockets.exceptions.ConnectionClosed:
# Соединение может быть закрыто при невалидном сообщении
pass
except Exception as e:
pytest.fail(f"Invalid message test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_connection_without_auth():
"""Test WebSocket connection without authentication"""
uri = "ws://localhost:8100/ws"
try:
async with websockets.connect(uri, timeout=10) as websocket:
# Отправляем сообщение без аутентификации
message = {
"type": "chat.send",
"content": "Unauthorized message"
}
await websocket.send(json.dumps(message))
# Должна прийти ошибка аутентификации
response = await asyncio.wait_for(websocket.recv(), timeout=5.0)
error_event = json.loads(response)
assert error_event["type"] == "auth.error" or error_event["type"] == "error"
except websockets.exceptions.ConnectionClosed:
# Соединение может быть закрыто если требуется немедленная аутентификация
pass
except Exception as e:
pytest.fail(f"Unauthenticated connection test failed: {e}")
@pytest.mark.asyncio
async def test_websocket_multiple_connections(admin_token: str):
"""Test multiple WebSocket connections for same user"""
uri = f"ws://localhost:8100/ws?token={admin_token}"
try:
# Открываем два соединения одновременно
async with websockets.connect(uri, timeout=10) as ws1, \
websockets.connect(uri, timeout=10) as ws2:
# Ждём аутентификацию на обоих соединениях
await ws1.recv() # auth.ok
await ws2.recv() # auth.ok
# Отправляем heartbeat в оба соединения
heartbeat = {"type": "heartbeat"}
await ws1.send(json.dumps(heartbeat))
await ws2.send(json.dumps(heartbeat))
# Оба соединения должны остаться живыми
await asyncio.sleep(1)
except Exception as e:
pytest.fail(f"Multiple connections test failed: {e}")