feat(backend): Add Syndarix domain models with CRUD operations

- Add Project model with slug, description, autonomy level, and settings
- Add AgentType model for agent templates with model config and failover
- Add AgentInstance model for running agents with status and memory
- Add Issue model with external tracker sync (Gitea/GitHub/GitLab)
- Add Sprint model with velocity tracking and lifecycle management
- Add comprehensive Pydantic schemas with validation
- Add full CRUD operations for all models with filtering/sorting
- Add 280+ tests for models, schemas, and CRUD operations

Implements #23, #24, #25, #26, #27

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 02:07:27 +01:00
parent d6db6af964
commit 520a4d60fb
42 changed files with 9567 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# tests/crud/syndarix/__init__.py
"""Syndarix CRUD operation tests."""

View File

@@ -0,0 +1,218 @@
# tests/crud/syndarix/conftest.py
"""
Shared fixtures for Syndarix CRUD tests.
"""
import uuid
from datetime import date, timedelta
from decimal import Decimal
import pytest
import pytest_asyncio
from app.models.syndarix import (
AgentInstance,
AgentStatus,
AgentType,
AutonomyLevel,
Issue,
IssuePriority,
IssueStatus,
Project,
ProjectStatus,
Sprint,
SprintStatus,
SyncStatus,
)
from app.models.user import User
from app.schemas.syndarix import (
AgentInstanceCreate,
AgentTypeCreate,
IssueCreate,
ProjectCreate,
SprintCreate,
)
@pytest.fixture
def project_create_data():
"""Return data for creating a project via schema."""
return ProjectCreate(
name="Test Project",
slug="test-project-crud",
description="A test project for CRUD testing",
autonomy_level=AutonomyLevel.MILESTONE,
status=ProjectStatus.ACTIVE,
settings={"mcp_servers": ["gitea"]},
)
@pytest.fixture
def agent_type_create_data():
"""Return data for creating an agent type via schema."""
return AgentTypeCreate(
name="Backend Engineer",
slug="backend-engineer-crud",
description="Specialized in backend development",
expertise=["python", "fastapi", "postgresql"],
personality_prompt="You are an expert backend engineer with deep knowledge of Python and FastAPI.",
primary_model="claude-opus-4-5-20251101",
fallback_models=["claude-sonnet-4-20250514"],
model_params={"temperature": 0.7, "max_tokens": 4096},
mcp_servers=["gitea", "file-system"],
tool_permissions={"allowed": ["*"], "denied": []},
is_active=True,
)
@pytest.fixture
def sprint_create_data():
"""Return data for creating a sprint via schema."""
today = date.today()
return {
"name": "Sprint 1",
"number": 1,
"goal": "Complete initial setup and core features",
"start_date": today,
"end_date": today + timedelta(days=14),
"status": SprintStatus.PLANNED,
"planned_points": 21,
"completed_points": 0,
}
@pytest.fixture
def issue_create_data():
"""Return data for creating an issue via schema."""
return {
"title": "Implement user authentication",
"body": "As a user, I want to log in securely so that I can access my account.",
"status": IssueStatus.OPEN,
"priority": IssuePriority.HIGH,
"labels": ["backend", "security"],
"story_points": 5,
}
@pytest_asyncio.fixture
async def test_owner_crud(async_test_db):
"""Create a test user to be used as project owner in CRUD tests."""
from app.core.auth import get_password_hash
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="crud-owner@example.com",
password_hash=get_password_hash("TestPassword123!"),
first_name="CRUD",
last_name="Owner",
is_active=True,
is_superuser=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def test_project_crud(async_test_db, test_owner_crud, project_create_data):
"""Create a test project in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = Project(
id=uuid.uuid4(),
name=project_create_data.name,
slug=project_create_data.slug,
description=project_create_data.description,
autonomy_level=project_create_data.autonomy_level,
status=project_create_data.status,
settings=project_create_data.settings,
owner_id=test_owner_crud.id,
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
@pytest_asyncio.fixture
async def test_agent_type_crud(async_test_db, agent_type_create_data):
"""Create a test agent type in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = AgentType(
id=uuid.uuid4(),
name=agent_type_create_data.name,
slug=agent_type_create_data.slug,
description=agent_type_create_data.description,
expertise=agent_type_create_data.expertise,
personality_prompt=agent_type_create_data.personality_prompt,
primary_model=agent_type_create_data.primary_model,
fallback_models=agent_type_create_data.fallback_models,
model_params=agent_type_create_data.model_params,
mcp_servers=agent_type_create_data.mcp_servers,
tool_permissions=agent_type_create_data.tool_permissions,
is_active=agent_type_create_data.is_active,
)
session.add(agent_type)
await session.commit()
await session.refresh(agent_type)
return agent_type
@pytest_asyncio.fixture
async def test_agent_instance_crud(async_test_db, test_project_crud, test_agent_type_crud):
"""Create a test agent instance in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.IDLE,
current_task=None,
short_term_memory={},
long_term_memory_ref=None,
session_id=None,
tasks_completed=0,
tokens_used=0,
cost_incurred=Decimal("0.0000"),
)
session.add(agent_instance)
await session.commit()
await session.refresh(agent_instance)
return agent_instance
@pytest_asyncio.fixture
async def test_sprint_crud(async_test_db, test_project_crud, sprint_create_data):
"""Create a test sprint in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
sprint = Sprint(
id=uuid.uuid4(),
project_id=test_project_crud.id,
**sprint_create_data,
)
session.add(sprint)
await session.commit()
await session.refresh(sprint)
return sprint
@pytest_asyncio.fixture
async def test_issue_crud(async_test_db, test_project_crud, issue_create_data):
"""Create a test issue in the database for CRUD tests."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = Issue(
id=uuid.uuid4(),
project_id=test_project_crud.id,
**issue_create_data,
)
session.add(issue)
await session.commit()
await session.refresh(issue)
return issue

View File

@@ -0,0 +1,386 @@
# tests/crud/syndarix/test_agent_instance_crud.py
"""
Tests for AgentInstance CRUD operations.
"""
import uuid
from decimal import Decimal
import pytest
from app.crud.syndarix import agent_instance as agent_instance_crud
from app.models.syndarix import AgentStatus
from app.schemas.syndarix import AgentInstanceCreate, AgentInstanceUpdate
class TestAgentInstanceCreate:
"""Tests for agent instance creation."""
@pytest.mark.asyncio
async def test_create_agent_instance_success(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test successfully creating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance_data = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.IDLE,
current_task=None,
short_term_memory={"context": "initial"},
long_term_memory_ref="project-123/agent-456",
session_id="session-abc",
)
result = await agent_instance_crud.create(session, obj_in=instance_data)
assert result.id is not None
assert result.agent_type_id == test_agent_type_crud.id
assert result.project_id == test_project_crud.id
assert result.status == AgentStatus.IDLE
assert result.short_term_memory == {"context": "initial"}
@pytest.mark.asyncio
async def test_create_agent_instance_minimal(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test creating agent instance with minimal fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance_data = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
)
result = await agent_instance_crud.create(session, obj_in=instance_data)
assert result.status == AgentStatus.IDLE # Default
assert result.tasks_completed == 0
assert result.tokens_used == 0
class TestAgentInstanceRead:
"""Tests for agent instance read operations."""
@pytest.mark.asyncio
async def test_get_agent_instance_by_id(self, async_test_db, test_agent_instance_crud):
"""Test getting agent instance by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
assert result is not None
assert result.id == test_agent_instance_crud.id
@pytest.mark.asyncio
async def test_get_agent_instance_by_id_not_found(self, async_test_db):
"""Test getting non-existent agent instance returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get(session, id=str(uuid.uuid4()))
assert result is None
@pytest.mark.asyncio
async def test_get_with_details(self, async_test_db, test_agent_instance_crud):
"""Test getting agent instance with related details."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get_with_details(
session,
instance_id=test_agent_instance_crud.id,
)
assert result is not None
assert result["instance"].id == test_agent_instance_crud.id
assert result["agent_type_name"] is not None
assert result["project_name"] is not None
class TestAgentInstanceUpdate:
"""Tests for agent instance update operations."""
@pytest.mark.asyncio
async def test_update_agent_instance_status(self, async_test_db, test_agent_instance_crud):
"""Test updating agent instance status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
update_data = AgentInstanceUpdate(
status=AgentStatus.WORKING,
current_task="Processing feature request",
)
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data)
assert result.status == AgentStatus.WORKING
assert result.current_task == "Processing feature request"
@pytest.mark.asyncio
async def test_update_agent_instance_memory(self, async_test_db, test_agent_instance_crud):
"""Test updating agent instance short-term memory."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instance = await agent_instance_crud.get(session, id=str(test_agent_instance_crud.id))
new_memory = {"conversation": ["msg1", "msg2"], "decisions": {"key": "value"}}
update_data = AgentInstanceUpdate(short_term_memory=new_memory)
result = await agent_instance_crud.update(session, db_obj=instance, obj_in=update_data)
assert result.short_term_memory == new_memory
class TestAgentInstanceStatusUpdate:
"""Tests for agent instance status update method."""
@pytest.mark.asyncio
async def test_update_status(self, async_test_db, test_agent_instance_crud):
"""Test updating agent instance status via dedicated method."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.update_status(
session,
instance_id=test_agent_instance_crud.id,
status=AgentStatus.WORKING,
current_task="Working on feature X",
)
assert result is not None
assert result.status == AgentStatus.WORKING
assert result.current_task == "Working on feature X"
assert result.last_activity_at is not None
@pytest.mark.asyncio
async def test_update_status_nonexistent(self, async_test_db):
"""Test updating status of non-existent instance returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.update_status(
session,
instance_id=uuid.uuid4(),
status=AgentStatus.WORKING,
)
assert result is None
class TestAgentInstanceTerminate:
"""Tests for agent instance termination."""
@pytest.mark.asyncio
async def test_terminate_agent_instance(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test terminating an agent instance."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create an instance to terminate
async with AsyncTestingSessionLocal() as session:
instance_data = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.WORKING,
)
created = await agent_instance_crud.create(session, obj_in=instance_data)
instance_id = created.id
# Terminate
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=instance_id)
assert result is not None
assert result.status == AgentStatus.TERMINATED
assert result.terminated_at is not None
assert result.current_task is None
assert result.session_id is None
@pytest.mark.asyncio
async def test_terminate_nonexistent_instance(self, async_test_db):
"""Test terminating non-existent instance returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.terminate(session, instance_id=uuid.uuid4())
assert result is None
class TestAgentInstanceMetrics:
"""Tests for agent instance metrics operations."""
@pytest.mark.asyncio
async def test_record_task_completion(self, async_test_db, test_agent_instance_crud):
"""Test recording task completion with metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.record_task_completion(
session,
instance_id=test_agent_instance_crud.id,
tokens_used=1500,
cost_incurred=Decimal("0.0150"),
)
assert result is not None
assert result.tasks_completed == 1
assert result.tokens_used == 1500
assert result.cost_incurred == Decimal("0.0150")
assert result.last_activity_at is not None
@pytest.mark.asyncio
async def test_record_multiple_task_completions(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test recording multiple task completions accumulates metrics."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create fresh instance
async with AsyncTestingSessionLocal() as session:
instance_data = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
)
created = await agent_instance_crud.create(session, obj_in=instance_data)
instance_id = created.id
# Record first task
async with AsyncTestingSessionLocal() as session:
await agent_instance_crud.record_task_completion(
session,
instance_id=instance_id,
tokens_used=1000,
cost_incurred=Decimal("0.0100"),
)
# Record second task
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.record_task_completion(
session,
instance_id=instance_id,
tokens_used=2000,
cost_incurred=Decimal("0.0200"),
)
assert result.tasks_completed == 2
assert result.tokens_used == 3000
assert result.cost_incurred == Decimal("0.0300")
@pytest.mark.asyncio
async def test_get_project_metrics(self, async_test_db, test_project_crud, test_agent_instance_crud):
"""Test getting aggregated metrics for a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_instance_crud.get_project_metrics(
session,
project_id=test_project_crud.id,
)
assert result is not None
assert "total_instances" in result
assert "active_instances" in result
assert "idle_instances" in result
assert "total_tasks_completed" in result
assert "total_tokens_used" in result
assert "total_cost_incurred" in result
class TestAgentInstanceByProject:
"""Tests for getting instances by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_agent_instance_crud):
"""Test getting instances by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instances, total = await agent_instance_crud.get_by_project(
session,
project_id=test_project_crud.id,
)
assert total >= 1
assert all(i.project_id == test_project_crud.id for i in instances)
@pytest.mark.asyncio
async def test_get_by_project_with_status(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test getting instances by project filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create instances with different statuses
async with AsyncTestingSessionLocal() as session:
idle_instance = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.IDLE,
)
await agent_instance_crud.create(session, obj_in=idle_instance)
working_instance = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.WORKING,
)
await agent_instance_crud.create(session, obj_in=working_instance)
async with AsyncTestingSessionLocal() as session:
instances, total = await agent_instance_crud.get_by_project(
session,
project_id=test_project_crud.id,
status=AgentStatus.WORKING,
)
assert all(i.status == AgentStatus.WORKING for i in instances)
class TestAgentInstanceByAgentType:
"""Tests for getting instances by agent type."""
@pytest.mark.asyncio
async def test_get_by_agent_type(self, async_test_db, test_agent_type_crud, test_agent_instance_crud):
"""Test getting instances by agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
instances = await agent_instance_crud.get_by_agent_type(
session,
agent_type_id=test_agent_type_crud.id,
)
assert len(instances) >= 1
assert all(i.agent_type_id == test_agent_type_crud.id for i in instances)
class TestBulkTerminate:
"""Tests for bulk termination of instances."""
@pytest.mark.asyncio
async def test_bulk_terminate_by_project(self, async_test_db, test_project_crud, test_agent_type_crud):
"""Test bulk terminating all instances in a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create multiple instances
async with AsyncTestingSessionLocal() as session:
for i in range(3):
instance_data = AgentInstanceCreate(
agent_type_id=test_agent_type_crud.id,
project_id=test_project_crud.id,
status=AgentStatus.WORKING if i < 2 else AgentStatus.IDLE,
)
await agent_instance_crud.create(session, obj_in=instance_data)
# Bulk terminate
async with AsyncTestingSessionLocal() as session:
count = await agent_instance_crud.bulk_terminate_by_project(
session,
project_id=test_project_crud.id,
)
assert count >= 3
# Verify all are terminated
async with AsyncTestingSessionLocal() as session:
instances, _ = await agent_instance_crud.get_by_project(
session,
project_id=test_project_crud.id,
)
for instance in instances:
assert instance.status == AgentStatus.TERMINATED

View File

@@ -0,0 +1,353 @@
# tests/crud/syndarix/test_agent_type_crud.py
"""
Tests for AgentType CRUD operations.
"""
import uuid
import pytest
from app.crud.syndarix import agent_type as agent_type_crud
from app.schemas.syndarix import AgentTypeCreate, AgentTypeUpdate
class TestAgentTypeCreate:
"""Tests for agent type creation."""
@pytest.mark.asyncio
async def test_create_agent_type_success(self, async_test_db):
"""Test successfully creating an agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="QA Engineer",
slug="qa-engineer",
description="Specialized in testing and quality assurance",
expertise=["testing", "pytest", "playwright"],
personality_prompt="You are an expert QA engineer...",
primary_model="claude-opus-4-5-20251101",
fallback_models=["claude-sonnet-4-20250514"],
model_params={"temperature": 0.5},
mcp_servers=["gitea"],
tool_permissions={"allowed": ["*"]},
is_active=True,
)
result = await agent_type_crud.create(session, obj_in=agent_type_data)
assert result.id is not None
assert result.name == "QA Engineer"
assert result.slug == "qa-engineer"
assert result.expertise == ["testing", "pytest", "playwright"]
assert result.is_active is True
@pytest.mark.asyncio
async def test_create_agent_type_duplicate_slug_fails(self, async_test_db, test_agent_type_crud):
"""Test creating agent type with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="Duplicate Agent",
slug=test_agent_type_crud.slug, # Duplicate slug
personality_prompt="Duplicate",
primary_model="claude-opus-4-5-20251101",
)
with pytest.raises(ValueError) as exc_info:
await agent_type_crud.create(session, obj_in=agent_type_data)
assert "already exists" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_create_agent_type_minimal_fields(self, async_test_db):
"""Test creating agent type with minimal required fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="Minimal Agent",
slug="minimal-agent",
personality_prompt="You are an assistant.",
primary_model="claude-opus-4-5-20251101",
)
result = await agent_type_crud.create(session, obj_in=agent_type_data)
assert result.name == "Minimal Agent"
assert result.expertise == [] # Default
assert result.fallback_models == [] # Default
assert result.is_active is True # Default
class TestAgentTypeRead:
"""Tests for agent type read operations."""
@pytest.mark.asyncio
async def test_get_agent_type_by_id(self, async_test_db, test_agent_type_crud):
"""Test getting agent type by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
assert result is not None
assert result.id == test_agent_type_crud.id
assert result.name == test_agent_type_crud.name
@pytest.mark.asyncio
async def test_get_agent_type_by_id_not_found(self, async_test_db):
"""Test getting non-existent agent type returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get(session, id=str(uuid.uuid4()))
assert result is None
@pytest.mark.asyncio
async def test_get_agent_type_by_slug(self, async_test_db, test_agent_type_crud):
"""Test getting agent type by slug."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug=test_agent_type_crud.slug)
assert result is not None
assert result.slug == test_agent_type_crud.slug
@pytest.mark.asyncio
async def test_get_agent_type_by_slug_not_found(self, async_test_db):
"""Test getting non-existent slug returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_by_slug(session, slug="non-existent-agent")
assert result is None
class TestAgentTypeUpdate:
"""Tests for agent type update operations."""
@pytest.mark.asyncio
async def test_update_agent_type_basic_fields(self, async_test_db, test_agent_type_crud):
"""Test updating basic agent type fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
update_data = AgentTypeUpdate(
name="Updated Agent Name",
description="Updated description",
)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
assert result.name == "Updated Agent Name"
assert result.description == "Updated description"
@pytest.mark.asyncio
async def test_update_agent_type_expertise(self, async_test_db, test_agent_type_crud):
"""Test updating agent type expertise."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
update_data = AgentTypeUpdate(
expertise=["new-skill", "another-skill"],
)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
assert "new-skill" in result.expertise
@pytest.mark.asyncio
async def test_update_agent_type_model_params(self, async_test_db, test_agent_type_crud):
"""Test updating agent type model parameters."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = await agent_type_crud.get(session, id=str(test_agent_type_crud.id))
new_params = {"temperature": 0.9, "max_tokens": 8192}
update_data = AgentTypeUpdate(model_params=new_params)
result = await agent_type_crud.update(session, db_obj=agent_type, obj_in=update_data)
assert result.model_params == new_params
class TestAgentTypeDelete:
"""Tests for agent type delete operations."""
@pytest.mark.asyncio
async def test_delete_agent_type(self, async_test_db):
"""Test deleting an agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create an agent type to delete
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="Delete Me Agent",
slug="delete-me-agent",
personality_prompt="Delete test",
primary_model="claude-opus-4-5-20251101",
)
created = await agent_type_crud.create(session, obj_in=agent_type_data)
agent_type_id = created.id
# Delete the agent type
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.remove(session, id=str(agent_type_id))
assert result is not None
# Verify deletion
async with AsyncTestingSessionLocal() as session:
deleted = await agent_type_crud.get(session, id=str(agent_type_id))
assert deleted is None
class TestAgentTypeFilters:
"""Tests for agent type filtering and search."""
@pytest.mark.asyncio
async def test_get_multi_with_filters_active(self, async_test_db):
"""Test filtering agent types by is_active."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create active and inactive agent types
async with AsyncTestingSessionLocal() as session:
active_type = AgentTypeCreate(
name="Active Agent Type",
slug="active-agent-type-filter",
personality_prompt="Active",
primary_model="claude-opus-4-5-20251101",
is_active=True,
)
await agent_type_crud.create(session, obj_in=active_type)
inactive_type = AgentTypeCreate(
name="Inactive Agent Type",
slug="inactive-agent-type-filter",
personality_prompt="Inactive",
primary_model="claude-opus-4-5-20251101",
is_active=False,
)
await agent_type_crud.create(session, obj_in=inactive_type)
async with AsyncTestingSessionLocal() as session:
active_types, _ = await agent_type_crud.get_multi_with_filters(
session,
is_active=True,
)
assert all(at.is_active for at in active_types)
@pytest.mark.asyncio
async def test_get_multi_with_filters_search(self, async_test_db):
"""Test searching agent types by name."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="Searchable Agent Type",
slug="searchable-agent-type",
description="This is searchable",
personality_prompt="Searchable",
primary_model="claude-opus-4-5-20251101",
)
await agent_type_crud.create(session, obj_in=agent_type_data)
async with AsyncTestingSessionLocal() as session:
agent_types, total = await agent_type_crud.get_multi_with_filters(
session,
search="Searchable",
)
assert total >= 1
assert any(at.name == "Searchable Agent Type" for at in agent_types)
@pytest.mark.asyncio
async def test_get_multi_with_filters_pagination(self, async_test_db):
"""Test pagination of agent type results."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
for i in range(5):
agent_type_data = AgentTypeCreate(
name=f"Page Agent Type {i}",
slug=f"page-agent-type-{i}",
personality_prompt=f"Page {i}",
primary_model="claude-opus-4-5-20251101",
)
await agent_type_crud.create(session, obj_in=agent_type_data)
async with AsyncTestingSessionLocal() as session:
page1, total = await agent_type_crud.get_multi_with_filters(
session,
skip=0,
limit=2,
)
assert len(page1) <= 2
class TestAgentTypeSpecialMethods:
"""Tests for special agent type CRUD methods."""
@pytest.mark.asyncio
async def test_deactivate_agent_type(self, async_test_db):
"""Test deactivating an agent type."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create an active agent type
async with AsyncTestingSessionLocal() as session:
agent_type_data = AgentTypeCreate(
name="Deactivate Me",
slug="deactivate-me-agent",
personality_prompt="Deactivate",
primary_model="claude-opus-4-5-20251101",
is_active=True,
)
created = await agent_type_crud.create(session, obj_in=agent_type_data)
agent_type_id = created.id
# Deactivate
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=agent_type_id)
assert result is not None
assert result.is_active is False
@pytest.mark.asyncio
async def test_deactivate_nonexistent_agent_type(self, async_test_db):
"""Test deactivating non-existent agent type returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.deactivate(session, agent_type_id=uuid.uuid4())
assert result is None
@pytest.mark.asyncio
async def test_get_with_instance_count(self, async_test_db, test_agent_type_crud, test_agent_instance_crud):
"""Test getting agent type with instance count."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_with_instance_count(
session,
agent_type_id=test_agent_type_crud.id,
)
assert result is not None
assert result["agent_type"].id == test_agent_type_crud.id
assert result["instance_count"] >= 1
@pytest.mark.asyncio
async def test_get_with_instance_count_not_found(self, async_test_db):
"""Test getting non-existent agent type with count returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await agent_type_crud.get_with_instance_count(
session,
agent_type_id=uuid.uuid4(),
)
assert result is None

View File

@@ -0,0 +1,556 @@
# tests/crud/syndarix/test_issue_crud.py
"""
Tests for Issue CRUD operations.
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.crud.syndarix import issue as issue_crud
from app.models.syndarix import IssuePriority, IssueStatus, SyncStatus
from app.schemas.syndarix import IssueCreate, IssueUpdate
class TestIssueCreate:
"""Tests for issue creation."""
@pytest.mark.asyncio
async def test_create_issue_success(self, async_test_db, test_project_crud):
"""Test successfully creating an issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Test Issue",
body="This is a test issue body",
status=IssueStatus.OPEN,
priority=IssuePriority.HIGH,
labels=["bug", "security"],
story_points=5,
)
result = await issue_crud.create(session, obj_in=issue_data)
assert result.id is not None
assert result.title == "Test Issue"
assert result.body == "This is a test issue body"
assert result.status == IssueStatus.OPEN
assert result.priority == IssuePriority.HIGH
assert result.labels == ["bug", "security"]
assert result.story_points == 5
@pytest.mark.asyncio
async def test_create_issue_with_external_tracker(self, async_test_db, test_project_crud):
"""Test creating issue with external tracker info."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="External Issue",
external_tracker="gitea",
external_id="gitea-123",
external_url="https://gitea.example.com/issues/123",
external_number=123,
)
result = await issue_crud.create(session, obj_in=issue_data)
assert result.external_tracker == "gitea"
assert result.external_id == "gitea-123"
assert result.external_number == 123
@pytest.mark.asyncio
async def test_create_issue_minimal(self, async_test_db, test_project_crud):
"""Test creating issue with minimal fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Minimal Issue",
)
result = await issue_crud.create(session, obj_in=issue_data)
assert result.title == "Minimal Issue"
assert result.body == "" # Default
assert result.status == IssueStatus.OPEN # Default
assert result.priority == IssuePriority.MEDIUM # Default
class TestIssueRead:
"""Tests for issue read operations."""
@pytest.mark.asyncio
async def test_get_issue_by_id(self, async_test_db, test_issue_crud):
"""Test getting issue by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.get(session, id=str(test_issue_crud.id))
assert result is not None
assert result.id == test_issue_crud.id
@pytest.mark.asyncio
async def test_get_issue_by_id_not_found(self, async_test_db):
"""Test getting non-existent issue returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.get(session, id=str(uuid.uuid4()))
assert result is None
@pytest.mark.asyncio
async def test_get_with_details(self, async_test_db, test_issue_crud):
"""Test getting issue with related details."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.get_with_details(
session,
issue_id=test_issue_crud.id,
)
assert result is not None
assert result["issue"].id == test_issue_crud.id
assert result["project_name"] is not None
class TestIssueUpdate:
"""Tests for issue update operations."""
@pytest.mark.asyncio
async def test_update_issue_basic_fields(self, async_test_db, test_issue_crud):
"""Test updating basic issue fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
update_data = IssueUpdate(
title="Updated Title",
body="Updated body content",
)
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
assert result.title == "Updated Title"
assert result.body == "Updated body content"
@pytest.mark.asyncio
async def test_update_issue_status(self, async_test_db, test_issue_crud):
"""Test updating issue status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
update_data = IssueUpdate(status=IssueStatus.IN_PROGRESS)
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
assert result.status == IssueStatus.IN_PROGRESS
@pytest.mark.asyncio
async def test_update_issue_priority(self, async_test_db, test_issue_crud):
"""Test updating issue priority."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
update_data = IssueUpdate(priority=IssuePriority.CRITICAL)
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
assert result.priority == IssuePriority.CRITICAL
@pytest.mark.asyncio
async def test_update_issue_labels(self, async_test_db, test_issue_crud):
"""Test updating issue labels."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = await issue_crud.get(session, id=str(test_issue_crud.id))
update_data = IssueUpdate(labels=["new-label", "updated"])
result = await issue_crud.update(session, db_obj=issue, obj_in=update_data)
assert "new-label" in result.labels
class TestIssueAssignment:
"""Tests for issue assignment operations."""
@pytest.mark.asyncio
async def test_assign_to_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
"""Test assigning issue to an agent."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.assign_to_agent(
session,
issue_id=test_issue_crud.id,
agent_id=test_agent_instance_crud.id,
)
assert result is not None
assert result.assigned_agent_id == test_agent_instance_crud.id
assert result.human_assignee is None
@pytest.mark.asyncio
async def test_unassign_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
"""Test unassigning agent from issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# First assign
async with AsyncTestingSessionLocal() as session:
await issue_crud.assign_to_agent(
session,
issue_id=test_issue_crud.id,
agent_id=test_agent_instance_crud.id,
)
# Then unassign
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.assign_to_agent(
session,
issue_id=test_issue_crud.id,
agent_id=None,
)
assert result.assigned_agent_id is None
@pytest.mark.asyncio
async def test_assign_to_human(self, async_test_db, test_issue_crud):
"""Test assigning issue to a human."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.assign_to_human(
session,
issue_id=test_issue_crud.id,
human_assignee="developer@example.com",
)
assert result is not None
assert result.human_assignee == "developer@example.com"
assert result.assigned_agent_id is None
@pytest.mark.asyncio
async def test_assign_to_human_clears_agent(self, async_test_db, test_issue_crud, test_agent_instance_crud):
"""Test assigning to human clears agent assignment."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# First assign to agent
async with AsyncTestingSessionLocal() as session:
await issue_crud.assign_to_agent(
session,
issue_id=test_issue_crud.id,
agent_id=test_agent_instance_crud.id,
)
# Then assign to human
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.assign_to_human(
session,
issue_id=test_issue_crud.id,
human_assignee="developer@example.com",
)
assert result.human_assignee == "developer@example.com"
assert result.assigned_agent_id is None
class TestIssueLifecycle:
"""Tests for issue lifecycle operations."""
@pytest.mark.asyncio
async def test_close_issue(self, async_test_db, test_issue_crud):
"""Test closing an issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.close_issue(session, issue_id=test_issue_crud.id)
assert result is not None
assert result.status == IssueStatus.CLOSED
assert result.closed_at is not None
@pytest.mark.asyncio
async def test_reopen_issue(self, async_test_db, test_project_crud):
"""Test reopening a closed issue."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create and close an issue
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Issue to Reopen",
)
created = await issue_crud.create(session, obj_in=issue_data)
await issue_crud.close_issue(session, issue_id=created.id)
issue_id = created.id
# Reopen
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.reopen_issue(session, issue_id=issue_id)
assert result is not None
assert result.status == IssueStatus.OPEN
assert result.closed_at is None
class TestIssueByProject:
"""Tests for getting issues by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_issue_crud):
"""Test getting issues by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issues, total = await issue_crud.get_by_project(
session,
project_id=test_project_crud.id,
)
assert total >= 1
assert all(i.project_id == test_project_crud.id for i in issues)
@pytest.mark.asyncio
async def test_get_by_project_with_status(self, async_test_db, test_project_crud):
"""Test filtering issues by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issues with different statuses
async with AsyncTestingSessionLocal() as session:
open_issue = IssueCreate(
project_id=test_project_crud.id,
title="Open Issue Filter",
status=IssueStatus.OPEN,
)
await issue_crud.create(session, obj_in=open_issue)
closed_issue = IssueCreate(
project_id=test_project_crud.id,
title="Closed Issue Filter",
status=IssueStatus.CLOSED,
)
await issue_crud.create(session, obj_in=closed_issue)
async with AsyncTestingSessionLocal() as session:
issues, _ = await issue_crud.get_by_project(
session,
project_id=test_project_crud.id,
status=IssueStatus.OPEN,
)
assert all(i.status == IssueStatus.OPEN for i in issues)
@pytest.mark.asyncio
async def test_get_by_project_with_priority(self, async_test_db, test_project_crud):
"""Test filtering issues by priority."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
high_issue = IssueCreate(
project_id=test_project_crud.id,
title="High Priority Issue",
priority=IssuePriority.HIGH,
)
await issue_crud.create(session, obj_in=high_issue)
async with AsyncTestingSessionLocal() as session:
issues, _ = await issue_crud.get_by_project(
session,
project_id=test_project_crud.id,
priority=IssuePriority.HIGH,
)
assert all(i.priority == IssuePriority.HIGH for i in issues)
@pytest.mark.asyncio
async def test_get_by_project_with_search(self, async_test_db, test_project_crud):
"""Test searching issues by title/body."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
searchable_issue = IssueCreate(
project_id=test_project_crud.id,
title="Searchable Unique Title",
body="This body contains searchable content",
)
await issue_crud.create(session, obj_in=searchable_issue)
async with AsyncTestingSessionLocal() as session:
issues, total = await issue_crud.get_by_project(
session,
project_id=test_project_crud.id,
search="Searchable Unique",
)
assert total >= 1
assert any(i.title == "Searchable Unique Title" for i in issues)
class TestIssueBySprint:
"""Tests for getting issues by sprint."""
@pytest.mark.asyncio
async def test_get_by_sprint(self, async_test_db, test_project_crud, test_sprint_crud):
"""Test getting issues by sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issue in sprint
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Sprint Issue",
sprint_id=test_sprint_crud.id,
)
await issue_crud.create(session, obj_in=issue_data)
async with AsyncTestingSessionLocal() as session:
issues = await issue_crud.get_by_sprint(
session,
sprint_id=test_sprint_crud.id,
)
assert len(issues) >= 1
assert all(i.sprint_id == test_sprint_crud.id for i in issues)
class TestIssueSyncStatus:
"""Tests for issue sync status operations."""
@pytest.mark.asyncio
async def test_update_sync_status(self, async_test_db, test_project_crud):
"""Test updating issue sync status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issue with external tracker
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Sync Status Issue",
external_tracker="gitea",
external_id="gitea-456",
)
created = await issue_crud.create(session, obj_in=issue_data)
issue_id = created.id
# Update sync status
now = datetime.now(UTC)
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.update_sync_status(
session,
issue_id=issue_id,
sync_status=SyncStatus.PENDING,
last_synced_at=now,
)
assert result is not None
assert result.sync_status == SyncStatus.PENDING
assert result.last_synced_at is not None
@pytest.mark.asyncio
async def test_get_pending_sync(self, async_test_db, test_project_crud):
"""Test getting issues pending sync."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issue with pending sync
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="Pending Sync Issue",
external_tracker="gitea",
external_id="gitea-789",
)
created = await issue_crud.create(session, obj_in=issue_data)
# Set to pending
await issue_crud.update_sync_status(
session,
issue_id=created.id,
sync_status=SyncStatus.PENDING,
)
async with AsyncTestingSessionLocal() as session:
issues = await issue_crud.get_pending_sync(session)
assert any(i.sync_status == SyncStatus.PENDING for i in issues)
class TestIssueExternalTracker:
"""Tests for external tracker operations."""
@pytest.mark.asyncio
async def test_get_by_external_id(self, async_test_db, test_project_crud):
"""Test getting issue by external tracker ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issue with external ID
async with AsyncTestingSessionLocal() as session:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title="External ID Issue",
external_tracker="github",
external_id="github-unique-123",
)
await issue_crud.create(session, obj_in=issue_data)
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.get_by_external_id(
session,
external_tracker="github",
external_id="github-unique-123",
)
assert result is not None
assert result.external_id == "github-unique-123"
@pytest.mark.asyncio
async def test_get_by_external_id_not_found(self, async_test_db):
"""Test getting non-existent external ID returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await issue_crud.get_by_external_id(
session,
external_tracker="gitea",
external_id="non-existent",
)
assert result is None
class TestIssueStats:
"""Tests for issue statistics."""
@pytest.mark.asyncio
async def test_get_project_stats(self, async_test_db, test_project_crud):
"""Test getting issue statistics for a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create issues with various statuses and priorities
async with AsyncTestingSessionLocal() as session:
for status in [IssueStatus.OPEN, IssueStatus.IN_PROGRESS, IssueStatus.CLOSED]:
issue_data = IssueCreate(
project_id=test_project_crud.id,
title=f"Stats Issue {status.value}",
status=status,
story_points=3,
)
await issue_crud.create(session, obj_in=issue_data)
async with AsyncTestingSessionLocal() as session:
stats = await issue_crud.get_project_stats(
session,
project_id=test_project_crud.id,
)
assert "total" in stats
assert "open" in stats
assert "in_progress" in stats
assert "closed" in stats
assert "by_priority" in stats
assert "total_story_points" in stats

View File

@@ -0,0 +1,409 @@
# tests/crud/syndarix/test_project_crud.py
"""
Tests for Project CRUD operations.
"""
import uuid
import pytest
from app.crud.syndarix import project as project_crud
from app.models.syndarix import AutonomyLevel, ProjectStatus
from app.schemas.syndarix import ProjectCreate, ProjectUpdate
class TestProjectCreate:
"""Tests for project creation."""
@pytest.mark.asyncio
async def test_create_project_success(self, async_test_db, test_owner_crud):
"""Test successfully creating a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project_data = ProjectCreate(
name="New Project",
slug="new-project",
description="A brand new project",
autonomy_level=AutonomyLevel.MILESTONE,
status=ProjectStatus.ACTIVE,
settings={"key": "value"},
owner_id=test_owner_crud.id,
)
result = await project_crud.create(session, obj_in=project_data)
assert result.id is not None
assert result.name == "New Project"
assert result.slug == "new-project"
assert result.description == "A brand new project"
assert result.autonomy_level == AutonomyLevel.MILESTONE
assert result.status == ProjectStatus.ACTIVE
assert result.settings == {"key": "value"}
assert result.owner_id == test_owner_crud.id
@pytest.mark.asyncio
async def test_create_project_duplicate_slug_fails(self, async_test_db, test_project_crud):
"""Test creating project with duplicate slug raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project_data = ProjectCreate(
name="Duplicate Project",
slug=test_project_crud.slug, # Duplicate slug
description="This should fail",
)
with pytest.raises(ValueError) as exc_info:
await project_crud.create(session, obj_in=project_data)
assert "already exists" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_create_project_minimal_fields(self, async_test_db):
"""Test creating project with minimal required fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project_data = ProjectCreate(
name="Minimal Project",
slug="minimal-project",
)
result = await project_crud.create(session, obj_in=project_data)
assert result.name == "Minimal Project"
assert result.slug == "minimal-project"
assert result.autonomy_level == AutonomyLevel.MILESTONE # Default
assert result.status == ProjectStatus.ACTIVE # Default
class TestProjectRead:
"""Tests for project read operations."""
@pytest.mark.asyncio
async def test_get_project_by_id(self, async_test_db, test_project_crud):
"""Test getting project by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.get(session, id=str(test_project_crud.id))
assert result is not None
assert result.id == test_project_crud.id
assert result.name == test_project_crud.name
@pytest.mark.asyncio
async def test_get_project_by_id_not_found(self, async_test_db):
"""Test getting non-existent project returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.get(session, id=str(uuid.uuid4()))
assert result is None
@pytest.mark.asyncio
async def test_get_project_by_slug(self, async_test_db, test_project_crud):
"""Test getting project by slug."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.get_by_slug(session, slug=test_project_crud.slug)
assert result is not None
assert result.slug == test_project_crud.slug
@pytest.mark.asyncio
async def test_get_project_by_slug_not_found(self, async_test_db):
"""Test getting non-existent slug returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.get_by_slug(session, slug="non-existent-slug")
assert result is None
class TestProjectUpdate:
"""Tests for project update operations."""
@pytest.mark.asyncio
async def test_update_project_basic_fields(self, async_test_db, test_project_crud):
"""Test updating basic project fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(
name="Updated Project Name",
description="Updated description",
)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
assert result.name == "Updated Project Name"
assert result.description == "Updated description"
@pytest.mark.asyncio
async def test_update_project_status(self, async_test_db, test_project_crud):
"""Test updating project status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(status=ProjectStatus.PAUSED)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
assert result.status == ProjectStatus.PAUSED
@pytest.mark.asyncio
async def test_update_project_autonomy_level(self, async_test_db, test_project_crud):
"""Test updating project autonomy level."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id))
update_data = ProjectUpdate(autonomy_level=AutonomyLevel.AUTONOMOUS)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
assert result.autonomy_level == AutonomyLevel.AUTONOMOUS
@pytest.mark.asyncio
async def test_update_project_settings(self, async_test_db, test_project_crud):
"""Test updating project settings."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = await project_crud.get(session, id=str(test_project_crud.id))
new_settings = {"mcp_servers": ["gitea", "slack"], "webhook_url": "https://example.com"}
update_data = ProjectUpdate(settings=new_settings)
result = await project_crud.update(session, db_obj=project, obj_in=update_data)
assert result.settings == new_settings
class TestProjectDelete:
"""Tests for project delete operations."""
@pytest.mark.asyncio
async def test_delete_project(self, async_test_db, test_owner_crud):
"""Test deleting a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create a project to delete
async with AsyncTestingSessionLocal() as session:
project_data = ProjectCreate(
name="Delete Me",
slug="delete-me-project",
owner_id=test_owner_crud.id,
)
created = await project_crud.create(session, obj_in=project_data)
project_id = created.id
# Delete the project
async with AsyncTestingSessionLocal() as session:
result = await project_crud.remove(session, id=str(project_id))
assert result is not None
assert result.id == project_id
# Verify deletion
async with AsyncTestingSessionLocal() as session:
deleted = await project_crud.get(session, id=str(project_id))
assert deleted is None
@pytest.mark.asyncio
async def test_delete_nonexistent_project(self, async_test_db):
"""Test deleting non-existent project returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.remove(session, id=str(uuid.uuid4()))
assert result is None
class TestProjectFilters:
"""Tests for project filtering and search."""
@pytest.mark.asyncio
async def test_get_multi_with_filters_status(self, async_test_db, test_owner_crud):
"""Test filtering projects by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create multiple projects with different statuses
async with AsyncTestingSessionLocal() as session:
for i, status in enumerate(ProjectStatus):
project_data = ProjectCreate(
name=f"Project {status.value}",
slug=f"project-filter-{status.value}-{i}",
status=status,
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=project_data)
# Filter by ACTIVE status
async with AsyncTestingSessionLocal() as session:
projects, total = await project_crud.get_multi_with_filters(
session,
status=ProjectStatus.ACTIVE,
)
assert all(p.status == ProjectStatus.ACTIVE for p in projects)
@pytest.mark.asyncio
async def test_get_multi_with_filters_search(self, async_test_db, test_owner_crud):
"""Test searching projects by name/slug."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project_data = ProjectCreate(
name="Searchable Project",
slug="searchable-unique-slug",
description="This project is searchable",
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=project_data)
async with AsyncTestingSessionLocal() as session:
projects, total = await project_crud.get_multi_with_filters(
session,
search="Searchable",
)
assert total >= 1
assert any(p.name == "Searchable Project" for p in projects)
@pytest.mark.asyncio
async def test_get_multi_with_filters_owner(self, async_test_db, test_owner_crud, test_project_crud):
"""Test filtering projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
projects, total = await project_crud.get_multi_with_filters(
session,
owner_id=test_owner_crud.id,
)
assert total >= 1
assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio
async def test_get_multi_with_filters_pagination(self, async_test_db, test_owner_crud):
"""Test pagination of project results."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create multiple projects
async with AsyncTestingSessionLocal() as session:
for i in range(5):
project_data = ProjectCreate(
name=f"Page Project {i}",
slug=f"page-project-{i}",
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=project_data)
# Get first page
async with AsyncTestingSessionLocal() as session:
page1, total = await project_crud.get_multi_with_filters(
session,
skip=0,
limit=2,
owner_id=test_owner_crud.id,
)
assert len(page1) <= 2
assert total >= 5
@pytest.mark.asyncio
async def test_get_multi_with_filters_sorting(self, async_test_db, test_owner_crud):
"""Test sorting project results."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
for i, name in enumerate(["Charlie", "Alice", "Bob"]):
project_data = ProjectCreate(
name=name,
slug=f"sort-project-{name.lower()}",
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=project_data)
async with AsyncTestingSessionLocal() as session:
projects, _ = await project_crud.get_multi_with_filters(
session,
sort_by="name",
sort_order="asc",
owner_id=test_owner_crud.id,
)
names = [p.name for p in projects if p.name in ["Alice", "Bob", "Charlie"]]
assert names == sorted(names)
class TestProjectSpecialMethods:
"""Tests for special project CRUD methods."""
@pytest.mark.asyncio
async def test_archive_project(self, async_test_db, test_project_crud):
"""Test archiving a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=test_project_crud.id)
assert result is not None
assert result.status == ProjectStatus.ARCHIVED
@pytest.mark.asyncio
async def test_archive_nonexistent_project(self, async_test_db):
"""Test archiving non-existent project returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await project_crud.archive_project(session, project_id=uuid.uuid4())
assert result is None
@pytest.mark.asyncio
async def test_get_projects_by_owner(self, async_test_db, test_owner_crud, test_project_crud):
"""Test getting all projects by owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
projects = await project_crud.get_projects_by_owner(
session,
owner_id=test_owner_crud.id,
)
assert len(projects) >= 1
assert all(p.owner_id == test_owner_crud.id for p in projects)
@pytest.mark.asyncio
async def test_get_projects_by_owner_with_status(self, async_test_db, test_owner_crud):
"""Test getting projects by owner filtered by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create projects with different statuses
async with AsyncTestingSessionLocal() as session:
active_project = ProjectCreate(
name="Active Owner Project",
slug="active-owner-project",
status=ProjectStatus.ACTIVE,
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=active_project)
paused_project = ProjectCreate(
name="Paused Owner Project",
slug="paused-owner-project",
status=ProjectStatus.PAUSED,
owner_id=test_owner_crud.id,
)
await project_crud.create(session, obj_in=paused_project)
async with AsyncTestingSessionLocal() as session:
projects = await project_crud.get_projects_by_owner(
session,
owner_id=test_owner_crud.id,
status=ProjectStatus.ACTIVE,
)
assert all(p.status == ProjectStatus.ACTIVE for p in projects)

View File

@@ -0,0 +1,524 @@
# tests/crud/syndarix/test_sprint_crud.py
"""
Tests for Sprint CRUD operations.
"""
import uuid
from datetime import date, timedelta
import pytest
from app.crud.syndarix import sprint as sprint_crud
from app.models.syndarix import SprintStatus
from app.schemas.syndarix import SprintCreate, SprintUpdate
class TestSprintCreate:
"""Tests for sprint creation."""
@pytest.mark.asyncio
async def test_create_sprint_success(self, async_test_db, test_project_crud):
"""Test successfully creating a sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Sprint 1",
number=1,
goal="Complete initial setup",
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.PLANNED,
planned_points=21,
)
result = await sprint_crud.create(session, obj_in=sprint_data)
assert result.id is not None
assert result.name == "Sprint 1"
assert result.number == 1
assert result.goal == "Complete initial setup"
assert result.status == SprintStatus.PLANNED
assert result.planned_points == 21
@pytest.mark.asyncio
async def test_create_sprint_minimal(self, async_test_db, test_project_crud):
"""Test creating sprint with minimal fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Minimal Sprint",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
result = await sprint_crud.create(session, obj_in=sprint_data)
assert result.name == "Minimal Sprint"
assert result.status == SprintStatus.PLANNED # Default
assert result.goal is None
assert result.planned_points is None
class TestSprintRead:
"""Tests for sprint read operations."""
@pytest.mark.asyncio
async def test_get_sprint_by_id(self, async_test_db, test_sprint_crud):
"""Test getting sprint by ID."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.get(session, id=str(test_sprint_crud.id))
assert result is not None
assert result.id == test_sprint_crud.id
@pytest.mark.asyncio
async def test_get_sprint_by_id_not_found(self, async_test_db):
"""Test getting non-existent sprint returns None."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.get(session, id=str(uuid.uuid4()))
assert result is None
@pytest.mark.asyncio
async def test_get_with_details(self, async_test_db, test_sprint_crud):
"""Test getting sprint with related details."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.get_with_details(
session,
sprint_id=test_sprint_crud.id,
)
assert result is not None
assert result["sprint"].id == test_sprint_crud.id
assert result["project_name"] is not None
assert "issue_count" in result
assert "open_issues" in result
assert "completed_issues" in result
class TestSprintUpdate:
"""Tests for sprint update operations."""
@pytest.mark.asyncio
async def test_update_sprint_basic_fields(self, async_test_db, test_sprint_crud):
"""Test updating basic sprint fields."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
sprint = await sprint_crud.get(session, id=str(test_sprint_crud.id))
update_data = SprintUpdate(
name="Updated Sprint Name",
goal="Updated goal",
)
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data)
assert result.name == "Updated Sprint Name"
assert result.goal == "Updated goal"
@pytest.mark.asyncio
async def test_update_sprint_dates(self, async_test_db, test_sprint_crud):
"""Test updating sprint dates."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint = await sprint_crud.get(session, id=str(test_sprint_crud.id))
update_data = SprintUpdate(
start_date=today + timedelta(days=1),
end_date=today + timedelta(days=21),
)
result = await sprint_crud.update(session, db_obj=sprint, obj_in=update_data)
assert result.start_date == today + timedelta(days=1)
assert result.end_date == today + timedelta(days=21)
class TestSprintLifecycle:
"""Tests for sprint lifecycle operations."""
@pytest.mark.asyncio
async def test_start_sprint(self, async_test_db, test_sprint_crud):
"""Test starting a planned sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.start_sprint(
session,
sprint_id=test_sprint_crud.id,
)
assert result is not None
assert result.status == SprintStatus.ACTIVE
@pytest.mark.asyncio
async def test_start_sprint_with_custom_date(self, async_test_db, test_project_crud):
"""Test starting sprint with custom start date."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create a planned sprint
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Start Date Sprint",
number=10,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.PLANNED,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
# Start with custom date
new_start = today + timedelta(days=2)
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.start_sprint(
session,
sprint_id=sprint_id,
start_date=new_start,
)
assert result.status == SprintStatus.ACTIVE
assert result.start_date == new_start
@pytest.mark.asyncio
async def test_start_sprint_already_active_fails(self, async_test_db, test_project_crud):
"""Test starting an already active sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create and start a sprint
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Already Active Sprint",
number=20,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.ACTIVE,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
# Try to start again
async with AsyncTestingSessionLocal() as session:
with pytest.raises(ValueError) as exc_info:
await sprint_crud.start_sprint(session, sprint_id=sprint_id)
assert "cannot start sprint" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_complete_sprint(self, async_test_db, test_project_crud):
"""Test completing an active sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create an active sprint
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Complete Me Sprint",
number=30,
start_date=today - timedelta(days=14),
end_date=today,
status=SprintStatus.ACTIVE,
planned_points=21,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
# Complete
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.complete_sprint(session, sprint_id=sprint_id)
assert result is not None
assert result.status == SprintStatus.COMPLETED
@pytest.mark.asyncio
async def test_complete_planned_sprint_fails(self, async_test_db, test_project_crud):
"""Test completing a planned sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Planned Sprint",
number=40,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.PLANNED,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
async with AsyncTestingSessionLocal() as session:
with pytest.raises(ValueError) as exc_info:
await sprint_crud.complete_sprint(session, sprint_id=sprint_id)
assert "cannot complete sprint" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_cancel_sprint(self, async_test_db, test_project_crud):
"""Test cancelling a sprint."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Cancel Me Sprint",
number=50,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.ACTIVE,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.cancel_sprint(session, sprint_id=sprint_id)
assert result is not None
assert result.status == SprintStatus.CANCELLED
@pytest.mark.asyncio
async def test_cancel_completed_sprint_fails(self, async_test_db, test_project_crud):
"""Test cancelling a completed sprint raises ValueError."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Completed Sprint",
number=60,
start_date=today - timedelta(days=14),
end_date=today,
status=SprintStatus.COMPLETED,
)
created = await sprint_crud.create(session, obj_in=sprint_data)
sprint_id = created.id
async with AsyncTestingSessionLocal() as session:
with pytest.raises(ValueError) as exc_info:
await sprint_crud.cancel_sprint(session, sprint_id=sprint_id)
assert "cannot cancel sprint" in str(exc_info.value).lower()
class TestSprintByProject:
"""Tests for getting sprints by project."""
@pytest.mark.asyncio
async def test_get_by_project(self, async_test_db, test_project_crud, test_sprint_crud):
"""Test getting sprints by project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
sprints, total = await sprint_crud.get_by_project(
session,
project_id=test_project_crud.id,
)
assert total >= 1
assert all(s.project_id == test_project_crud.id for s in sprints)
@pytest.mark.asyncio
async def test_get_by_project_with_status(self, async_test_db, test_project_crud):
"""Test filtering sprints by status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create sprints with different statuses
async with AsyncTestingSessionLocal() as session:
planned_sprint = SprintCreate(
project_id=test_project_crud.id,
name="Planned Filter Sprint",
number=70,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.PLANNED,
)
await sprint_crud.create(session, obj_in=planned_sprint)
active_sprint = SprintCreate(
project_id=test_project_crud.id,
name="Active Filter Sprint",
number=71,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.ACTIVE,
)
await sprint_crud.create(session, obj_in=active_sprint)
async with AsyncTestingSessionLocal() as session:
sprints, _ = await sprint_crud.get_by_project(
session,
project_id=test_project_crud.id,
status=SprintStatus.ACTIVE,
)
assert all(s.status == SprintStatus.ACTIVE for s in sprints)
class TestSprintActiveSprint:
"""Tests for active sprint operations."""
@pytest.mark.asyncio
async def test_get_active_sprint(self, async_test_db, test_project_crud):
"""Test getting active sprint for a project."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create an active sprint
async with AsyncTestingSessionLocal() as session:
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name="Active Sprint",
number=80,
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.ACTIVE,
)
await sprint_crud.create(session, obj_in=sprint_data)
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.get_active_sprint(
session,
project_id=test_project_crud.id,
)
assert result is not None
assert result.status == SprintStatus.ACTIVE
@pytest.mark.asyncio
async def test_get_active_sprint_none(self, async_test_db, test_project_crud):
"""Test getting active sprint when none exists."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Note: test_sprint_crud has PLANNED status by default
async with AsyncTestingSessionLocal() as session:
result = await sprint_crud.get_active_sprint(
session,
project_id=test_project_crud.id,
)
# May or may not be None depending on other tests
if result is not None:
assert result.status == SprintStatus.ACTIVE
class TestSprintNextNumber:
"""Tests for getting next sprint number."""
@pytest.mark.asyncio
async def test_get_next_sprint_number(self, async_test_db, test_project_crud):
"""Test getting next sprint number."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create sprints with numbers
async with AsyncTestingSessionLocal() as session:
for i in range(1, 4):
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name=f"Number Sprint {i}",
number=i,
start_date=today,
end_date=today + timedelta(days=14),
)
await sprint_crud.create(session, obj_in=sprint_data)
async with AsyncTestingSessionLocal() as session:
next_number = await sprint_crud.get_next_sprint_number(
session,
project_id=test_project_crud.id,
)
assert next_number >= 4
class TestSprintVelocity:
"""Tests for sprint velocity operations."""
@pytest.mark.asyncio
async def test_get_velocity(self, async_test_db, test_project_crud):
"""Test getting velocity data for completed sprints."""
_test_engine, AsyncTestingSessionLocal = async_test_db
today = date.today()
# Create completed sprints with points
async with AsyncTestingSessionLocal() as session:
for i in range(1, 4):
sprint_data = SprintCreate(
project_id=test_project_crud.id,
name=f"Velocity Sprint {i}",
number=100 + i,
start_date=today - timedelta(days=14 * i),
end_date=today - timedelta(days=14 * (i - 1)),
status=SprintStatus.COMPLETED,
planned_points=20,
completed_points=15 + i,
)
await sprint_crud.create(session, obj_in=sprint_data)
async with AsyncTestingSessionLocal() as session:
velocity_data = await sprint_crud.get_velocity(
session,
project_id=test_project_crud.id,
limit=5,
)
assert len(velocity_data) >= 1
for data in velocity_data:
assert "sprint_number" in data
assert "sprint_name" in data
assert "planned_points" in data
assert "completed_points" in data
assert "velocity" in data
class TestSprintWithIssueCounts:
"""Tests for getting sprints with issue counts."""
@pytest.mark.asyncio
async def test_get_sprints_with_issue_counts(self, async_test_db, test_project_crud, test_sprint_crud):
"""Test getting sprints with issue counts."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
results, total = await sprint_crud.get_sprints_with_issue_counts(
session,
project_id=test_project_crud.id,
)
assert total >= 1
for result in results:
assert "sprint" in result
assert "issue_count" in result
assert "open_issues" in result
assert "completed_issues" in result

View File

@@ -0,0 +1,2 @@
# tests/models/syndarix/__init__.py
"""Syndarix model unit tests."""

View File

@@ -0,0 +1,192 @@
# tests/models/syndarix/conftest.py
"""
Shared fixtures for Syndarix model tests.
"""
import uuid
from datetime import date, timedelta
import pytest
import pytest_asyncio
from app.models.syndarix import (
AgentInstance,
AgentStatus,
AgentType,
AutonomyLevel,
Issue,
IssuePriority,
IssueStatus,
Project,
ProjectStatus,
Sprint,
SprintStatus,
SyncStatus,
)
from app.models.user import User
@pytest.fixture
def sample_project_data():
"""Return sample project data for testing."""
return {
"name": "Test Project",
"slug": "test-project",
"description": "A test project for unit testing",
"autonomy_level": AutonomyLevel.MILESTONE,
"status": ProjectStatus.ACTIVE,
"settings": {"mcp_servers": ["gitea", "slack"]},
}
@pytest.fixture
def sample_agent_type_data():
"""Return sample agent type data for testing."""
return {
"name": "Backend Engineer",
"slug": "backend-engineer",
"description": "Specialized in backend development",
"expertise": ["python", "fastapi", "postgresql"],
"personality_prompt": "You are an expert backend engineer...",
"primary_model": "claude-opus-4-5-20251101",
"fallback_models": ["claude-sonnet-4-20250514"],
"model_params": {"temperature": 0.7, "max_tokens": 4096},
"mcp_servers": ["gitea", "file-system"],
"tool_permissions": {"allowed": ["*"], "denied": []},
"is_active": True,
}
@pytest.fixture
def sample_sprint_data():
"""Return sample sprint data for testing."""
today = date.today()
return {
"name": "Sprint 1",
"number": 1,
"goal": "Complete initial setup and core features",
"start_date": today,
"end_date": today + timedelta(days=14),
"status": SprintStatus.PLANNED,
"planned_points": 21,
"completed_points": 0,
}
@pytest.fixture
def sample_issue_data():
"""Return sample issue data for testing."""
return {
"title": "Implement user authentication",
"body": "As a user, I want to log in securely...",
"status": IssueStatus.OPEN,
"priority": IssuePriority.HIGH,
"labels": ["backend", "security"],
"story_points": 5,
}
@pytest_asyncio.fixture
async def test_owner(async_test_db):
"""Create a test user to be used as project owner."""
from app.core.auth import get_password_hash
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid.uuid4(),
email="owner@example.com",
password_hash=get_password_hash("TestPassword123!"),
first_name="Test",
last_name="Owner",
is_active=True,
is_superuser=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def test_project(async_test_db, test_owner, sample_project_data):
"""Create a test project in the database."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
project = Project(
id=uuid.uuid4(),
owner_id=test_owner.id,
**sample_project_data,
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
@pytest_asyncio.fixture
async def test_agent_type(async_test_db, sample_agent_type_data):
"""Create a test agent type in the database."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_type = AgentType(
id=uuid.uuid4(),
**sample_agent_type_data,
)
session.add(agent_type)
await session.commit()
await session.refresh(agent_type)
return agent_type
@pytest_asyncio.fixture
async def test_agent_instance(async_test_db, test_project, test_agent_type):
"""Create a test agent instance in the database."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
agent_instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=test_agent_type.id,
project_id=test_project.id,
status=AgentStatus.IDLE,
current_task=None,
short_term_memory={},
long_term_memory_ref=None,
session_id=None,
)
session.add(agent_instance)
await session.commit()
await session.refresh(agent_instance)
return agent_instance
@pytest_asyncio.fixture
async def test_sprint(async_test_db, test_project, sample_sprint_data):
"""Create a test sprint in the database."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
sprint = Sprint(
id=uuid.uuid4(),
project_id=test_project.id,
**sample_sprint_data,
)
session.add(sprint)
await session.commit()
await session.refresh(sprint)
return sprint
@pytest_asyncio.fixture
async def test_issue(async_test_db, test_project, sample_issue_data):
"""Create a test issue in the database."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
issue = Issue(
id=uuid.uuid4(),
project_id=test_project.id,
**sample_issue_data,
)
session.add(issue)
await session.commit()
await session.refresh(issue)
return issue

