Добавлены 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:
parent
a17c37a7b3
commit
2bab3cf60a
BIN
tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc
Normal file
BIN
tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc
Normal file
Binary file not shown.
174
tests/conftest.py
Normal file
174
tests/conftest.py
Normal 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
3
tests/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
5
tests/requirements.txt
Normal file
5
tests/requirements.txt
Normal 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
117
tests/test_auth.py
Normal 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
315
tests/test_chat.py
Normal 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
404
tests/test_files.py
Normal 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
352
tests/test_labels.py
Normal 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
287
tests/test_members.py
Normal 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
298
tests/test_projects.py
Normal 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
538
tests/test_streaming.py
Normal 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
487
tests/test_tasks.py
Normal 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
411
tests/test_websocket.py
Normal 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}")
|
||||
Loading…
Reference in New Issue
Block a user