- 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>
421 lines
13 KiB
Python
421 lines
13 KiB
Python
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)
|