View File

@@ -0,0 +1,424 @@
# tests/models/syndarix/test_agent_instance.py
"""
Unit tests for the AgentInstance model.
"""
import uuid
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from app.models.syndarix import (
AgentInstance,
AgentStatus,
AgentType,
Project,
)
class TestAgentInstanceModel:
"""Tests for AgentInstance model creation and fields."""
def test_create_agent_instance_with_required_fields(self, db_session):
"""Test creating an agent instance with only required fields."""
# First create dependencies
project = Project(
id=uuid.uuid4(),
name="Test Project",
slug="test-project-instance",
)
db_session.add(project)
agent_type = AgentType(
id=uuid.uuid4(),
name="Test Agent",
slug="test-agent-instance",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
# Create agent instance
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
)
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(project_id=project.id).first()
assert retrieved is not None
assert retrieved.agent_type_id == agent_type.id
assert retrieved.project_id == project.id
assert retrieved.status == AgentStatus.IDLE # Default
assert retrieved.current_task is None
assert retrieved.short_term_memory == {}
assert retrieved.long_term_memory_ref is None
assert retrieved.session_id is None
assert retrieved.tasks_completed == 0
assert retrieved.tokens_used == 0
assert retrieved.cost_incurred == Decimal("0")
def test_create_agent_instance_with_all_fields(self, db_session):
"""Test creating an agent instance with all optional fields."""
# First create dependencies
project = Project(
id=uuid.uuid4(),
name="Full Project",
slug="full-project-instance",
)
db_session.add(project)
agent_type = AgentType(
id=uuid.uuid4(),
name="Full Agent",
slug="full-agent-instance",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
instance_id = uuid.uuid4()
now = datetime.now(UTC)
instance = AgentInstance(
id=instance_id,
agent_type_id=agent_type.id,
project_id=project.id,
status=AgentStatus.WORKING,
current_task="Implementing user authentication",
short_term_memory={"context": "Working on auth", "recent_files": ["auth.py"]},
long_term_memory_ref="project-123/agent-456",
session_id="session-abc-123",
last_activity_at=now,
tasks_completed=5,
tokens_used=10000,
cost_incurred=Decimal("0.5000"),
)
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance_id).first()
assert retrieved.status == AgentStatus.WORKING
assert retrieved.current_task == "Implementing user authentication"
assert retrieved.short_term_memory == {"context": "Working on auth", "recent_files": ["auth.py"]}
assert retrieved.long_term_memory_ref == "project-123/agent-456"
assert retrieved.session_id == "session-abc-123"
assert retrieved.tasks_completed == 5
assert retrieved.tokens_used == 10000
assert retrieved.cost_incurred == Decimal("0.5000")
def test_agent_instance_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Project", slug="timestamp-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Timestamp Agent",
slug="timestamp-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
)
db_session.add(instance)
db_session.commit()
assert isinstance(instance.created_at, datetime)
assert isinstance(instance.updated_at, datetime)
def test_agent_instance_string_representation(self, db_session):
"""Test the string representation of an agent instance."""
project = Project(id=uuid.uuid4(), name="Repr Project", slug="repr-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Repr Agent",
slug="repr-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance_id = uuid.uuid4()
instance = AgentInstance(
id=instance_id,
agent_type_id=agent_type.id,
project_id=project.id,
status=AgentStatus.IDLE,
)
repr_str = repr(instance)
assert str(instance_id) in repr_str
assert str(agent_type.id) in repr_str
assert str(project.id) in repr_str
assert "idle" in repr_str
class TestAgentInstanceStatus:
"""Tests for AgentInstance status transitions."""
def test_all_agent_statuses(self, db_session):
"""Test that all agent statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Project", slug="status-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Status Agent",
slug="status-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
for status in AgentStatus:
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
status=status,
)
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.status == status
def test_status_update(self, db_session):
"""Test updating agent instance status."""
project = Project(id=uuid.uuid4(), name="Update Status Project", slug="update-status-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Update Status Agent",
slug="update-status-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
status=AgentStatus.IDLE,
)
db_session.add(instance)
db_session.commit()
# Update to WORKING
instance.status = AgentStatus.WORKING
instance.current_task = "Processing feature request"
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.status == AgentStatus.WORKING
assert retrieved.current_task == "Processing feature request"
def test_terminate_agent_instance(self, db_session):
"""Test terminating an agent instance."""
project = Project(id=uuid.uuid4(), name="Terminate Project", slug="terminate-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Terminate Agent",
slug="terminate-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
status=AgentStatus.WORKING,
current_task="Working on something",
session_id="active-session",
)
db_session.add(instance)
db_session.commit()
# Terminate
now = datetime.now(UTC)
instance.status = AgentStatus.TERMINATED
instance.terminated_at = now
instance.current_task = None
instance.session_id = None
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.status == AgentStatus.TERMINATED
assert retrieved.terminated_at is not None
assert retrieved.current_task is None
assert retrieved.session_id is None
class TestAgentInstanceMetrics:
"""Tests for AgentInstance usage metrics."""
def test_increment_metrics(self, db_session):
"""Test incrementing usage metrics."""
project = Project(id=uuid.uuid4(), name="Metrics Project", slug="metrics-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Metrics Agent",
slug="metrics-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
)
db_session.add(instance)
db_session.commit()
# Record task completion
instance.tasks_completed += 1
instance.tokens_used += 1500
instance.cost_incurred += Decimal("0.0150")
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.tasks_completed == 1
assert retrieved.tokens_used == 1500
assert retrieved.cost_incurred == Decimal("0.0150")
# Record another task
retrieved.tasks_completed += 1
retrieved.tokens_used += 2500
retrieved.cost_incurred += Decimal("0.0250")
db_session.commit()
updated = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert updated.tasks_completed == 2
assert updated.tokens_used == 4000
assert updated.cost_incurred == Decimal("0.0400")
def test_large_token_count(self, db_session):
"""Test handling large token counts."""
project = Project(id=uuid.uuid4(), name="Large Tokens Project", slug="large-tokens-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Large Tokens Agent",
slug="large-tokens-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
tokens_used=10_000_000_000, # 10 billion tokens
cost_incurred=Decimal("100000.0000"), # $100,000
)
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.tokens_used == 10_000_000_000
assert retrieved.cost_incurred == Decimal("100000.0000")
class TestAgentInstanceShortTermMemory:
"""Tests for AgentInstance short-term memory JSON field."""
def test_store_complex_memory(self, db_session):
"""Test storing complex short-term memory."""
project = Project(id=uuid.uuid4(), name="Memory Project", slug="memory-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Memory Agent",
slug="memory-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
memory = {
"conversation_history": [
{"role": "user", "content": "Implement feature X"},
{"role": "assistant", "content": "I'll start by..."},
],
"recent_files": ["auth.py", "models.py", "test_auth.py"],
"decisions": {
"architecture": "Use repository pattern",
"testing": "TDD approach",
},
"blockers": [],
"context_tokens": 2048,
}
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
short_term_memory=memory,
)
db_session.add(instance)
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert retrieved.short_term_memory == memory
assert len(retrieved.short_term_memory["conversation_history"]) == 2
assert "auth.py" in retrieved.short_term_memory["recent_files"]
def test_update_memory(self, db_session):
"""Test updating short-term memory."""
project = Project(id=uuid.uuid4(), name="Update Memory Project", slug="update-memory-project-ai")
agent_type = AgentType(
id=uuid.uuid4(),
name="Update Memory Agent",
slug="update-memory-agent-ai",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
short_term_memory={"initial": "state"},
)
db_session.add(instance)
db_session.commit()
# Update memory
instance.short_term_memory = {"updated": "state", "new_key": "new_value"}
db_session.commit()
retrieved = db_session.query(AgentInstance).filter_by(id=instance.id).first()
assert "initial" not in retrieved.short_term_memory
assert retrieved.short_term_memory["updated"] == "state"
assert retrieved.short_term_memory["new_key"] == "new_value"

View File

@@ -0,0 +1,315 @@
# tests/models/syndarix/test_agent_type.py
"""
Unit tests for the AgentType model.
"""
import uuid
from datetime import datetime
import pytest
from sqlalchemy.exc import IntegrityError
from app.models.syndarix import AgentType
class TestAgentTypeModel:
"""Tests for AgentType model creation and fields."""
def test_create_agent_type_with_required_fields(self, db_session):
"""Test creating an agent type with only required fields."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Test Agent",
slug="test-agent",
personality_prompt="You are a helpful assistant.",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="test-agent").first()
assert retrieved is not None
assert retrieved.name == "Test Agent"
assert retrieved.slug == "test-agent"
assert retrieved.personality_prompt == "You are a helpful assistant."
assert retrieved.primary_model == "claude-opus-4-5-20251101"
assert retrieved.is_active is True # Default
assert retrieved.expertise == [] # Default empty list
assert retrieved.fallback_models == [] # Default empty list
assert retrieved.model_params == {} # Default empty dict
assert retrieved.mcp_servers == [] # Default empty list
assert retrieved.tool_permissions == {} # Default empty dict
def test_create_agent_type_with_all_fields(self, db_session):
"""Test creating an agent type with all optional fields."""
agent_type_id = uuid.uuid4()
agent_type = AgentType(
id=agent_type_id,
name="Full Agent Type",
slug="full-agent-type",
description="A fully configured agent type",
expertise=["python", "fastapi", "testing"],
personality_prompt="You are an expert Python developer...",
primary_model="claude-opus-4-5-20251101",
fallback_models=["claude-sonnet-4-20250514", "gpt-4o"],
model_params={"temperature": 0.7, "max_tokens": 4096},
mcp_servers=["gitea", "file-system", "slack"],
tool_permissions={"allowed": ["*"], "denied": ["dangerous_tool"]},
is_active=True,
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(id=agent_type_id).first()
assert retrieved.name == "Full Agent Type"
assert retrieved.description == "A fully configured agent type"
assert retrieved.expertise == ["python", "fastapi", "testing"]
assert retrieved.fallback_models == ["claude-sonnet-4-20250514", "gpt-4o"]
assert retrieved.model_params == {"temperature": 0.7, "max_tokens": 4096}
assert retrieved.mcp_servers == ["gitea", "file-system", "slack"]
assert retrieved.tool_permissions == {"allowed": ["*"], "denied": ["dangerous_tool"]}
assert retrieved.is_active is True
def test_agent_type_unique_slug_constraint(self, db_session):
"""Test that agent types cannot have duplicate slugs."""
agent_type1 = AgentType(
id=uuid.uuid4(),
name="Agent One",
slug="duplicate-agent-slug",
personality_prompt="First agent",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type1)
db_session.commit()
agent_type2 = AgentType(
id=uuid.uuid4(),
name="Agent Two",
slug="duplicate-agent-slug", # Same slug
personality_prompt="Second agent",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type2)
with pytest.raises(IntegrityError):
db_session.commit()
db_session.rollback()
def test_agent_type_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Timestamp Agent",
slug="timestamp-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="timestamp-agent").first()
assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime)
def test_agent_type_update(self, db_session):
"""Test updating agent type fields."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Original Agent",
slug="original-agent",
personality_prompt="Original prompt",
primary_model="claude-opus-4-5-20251101",
is_active=True,
)
db_session.add(agent_type)
db_session.commit()
original_created_at = agent_type.created_at
# Update fields
agent_type.name = "Updated Agent"
agent_type.is_active = False
agent_type.expertise = ["new", "skills"]
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="original-agent").first()
assert retrieved.name == "Updated Agent"
assert retrieved.is_active is False
assert retrieved.expertise == ["new", "skills"]
assert retrieved.created_at == original_created_at
assert retrieved.updated_at > original_created_at
def test_agent_type_delete(self, db_session):
"""Test deleting an agent type."""
agent_type_id = uuid.uuid4()
agent_type = AgentType(
id=agent_type_id,
name="Delete Me",
slug="delete-me-agent",
personality_prompt="Delete test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
db_session.delete(agent_type)
db_session.commit()
deleted = db_session.query(AgentType).filter_by(id=agent_type_id).first()
assert deleted is None
def test_agent_type_string_representation(self, db_session):
"""Test the string representation of an agent type."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Repr Agent",
slug="repr-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
is_active=True,
)
assert str(agent_type) == "<AgentType Repr Agent (repr-agent) active=True>"
assert repr(agent_type) == "<AgentType Repr Agent (repr-agent) active=True>"
class TestAgentTypeJsonFields:
"""Tests for AgentType JSON fields."""
def test_complex_expertise_list(self, db_session):
"""Test storing a list of expertise areas."""
expertise = ["python", "fastapi", "sqlalchemy", "postgresql", "redis", "docker"]
agent_type = AgentType(
id=uuid.uuid4(),
name="Expert Agent",
slug="expert-agent",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
expertise=expertise,
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="expert-agent").first()
assert retrieved.expertise == expertise
assert "python" in retrieved.expertise
assert len(retrieved.expertise) == 6
def test_complex_model_params(self, db_session):
"""Test storing complex model parameters."""
model_params = {
"temperature": 0.7,
"max_tokens": 4096,
"top_p": 0.9,
"frequency_penalty": 0.1,
"presence_penalty": 0.1,
"stop_sequences": ["###", "END"],
}
agent_type = AgentType(
id=uuid.uuid4(),
name="Params Agent",
slug="params-agent",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
model_params=model_params,
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="params-agent").first()
assert retrieved.model_params == model_params
assert retrieved.model_params["temperature"] == 0.7
assert retrieved.model_params["stop_sequences"] == ["###", "END"]
def test_complex_tool_permissions(self, db_session):
"""Test storing complex tool permissions."""
tool_permissions = {
"allowed": ["file:read", "file:write", "git:commit"],
"denied": ["file:delete", "system:exec"],
"require_approval": ["git:push", "gitea:create_pr"],
"limits": {
"file:write": {"max_size_mb": 10},
"git:commit": {"require_message": True},
},
}
agent_type = AgentType(
id=uuid.uuid4(),
name="Permissions Agent",
slug="permissions-agent",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
tool_permissions=tool_permissions,
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="permissions-agent").first()
assert retrieved.tool_permissions == tool_permissions
assert "file:read" in retrieved.tool_permissions["allowed"]
assert retrieved.tool_permissions["limits"]["file:write"]["max_size_mb"] == 10
def test_empty_json_fields_default(self, db_session):
"""Test that JSON fields default to empty structures."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Empty JSON Agent",
slug="empty-json-agent",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="empty-json-agent").first()
assert retrieved.expertise == []
assert retrieved.fallback_models == []
assert retrieved.model_params == {}
assert retrieved.mcp_servers == []
assert retrieved.tool_permissions == {}
class TestAgentTypeIsActive:
"""Tests for AgentType is_active field."""
def test_default_is_active(self, db_session):
"""Test that is_active defaults to True."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Default Active",
slug="default-active",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(agent_type)
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="default-active").first()
assert retrieved.is_active is True
def test_deactivate_agent_type(self, db_session):
"""Test deactivating an agent type."""
agent_type = AgentType(
id=uuid.uuid4(),
name="Deactivate Me",
slug="deactivate-me",
personality_prompt="Prompt",
primary_model="claude-opus-4-5-20251101",
is_active=True,
)
db_session.add(agent_type)
db_session.commit()
agent_type.is_active = False
db_session.commit()
retrieved = db_session.query(AgentType).filter_by(slug="deactivate-me").first()
assert retrieved.is_active is False

