Infrastructure: - Add Redis and Celery workers to all docker-compose files - Fix celery migration race condition in entrypoint.sh - Add healthchecks and resource limits to dev compose - Update .env.template with Redis/Celery variables Backend Models & Schemas: - Rename Sprint.completed_points to velocity (per requirements) - Add AgentInstance.name as required field - Rename Issue external tracker fields for consistency - Add IssueSource and TrackerType enums - Add Project.default_tracker_type field Backend Fixes: - Add Celery retry configuration with exponential backoff - Remove unused sequence counter from EventBus - Add mypy overrides for test dependencies - Fix test file using wrong schema (UserUpdate -> dict) Frontend Fixes: - Fix memory leak in useProjectEvents (proper cleanup) - Fix race condition with stale closure in reconnection - Sync TokenWithUser type with regenerated API client - Fix expires_in null handling in useAuth - Clean up unused imports in prototype pages - Add ESLint relaxed rules for prototype files CI/CD: - Add E2E testing stage with Testcontainers - Add security scanning with Trivy and pip-audit - Add dependency caching for faster builds Tests: - Update all tests to use renamed fields (velocity, name, etc.) - Fix 14 schema test failures - All 1500 tests pass with 91% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
508 lines
16 KiB
Python
508 lines
16 KiB
Python
# tests/models/syndarix/test_sprint.py
|
|
"""
|
|
Unit tests for the Sprint model.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import date, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.models.syndarix import (
|
|
Project,
|
|
Sprint,
|
|
SprintStatus,
|
|
)
|
|
|
|
|
|
class TestSprintModel:
|
|
"""Tests for Sprint model creation and fields."""
|
|
|
|
def test_create_sprint_with_required_fields(self, db_session):
|
|
"""Test creating a sprint with only required fields."""
|
|
project = Project(
|
|
id=uuid.uuid4(),
|
|
name="Sprint Project",
|
|
slug="sprint-project",
|
|
)
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint 1",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Sprint 1").first()
|
|
|
|
assert retrieved is not None
|
|
assert retrieved.name == "Sprint 1"
|
|
assert retrieved.number == 1
|
|
assert retrieved.start_date == today
|
|
assert retrieved.end_date == today + timedelta(days=14)
|
|
assert retrieved.status == SprintStatus.PLANNED # Default
|
|
assert retrieved.goal is None
|
|
assert retrieved.planned_points is None
|
|
assert retrieved.velocity is None
|
|
|
|
def test_create_sprint_with_all_fields(self, db_session):
|
|
"""Test creating a sprint with all optional fields."""
|
|
project = Project(
|
|
id=uuid.uuid4(),
|
|
name="Full Sprint Project",
|
|
slug="full-sprint-project",
|
|
)
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint_id = uuid.uuid4()
|
|
|
|
sprint = Sprint(
|
|
id=sprint_id,
|
|
project_id=project.id,
|
|
name="Full Sprint",
|
|
number=5,
|
|
goal="Complete all authentication features",
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.ACTIVE,
|
|
planned_points=34,
|
|
velocity=21,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(id=sprint_id).first()
|
|
|
|
assert retrieved.name == "Full Sprint"
|
|
assert retrieved.number == 5
|
|
assert retrieved.goal == "Complete all authentication features"
|
|
assert retrieved.status == SprintStatus.ACTIVE
|
|
assert retrieved.planned_points == 34
|
|
assert retrieved.velocity == 21
|
|
|
|
def test_sprint_timestamps(self, db_session):
|
|
"""Test that timestamps are automatically set."""
|
|
project = Project(id=uuid.uuid4(), name="Timestamp Sprint Project", slug="timestamp-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Timestamp Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
assert isinstance(sprint.created_at, datetime)
|
|
assert isinstance(sprint.updated_at, datetime)
|
|
|
|
def test_sprint_string_representation(self, db_session):
|
|
"""Test the string representation of a sprint."""
|
|
project = Project(id=uuid.uuid4(), name="Repr Sprint Project", slug="repr-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint Alpha",
|
|
number=3,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.ACTIVE,
|
|
)
|
|
|
|
repr_str = repr(sprint)
|
|
assert "Sprint Alpha" in repr_str
|
|
assert "#3" in repr_str
|
|
assert str(project.id) in repr_str
|
|
assert "active" in repr_str
|
|
|
|
|
|
class TestSprintStatus:
|
|
"""Tests for Sprint status field."""
|
|
|
|
def test_all_sprint_statuses(self, db_session):
|
|
"""Test that all sprint statuses can be stored."""
|
|
project = Project(id=uuid.uuid4(), name="Status Sprint Project", slug="status-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
for idx, status in enumerate(SprintStatus):
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name=f"Sprint {status.value}",
|
|
number=idx + 1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=status,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(id=sprint.id).first()
|
|
assert retrieved.status == status
|
|
|
|
|
|
class TestSprintLifecycle:
|
|
"""Tests for Sprint lifecycle operations."""
|
|
|
|
def test_start_sprint(self, db_session):
|
|
"""Test starting a planned sprint."""
|
|
project = Project(id=uuid.uuid4(), name="Start Sprint Project", slug="start-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint to Start",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.PLANNED,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
# Start the sprint
|
|
sprint.status = SprintStatus.ACTIVE
|
|
sprint.planned_points = 21
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Start").first()
|
|
assert retrieved.status == SprintStatus.ACTIVE
|
|
assert retrieved.planned_points == 21
|
|
|
|
def test_complete_sprint(self, db_session):
|
|
"""Test completing an active sprint."""
|
|
project = Project(id=uuid.uuid4(), name="Complete Sprint Project", slug="complete-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint to Complete",
|
|
number=1,
|
|
start_date=today - timedelta(days=14),
|
|
end_date=today,
|
|
status=SprintStatus.ACTIVE,
|
|
planned_points=21,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
# Complete the sprint
|
|
sprint.status = SprintStatus.COMPLETED
|
|
sprint.velocity = 18
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Complete").first()
|
|
assert retrieved.status == SprintStatus.COMPLETED
|
|
assert retrieved.velocity == 18
|
|
|
|
def test_cancel_sprint(self, db_session):
|
|
"""Test cancelling a sprint."""
|
|
project = Project(id=uuid.uuid4(), name="Cancel Sprint Project", slug="cancel-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint to Cancel",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.ACTIVE,
|
|
planned_points=21,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
# Cancel the sprint
|
|
sprint.status = SprintStatus.CANCELLED
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Cancel").first()
|
|
assert retrieved.status == SprintStatus.CANCELLED
|
|
|
|
|
|
class TestSprintDates:
|
|
"""Tests for Sprint date fields."""
|
|
|
|
def test_sprint_date_range(self, db_session):
|
|
"""Test storing sprint date range."""
|
|
project = Project(id=uuid.uuid4(), name="Date Range Project", slug="date-range-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
start = date(2024, 1, 1)
|
|
end = date(2024, 1, 14)
|
|
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Date Range Sprint",
|
|
number=1,
|
|
start_date=start,
|
|
end_date=end,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Date Range Sprint").first()
|
|
assert retrieved.start_date == start
|
|
assert retrieved.end_date == end
|
|
|
|
def test_one_day_sprint(self, db_session):
|
|
"""Test creating a one-day sprint."""
|
|
project = Project(id=uuid.uuid4(), name="One Day Project", slug="one-day-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="One Day Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today, # Same day
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="One Day Sprint").first()
|
|
assert retrieved.start_date == retrieved.end_date
|
|
|
|
def test_long_sprint(self, db_session):
|
|
"""Test creating a long sprint (e.g., 4 weeks)."""
|
|
project = Project(id=uuid.uuid4(), name="Long Sprint Project", slug="long-sprint-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Long Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=28), # 4 weeks
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Long Sprint").first()
|
|
delta = retrieved.end_date - retrieved.start_date
|
|
assert delta.days == 28
|
|
|
|
|
|
class TestSprintPoints:
|
|
"""Tests for Sprint story points fields."""
|
|
|
|
def test_sprint_with_zero_points(self, db_session):
|
|
"""Test sprint with zero planned points."""
|
|
project = Project(id=uuid.uuid4(), name="Zero Points Project", slug="zero-points-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Zero Points Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
planned_points=0,
|
|
velocity=0,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Zero Points Sprint").first()
|
|
assert retrieved.planned_points == 0
|
|
assert retrieved.velocity == 0
|
|
|
|
def test_sprint_velocity_calculation(self, db_session):
|
|
"""Test that we can calculate velocity from points."""
|
|
project = Project(id=uuid.uuid4(), name="Velocity Project", slug="velocity-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Velocity Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.COMPLETED,
|
|
planned_points=21,
|
|
velocity=18,
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Velocity Sprint").first()
|
|
|
|
# Calculate completion ratio from velocity
|
|
completion_ratio = retrieved.velocity / retrieved.planned_points
|
|
assert completion_ratio == pytest.approx(18 / 21, rel=0.01)
|
|
|
|
def test_sprint_overdelivery(self, db_session):
|
|
"""Test sprint where completed > planned (stretch goals)."""
|
|
project = Project(id=uuid.uuid4(), name="Overdelivery Project", slug="overdelivery-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Overdelivery Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
status=SprintStatus.COMPLETED,
|
|
planned_points=20,
|
|
velocity=25, # Completed more than planned
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Overdelivery Sprint").first()
|
|
assert retrieved.velocity > retrieved.planned_points
|
|
|
|
|
|
class TestSprintNumber:
|
|
"""Tests for Sprint number field."""
|
|
|
|
def test_sequential_sprint_numbers(self, db_session):
|
|
"""Test creating sprints with sequential numbers."""
|
|
project = Project(id=uuid.uuid4(), name="Sequential Project", slug="sequential-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
for i in range(1, 6):
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name=f"Sprint {i}",
|
|
number=i,
|
|
start_date=today + timedelta(days=(i - 1) * 14),
|
|
end_date=today + timedelta(days=i * 14 - 1),
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
sprints = db_session.query(Sprint).filter_by(project_id=project.id).order_by(Sprint.number).all()
|
|
assert len(sprints) == 5
|
|
for i, sprint in enumerate(sprints, 1):
|
|
assert sprint.number == i
|
|
|
|
def test_large_sprint_number(self, db_session):
|
|
"""Test sprint with large number (e.g., long-running project)."""
|
|
project = Project(id=uuid.uuid4(), name="Large Number Project", slug="large-number-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Sprint 100",
|
|
number=100,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Sprint 100").first()
|
|
assert retrieved.number == 100
|
|
|
|
|
|
class TestSprintUpdate:
|
|
"""Tests for Sprint update operations."""
|
|
|
|
def test_update_sprint_goal(self, db_session):
|
|
"""Test updating sprint goal."""
|
|
project = Project(id=uuid.uuid4(), name="Update Goal Project", slug="update-goal-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Update Goal Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
goal="Original goal",
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
original_created_at = sprint.created_at
|
|
|
|
sprint.goal = "Updated goal with more detail"
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Update Goal Sprint").first()
|
|
assert retrieved.goal == "Updated goal with more detail"
|
|
assert retrieved.created_at == original_created_at
|
|
assert retrieved.updated_at > original_created_at
|
|
|
|
def test_update_sprint_dates(self, db_session):
|
|
"""Test updating sprint dates."""
|
|
project = Project(id=uuid.uuid4(), name="Update Dates Project", slug="update-dates-project")
|
|
db_session.add(project)
|
|
db_session.commit()
|
|
|
|
today = date.today()
|
|
sprint = Sprint(
|
|
id=uuid.uuid4(),
|
|
project_id=project.id,
|
|
name="Update Dates Sprint",
|
|
number=1,
|
|
start_date=today,
|
|
end_date=today + timedelta(days=14),
|
|
)
|
|
db_session.add(sprint)
|
|
db_session.commit()
|
|
|
|
# Extend sprint by a week
|
|
sprint.end_date = today + timedelta(days=21)
|
|
db_session.commit()
|
|
|
|
retrieved = db_session.query(Sprint).filter_by(name="Update Dates Sprint").first()
|
|
delta = retrieved.end_date - retrieved.start_date
|
|
assert delta.days == 21
|