feat: implement collaboration module
- Backend (FastAPI): - Task comments with nested replies and soft delete - @mention parsing with 10-mention limit per comment - Notification system with read/unread tracking - Blocker management with project owner notification - WebSocket endpoint with JWT auth and keepalive - User search API for @mention autocomplete - Alembic migration for 4 new tables - Frontend (React + Vite): - Comments component with @mention autocomplete - NotificationBell with real-time WebSocket updates - BlockerDialog for task blocking workflow - NotificationContext for state management - OpenSpec: - 4 requirements with scenarios defined - add-collaboration change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
420
backend/tests/test_collaboration.py
Normal file
420
backend/tests/test_collaboration.py
Normal file
@@ -0,0 +1,420 @@
|
||||
import pytest
|
||||
import uuid
|
||||
from app.models import User, Space, Project, Task, TaskStatus, Comment, Notification, Blocker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db):
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="testuser@example.com",
|
||||
name="Test User",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
is_system_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_token(client, mock_redis, test_user):
|
||||
"""Get a user token for testing."""
|
||||
from app.core.security import create_access_token, create_token_payload
|
||||
|
||||
token_data = create_token_payload(
|
||||
user_id=test_user.id,
|
||||
email=test_user.email,
|
||||
role="engineer",
|
||||
department_id=None,
|
||||
is_system_admin=False,
|
||||
)
|
||||
token = create_access_token(token_data)
|
||||
mock_redis.setex(f"session:{test_user.id}", 900, token)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_space(db):
|
||||
"""Create a test space."""
|
||||
space = Space(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Test Space",
|
||||
description="A test space",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(db, test_space):
|
||||
"""Create a test project."""
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
space_id=test_space.id,
|
||||
title="Test Project",
|
||||
description="A test project",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_status(db, test_project):
|
||||
"""Create a test task status."""
|
||||
status = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="To Do",
|
||||
color="#3498db",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
db.commit()
|
||||
return status
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_task(db, test_project, test_status):
|
||||
"""Create a test task."""
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title="Test Task",
|
||||
description="A test task",
|
||||
status_id=test_status.id,
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
return task
|
||||
|
||||
|
||||
class TestComments:
|
||||
"""Tests for Comments API."""
|
||||
|
||||
def test_create_comment(self, client, admin_token, test_task):
|
||||
"""Test creating a comment."""
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/comments",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"content": "This is a test comment"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["content"] == "This is a test comment"
|
||||
assert data["task_id"] == test_task.id
|
||||
assert data["is_edited"] is False
|
||||
assert data["is_deleted"] is False
|
||||
|
||||
def test_list_comments(self, client, admin_token, db, test_task):
|
||||
"""Test listing comments."""
|
||||
# Create a comment first
|
||||
comment = Comment(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
author_id="00000000-0000-0000-0000-000000000001",
|
||||
content="Test comment",
|
||||
)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
f"/api/tasks/{test_task.id}/comments",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["comments"]) == 1
|
||||
assert data["comments"][0]["content"] == "Test comment"
|
||||
|
||||
def test_update_comment(self, client, admin_token, db, test_task):
|
||||
"""Test updating a comment."""
|
||||
comment = Comment(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
author_id="00000000-0000-0000-0000-000000000001",
|
||||
content="Original content",
|
||||
)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
f"/api/comments/{comment.id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"content": "Updated content"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["content"] == "Updated content"
|
||||
assert data["is_edited"] is True
|
||||
|
||||
def test_delete_comment(self, client, admin_token, db, test_task):
|
||||
"""Test deleting a comment (soft delete)."""
|
||||
comment = Comment(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
author_id="00000000-0000-0000-0000-000000000001",
|
||||
content="To be deleted",
|
||||
)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/comments/{comment.id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify soft delete
|
||||
db.refresh(comment)
|
||||
assert comment.is_deleted is True
|
||||
|
||||
def test_mention_limit(self, client, admin_token, test_task):
|
||||
"""Test that @mention limit is enforced."""
|
||||
# Create content with more than 10 mentions
|
||||
mentions = " ".join([f"@user{i}" for i in range(15)])
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/comments",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"content": f"Test with many mentions: {mentions}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "10 mentions" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestNotifications:
|
||||
"""Tests for Notifications API."""
|
||||
|
||||
def test_list_notifications(self, client, admin_token, db):
|
||||
"""Test listing notifications."""
|
||||
# Create a notification
|
||||
notification = Notification(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
type="mention",
|
||||
reference_type="comment",
|
||||
reference_id=str(uuid.uuid4()),
|
||||
title="Test notification",
|
||||
message="Test message",
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/notifications",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert data["unread_count"] >= 1
|
||||
|
||||
def test_mark_notification_as_read(self, client, admin_token, db):
|
||||
"""Test marking a notification as read."""
|
||||
notification = Notification(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
type="assignment",
|
||||
reference_type="task",
|
||||
reference_id=str(uuid.uuid4()),
|
||||
title="New assignment",
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
f"/api/notifications/{notification.id}/read",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_read"] is True
|
||||
assert data["read_at"] is not None
|
||||
|
||||
def test_mark_all_as_read(self, client, admin_token, db):
|
||||
"""Test marking all notifications as read."""
|
||||
# Create multiple unread notifications
|
||||
for i in range(3):
|
||||
notification = Notification(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
type="comment",
|
||||
reference_type="task",
|
||||
reference_id=str(uuid.uuid4()),
|
||||
title=f"Notification {i}",
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
"/api/notifications/read-all",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] >= 3
|
||||
|
||||
def test_get_unread_count(self, client, admin_token, db):
|
||||
"""Test getting unread notification count."""
|
||||
# Create unread notifications
|
||||
for i in range(2):
|
||||
notification = Notification(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
type="blocker",
|
||||
reference_type="task",
|
||||
reference_id=str(uuid.uuid4()),
|
||||
title=f"Blocker {i}",
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/notifications/unread-count",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["unread_count"] >= 2
|
||||
|
||||
|
||||
class TestBlockers:
|
||||
"""Tests for Blockers API."""
|
||||
|
||||
def test_create_blocker(self, client, admin_token, test_task):
|
||||
"""Test creating a blocker."""
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/blockers",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"reason": "Waiting for external dependency"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["reason"] == "Waiting for external dependency"
|
||||
assert data["resolved_at"] is None
|
||||
|
||||
def test_resolve_blocker(self, client, admin_token, db, test_task):
|
||||
"""Test resolving a blocker."""
|
||||
blocker = Blocker(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
reported_by="00000000-0000-0000-0000-000000000001",
|
||||
reason="Test blocker",
|
||||
)
|
||||
db.add(blocker)
|
||||
test_task.blocker_flag = True
|
||||
db.commit()
|
||||
|
||||
response = client.put(
|
||||
f"/api/blockers/{blocker.id}/resolve",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"resolution_note": "Issue resolved by updating config"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["resolved_at"] is not None
|
||||
assert data["resolution_note"] == "Issue resolved by updating config"
|
||||
|
||||
# Verify task blocker_flag is cleared
|
||||
db.refresh(test_task)
|
||||
assert test_task.blocker_flag is False
|
||||
|
||||
def test_list_blockers(self, client, admin_token, db, test_task):
|
||||
"""Test listing blockers for a task."""
|
||||
blocker = Blocker(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
reported_by="00000000-0000-0000-0000-000000000001",
|
||||
reason="Test blocker",
|
||||
)
|
||||
db.add(blocker)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
f"/api/tasks/{test_task.id}/blockers",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["blockers"][0]["reason"] == "Test blocker"
|
||||
|
||||
def test_cannot_create_duplicate_active_blocker(self, client, admin_token, db, test_task):
|
||||
"""Test that duplicate active blockers are prevented."""
|
||||
# Create first blocker
|
||||
blocker = Blocker(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=test_task.id,
|
||||
reported_by="00000000-0000-0000-0000-000000000001",
|
||||
reason="First blocker",
|
||||
)
|
||||
db.add(blocker)
|
||||
db.commit()
|
||||
|
||||
# Try to create second blocker
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/blockers",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"reason": "Second blocker"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already has an unresolved blocker" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestUserSearch:
|
||||
"""Tests for User Search API."""
|
||||
|
||||
def test_search_users(self, client, admin_token, db):
|
||||
"""Test searching users by name."""
|
||||
# Create test users
|
||||
user1 = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="john@example.com",
|
||||
name="John Doe",
|
||||
is_active=True,
|
||||
)
|
||||
user2 = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="jane@example.com",
|
||||
name="Jane Doe",
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([user1, user2])
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/users/search?q=Doe",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 2
|
||||
|
||||
def test_search_users_by_email(self, client, admin_token, db):
|
||||
"""Test searching users by email."""
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="searchtest@example.com",
|
||||
name="Search Test",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/users/search?q=searchtest",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
assert any(u["email"] == "searchtest@example.com" for u in data)
|
||||
Reference in New Issue
Block a user