View File

@@ -0,0 +1,463 @@
# tests/models/syndarix/test_issue.py
"""
Unit tests for the Issue model.
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.models.syndarix import (
AgentInstance,
AgentType,
Issue,
IssuePriority,
IssueStatus,
Project,
Sprint,
SprintStatus,
SyncStatus,
)
class TestIssueModel:
"""Tests for Issue model creation and fields."""
def test_create_issue_with_required_fields(self, db_session):
"""Test creating an issue with only required fields."""
project = Project(
id=uuid.uuid4(),
name="Issue Project",
slug="issue-project",
)
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Test Issue",
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Test Issue").first()
assert retrieved is not None
assert retrieved.title == "Test Issue"
assert retrieved.body == "" # Default empty string
assert retrieved.status == IssueStatus.OPEN # Default
assert retrieved.priority == IssuePriority.MEDIUM # Default
assert retrieved.labels == [] # Default empty list
assert retrieved.story_points is None
assert retrieved.assigned_agent_id is None
assert retrieved.human_assignee is None
assert retrieved.sprint_id is None
assert retrieved.sync_status == SyncStatus.SYNCED # Default
def test_create_issue_with_all_fields(self, db_session):
"""Test creating an issue with all optional fields."""
project = Project(
id=uuid.uuid4(),
name="Full Issue Project",
slug="full-issue-project",
)
db_session.add(project)
db_session.commit()
issue_id = uuid.uuid4()
now = datetime.now(UTC)
issue = Issue(
id=issue_id,
project_id=project.id,
title="Full Issue",
body="A complete issue with all fields set",
status=IssueStatus.IN_PROGRESS,
priority=IssuePriority.CRITICAL,
labels=["bug", "security", "urgent"],
story_points=8,
human_assignee="john.doe@example.com",
external_tracker="gitea",
external_id="gitea-123",
external_url="https://gitea.example.com/issues/123",
external_number=123,
sync_status=SyncStatus.SYNCED,
last_synced_at=now,
external_updated_at=now,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(id=issue_id).first()
assert retrieved.title == "Full Issue"
assert retrieved.body == "A complete issue with all fields set"
assert retrieved.status == IssueStatus.IN_PROGRESS
assert retrieved.priority == IssuePriority.CRITICAL
assert retrieved.labels == ["bug", "security", "urgent"]
assert retrieved.story_points == 8
assert retrieved.human_assignee == "john.doe@example.com"
assert retrieved.external_tracker == "gitea"
assert retrieved.external_id == "gitea-123"
assert retrieved.external_number == 123
assert retrieved.sync_status == SyncStatus.SYNCED
def test_issue_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(id=uuid.uuid4(), name="Timestamp Issue Project", slug="timestamp-issue-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Timestamp Issue",
)
db_session.add(issue)
db_session.commit()
assert isinstance(issue.created_at, datetime)
assert isinstance(issue.updated_at, datetime)
def test_issue_string_representation(self, db_session):
"""Test the string representation of an issue."""
project = Project(id=uuid.uuid4(), name="Repr Issue Project", slug="repr-issue-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="This is a very long issue title that should be truncated in repr",
status=IssueStatus.OPEN,
priority=IssuePriority.HIGH,
)
repr_str = repr(issue)
assert "This is a very long issue tit" in repr_str # First 30 chars
assert "open" in repr_str
assert "high" in repr_str
class TestIssueStatus:
"""Tests for Issue status field."""
def test_all_issue_statuses(self, db_session):
"""Test that all issue statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Status Issue Project", slug="status-issue-project")
db_session.add(project)
db_session.commit()
for status in IssueStatus:
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title=f"Issue {status.value}",
status=status,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(id=issue.id).first()
assert retrieved.status == status
class TestIssuePriority:
"""Tests for Issue priority field."""
def test_all_issue_priorities(self, db_session):
"""Test that all issue priorities can be stored."""
project = Project(id=uuid.uuid4(), name="Priority Issue Project", slug="priority-issue-project")
db_session.add(project)
db_session.commit()
for priority in IssuePriority:
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title=f"Issue {priority.value}",
priority=priority,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(id=issue.id).first()
assert retrieved.priority == priority
class TestIssueSyncStatus:
"""Tests for Issue sync status field."""
def test_all_sync_statuses(self, db_session):
"""Test that all sync statuses can be stored."""
project = Project(id=uuid.uuid4(), name="Sync Issue Project", slug="sync-issue-project")
db_session.add(project)
db_session.commit()
for sync_status in SyncStatus:
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title=f"Issue {sync_status.value}",
external_tracker="gitea",
external_id=f"ext-{sync_status.value}",
sync_status=sync_status,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(id=issue.id).first()
assert retrieved.sync_status == sync_status
class TestIssueLabels:
"""Tests for Issue labels JSON field."""
def test_store_labels(self, db_session):
"""Test storing labels list."""
project = Project(id=uuid.uuid4(), name="Labels Issue Project", slug="labels-issue-project")
db_session.add(project)
db_session.commit()
labels = ["bug", "security", "high-priority", "needs-review"]
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Issue with Labels",
labels=labels,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Issue with Labels").first()
assert retrieved.labels == labels
assert "security" in retrieved.labels
def test_update_labels(self, db_session):
"""Test updating labels."""
project = Project(id=uuid.uuid4(), name="Update Labels Project", slug="update-labels-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Update Labels Issue",
labels=["initial"],
)
db_session.add(issue)
db_session.commit()
issue.labels = ["updated", "new-label"]
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Update Labels Issue").first()
assert "initial" not in retrieved.labels
assert "updated" in retrieved.labels
class TestIssueAssignment:
"""Tests for Issue assignment fields."""
def test_assign_to_agent(self, db_session):
"""Test assigning an issue to an agent."""
project = Project(id=uuid.uuid4(), name="Agent Assign Project", slug="agent-assign-project")
agent_type = AgentType(
id=uuid.uuid4(),
name="Test Agent Type",
slug="test-agent-type-assign",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
db_session.add(project)
db_session.add(agent_type)
db_session.commit()
agent_instance = AgentInstance(
id=uuid.uuid4(),
agent_type_id=agent_type.id,
project_id=project.id,
)
db_session.add(agent_instance)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Agent Assignment Issue",
assigned_agent_id=agent_instance.id,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Agent Assignment Issue").first()
assert retrieved.assigned_agent_id == agent_instance.id
assert retrieved.human_assignee is None
def test_assign_to_human(self, db_session):
"""Test assigning an issue to a human."""
project = Project(id=uuid.uuid4(), name="Human Assign Project", slug="human-assign-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Human Assignment Issue",
human_assignee="developer@example.com",
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Human Assignment Issue").first()
assert retrieved.human_assignee == "developer@example.com"
assert retrieved.assigned_agent_id is None
class TestIssueSprintAssociation:
"""Tests for Issue sprint association."""
def test_assign_issue_to_sprint(self, db_session):
"""Test assigning an issue to a sprint."""
project = Project(id=uuid.uuid4(), name="Sprint Assign Project", slug="sprint-assign-project")
db_session.add(project)
db_session.commit()
from datetime import date
sprint = Sprint(
id=uuid.uuid4(),
project_id=project.id,
name="Sprint 1",
number=1,
start_date=date.today(),
end_date=date.today() + timedelta(days=14),
status=SprintStatus.ACTIVE,
)
db_session.add(sprint)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Sprint Issue",
sprint_id=sprint.id,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Sprint Issue").first()
assert retrieved.sprint_id == sprint.id
class TestIssueExternalTracker:
"""Tests for Issue external tracker integration."""
def test_gitea_integration(self, db_session):
"""Test Gitea external tracker fields."""
project = Project(id=uuid.uuid4(), name="Gitea Project", slug="gitea-project")
db_session.add(project)
db_session.commit()
now = datetime.now(UTC)
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Gitea Synced Issue",
external_tracker="gitea",
external_id="abc123xyz",
external_url="https://gitea.example.com/org/repo/issues/42",
external_number=42,
sync_status=SyncStatus.SYNCED,
last_synced_at=now,
external_updated_at=now,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Gitea Synced Issue").first()
assert retrieved.external_tracker == "gitea"
assert retrieved.external_id == "abc123xyz"
assert retrieved.external_number == 42
assert "/issues/42" in retrieved.external_url
def test_github_integration(self, db_session):
"""Test GitHub external tracker fields."""
project = Project(id=uuid.uuid4(), name="GitHub Project", slug="github-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="GitHub Synced Issue",
external_tracker="github",
external_id="gh-12345",
external_url="https://github.com/org/repo/issues/100",
external_number=100,
)
db_session.add(issue)
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="GitHub Synced Issue").first()
assert retrieved.external_tracker == "github"
assert retrieved.external_number == 100
class TestIssueLifecycle:
"""Tests for Issue lifecycle operations."""
def test_close_issue(self, db_session):
"""Test closing an issue."""
project = Project(id=uuid.uuid4(), name="Close Issue Project", slug="close-issue-project")
db_session.add(project)
db_session.commit()
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Issue to Close",
status=IssueStatus.OPEN,
)
db_session.add(issue)
db_session.commit()
# Close the issue
now = datetime.now(UTC)
issue.status = IssueStatus.CLOSED
issue.closed_at = now
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Issue to Close").first()
assert retrieved.status == IssueStatus.CLOSED
assert retrieved.closed_at is not None
def test_reopen_issue(self, db_session):
"""Test reopening a closed issue."""
project = Project(id=uuid.uuid4(), name="Reopen Issue Project", slug="reopen-issue-project")
db_session.add(project)
db_session.commit()
now = datetime.now(UTC)
issue = Issue(
id=uuid.uuid4(),
project_id=project.id,
title="Issue to Reopen",
status=IssueStatus.CLOSED,
closed_at=now,
)
db_session.add(issue)
db_session.commit()
# Reopen the issue
issue.status = IssueStatus.OPEN
issue.closed_at = None
db_session.commit()
retrieved = db_session.query(Issue).filter_by(title="Issue to Reopen").first()
assert retrieved.status == IssueStatus.OPEN
assert retrieved.closed_at is None

