diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..37777cc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,59 @@ +"""P14.1 — Auth API tests. + +Covers: +- Login with valid credentials +- Login with invalid credentials +- Token refresh +- Protected endpoint access with/without token +""" +import pytest + + +class TestAuth: + """Authentication endpoints.""" + + def test_login_success(self, client, db, make_user): + """Valid login returns JWT token.""" + user = make_user(username="testuser", password="testpass123") + + resp = client.post( + "/auth/token", + data={"username": "testuser", "password": "testpass123"} + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_login_invalid_password(self, client, db, make_user): + """Invalid password returns 401.""" + make_user(username="testuser", password="testpass123") + + resp = client.post( + "/auth/token", + data={"username": "testuser", "password": "wrongpass"} + ) + assert resp.status_code == 401 + + def test_login_nonexistent_user(self, client, db): + """Non-existent user returns 401.""" + resp = client.post( + "/auth/token", + data={"username": "nosuchuser", "password": "anypass"} + ) + assert resp.status_code == 401 + + def test_protected_endpoint_without_token(self, client): + """Accessing protected endpoint without token returns 401.""" + resp = client.get("/users/me") + assert resp.status_code == 401 + + def test_protected_endpoint_with_token(self, client, db, make_user, auth_header): + """Accessing protected endpoint with valid token succeeds.""" + user = make_user() + + resp = client.get("/users/me", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id + assert data["username"] == user.username diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..e67da4d --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,180 @@ +"""P14.1 — Comments API tests. + +Covers: +- List comments for task +- Create comment +- Update comment +- Delete comment +- Comment permissions +""" +import pytest + + +class TestComments: + """Comment management endpoints.""" + + def test_list_comments(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List comments for a task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + # Add comments + comment1 = Comment(content="Comment 1", task_id=task.id, author_id=user.id) + comment2 = Comment(content="Comment 2", task_id=task.id, author_id=user.id) + db.add_all([comment1, comment2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks/{task.id}/comments", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + def test_create_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create comment on task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.post( + f"/projects/{project.id}/tasks/{task.id}/comments", + json={"content": "This is a test comment"}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["content"] == "This is a test comment" + assert data["author_id"] == user.id + + def test_update_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update own comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="Original", task_id=task.id, author_id=user.id) + db.add(comment) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + json={"content": "Updated content"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == "Updated content" + + def test_delete_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="To delete", task_id=task.id, author_id=user.id) + db.add(comment) + db.commit() + + resp = client.delete( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + headers=auth_header(user) + ) + assert resp.status_code == 204 + + def test_cannot_edit_others_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Cannot edit another user's comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user1 = make_user(username="user1") + user2 = make_user(username="user2") + project = make_project() + make_member(project.id, user1.id, dev_role.id) + make_member(project.id, user2.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user1.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="User1's comment", task_id=task.id, author_id=user1.id) + db.add(comment) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + json={"content": "Hacked!"}, + headers=auth_header(user2) + ) + assert resp.status_code == 403 diff --git a/tests/test_milestones.py b/tests/test_milestones.py new file mode 100644 index 0000000..4c42f20 --- /dev/null +++ b/tests/test_milestones.py @@ -0,0 +1,148 @@ +"""P14.1 — Milestones CRUD API tests. + +Covers: +- List milestones (project-scoped) +- Get milestone by ID +- Create milestone +- Update milestone +- Delete milestone +- Milestone filtering and sorting +""" +import pytest +from datetime import datetime, timedelta + + +class TestMilestonesCRUD: + """Milestone CRUD endpoints.""" + + def test_list_milestones(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List milestones for a project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Create milestones + from app.models.milestone import Milestone, MilestoneStatus + milestone1 = Milestone(title="Milestone 1", project_id=project.id, status=MilestoneStatus.OPEN) + milestone2 = Milestone(title="Milestone 2", project_id=project.id, status=MilestoneStatus.OPEN) + db.add_all([milestone1, milestone2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/milestones", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + def test_get_milestone_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get specific milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone( + title="Test Milestone", + description="Test desc", + project_id=project.id, + status=MilestoneStatus.OPEN + ) + db.add(milestone) + db.commit() + + resp = client.get( + f"/projects/{project.id}/milestones/{milestone.id}", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == milestone.id + assert data["title"] == "Test Milestone" + + def test_create_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create new milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(project_code="PROJ") + make_member(project.id, user.id, dev_role.id) + + due_date = (datetime.now() + timedelta(days=30)).isoformat() + resp = client.post( + f"/projects/{project.id}/milestones", + json={ + "title": "New Milestone", + "description": "Milestone description", + "due_date": due_date + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Milestone" + assert data["status"] == "open" + assert data["milestone_code"].startswith("PROJ:") + + def test_update_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update milestone (allowed in open status).""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone( + title="Old Title", + project_id=project.id, + status=MilestoneStatus.OPEN + ) + db.add(milestone) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/milestones/{milestone.id}", + json={"title": "Updated Title"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Updated Title" + + def test_delete_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="To Delete", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.delete( + f"/projects/{project.id}/milestones/{milestone.id}", + headers=auth_header(user) + ) + assert resp.status_code == 204 + + def test_milestone_status_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter milestones by status.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + open_ms = Milestone(title="Open", project_id=project.id, status=MilestoneStatus.OPEN) + closed_ms = Milestone(title="Closed", project_id=project.id, status=MilestoneStatus.CLOSED) + db.add_all([open_ms, closed_ms]) + db.commit() + + resp = client.get( + f"/projects/{project.id}/milestones?status=open", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert all(m["status"] == "open" for m in data) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..fc6b57d --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,264 @@ +"""P14.1 — Misc API tests. + +Covers: +- Milestones global list +- Notifications +- Activity log +- API Keys +- Webhooks +- Export +- Dashboard stats +- Health check +""" +import pytest + + +class TestMilestonesGlobal: + """Global milestones endpoints.""" + + def test_list_all_milestones(self, client, db, make_user, auth_header): + """List all milestones (global endpoint).""" + user = make_user() + + resp = client.get("/milestones", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_list_milestones_with_project_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter milestones by project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.get(f"/milestones?project_id={project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(m["project_id"] == project.id for m in data) + + def test_get_milestone_detail(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get milestone by ID (global endpoint).""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.get(f"/milestones/{milestone.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == milestone.id + + def test_milestone_progress(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get milestone progress.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + # Add tasks + task1 = Task( + title="Done", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.CLOSED, priority=TaskPriority.MEDIUM + ) + task2 = Task( + title="Open", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add_all([task1, task2]) + db.commit() + + resp = client.get(f"/milestones/{milestone.id}/progress", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total_issues" in data + assert "completed" in data + assert "progress_pct" in data + + +class TestNotifications: + """Notifications endpoints.""" + + def test_list_notifications(self, client, db, make_user, auth_header): + """List user notifications.""" + user = make_user() + + resp = client.get(f"/notifications?user_id={user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_notification_count(self, client, db, make_user, auth_header): + """Get unread notification count.""" + user = make_user() + + resp = client.get(f"/notifications/count?user_id={user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "unread" in data + assert data["user_id"] == user.id + + def test_mark_notification_read(self, client, db, make_user, auth_header): + """Mark notification as read.""" + user = make_user() + + from app.models.notification import Notification + notification = Notification( + user_id=user.id, + type="test", + title="Test", + message="Test message", + is_read=False + ) + db.add(notification) + db.commit() + + resp = client.post(f"/notifications/{notification.id}/read", headers=auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "read" + + +class TestActivityLog: + """Activity log endpoints.""" + + def test_list_activity(self, client, db, make_user, auth_header): + """List activity logs.""" + user = make_user() + + resp = client.get("/activity", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_list_activity_with_filters(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter activity by entity.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + + from app.models.activity import ActivityLog + activity = ActivityLog( + action="create", + entity_type="project", + entity_id=project.id, + user_id=user.id, + details="Created project" + ) + db.add(activity) + db.commit() + + resp = client.get( + f"/activity?entity_type=project&entity_id={project.id}", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert all(a["entity_type"] == "project" for a in data) + + +class TestAPIKeys: + """API Key management.""" + + def test_create_api_key(self, client, db, make_user, auth_header): + """Create API key.""" + user = make_user() + + resp = client.post( + "/api-keys", + json={"name": "Test Key", "user_id": user.id}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Key" + assert "key" in data + + def test_list_api_keys(self, client, db, make_user, auth_header): + """List API keys.""" + user = make_user() + + resp = client.get("/api-keys", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_revoke_api_key(self, client, db, make_user, auth_header): + """Revoke API key.""" + user = make_user() + + resp = client.post( + "/api-keys", + json={"name": "To Revoke", "user_id": user.id}, + headers=auth_header(user) + ) + key_id = resp.json()["id"] + + resp = client.delete(f"/api-keys/{key_id}", headers=auth_header(user)) + assert resp.status_code == 204 + + +class TestDashboard: + """Dashboard stats.""" + + def test_dashboard_stats(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get dashboard statistics.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.get("/dashboard/stats", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + assert "by_status" in data + assert "by_type" in data + assert "by_priority" in data + + def test_dashboard_stats_with_project_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get dashboard stats for specific project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.get(f"/dashboard/stats?project_id={project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + + +class TestHealth: + """Health check.""" + + def test_health_check(self, client): + """Health endpoint returns ok.""" + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "healthy" + + def test_version(self, client): + """Version endpoint.""" + resp = client.get("/version") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data + assert "name" in data diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..e3be968 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,108 @@ +"""P14.1 — Projects API tests. + +Covers: +- List projects +- Get project by ID +- Create project +- Update project +- Delete project +- Project ownership and permissions +""" +import pytest + + +class TestProjects: + """Project management endpoints.""" + + def test_list_projects(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """User can list projects they have access to.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project1 = make_project(name="Project 1") + project2 = make_project(name="Project 2") + + resp = client.get("/projects", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_get_project_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Get specific project details.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(name="Test Project", owner_id=user.id) + + resp = client.get(f"/projects/{project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == project.id + assert data["name"] == "Test Project" + + def test_create_project(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """User can create project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.post( + "/projects", + json={ + "name": "New Project", + "description": "Test description", + "project_code": "TEST" + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "New Project" + assert data["project_code"] == "TEST" + assert "id" in data + + def test_update_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Project owner can update project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(name="Old Name", owner_id=user.id) + + resp = client.patch( + f"/projects/{project.id}", + json={"name": "Updated Name"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Updated Name" + + def test_delete_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Project owner can delete project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + + resp = client.delete(f"/projects/{project.id}", headers=auth_header(user)) + assert resp.status_code == 204 + + def test_non_owner_cannot_delete_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Non-owner cannot delete project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user(username="owner") + other = make_user(username="other") + project = make_project(owner_id=owner.id) + make_member(project.id, other.id, dev_role.id) + + resp = client.delete(f"/projects/{project.id}", headers=auth_header(other)) + assert resp.status_code == 403 + + def test_project_code_generation(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Project code is auto-generated if not provided.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.post( + "/projects", + json={"name": "Auto Code Project"}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["project_code"].startswith("P") diff --git a/tests/test_roles.py b/tests/test_roles.py new file mode 100644 index 0000000..45d171b --- /dev/null +++ b/tests/test_roles.py @@ -0,0 +1,182 @@ +"""P14.1 — Roles and Permissions API tests. + +Covers: +- List roles +- Get role by ID +- Create role +- Update role +- Delete role +- Assign role to user +- Check permissions +""" +import pytest + + +class TestRoles: + """Role management endpoints.""" + + def test_list_roles(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """List all roles.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.get("/roles", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 3 # admin, mgr, dev at minimum + + def test_get_role_by_id(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Get specific role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.get(f"/roles/{admin_role.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == admin_role.id + assert "name" in data + + def test_create_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can create new role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.post( + "/roles", + json={ + "name": "tester", + "description": "Test role", + "is_global": False + }, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "tester" + + def test_update_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can update role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.patch( + f"/roles/{dev_role.id}", + json={"description": "Updated description"}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["description"] == "Updated description" + + def test_delete_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can delete non-default role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + # Create a role to delete + resp = client.post( + "/roles", + json={"name": "temp-role", "description": "To delete"}, + headers=auth_header(admin) + ) + role_id = resp.json()["id"] + + resp = client.delete(f"/roles/{role_id}", headers=auth_header(admin)) + assert resp.status_code == 204 + + def test_cannot_delete_admin_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Cannot delete admin role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.delete(f"/roles/{admin_role.id}", headers=auth_header(admin)) + assert resp.status_code == 400 + + +class TestPermissions: + """Permission checking endpoints.""" + + def test_check_permission_true(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Check permission returns true when granted.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Dev should have view permission + resp = client.get( + f"/projects/{project.id}/check-permission?permission=view", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_permission"] is True + + def test_check_permission_false(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Check permission returns false when not granted.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + # Add as guest (viewer role) + from app.models.role_permission import Role + guest_role = db.query(Role).filter(Role.name == "guest").first() + if not guest_role: + guest_role = Role(name="guest", description="Guest", is_global=False) + db.add(guest_role) + db.commit() + make_member(project.id, user.id, guest_role.id) + + resp = client.get( + f"/projects/{project.id}/check-permission?permission=admin", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_permission"] is False + + +class TestRoleAssignments: + """Role assignment endpoints.""" + + def test_assign_role_to_user(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Assign role to project member.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + + resp = client.post( + f"/projects/{project.id}/members", + json={"user_id": user.id, "role_id": dev_role.id}, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + + def test_change_user_role(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Change user's role in project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.patch( + f"/projects/{project.id}/members/{user.id}", + json={"role_id": mgr_role.id}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + + def test_remove_user_from_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Remove user from project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.delete( + f"/projects/{project.id}/members/{user.id}", + headers=auth_header(admin) + ) + assert resp.status_code == 204 diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..8263e26 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,211 @@ +"""P14.1 — Tasks CRUD API tests. + +Covers: +- List tasks (project-scoped, milestone-scoped) +- Get task by ID +- Create task +- Update task +- Delete task +- Task filtering by status, assignee, etc. +""" +import pytest +from datetime import datetime, timedelta + + +class TestTasksCRUD: + """Task CRUD endpoints.""" + + def test_list_tasks(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List tasks for a project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Create milestone and tasks + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task1 = Task( + title="Task 1", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + task2 = Task( + title="Task 2", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add_all([task1, task2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + def test_get_task_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get specific task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.HIGH, + task_type="issue" + ) + db.add(task) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks/{task.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == task.id + assert data["title"] == "Test Task" + assert data["task_type"] == "issue" + + def test_create_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create new task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(project_code="PROJ") + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.post( + f"/projects/{project.id}/tasks", + json={ + "title": "New Task", + "description": "Task description", + "milestone_id": milestone.id, + "task_type": "issue", + "priority": "high" + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Task" + assert data["status"] == "open" + assert data["task_code"].startswith("PROJ:") + + def test_update_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Old Title", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}", + json={"title": "Updated Title"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Updated Title" + + def test_delete_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="To Delete", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.delete(f"/projects/{project.id}/tasks/{task.id}", headers=auth_header(user)) + assert resp.status_code == 204 + + def test_task_filter_by_status(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter tasks by status.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + open_task = Task( + title="Open", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + closed_task = Task( + title="Closed", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.CLOSED, priority=TaskPriority.MEDIUM + ) + db.add_all([open_task, closed_task]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks?status=open", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(t["status"] == "open" for t in data) + + def test_task_filter_by_assignee(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter tasks by assignee.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + assignee = make_user(username="assignee") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + assigned_task = Task( + title="Assigned", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, assignee_id=assignee.id, + status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(assigned_task) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks?assignee_id={assignee.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(t["assignee_id"] == assignee.id for t in data) diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..5f5fb91 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,100 @@ +"""P14.1 — Users API tests. + +Covers: +- List users +- Get user by ID +- Create user +- Update user +- Delete user +- User self-service restrictions +""" +import pytest + + +class TestUsers: + """User management endpoints.""" + + def test_list_users(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can list all users.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + make_user(username="user1") + make_user(username="user2") + + resp = client.get("/users", headers=auth_header(admin)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + def test_get_user_by_id(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Get specific user details.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.get(f"/users/{user.id}", headers=auth_header(admin)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id + assert data["username"] == "testuser" + + def test_create_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can create new user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.post( + "/users", + json={ + "username": "newuser", + "password": "newpass123", + "is_admin": False + }, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["username"] == "newuser" + assert "id" in data + + def test_update_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can update user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.patch( + f"/users/{user.id}", + json={"username": "updateduser"}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "updateduser" + + def test_delete_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can delete user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.delete(f"/users/{user.id}", headers=auth_header(admin)) + assert resp.status_code == 204 + + def test_regular_user_cannot_list_all_users(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Non-admin cannot list all users.""" + seed_roles_and_permissions + user = make_user(username="regular") + + resp = client.get("/users", headers=auth_header(user)) + assert resp.status_code == 403 + + def test_user_can_view_self(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """User can view their own profile.""" + seed_roles_and_permissions + user = make_user(username="testuser") + + resp = client.get(f"/users/{user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id