"""Tests for workload API and service.""" import pytest from datetime import date, datetime, timedelta from decimal import Decimal from app.models import User, Department, Space, Project, Task from app.models.task_status import TaskStatus from app.services.workload_service import ( get_week_bounds, get_current_week_start, determine_load_level, calculate_load_percentage, calculate_user_workload, get_workload_heatmap, get_user_workload_detail, ) from app.schemas.workload import LoadLevel class TestWeekBounds: """Tests for week boundary calculations.""" def test_get_week_bounds_monday(self): """Monday should return same day as week start.""" monday = date(2024, 1, 1) # This is a Monday week_start, week_end = get_week_bounds(monday) assert week_start == monday assert week_end == date(2024, 1, 7) def test_get_week_bounds_wednesday(self): """Wednesday should return previous Monday as week start.""" wednesday = date(2024, 1, 3) week_start, week_end = get_week_bounds(wednesday) assert week_start == date(2024, 1, 1) assert week_end == date(2024, 1, 7) def test_get_week_bounds_sunday(self): """Sunday should return previous Monday as week start.""" sunday = date(2024, 1, 7) week_start, week_end = get_week_bounds(sunday) assert week_start == date(2024, 1, 1) assert week_end == date(2024, 1, 7) def test_get_current_week_start(self): """Current week start should be a Monday.""" week_start = get_current_week_start() # Monday = 0 assert week_start.weekday() == 0 class TestLoadLevel: """Tests for load level determination.""" def test_load_level_normal(self): """Load below 80% should be normal.""" assert determine_load_level(Decimal("0")) == LoadLevel.NORMAL assert determine_load_level(Decimal("50")) == LoadLevel.NORMAL assert determine_load_level(Decimal("79.99")) == LoadLevel.NORMAL def test_load_level_warning(self): """Load 80-99% should be warning.""" assert determine_load_level(Decimal("80")) == LoadLevel.WARNING assert determine_load_level(Decimal("90")) == LoadLevel.WARNING assert determine_load_level(Decimal("99.99")) == LoadLevel.WARNING def test_load_level_overloaded(self): """Load 100%+ should be overloaded.""" assert determine_load_level(Decimal("100")) == LoadLevel.OVERLOADED assert determine_load_level(Decimal("150")) == LoadLevel.OVERLOADED def test_load_level_unavailable(self): """None percentage should be unavailable.""" assert determine_load_level(None) == LoadLevel.UNAVAILABLE class TestLoadPercentage: """Tests for load percentage calculation.""" def test_normal_calculation(self): """Normal calculation should work.""" result = calculate_load_percentage(Decimal("32"), Decimal("40")) assert result == Decimal("80.00") def test_zero_capacity(self): """Zero capacity should return None.""" result = calculate_load_percentage(Decimal("32"), Decimal("0")) assert result is None def test_zero_allocated(self): """Zero allocated should return 0.""" result = calculate_load_percentage(Decimal("0"), Decimal("40")) assert result == Decimal("0.00") class TestWorkloadService: """Tests for workload service with database.""" def setup_test_data(self, db): """Set up test data for workload tests.""" # Create department dept = Department( id="dept-001", name="R&D", ) db.add(dept) # Create engineer user engineer = User( id="user-engineer-001", email="engineer@test.com", name="Test Engineer", department_id="dept-001", role_id="00000000-0000-0000-0000-000000000003", capacity=40, is_active=True, is_system_admin=False, ) db.add(engineer) # Create space space = Space( id="space-001", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", is_active=True, ) db.add(space) # Create project project = Project( id="project-001", space_id="space-001", title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", department_id="dept-001", security_level="department", ) db.add(project) # Create task status (not done) status_todo = TaskStatus( id="status-todo", project_id="project-001", name="To Do", is_done=False, ) db.add(status_todo) status_done = TaskStatus( id="status-done", project_id="project-001", name="Done", is_done=True, ) db.add(status_done) db.commit() return { "department": dept, "engineer": engineer, "space": space, "project": project, "status_todo": status_todo, "status_done": status_done, } def create_task(self, db, data, task_id, estimate, due_date, status_id=None, done=False): """Helper to create a task.""" task = Task( id=task_id, project_id=data["project"].id, title=f"Task {task_id}", assignee_id=data["engineer"].id, status_id=status_id or (data["status_done"].id if done else data["status_todo"].id), original_estimate=estimate, due_date=due_date, created_by="00000000-0000-0000-0000-000000000001", ) db.add(task) db.commit() return task def test_calculate_user_workload_empty(self, db): """User with no tasks should have 0 allocated hours.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.user_id == data["engineer"].id assert summary.allocated_hours == Decimal("0") assert summary.capacity_hours == Decimal("40") assert summary.load_percentage == Decimal("0.00") assert summary.load_level == LoadLevel.NORMAL assert summary.task_count == 0 def test_calculate_user_workload_zero_capacity(self, db): """User with zero capacity should return unavailable load level.""" data = self.setup_test_data(db) data["engineer"].capacity = 0 db.commit() week_start = date(2024, 1, 1) summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.capacity_hours == Decimal("0") assert summary.load_percentage is None assert summary.load_level == LoadLevel.UNAVAILABLE def test_calculate_user_workload_with_tasks(self, db): """User with tasks should have correct allocated hours.""" data = self.setup_test_data(db) # Create tasks due in the week of 2024-01-01 week_start = date(2024, 1, 1) due = datetime(2024, 1, 3, 12, 0, 0) # Wednesday self.create_task(db, data, "task-1", Decimal("8"), due) self.create_task(db, data, "task-2", Decimal("16"), due) summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.allocated_hours == Decimal("24") assert summary.load_percentage == Decimal("60.00") assert summary.load_level == LoadLevel.NORMAL assert summary.task_count == 2 def test_calculate_user_workload_overloaded(self, db): """User with too many tasks should be overloaded.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) due = datetime(2024, 1, 3, 12, 0, 0) # 48 hours > 40 capacity = overloaded self.create_task(db, data, "task-1", Decimal("24"), due) self.create_task(db, data, "task-2", Decimal("24"), due) summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.allocated_hours == Decimal("48") assert summary.load_percentage == Decimal("120.00") assert summary.load_level == LoadLevel.OVERLOADED def test_completed_tasks_excluded(self, db): """Completed tasks should not count toward workload.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) due = datetime(2024, 1, 3, 12, 0, 0) self.create_task(db, data, "task-1", Decimal("8"), due, done=False) self.create_task(db, data, "task-2", Decimal("16"), due, done=True) # Done summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.allocated_hours == Decimal("8") # Only uncompleted task assert summary.task_count == 1 def test_tasks_outside_week_excluded(self, db): """Tasks due outside the week should not count.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) # Task due in this week self.create_task(db, data, "task-1", Decimal("8"), datetime(2024, 1, 3, 12, 0, 0)) # Task due next week self.create_task(db, data, "task-2", Decimal("16"), datetime(2024, 1, 10, 12, 0, 0)) summary = calculate_user_workload(db, data["engineer"], week_start) assert summary.allocated_hours == Decimal("8") # Only this week's task assert summary.task_count == 1 def test_get_workload_heatmap(self, db): """Heatmap should return all matching users.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) due = datetime(2024, 1, 3, 12, 0, 0) self.create_task(db, data, "task-1", Decimal("32"), due) # Get heatmap for the department summaries = get_workload_heatmap( db=db, week_start=week_start, department_id="dept-001", ) # Should include engineer (not admin, admin has no department) assert len(summaries) == 1 assert summaries[0].user_id == data["engineer"].id assert summaries[0].load_level == LoadLevel.WARNING # 80% def test_get_user_workload_detail(self, db): """Detail should include task list.""" data = self.setup_test_data(db) week_start = date(2024, 1, 1) due = datetime(2024, 1, 3, 12, 0, 0) self.create_task(db, data, "task-1", Decimal("8"), due) self.create_task(db, data, "task-2", Decimal("16"), due) detail = get_user_workload_detail(db, data["engineer"].id, week_start) assert detail is not None assert detail.user_id == data["engineer"].id assert len(detail.tasks) == 2 assert detail.allocated_hours == Decimal("24") class TestWorkloadAPI: """Tests for workload API endpoints.""" def setup_test_data(self, db): """Set up test data for API tests.""" # Create department dept = Department( id="dept-001", name="R&D", ) db.add(dept) # Create engineer user engineer = User( id="user-engineer-001", email="engineer@test.com", name="Test Engineer", department_id="dept-001", role_id="00000000-0000-0000-0000-000000000003", capacity=40, is_active=True, is_system_admin=False, ) db.add(engineer) # Create space space = Space( id="space-001", name="Test Space", owner_id="00000000-0000-0000-0000-000000000001", is_active=True, ) db.add(space) # Create project project = Project( id="project-001", space_id="space-001", title="Test Project", owner_id="00000000-0000-0000-0000-000000000001", department_id="dept-001", security_level="department", ) db.add(project) # Create task status status_todo = TaskStatus( id="status-todo", project_id="project-001", name="To Do", is_done=False, ) db.add(status_todo) # Create a task due this week task = Task( id="task-001", project_id="project-001", title="Test Task", assignee_id="user-engineer-001", status_id="status-todo", original_estimate=Decimal("32"), due_date=datetime.now() + timedelta(days=1), created_by="00000000-0000-0000-0000-000000000001", ) db.add(task) db.commit() return { "department": dept, "engineer": engineer, } def test_heatmap_as_admin(self, client, db, admin_token): """Admin should see all users in heatmap.""" data = self.setup_test_data(db) response = client.get( "/api/workload/heatmap", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 result = response.json() assert "week_start" in result assert "week_end" in result assert "users" in result # Admin sees all users including the engineer assert len(result["users"]) >= 1 def test_heatmap_with_department_filter(self, client, db, admin_token): """Admin can filter by department.""" data = self.setup_test_data(db) response = client.get( "/api/workload/heatmap?department_id=dept-001", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 result = response.json() # Should only include users in dept-001 for user in result["users"]: assert user["department_id"] == "dept-001" def test_my_workload(self, client, db, admin_token): """User can get their own workload.""" response = client.get( "/api/workload/me", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 result = response.json() assert result["user_id"] == "00000000-0000-0000-0000-000000000001" assert "tasks" in result def test_user_workload_detail(self, client, db, admin_token): """Admin can get any user's workload detail.""" data = self.setup_test_data(db) response = client.get( f"/api/workload/user/{data['engineer'].id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 result = response.json() assert result["user_id"] == data["engineer"].id assert len(result["tasks"]) == 1 assert result["allocated_hours"] == "32.00" # Decimal comes as string with precision def test_unauthorized_access(self, client, db): """Unauthenticated requests should fail.""" response = client.get("/api/workload/heatmap") assert response.status_code == 401 # 401 for unauthenticated, 403 for unauthorized class TestWorkloadAccessControl: """Tests for workload access control.""" def setup_test_data(self, db, mock_redis): """Set up test data with two departments.""" from app.core.security import create_access_token, create_token_payload from app.services.workload_service import get_current_week_start # Create departments dept_rd = Department(id="dept-rd", name="R&D") dept_ops = Department(id="dept-ops", name="Operations") db.add(dept_rd) db.add(dept_ops) # Create engineer in R&D engineer_rd = User( id="user-rd-001", email="rd@test.com", name="R&D Engineer", department_id="dept-rd", role_id="00000000-0000-0000-0000-000000000003", capacity=40, is_active=True, is_system_admin=False, ) db.add(engineer_rd) # Create engineer in Operations engineer_ops = User( id="user-ops-001", email="ops@test.com", name="Ops Engineer", department_id="dept-ops", role_id="00000000-0000-0000-0000-000000000003", capacity=40, is_active=True, is_system_admin=False, ) db.add(engineer_ops) # Create space and project for workload task space = Space( id="space-wl-acl-001", name="Workload ACL Space", owner_id="00000000-0000-0000-0000-000000000001", is_active=True, ) db.add(space) project = Project( id="project-wl-acl-001", space_id=space.id, title="Workload ACL Project", owner_id="00000000-0000-0000-0000-000000000001", department_id=dept_rd.id, security_level="department", ) db.add(project) # Create a task for the R&D engineer so they appear in heatmap week_start = get_current_week_start() due_date = datetime.combine(week_start, datetime.min.time()) + timedelta(days=2) task = Task( id="task-wl-acl-001", project_id=project.id, title="Workload ACL Task", assignee_id=engineer_rd.id, due_date=due_date, created_by="00000000-0000-0000-0000-000000000001", ) db.add(task) db.commit() # Create token for R&D engineer token_data = create_token_payload( user_id="user-rd-001", email="rd@test.com", role="engineer", department_id="dept-rd", is_system_admin=False, ) rd_token = create_access_token(token_data) mock_redis.setex("session:user-rd-001", 900, rd_token) return { "dept_rd": dept_rd, "dept_ops": dept_ops, "engineer_rd": engineer_rd, "engineer_ops": engineer_ops, "rd_token": rd_token, } def test_regular_user_sees_only_self(self, client, db, mock_redis): """Regular user should only see their own workload.""" data = self.setup_test_data(db, mock_redis) response = client.get( "/api/workload/heatmap", headers={"Authorization": f"Bearer {data['rd_token']}"}, ) assert response.status_code == 200 result = response.json() # Should only see themselves assert len(result["users"]) == 1 assert result["users"][0]["user_id"] == "user-rd-001" def test_regular_user_cannot_filter_other_user_ids(self, client, db, mock_redis): """Regular user should not filter workload for other users.""" data = self.setup_test_data(db, mock_redis) user_ids = f"{data['engineer_rd'].id},{data['engineer_ops'].id}" response = client.get( f"/api/workload/heatmap?user_ids={user_ids}", headers={"Authorization": f"Bearer {data['rd_token']}"}, ) assert response.status_code == 403 def test_regular_user_cannot_access_other_department(self, client, db, mock_redis): """Regular user should not access other department's workload.""" data = self.setup_test_data(db, mock_redis) response = client.get( "/api/workload/heatmap?department_id=dept-ops", headers={"Authorization": f"Bearer {data['rd_token']}"}, ) assert response.status_code == 403 def test_regular_user_cannot_access_other_user_detail(self, client, db, mock_redis): """Regular user should not access other user's detail.""" data = self.setup_test_data(db, mock_redis) response = client.get( f"/api/workload/user/{data['engineer_ops'].id}", headers={"Authorization": f"Bearer {data['rd_token']}"}, ) assert response.status_code == 403