View File

@@ -0,0 +1,262 @@
# tests/models/syndarix/test_project.py
"""
Unit tests for the Project model.
"""
import uuid
from datetime import datetime
import pytest
from sqlalchemy.exc import IntegrityError
from app.models.syndarix import (
AutonomyLevel,
Project,
ProjectStatus,
)
class TestProjectModel:
"""Tests for Project model creation and fields."""
def test_create_project_with_required_fields(self, db_session):
"""Test creating a project with only required fields."""
project = Project(
id=uuid.uuid4(),
name="Test Project",
slug="test-project",
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="test-project").first()
assert retrieved is not None
assert retrieved.name == "Test Project"
assert retrieved.slug == "test-project"
assert retrieved.autonomy_level == AutonomyLevel.MILESTONE # Default
assert retrieved.status == ProjectStatus.ACTIVE # Default
assert retrieved.settings == {} # Default empty dict
assert retrieved.description is None
assert retrieved.owner_id is None
def test_create_project_with_all_fields(self, db_session):
"""Test creating a project with all optional fields."""
project_id = uuid.uuid4()
owner_id = uuid.uuid4()
project = Project(
id=project_id,
name="Full Project",
slug="full-project",
description="A complete project with all fields",
autonomy_level=AutonomyLevel.AUTONOMOUS,
status=ProjectStatus.PAUSED,
settings={"webhook_url": "https://example.com/webhook"},
owner_id=owner_id,
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(id=project_id).first()
assert retrieved.name == "Full Project"
assert retrieved.slug == "full-project"
assert retrieved.description == "A complete project with all fields"
assert retrieved.autonomy_level == AutonomyLevel.AUTONOMOUS
assert retrieved.status == ProjectStatus.PAUSED
assert retrieved.settings == {"webhook_url": "https://example.com/webhook"}
assert retrieved.owner_id == owner_id
def test_project_unique_slug_constraint(self, db_session):
"""Test that projects cannot have duplicate slugs."""
project1 = Project(
id=uuid.uuid4(),
name="Project One",
slug="duplicate-slug",
)
db_session.add(project1)
db_session.commit()
project2 = Project(
id=uuid.uuid4(),
name="Project Two",
slug="duplicate-slug", # Same slug
)
db_session.add(project2)
with pytest.raises(IntegrityError):
db_session.commit()
db_session.rollback()
def test_project_timestamps(self, db_session):
"""Test that timestamps are automatically set."""
project = Project(
id=uuid.uuid4(),
name="Timestamp Project",
slug="timestamp-project",
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="timestamp-project").first()
assert isinstance(retrieved.created_at, datetime)
assert isinstance(retrieved.updated_at, datetime)
def test_project_update(self, db_session):
"""Test updating project fields."""
project = Project(
id=uuid.uuid4(),
name="Original Name",
slug="original-slug",
status=ProjectStatus.ACTIVE,
)
db_session.add(project)
db_session.commit()
original_created_at = project.created_at
# Update fields
project.name = "Updated Name"
project.status = ProjectStatus.COMPLETED
project.settings = {"new_setting": "value"}
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="original-slug").first()
assert retrieved.name == "Updated Name"
assert retrieved.status == ProjectStatus.COMPLETED
assert retrieved.settings == {"new_setting": "value"}
assert retrieved.created_at == original_created_at
assert retrieved.updated_at > original_created_at
def test_project_delete(self, db_session):
"""Test deleting a project."""
project_id = uuid.uuid4()
project = Project(
id=project_id,
name="Delete Me",
slug="delete-me",
)
db_session.add(project)
db_session.commit()
db_session.delete(project)
db_session.commit()
deleted = db_session.query(Project).filter_by(id=project_id).first()
assert deleted is None
def test_project_string_representation(self, db_session):
"""Test the string representation of a project."""
project = Project(
id=uuid.uuid4(),
name="Repr Project",
slug="repr-project",
status=ProjectStatus.ACTIVE,
)
assert str(project) == "<Project Repr Project (repr-project) status=active>"
assert repr(project) == "<Project Repr Project (repr-project) status=active>"
class TestProjectEnums:
"""Tests for Project enum fields."""
def test_all_autonomy_levels(self, db_session):
"""Test that all autonomy levels can be stored."""
for level in AutonomyLevel:
project = Project(
id=uuid.uuid4(),
name=f"Project {level.value}",
slug=f"project-{level.value}",
autonomy_level=level,
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-{level.value}").first()
assert retrieved.autonomy_level == level
def test_all_project_statuses(self, db_session):
"""Test that all project statuses can be stored."""
for status in ProjectStatus:
project = Project(
id=uuid.uuid4(),
name=f"Project {status.value}",
slug=f"project-status-{status.value}",
status=status,
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug=f"project-status-{status.value}").first()
assert retrieved.status == status
class TestProjectSettings:
"""Tests for Project JSON settings field."""
def test_complex_json_settings(self, db_session):
"""Test storing complex JSON in settings."""
complex_settings = {
"mcp_servers": ["gitea", "slack", "file-system"],
"webhook_urls": {
"on_issue_created": "https://example.com/issue",
"on_sprint_completed": "https://example.com/sprint",
},
"notification_settings": {
"email": True,
"slack_channel": "#syndarix-updates",
},
"tags": ["important", "client-a"],
}
project = Project(
id=uuid.uuid4(),
name="Complex Settings Project",
slug="complex-settings",
settings=complex_settings,
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="complex-settings").first()
assert retrieved.settings == complex_settings
assert retrieved.settings["mcp_servers"] == ["gitea", "slack", "file-system"]
assert retrieved.settings["webhook_urls"]["on_issue_created"] == "https://example.com/issue"
assert "important" in retrieved.settings["tags"]
def test_empty_settings(self, db_session):
"""Test that empty settings defaults correctly."""
project = Project(
id=uuid.uuid4(),
name="Empty Settings",
slug="empty-settings",
)
db_session.add(project)
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="empty-settings").first()
assert retrieved.settings == {}
def test_update_settings(self, db_session):
"""Test updating settings field."""
project = Project(
id=uuid.uuid4(),
name="Update Settings",
slug="update-settings",
settings={"initial": "value"},
)
db_session.add(project)
db_session.commit()
# Update settings
project.settings = {"updated": "new_value", "additional": "data"}
db_session.commit()
retrieved = db_session.query(Project).filter_by(slug="update-settings").first()
assert retrieved.settings == {"updated": "new_value", "additional": "data"}

