Files
PROJECT-CONTORL/backend/tests/test_workload.py
2026-01-11 08:37:21 +08:00

596 lines
20 KiB
Python

"""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 == 403 # No auth header
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