View File

@@ -0,0 +1,507 @@
# 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.completed_points 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,
completed_points=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.completed_points == 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.completed_points = 18
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Sprint to Complete").first()
assert retrieved.status == SprintStatus.COMPLETED
assert retrieved.completed_points == 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,
completed_points=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.completed_points == 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,
completed_points=18,
)
db_session.add(sprint)
db_session.commit()
retrieved = db_session.query(Sprint).filter_by(name="Velocity Sprint").first()
# Calculate velocity
velocity = retrieved.completed_points / retrieved.planned_points
assert velocity == 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,
completed_points=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.completed_points > 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

View File

@@ -0,0 +1,2 @@
# tests/schemas/syndarix/__init__.py
"""Syndarix schema validation tests."""

View File

@@ -0,0 +1,68 @@
# tests/schemas/syndarix/conftest.py
"""
Shared fixtures for Syndarix schema tests.
"""
import uuid
from datetime import date, timedelta
import pytest
@pytest.fixture
def valid_uuid():
"""Return a valid UUID for testing."""
return uuid.uuid4()
@pytest.fixture
def valid_project_data():
"""Return valid project data for schema testing."""
return {
"name": "Test Project",
"slug": "test-project",
"description": "A test project",
}
@pytest.fixture
def valid_agent_type_data():
"""Return valid agent type data for schema testing."""
return {
"name": "Backend Engineer",
"slug": "backend-engineer",
"personality_prompt": "You are an expert backend engineer.",
"primary_model": "claude-opus-4-5-20251101",
}
@pytest.fixture
def valid_sprint_data(valid_uuid):
"""Return valid sprint data for schema testing."""
today = date.today()
return {
"project_id": valid_uuid,
"name": "Sprint 1",
"number": 1,
"start_date": today,
"end_date": today + timedelta(days=14),
}
@pytest.fixture
def valid_issue_data(valid_uuid):
"""Return valid issue data for schema testing."""
return {
"project_id": valid_uuid,
"title": "Test Issue",
"body": "Issue description",
}
@pytest.fixture
def valid_agent_instance_data(valid_uuid):
"""Return valid agent instance data for schema testing."""
return {
"agent_type_id": valid_uuid,
"project_id": valid_uuid,
}

View File

@@ -0,0 +1,244 @@
# tests/schemas/syndarix/test_agent_instance_schemas.py
"""
Tests for AgentInstance schema validation.
"""
import uuid
from decimal import Decimal
import pytest
from pydantic import ValidationError
from app.schemas.syndarix import (
AgentInstanceCreate,
AgentInstanceUpdate,
AgentStatus,
)
class TestAgentInstanceCreateValidation:
"""Tests for AgentInstanceCreate schema validation."""
def test_valid_agent_instance_create(self, valid_agent_instance_data):
"""Test creating agent instance with valid data."""
instance = AgentInstanceCreate(**valid_agent_instance_data)
assert instance.agent_type_id is not None
assert instance.project_id is not None
def test_agent_instance_create_defaults(self, valid_agent_instance_data):
"""Test that defaults are applied correctly."""
instance = AgentInstanceCreate(**valid_agent_instance_data)
assert instance.status == AgentStatus.IDLE
assert instance.current_task is None
assert instance.short_term_memory == {}
assert instance.long_term_memory_ref is None
assert instance.session_id is None
def test_agent_instance_create_with_all_fields(self, valid_uuid):
"""Test creating agent instance with all optional fields."""
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
status=AgentStatus.WORKING,
current_task="Processing feature request",
short_term_memory={"context": "working"},
long_term_memory_ref="project-123/agent-456",
session_id="session-abc",
)
assert instance.status == AgentStatus.WORKING
assert instance.current_task == "Processing feature request"
assert instance.short_term_memory == {"context": "working"}
assert instance.long_term_memory_ref == "project-123/agent-456"
assert instance.session_id == "session-abc"
def test_agent_instance_create_agent_type_id_required(self, valid_uuid):
"""Test that agent_type_id is required."""
with pytest.raises(ValidationError) as exc_info:
AgentInstanceCreate(
project_id=valid_uuid,
)
errors = exc_info.value.errors()
assert any("agent_type_id" in str(e).lower() for e in errors)
def test_agent_instance_create_project_id_required(self, valid_uuid):
"""Test that project_id is required."""
with pytest.raises(ValidationError) as exc_info:
AgentInstanceCreate(
agent_type_id=valid_uuid,
)
errors = exc_info.value.errors()
assert any("project_id" in str(e).lower() for e in errors)
class TestAgentInstanceUpdateValidation:
"""Tests for AgentInstanceUpdate schema validation."""
def test_agent_instance_update_partial(self):
"""Test updating only some fields."""
update = AgentInstanceUpdate(
status=AgentStatus.WORKING,
)
assert update.status == AgentStatus.WORKING
assert update.current_task is None
assert update.short_term_memory is None
def test_agent_instance_update_all_fields(self):
"""Test updating all fields."""
from datetime import UTC, datetime
now = datetime.now(UTC)
update = AgentInstanceUpdate(
status=AgentStatus.WORKING,
current_task="New task",
short_term_memory={"new": "context"},
long_term_memory_ref="new-ref",
session_id="new-session",
last_activity_at=now,
tasks_completed=5,
tokens_used=10000,
cost_incurred=Decimal("1.5000"),
)
assert update.status == AgentStatus.WORKING
assert update.current_task == "New task"
assert update.tasks_completed == 5
assert update.tokens_used == 10000
assert update.cost_incurred == Decimal("1.5000")
def test_agent_instance_update_tasks_completed_negative_fails(self):
"""Test that negative tasks_completed raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgentInstanceUpdate(tasks_completed=-1)
errors = exc_info.value.errors()
assert any("tasks_completed" in str(e).lower() for e in errors)
def test_agent_instance_update_tokens_used_negative_fails(self):
"""Test that negative tokens_used raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgentInstanceUpdate(tokens_used=-1)
errors = exc_info.value.errors()
assert any("tokens_used" in str(e).lower() for e in errors)
def test_agent_instance_update_cost_incurred_negative_fails(self):
"""Test that negative cost_incurred raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgentInstanceUpdate(cost_incurred=Decimal("-0.01"))
errors = exc_info.value.errors()
assert any("cost_incurred" in str(e).lower() for e in errors)
class TestAgentStatusEnum:
"""Tests for AgentStatus enum validation."""
def test_valid_agent_statuses(self, valid_uuid):
"""Test all valid agent statuses."""
for status in AgentStatus:
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
status=status,
)
assert instance.status == status
def test_invalid_agent_status(self, valid_uuid):
"""Test that invalid agent status raises ValidationError."""
with pytest.raises(ValidationError):
AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
status="invalid", # type: ignore
)
class TestAgentInstanceShortTermMemory:
"""Tests for AgentInstance short_term_memory validation."""
def test_short_term_memory_empty_dict(self, valid_uuid):
"""Test that empty short_term_memory is valid."""
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
short_term_memory={},
)
assert instance.short_term_memory == {}
def test_short_term_memory_complex(self, valid_uuid):
"""Test complex short_term_memory structure."""
memory = {
"conversation_history": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
],
"recent_files": ["file1.py", "file2.py"],
"decisions": {"key": "value"},
"context_tokens": 1024,
}
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
short_term_memory=memory,
)
assert instance.short_term_memory == memory
class TestAgentInstanceStringFields:
"""Tests for AgentInstance string field validation."""
def test_long_term_memory_ref_max_length(self, valid_uuid):
"""Test long_term_memory_ref max length."""
long_ref = "a" * 500 # Max length is 500
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
long_term_memory_ref=long_ref,
)
assert instance.long_term_memory_ref == long_ref
def test_long_term_memory_ref_too_long(self, valid_uuid):
"""Test that too long long_term_memory_ref raises ValidationError."""
too_long = "a" * 501
with pytest.raises(ValidationError) as exc_info:
AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
long_term_memory_ref=too_long,
)
errors = exc_info.value.errors()
assert any("long_term_memory_ref" in str(e).lower() for e in errors)
def test_session_id_max_length(self, valid_uuid):
"""Test session_id max length."""
long_session = "a" * 255 # Max length is 255
instance = AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
session_id=long_session,
)
assert instance.session_id == long_session
def test_session_id_too_long(self, valid_uuid):
"""Test that too long session_id raises ValidationError."""
too_long = "a" * 256
with pytest.raises(ValidationError) as exc_info:
AgentInstanceCreate(
agent_type_id=valid_uuid,
project_id=valid_uuid,
session_id=too_long,
)
errors = exc_info.value.errors()
assert any("session_id" in str(e).lower() for e in errors)

View File

@@ -0,0 +1,318 @@
# tests/schemas/syndarix/test_agent_type_schemas.py
"""
Tests for AgentType schema validation.
"""
import pytest
from pydantic import ValidationError
from app.schemas.syndarix import (
AgentTypeCreate,
AgentTypeUpdate,
)
class TestAgentTypeCreateValidation:
"""Tests for AgentTypeCreate schema validation."""
def test_valid_agent_type_create(self, valid_agent_type_data):
"""Test creating agent type with valid data."""
agent_type = AgentTypeCreate(**valid_agent_type_data)
assert agent_type.name == "Backend Engineer"
assert agent_type.slug == "backend-engineer"
assert agent_type.personality_prompt == "You are an expert backend engineer."
assert agent_type.primary_model == "claude-opus-4-5-20251101"
def test_agent_type_create_defaults(self, valid_agent_type_data):
"""Test that defaults are applied correctly."""
agent_type = AgentTypeCreate(**valid_agent_type_data)
assert agent_type.expertise == []
assert agent_type.fallback_models == []
assert agent_type.model_params == {}
assert agent_type.mcp_servers == []
assert agent_type.tool_permissions == {}
assert agent_type.is_active is True
def test_agent_type_create_with_all_fields(self, valid_agent_type_data):
"""Test creating agent type with all optional fields."""
agent_type = AgentTypeCreate(
**valid_agent_type_data,
description="Detailed description",
expertise=["python", "fastapi"],
fallback_models=["claude-sonnet-4-20250514"],
model_params={"temperature": 0.7},
mcp_servers=["gitea", "slack"],
tool_permissions={"allowed": ["*"]},
is_active=True,
)
assert agent_type.description == "Detailed description"
assert agent_type.expertise == ["python", "fastapi"]
assert agent_type.fallback_models == ["claude-sonnet-4-20250514"]
def test_agent_type_create_name_empty_fails(self):
"""Test that empty name raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgentTypeCreate(
name="",
slug="valid-slug",
personality_prompt="Test prompt",
primary_model="claude-opus-4-5-20251101",
)
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_agent_type_create_name_stripped(self):
"""Test that name is stripped of whitespace."""
agent_type = AgentTypeCreate(
name=" Padded Name ",
slug="padded-slug",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
assert agent_type.name == "Padded Name"
def test_agent_type_create_personality_prompt_required(self):
"""Test that personality_prompt is required."""
with pytest.raises(ValidationError) as exc_info:
AgentTypeCreate(
name="Test Agent",
slug="test-agent",
primary_model="claude-opus-4-5-20251101",
)
errors = exc_info.value.errors()
assert any("personality_prompt" in str(e).lower() for e in errors)
def test_agent_type_create_primary_model_required(self):
"""Test that primary_model is required."""
with pytest.raises(ValidationError) as exc_info:
AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test prompt",
)
errors = exc_info.value.errors()
assert any("primary_model" in str(e).lower() for e in errors)
class TestAgentTypeSlugValidation:
"""Tests for AgentType slug validation."""
def test_valid_slugs(self):
"""Test various valid slug formats."""
valid_slugs = [
"simple",
"with-hyphens",
"has123numbers",
]
for slug in valid_slugs:
agent_type = AgentTypeCreate(
name="Test Agent",
slug=slug,
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
assert agent_type.slug == slug
def test_invalid_slug_uppercase(self):
"""Test that uppercase letters in slug raise ValidationError."""
with pytest.raises(ValidationError):
AgentTypeCreate(
name="Test Agent",
slug="Invalid-Uppercase",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
def test_invalid_slug_special_chars(self):
"""Test that special characters raise ValidationError."""
with pytest.raises(ValidationError):
AgentTypeCreate(
name="Test Agent",
slug="has_underscore",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
)
class TestAgentTypeExpertiseValidation:
"""Tests for AgentType expertise validation."""
def test_expertise_normalized_lowercase(self):
"""Test that expertise is normalized to lowercase."""
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
expertise=["Python", "FastAPI", "PostgreSQL"],
)
assert agent_type.expertise == ["python", "fastapi", "postgresql"]
def test_expertise_stripped(self):
"""Test that expertise items are stripped."""
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
expertise=[" python ", " fastapi "],
)
assert agent_type.expertise == ["python", "fastapi"]
def test_expertise_empty_strings_removed(self):
"""Test that empty expertise strings are removed."""
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
expertise=["python", "", " ", "fastapi"],
)
assert agent_type.expertise == ["python", "fastapi"]
class TestAgentTypeMcpServersValidation:
"""Tests for AgentType MCP servers validation."""
def test_mcp_servers_stripped(self):
"""Test that MCP server names are stripped."""
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
mcp_servers=[" gitea ", " slack "],
)
assert agent_type.mcp_servers == ["gitea", "slack"]
def test_mcp_servers_empty_strings_removed(self):
"""Test that empty MCP server strings are removed."""
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
mcp_servers=["gitea", "", " ", "slack"],
)
assert agent_type.mcp_servers == ["gitea", "slack"]
class TestAgentTypeUpdateValidation:
"""Tests for AgentTypeUpdate schema validation."""
def test_agent_type_update_partial(self):
"""Test updating only some fields."""
update = AgentTypeUpdate(
name="Updated Name",
)
assert update.name == "Updated Name"
assert update.slug is None
assert update.description is None
assert update.expertise is None
def test_agent_type_update_all_fields(self):
"""Test updating all fields."""
update = AgentTypeUpdate(
name="Updated Name",
slug="updated-slug",
description="Updated description",
expertise=["new-skill"],
personality_prompt="Updated prompt",
primary_model="new-model",
fallback_models=["fallback-1"],
model_params={"temp": 0.5},
mcp_servers=["server-1"],
tool_permissions={"key": "value"},
is_active=False,
)
assert update.name == "Updated Name"
assert update.slug == "updated-slug"
assert update.is_active is False
def test_agent_type_update_empty_name_fails(self):
"""Test that empty name in update raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgentTypeUpdate(name="")
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_agent_type_update_slug_validation(self):
"""Test that slug validation applies to updates."""
with pytest.raises(ValidationError):
AgentTypeUpdate(slug="Invalid-Slug")
def test_agent_type_update_expertise_normalized(self):
"""Test that expertise is normalized in updates."""
update = AgentTypeUpdate(
expertise=["Python", "FastAPI"],
)
assert update.expertise == ["python", "fastapi"]
class TestAgentTypeJsonFields:
"""Tests for AgentType JSON field validation."""
def test_model_params_complex(self):
"""Test complex model_params structure."""
params = {
"temperature": 0.7,
"max_tokens": 4096,
"top_p": 0.9,
"stop_sequences": ["###"],
}
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
model_params=params,
)
assert agent_type.model_params == params
def test_tool_permissions_complex(self):
"""Test complex tool_permissions structure."""
permissions = {
"allowed": ["file:read", "git:commit"],
"denied": ["file:delete"],
"require_approval": ["git:push"],
}
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
tool_permissions=permissions,
)
assert agent_type.tool_permissions == permissions
def test_fallback_models_list(self):
"""Test fallback_models as a list."""
models = ["claude-sonnet-4-20250514", "gpt-4o", "mistral-large"]
agent_type = AgentTypeCreate(
name="Test Agent",
slug="test-agent",
personality_prompt="Test",
primary_model="claude-opus-4-5-20251101",
fallback_models=models,
)
assert agent_type.fallback_models == models

View File

@@ -0,0 +1,342 @@
# tests/schemas/syndarix/test_issue_schemas.py
"""
Tests for Issue schema validation.
"""
import uuid
import pytest
from pydantic import ValidationError
from app.schemas.syndarix import (
IssueAssign,
IssueCreate,
IssuePriority,
IssueStatus,
IssueUpdate,
SyncStatus,
)
class TestIssueCreateValidation:
"""Tests for IssueCreate schema validation."""
def test_valid_issue_create(self, valid_issue_data):
"""Test creating issue with valid data."""
issue = IssueCreate(**valid_issue_data)
assert issue.title == "Test Issue"
assert issue.body == "Issue description"
def test_issue_create_defaults(self, valid_issue_data):
"""Test that defaults are applied correctly."""
issue = IssueCreate(**valid_issue_data)
assert issue.status == IssueStatus.OPEN
assert issue.priority == IssuePriority.MEDIUM
assert issue.labels == []
assert issue.story_points is None
assert issue.assigned_agent_id is None
assert issue.human_assignee is None
assert issue.sprint_id is None
def test_issue_create_with_all_fields(self, valid_uuid):
"""Test creating issue with all optional fields."""
agent_id = uuid.uuid4()
sprint_id = uuid.uuid4()
issue = IssueCreate(
project_id=valid_uuid,
title="Full Issue",
body="Detailed body",
status=IssueStatus.IN_PROGRESS,
priority=IssuePriority.HIGH,
labels=["bug", "security"],
story_points=5,
assigned_agent_id=agent_id,
sprint_id=sprint_id,
external_tracker="gitea",
external_id="gitea-123",
external_url="https://gitea.example.com/issues/123",
external_number=123,
)
assert issue.status == IssueStatus.IN_PROGRESS
assert issue.priority == IssuePriority.HIGH
assert issue.labels == ["bug", "security"]
assert issue.story_points == 5
assert issue.external_tracker == "gitea"
def test_issue_create_title_empty_fails(self, valid_uuid):
"""Test that empty title raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueCreate(
project_id=valid_uuid,
title="",
)
errors = exc_info.value.errors()
assert any("title" in str(e) for e in errors)
def test_issue_create_title_whitespace_only_fails(self, valid_uuid):
"""Test that whitespace-only title raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueCreate(
project_id=valid_uuid,
title=" ",
)
errors = exc_info.value.errors()
assert any("title" in str(e) for e in errors)
def test_issue_create_title_stripped(self, valid_uuid):
"""Test that title is stripped."""
issue = IssueCreate(
project_id=valid_uuid,
title=" Padded Title ",
)
assert issue.title == "Padded Title"
def test_issue_create_project_id_required(self):
"""Test that project_id is required."""
with pytest.raises(ValidationError) as exc_info:
IssueCreate(title="No Project Issue")
errors = exc_info.value.errors()
assert any("project_id" in str(e).lower() for e in errors)
class TestIssueLabelsValidation:
"""Tests for Issue labels validation."""
def test_labels_normalized_lowercase(self, valid_uuid):
"""Test that labels are normalized to lowercase."""
issue = IssueCreate(
project_id=valid_uuid,
title="Test Issue",
labels=["Bug", "SECURITY", "FrontEnd"],
)
assert issue.labels == ["bug", "security", "frontend"]
def test_labels_stripped(self, valid_uuid):
"""Test that labels are stripped."""
issue = IssueCreate(
project_id=valid_uuid,
title="Test Issue",
labels=[" bug ", " security "],
)
assert issue.labels == ["bug", "security"]
def test_labels_empty_strings_removed(self, valid_uuid):
"""Test that empty label strings are removed."""
issue = IssueCreate(
project_id=valid_uuid,
title="Test Issue",
labels=["bug", "", " ", "security"],
)
assert issue.labels == ["bug", "security"]
class TestIssueStoryPointsValidation:
"""Tests for Issue story_points validation."""
def test_story_points_valid_range(self, valid_uuid):
"""Test valid story_points values."""
for points in [0, 1, 5, 13, 21, 100]:
issue = IssueCreate(
project_id=valid_uuid,
title="Test Issue",
story_points=points,
)
assert issue.story_points == points
def test_story_points_negative_fails(self, valid_uuid):
"""Test that negative story_points raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueCreate(
project_id=valid_uuid,
title="Test Issue",
story_points=-1,
)
errors = exc_info.value.errors()
assert any("story_points" in str(e).lower() for e in errors)
def test_story_points_over_100_fails(self, valid_uuid):
"""Test that story_points > 100 raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueCreate(
project_id=valid_uuid,
title="Test Issue",
story_points=101,
)
errors = exc_info.value.errors()
assert any("story_points" in str(e).lower() for e in errors)
class TestIssueExternalTrackerValidation:
"""Tests for Issue external tracker validation."""
def test_valid_external_trackers(self, valid_uuid):
"""Test valid external tracker values."""
for tracker in ["gitea", "github", "gitlab"]:
issue = IssueCreate(
project_id=valid_uuid,
title="Test Issue",
external_tracker=tracker,
external_id="ext-123",
)
assert issue.external_tracker == tracker
def test_invalid_external_tracker(self, valid_uuid):
"""Test that invalid external tracker raises ValidationError."""
with pytest.raises(ValidationError):
IssueCreate(
project_id=valid_uuid,
title="Test Issue",
external_tracker="invalid", # type: ignore
external_id="ext-123",
)
class TestIssueUpdateValidation:
"""Tests for IssueUpdate schema validation."""
def test_issue_update_partial(self):
"""Test updating only some fields."""
update = IssueUpdate(
title="Updated Title",
)
assert update.title == "Updated Title"
assert update.body is None
assert update.status is None
def test_issue_update_all_fields(self):
"""Test updating all fields."""
agent_id = uuid.uuid4()
sprint_id = uuid.uuid4()
update = IssueUpdate(
title="Updated Title",
body="Updated body",
status=IssueStatus.CLOSED,
priority=IssuePriority.CRITICAL,
labels=["updated"],
assigned_agent_id=agent_id,
human_assignee=None,
sprint_id=sprint_id,
story_points=8,
sync_status=SyncStatus.PENDING,
)
assert update.title == "Updated Title"
assert update.status == IssueStatus.CLOSED
assert update.priority == IssuePriority.CRITICAL
def test_issue_update_empty_title_fails(self):
"""Test that empty title in update raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueUpdate(title="")
errors = exc_info.value.errors()
assert any("title" in str(e) for e in errors)
def test_issue_update_labels_normalized(self):
"""Test that labels are normalized in updates."""
update = IssueUpdate(
labels=["Bug", "SECURITY"],
)
assert update.labels == ["bug", "security"]
class TestIssueAssignValidation:
"""Tests for IssueAssign schema validation."""
def test_assign_to_agent(self):
"""Test assigning to an agent."""
agent_id = uuid.uuid4()
assign = IssueAssign(assigned_agent_id=agent_id)
assert assign.assigned_agent_id == agent_id
assert assign.human_assignee is None
def test_assign_to_human(self):
"""Test assigning to a human."""
assign = IssueAssign(human_assignee="developer@example.com")
assert assign.human_assignee == "developer@example.com"
assert assign.assigned_agent_id is None
def test_unassign(self):
"""Test unassigning (both None)."""
assign = IssueAssign()
assert assign.assigned_agent_id is None
assert assign.human_assignee is None
def test_assign_both_fails(self):
"""Test that assigning to both agent and human raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
IssueAssign(
assigned_agent_id=uuid.uuid4(),
human_assignee="developer@example.com",
)
errors = exc_info.value.errors()
# Check for the validation error message
assert len(errors) > 0
class TestIssueEnums:
"""Tests for Issue enum validation."""
def test_valid_issue_statuses(self, valid_uuid):
"""Test all valid issue statuses."""
for status in IssueStatus:
issue = IssueCreate(
project_id=valid_uuid,
title=f"Issue {status.value}",
status=status,
)
assert issue.status == status
def test_invalid_issue_status(self, valid_uuid):
"""Test that invalid issue status raises ValidationError."""
with pytest.raises(ValidationError):
IssueCreate(
project_id=valid_uuid,
title="Test Issue",
status="invalid", # type: ignore
)
def test_valid_issue_priorities(self, valid_uuid):
"""Test all valid issue priorities."""
for priority in IssuePriority:
issue = IssueCreate(
project_id=valid_uuid,
title=f"Issue {priority.value}",
priority=priority,
)
assert issue.priority == priority
def test_invalid_issue_priority(self, valid_uuid):
"""Test that invalid issue priority raises ValidationError."""
with pytest.raises(ValidationError):
IssueCreate(
project_id=valid_uuid,
title="Test Issue",
priority="invalid", # type: ignore
)
def test_valid_sync_statuses(self):
"""Test all valid sync statuses in update."""
for status in SyncStatus:
update = IssueUpdate(sync_status=status)
assert update.sync_status == status

View File

@@ -0,0 +1,300 @@
# tests/schemas/syndarix/test_project_schemas.py
"""
Tests for Project schema validation.
"""
import uuid
import pytest
from pydantic import ValidationError
from app.schemas.syndarix import (
AutonomyLevel,
ProjectCreate,
ProjectStatus,
ProjectUpdate,
)
class TestProjectCreateValidation:
"""Tests for ProjectCreate schema validation."""
def test_valid_project_create(self, valid_project_data):
"""Test creating project with valid data."""
project = ProjectCreate(**valid_project_data)
assert project.name == "Test Project"
assert project.slug == "test-project"
assert project.description == "A test project"
def test_project_create_defaults(self):
"""Test that defaults are applied correctly."""
project = ProjectCreate(
name="Minimal Project",
slug="minimal-project",
)
assert project.autonomy_level == AutonomyLevel.MILESTONE
assert project.status == ProjectStatus.ACTIVE
assert project.settings == {}
assert project.owner_id is None
def test_project_create_with_owner(self, valid_project_data):
"""Test creating project with owner ID."""
owner_id = uuid.uuid4()
project = ProjectCreate(
**valid_project_data,
owner_id=owner_id,
)
assert project.owner_id == owner_id
def test_project_create_name_empty_fails(self):
"""Test that empty name raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name="",
slug="valid-slug",
)
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_project_create_name_whitespace_only_fails(self):
"""Test that whitespace-only name raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name=" ",
slug="valid-slug",
)
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_project_create_name_stripped(self):
"""Test that name is stripped of leading/trailing whitespace."""
project = ProjectCreate(
name=" Padded Name ",
slug="padded-slug",
)
assert project.name == "Padded Name"
def test_project_create_slug_required(self):
"""Test that slug is required for create."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(name="No Slug Project")
errors = exc_info.value.errors()
assert any("slug" in str(e).lower() for e in errors)
class TestProjectSlugValidation:
"""Tests for Project slug validation."""
def test_valid_slugs(self):
"""Test various valid slug formats."""
valid_slugs = [
"simple",
"with-hyphens",
"has123numbers",
"mix3d-with-hyphen5",
"a", # Single character
]
for slug in valid_slugs:
project = ProjectCreate(
name="Test Project",
slug=slug,
)
assert project.slug == slug
def test_invalid_slug_uppercase(self):
"""Test that uppercase letters in slug raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name="Test Project",
slug="Invalid-Uppercase",
)
errors = exc_info.value.errors()
assert any("slug" in str(e).lower() for e in errors)
def test_invalid_slug_special_chars(self):
"""Test that special characters in slug raise ValidationError."""
invalid_slugs = [
"has_underscore",
"has.dot",
"has@symbol",
"has space",
"has/slash",
]
for slug in invalid_slugs:
with pytest.raises(ValidationError):
ProjectCreate(
name="Test Project",
slug=slug,
)
def test_invalid_slug_starts_with_hyphen(self):
"""Test that slug starting with hyphen raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name="Test Project",
slug="-invalid-start",
)
errors = exc_info.value.errors()
assert any("hyphen" in str(e).lower() for e in errors)
def test_invalid_slug_ends_with_hyphen(self):
"""Test that slug ending with hyphen raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name="Test Project",
slug="invalid-end-",
)
errors = exc_info.value.errors()
assert any("hyphen" in str(e).lower() for e in errors)
def test_invalid_slug_consecutive_hyphens(self):
"""Test that consecutive hyphens in slug raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectCreate(
name="Test Project",
slug="invalid--consecutive",
)
errors = exc_info.value.errors()
assert any("consecutive" in str(e).lower() for e in errors)
class TestProjectUpdateValidation:
"""Tests for ProjectUpdate schema validation."""
def test_project_update_partial(self):
"""Test updating only some fields."""
update = ProjectUpdate(
name="Updated Name",
)
assert update.name == "Updated Name"
assert update.slug is None
assert update.description is None
assert update.autonomy_level is None
assert update.status is None
def test_project_update_all_fields(self):
"""Test updating all fields."""
owner_id = uuid.uuid4()
update = ProjectUpdate(
name="Updated Name",
slug="updated-slug",
description="Updated description",
autonomy_level=AutonomyLevel.AUTONOMOUS,
status=ProjectStatus.PAUSED,
settings={"key": "value"},
owner_id=owner_id,
)
assert update.name == "Updated Name"
assert update.slug == "updated-slug"
assert update.autonomy_level == AutonomyLevel.AUTONOMOUS
assert update.status == ProjectStatus.PAUSED
def test_project_update_empty_name_fails(self):
"""Test that empty name in update raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
ProjectUpdate(name="")
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_project_update_slug_validation(self):
"""Test that slug validation applies to updates too."""
with pytest.raises(ValidationError):
ProjectUpdate(slug="Invalid-Slug")
class TestProjectEnums:
"""Tests for Project enum validation."""
def test_valid_autonomy_levels(self):
"""Test all valid autonomy levels."""
for level in AutonomyLevel:
# Replace underscores with hyphens for valid slug
slug_suffix = level.value.replace("_", "-")
project = ProjectCreate(
name="Test Project",
slug=f"project-{slug_suffix}",
autonomy_level=level,
)
assert project.autonomy_level == level
def test_invalid_autonomy_level(self):
"""Test that invalid autonomy level raises ValidationError."""
with pytest.raises(ValidationError):
ProjectCreate(
name="Test Project",
slug="invalid-autonomy",
autonomy_level="invalid", # type: ignore
)
def test_valid_project_statuses(self):
"""Test all valid project statuses."""
for status in ProjectStatus:
project = ProjectCreate(
name="Test Project",
slug=f"project-status-{status.value}",
status=status,
)
assert project.status == status
def test_invalid_project_status(self):
"""Test that invalid project status raises ValidationError."""
with pytest.raises(ValidationError):
ProjectCreate(
name="Test Project",
slug="invalid-status",
status="invalid", # type: ignore
)
class TestProjectSettings:
"""Tests for Project settings validation."""
def test_settings_empty_dict(self):
"""Test that empty settings dict is valid."""
project = ProjectCreate(
name="Test Project",
slug="empty-settings",
settings={},
)
assert project.settings == {}
def test_settings_complex_structure(self):
"""Test that complex settings structure is valid."""
complex_settings = {
"mcp_servers": ["gitea", "slack"],
"webhooks": {
"on_issue_created": "https://example.com",
},
"flags": True,
"count": 42,
}
project = ProjectCreate(
name="Test Project",
slug="complex-settings",
settings=complex_settings,
)
assert project.settings == complex_settings
def test_settings_default_to_empty_dict(self):
"""Test that settings default to empty dict when not provided."""
project = ProjectCreate(
name="Test Project",
slug="default-settings",
)
assert project.settings == {}

View File

@@ -0,0 +1,366 @@
# tests/schemas/syndarix/test_sprint_schemas.py
"""
Tests for Sprint schema validation.
"""
from datetime import date, timedelta
import pytest
from pydantic import ValidationError
from app.schemas.syndarix import (
SprintCreate,
SprintStatus,
SprintUpdate,
)
class TestSprintCreateValidation:
"""Tests for SprintCreate schema validation."""
def test_valid_sprint_create(self, valid_sprint_data):
"""Test creating sprint with valid data."""
sprint = SprintCreate(**valid_sprint_data)
assert sprint.name == "Sprint 1"
assert sprint.number == 1
assert sprint.start_date is not None
assert sprint.end_date is not None
def test_sprint_create_defaults(self, valid_sprint_data):
"""Test that defaults are applied correctly."""
sprint = SprintCreate(**valid_sprint_data)
assert sprint.status == SprintStatus.PLANNED
assert sprint.goal is None
assert sprint.planned_points is None
assert sprint.completed_points is None
def test_sprint_create_with_all_fields(self, valid_uuid):
"""Test creating sprint with all optional fields."""
today = date.today()
sprint = SprintCreate(
project_id=valid_uuid,
name="Full Sprint",
number=5,
goal="Complete all features",
start_date=today,
end_date=today + timedelta(days=14),
status=SprintStatus.PLANNED,
planned_points=21,
completed_points=0,
)
assert sprint.name == "Full Sprint"
assert sprint.number == 5
assert sprint.goal == "Complete all features"
assert sprint.planned_points == 21
def test_sprint_create_name_empty_fails(self, valid_uuid):
"""Test that empty name raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_sprint_create_name_whitespace_only_fails(self, valid_uuid):
"""Test that whitespace-only name raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name=" ",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_sprint_create_name_stripped(self, valid_uuid):
"""Test that name is stripped."""
today = date.today()
sprint = SprintCreate(
project_id=valid_uuid,
name=" Padded Sprint Name ",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
assert sprint.name == "Padded Sprint Name"
def test_sprint_create_project_id_required(self):
"""Test that project_id is required."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
name="Sprint 1",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
errors = exc_info.value.errors()
assert any("project_id" in str(e).lower() for e in errors)
class TestSprintNumberValidation:
"""Tests for Sprint number validation."""
def test_sprint_number_valid(self, valid_uuid):
"""Test valid sprint numbers."""
today = date.today()
for number in [1, 10, 100]:
sprint = SprintCreate(
project_id=valid_uuid,
name=f"Sprint {number}",
number=number,
start_date=today,
end_date=today + timedelta(days=14),
)
assert sprint.number == number
def test_sprint_number_zero_fails(self, valid_uuid):
"""Test that sprint number 0 raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="Sprint Zero",
number=0,
start_date=today,
end_date=today + timedelta(days=14),
)
errors = exc_info.value.errors()
assert any("number" in str(e).lower() for e in errors)
def test_sprint_number_negative_fails(self, valid_uuid):
"""Test that negative sprint number raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="Negative Sprint",
number=-1,
start_date=today,
end_date=today + timedelta(days=14),
)
errors = exc_info.value.errors()
assert any("number" in str(e).lower() for e in errors)
class TestSprintDateValidation:
"""Tests for Sprint date validation."""
def test_valid_date_range(self, valid_uuid):
"""Test valid date range (end > start)."""
today = date.today()
sprint = SprintCreate(
project_id=valid_uuid,
name="Sprint 1",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
)
assert sprint.end_date > sprint.start_date
def test_same_day_sprint(self, valid_uuid):
"""Test that same day sprint is valid."""
today = date.today()
sprint = SprintCreate(
project_id=valid_uuid,
name="One Day Sprint",
number=1,
start_date=today,
end_date=today, # Same day is allowed
)
assert sprint.start_date == sprint.end_date
def test_end_before_start_fails(self, valid_uuid):
"""Test that end date before start date raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="Invalid Sprint",
number=1,
start_date=today,
end_date=today - timedelta(days=1), # Before start
)
errors = exc_info.value.errors()
assert len(errors) > 0
class TestSprintPointsValidation:
"""Tests for Sprint points validation."""
def test_valid_planned_points(self, valid_uuid):
"""Test valid planned_points values."""
today = date.today()
for points in [0, 1, 21, 100]:
sprint = SprintCreate(
project_id=valid_uuid,
name=f"Sprint {points}",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
planned_points=points,
)
assert sprint.planned_points == points
def test_planned_points_negative_fails(self, valid_uuid):
"""Test that negative planned_points raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="Negative Points Sprint",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
planned_points=-1,
)
errors = exc_info.value.errors()
assert any("planned_points" in str(e).lower() for e in errors)
def test_valid_completed_points(self, valid_uuid):
"""Test valid completed_points values."""
today = date.today()
for points in [0, 5, 21]:
sprint = SprintCreate(
project_id=valid_uuid,
name=f"Sprint {points}",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
completed_points=points,
)
assert sprint.completed_points == points
def test_completed_points_negative_fails(self, valid_uuid):
"""Test that negative completed_points raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError) as exc_info:
SprintCreate(
project_id=valid_uuid,
name="Negative Completed Sprint",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
completed_points=-1,
)
errors = exc_info.value.errors()
assert any("completed_points" in str(e).lower() for e in errors)
class TestSprintUpdateValidation:
"""Tests for SprintUpdate schema validation."""
def test_sprint_update_partial(self):
"""Test updating only some fields."""
update = SprintUpdate(
name="Updated Name",
)
assert update.name == "Updated Name"
assert update.goal is None
assert update.start_date is None
assert update.end_date is None
def test_sprint_update_all_fields(self):
"""Test updating all fields."""
today = date.today()
update = SprintUpdate(
name="Updated Name",
goal="Updated goal",
start_date=today,
end_date=today + timedelta(days=21),
status=SprintStatus.ACTIVE,
planned_points=34,
completed_points=20,
)
assert update.name == "Updated Name"
assert update.goal == "Updated goal"
assert update.status == SprintStatus.ACTIVE
assert update.planned_points == 34
def test_sprint_update_empty_name_fails(self):
"""Test that empty name in update raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
SprintUpdate(name="")
errors = exc_info.value.errors()
assert any("name" in str(e) for e in errors)
def test_sprint_update_name_stripped(self):
"""Test that name is stripped in updates."""
update = SprintUpdate(name=" Updated ")
assert update.name == "Updated"
class TestSprintStatusEnum:
"""Tests for SprintStatus enum validation."""
def test_valid_sprint_statuses(self, valid_uuid):
"""Test all valid sprint statuses."""
today = date.today()
for status in SprintStatus:
sprint = SprintCreate(
project_id=valid_uuid,
name=f"Sprint {status.value}",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
status=status,
)
assert sprint.status == status
def test_invalid_sprint_status(self, valid_uuid):
"""Test that invalid sprint status raises ValidationError."""
today = date.today()
with pytest.raises(ValidationError):
SprintCreate(
project_id=valid_uuid,
name="Invalid Status Sprint",
number=1,
start_date=today,
end_date=today + timedelta(days=14),
status="invalid", # type: ignore
)