""" Tests for Schedule Triggers functionality. This module tests: - Cron expression parsing and validation - Deadline reminder logic - Schedule trigger execution """ import pytest import uuid from datetime import datetime, timezone, timedelta from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Notification from app.services.trigger_scheduler import TriggerSchedulerService # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def test_user(db): """Create a test user.""" user = User( id=str(uuid.uuid4()), email="scheduleuser@example.com", name="Schedule Test User", role_id="00000000-0000-0000-0000-000000000003", is_active=True, is_system_admin=False, ) db.add(user) db.commit() return user @pytest.fixture def test_user_token(client, mock_redis, test_user): """Get a token for test user.""" from app.core.security import create_access_token, create_token_payload token_data = create_token_payload( user_id=test_user.id, email=test_user.email, role="engineer", department_id=None, is_system_admin=False, ) token = create_access_token(token_data) mock_redis.setex(f"session:{test_user.id}", 900, token) return token @pytest.fixture def test_user_csrf_token(test_user): """Generate a CSRF token for the test user.""" from app.core.security import generate_csrf_token return generate_csrf_token(test_user.id) @pytest.fixture def test_space(db, test_user): """Create a test space.""" space = Space( id=str(uuid.uuid4()), name="Schedule Test Space", description="Test space for schedule triggers", owner_id=test_user.id, ) db.add(space) db.commit() return space @pytest.fixture def test_project(db, test_space, test_user): """Create a test project.""" project = Project( id=str(uuid.uuid4()), space_id=test_space.id, title="Schedule Test Project", description="Test project for schedule triggers", owner_id=test_user.id, ) db.add(project) db.commit() return project @pytest.fixture def test_status(db, test_project): """Create test task statuses.""" status = TaskStatus( id=str(uuid.uuid4()), project_id=test_project.id, name="To Do", color="#808080", position=0, ) db.add(status) db.commit() return status @pytest.fixture def cron_trigger(db, test_project, test_user): """Create a cron-based schedule trigger.""" trigger = Trigger( id=str(uuid.uuid4()), project_id=test_project.id, name="Daily Reminder", description="Daily reminder at 9am", trigger_type="schedule", conditions={ "cron_expression": "0 9 * * *", # Every day at 9am }, actions=[{ "type": "notify", "target": "project_owner", "template": "Daily scheduled trigger fired for {project_name}", }], is_active=True, created_by=test_user.id, ) db.add(trigger) db.commit() return trigger @pytest.fixture def deadline_trigger(db, test_project, test_user): """Create a deadline reminder trigger.""" trigger = Trigger( id=str(uuid.uuid4()), project_id=test_project.id, name="Deadline Reminder", description="Remind 3 days before deadline", trigger_type="schedule", conditions={ "deadline_reminder_days": 3, }, actions=[{ "type": "notify", "target": "assignee", "template": "Task '{task_title}' is due in {reminder_days} days", }], is_active=True, created_by=test_user.id, ) db.add(trigger) db.commit() return trigger @pytest.fixture def task_with_deadline(db, test_project, test_user, test_status): """Create a task with a deadline 3 days from now.""" due_date = datetime.now(timezone.utc) + timedelta(days=3) task = Task( id=str(uuid.uuid4()), project_id=test_project.id, title="Task with Deadline", description="This task has a deadline", status_id=test_status.id, created_by=test_user.id, assignee_id=test_user.id, due_date=due_date, ) db.add(task) db.commit() return task # ============================================================================ # Tests: Cron Expression Parsing # ============================================================================ class TestCronExpressionParsing: """Tests for cron expression parsing and validation.""" def test_parse_valid_cron_expression(self): """Test parsing a valid cron expression.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("0 9 * * 1") assert is_valid is True assert error is None def test_parse_valid_cron_every_minute(self): """Test parsing every minute cron expression.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("* * * * *") assert is_valid is True def test_parse_valid_cron_weekdays(self): """Test parsing weekdays-only cron expression.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("0 9 * * 1-5") assert is_valid is True def test_parse_valid_cron_monthly(self): """Test parsing monthly cron expression.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("0 0 1 * *") assert is_valid is True def test_parse_invalid_cron_expression(self): """Test parsing an invalid cron expression.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("invalid") assert is_valid is False assert error is not None assert "Invalid cron expression" in error def test_parse_invalid_cron_too_many_fields(self): """Test parsing cron with too many fields.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("0 0 0 0 0 0 0") assert is_valid is False def test_parse_invalid_cron_bad_range(self): """Test parsing cron with invalid range.""" is_valid, error = TriggerSchedulerService.parse_cron_expression("0 25 * * *") assert is_valid is False def test_get_next_run_time(self): """Test getting next run time from cron expression.""" base_time = datetime(2025, 1, 1, 8, 0, 0, tzinfo=timezone.utc) next_time = TriggerSchedulerService.get_next_run_time("0 9 * * *", base_time) assert next_time is not None assert next_time.hour == 9 assert next_time.minute == 0 def test_get_previous_run_time(self): """Test getting previous run time from cron expression.""" base_time = datetime(2025, 1, 1, 10, 0, 0, tzinfo=timezone.utc) prev_time = TriggerSchedulerService.get_previous_run_time("0 9 * * *", base_time) assert prev_time is not None assert prev_time.hour == 9 assert prev_time.minute == 0 def test_get_next_run_time_invalid_cron(self): """Test getting next run time with invalid cron returns None.""" result = TriggerSchedulerService.get_next_run_time("invalid") assert result is None # ============================================================================ # Tests: Schedule Trigger Should Fire Logic # ============================================================================ class TestScheduleTriggerShouldFire: """Tests for schedule trigger firing logic.""" def test_should_trigger_within_window(self, db, cron_trigger): """Test trigger should fire when within execution window.""" # Set current time to just after scheduled time scheduled_time = datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone.utc) current_time = scheduled_time + timedelta(minutes=2) result = TriggerSchedulerService.should_trigger( cron_trigger, current_time, last_execution_time=None ) assert result is True def test_should_not_trigger_outside_window(self, db, cron_trigger): """Test trigger should not fire when outside execution window.""" # Set current time to well after scheduled time (more than 5 minutes) scheduled_time = datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone.utc) current_time = scheduled_time + timedelta(minutes=10) result = TriggerSchedulerService.should_trigger( cron_trigger, current_time, last_execution_time=None ) assert result is False def test_should_not_trigger_if_already_executed(self, db, cron_trigger): """Test trigger should not fire if already executed after last schedule.""" scheduled_time = datetime(2025, 1, 1, 9, 0, 0, tzinfo=timezone.utc) current_time = scheduled_time + timedelta(minutes=2) last_execution = scheduled_time + timedelta(minutes=1) result = TriggerSchedulerService.should_trigger( cron_trigger, current_time, last_execution_time=last_execution ) assert result is False def test_should_trigger_if_new_schedule_since_last_execution(self, db, cron_trigger): """Test trigger should fire if a new schedule time has passed since last execution.""" # Last execution was yesterday at 9:01 last_execution = datetime(2025, 1, 1, 9, 1, 0, tzinfo=timezone.utc) # Current time is today at 9:02 (new schedule at 9:00 passed) current_time = datetime(2025, 1, 2, 9, 2, 0, tzinfo=timezone.utc) result = TriggerSchedulerService.should_trigger( cron_trigger, current_time, last_execution_time=last_execution ) assert result is True def test_should_not_trigger_inactive(self, db, cron_trigger): """Test inactive trigger should not fire.""" cron_trigger.is_active = False db.commit() current_time = datetime(2025, 1, 1, 9, 1, 0, tzinfo=timezone.utc) result = TriggerSchedulerService.should_trigger( cron_trigger, current_time, last_execution_time=None ) assert result is False def test_should_not_trigger_field_change_type(self, db, test_project, test_user): """Test field_change trigger type should not be evaluated as schedule trigger.""" trigger = Trigger( id=str(uuid.uuid4()), project_id=test_project.id, name="Field Change Trigger", trigger_type="field_change", conditions={ "field": "status_id", "operator": "equals", "value": "some-id", }, actions=[{"type": "notify"}], is_active=True, created_by=test_user.id, ) db.add(trigger) db.commit() result = TriggerSchedulerService.should_trigger( trigger, datetime.now(timezone.utc), last_execution_time=None ) assert result is False # ============================================================================ # Tests: Deadline Reminder Logic # ============================================================================ class TestDeadlineReminderLogic: """Tests for deadline reminder functionality.""" def test_deadline_reminder_finds_matching_tasks( self, db, deadline_trigger, task_with_deadline, test_user ): """Test that deadline reminder finds tasks due in N days.""" # Execute deadline reminders logs = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() assert len(logs) == 1 assert logs[0].status == "success" assert logs[0].task_id == task_with_deadline.id assert logs[0].details["trigger_type"] == "deadline_reminder" assert logs[0].details["reminder_days"] == 3 def test_deadline_reminder_creates_notification( self, db, deadline_trigger, task_with_deadline, test_user ): """Test that deadline reminder creates a notification.""" logs = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() # Check notification was created notifications = db.query(Notification).filter( Notification.user_id == test_user.id, Notification.type == "deadline_reminder", ).all() assert len(notifications) == 1 assert task_with_deadline.title in notifications[0].message def test_deadline_reminder_only_sends_once( self, db, deadline_trigger, task_with_deadline ): """Test that deadline reminder only sends once per task per trigger.""" # First execution logs1 = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() assert len(logs1) == 1 # Second execution should not send again logs2 = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() assert len(logs2) == 0 def test_deadline_reminder_ignores_deleted_tasks( self, db, deadline_trigger, task_with_deadline ): """Test that deadline reminder ignores soft-deleted tasks.""" task_with_deadline.is_deleted = True db.commit() logs = TriggerSchedulerService.execute_deadline_reminders(db) assert len(logs) == 0 def test_deadline_reminder_ignores_tasks_without_due_date( self, db, deadline_trigger, test_project, test_user, test_status ): """Test that deadline reminder ignores tasks without due dates.""" task = Task( id=str(uuid.uuid4()), project_id=test_project.id, title="No Deadline Task", status_id=test_status.id, created_by=test_user.id, due_date=None, ) db.add(task) db.commit() logs = TriggerSchedulerService.execute_deadline_reminders(db) assert len(logs) == 0 def test_deadline_reminder_different_reminder_days( self, db, test_project, test_user, test_status ): """Test deadline reminder with different reminder days configuration.""" # Create a trigger for 7 days reminder trigger = Trigger( id=str(uuid.uuid4()), project_id=test_project.id, name="7 Day Reminder", trigger_type="schedule", conditions={"deadline_reminder_days": 7}, actions=[{"type": "notify", "target": "assignee"}], is_active=True, created_by=test_user.id, ) db.add(trigger) # Create a task due in 7 days task = Task( id=str(uuid.uuid4()), project_id=test_project.id, title="Task Due in 7 Days", status_id=test_status.id, created_by=test_user.id, assignee_id=test_user.id, due_date=datetime.now(timezone.utc) + timedelta(days=7), ) db.add(task) db.commit() logs = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() assert len(logs) == 1 assert logs[0].details["reminder_days"] == 7 # ============================================================================ # Tests: Schedule Trigger API # ============================================================================ class TestScheduleTriggerAPI: """Tests for Schedule Trigger API endpoints.""" def test_create_cron_trigger(self, client, test_user_token, test_user_csrf_token, test_project): """Test creating a schedule trigger with cron expression.""" response = client.post( f"/api/projects/{test_project.id}/triggers", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "name": "Weekly Monday Reminder", "description": "Remind every Monday at 9am", "trigger_type": "schedule", "conditions": { "cron_expression": "0 9 * * 1", }, "actions": [{ "type": "notify", "target": "project_owner", "template": "Weekly reminder for {project_name}", }], }, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Weekly Monday Reminder" assert data["trigger_type"] == "schedule" assert data["conditions"]["cron_expression"] == "0 9 * * 1" def test_create_deadline_trigger(self, client, test_user_token, test_user_csrf_token, test_project): """Test creating a schedule trigger with deadline reminder.""" response = client.post( f"/api/projects/{test_project.id}/triggers", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "name": "Deadline Reminder", "description": "Remind 5 days before deadline", "trigger_type": "schedule", "conditions": { "deadline_reminder_days": 5, }, "actions": [{ "type": "notify", "target": "assignee", }], }, ) assert response.status_code == 201 data = response.json() assert data["conditions"]["deadline_reminder_days"] == 5 def test_create_schedule_trigger_invalid_cron(self, client, test_user_token, test_user_csrf_token, test_project): """Test creating a schedule trigger with invalid cron expression.""" response = client.post( f"/api/projects/{test_project.id}/triggers", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "name": "Invalid Cron Trigger", "trigger_type": "schedule", "conditions": { "cron_expression": "invalid cron", }, "actions": [{"type": "notify"}], }, ) assert response.status_code == 400 assert "Invalid cron expression" in response.json()["detail"] def test_create_schedule_trigger_missing_condition(self, client, test_user_token, test_user_csrf_token, test_project): """Test creating a schedule trigger without cron or deadline condition.""" response = client.post( f"/api/projects/{test_project.id}/triggers", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "name": "Empty Schedule Trigger", "trigger_type": "schedule", "conditions": {}, "actions": [{"type": "notify"}], }, ) assert response.status_code == 400 assert "require either cron_expression or deadline_reminder_days" in response.json()["detail"] def test_update_schedule_trigger_cron(self, client, test_user_token, test_user_csrf_token, cron_trigger): """Test updating a schedule trigger's cron expression.""" response = client.put( f"/api/triggers/{cron_trigger.id}", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "conditions": { "cron_expression": "0 10 * * *", # Changed to 10am }, }, ) assert response.status_code == 200 data = response.json() assert data["conditions"]["cron_expression"] == "0 10 * * *" def test_update_schedule_trigger_invalid_cron(self, client, test_user_token, test_user_csrf_token, cron_trigger): """Test updating a schedule trigger with invalid cron expression.""" response = client.put( f"/api/triggers/{cron_trigger.id}", headers={"Authorization": f"Bearer {test_user_token}", "X-CSRF-Token": test_user_csrf_token}, json={ "conditions": { "cron_expression": "not valid", }, }, ) assert response.status_code == 400 assert "Invalid cron expression" in response.json()["detail"] # ============================================================================ # Tests: Integration - Schedule Trigger Execution # ============================================================================ class TestScheduleTriggerExecution: """Integration tests for schedule trigger execution.""" def test_execute_scheduled_triggers(self, db, cron_trigger, test_user): """Test executing scheduled triggers creates logs.""" # Manually set conditions to trigger execution # Create a log entry as if it was executed before # The trigger should not fire again immediately # First, verify no logs exist logs_before = db.query(TriggerLog).filter( TriggerLog.trigger_id == cron_trigger.id ).all() assert len(logs_before) == 0 def test_evaluate_schedule_triggers_combined( self, db, cron_trigger, deadline_trigger, task_with_deadline ): """Test that evaluate_schedule_triggers runs both cron and deadline triggers.""" # Note: This test verifies the combined execution method exists and works # The actual execution depends on timing, so we mainly test structure # Execute the combined evaluation logs = TriggerSchedulerService.evaluate_schedule_triggers(db) # Should have deadline reminder executed deadline_logs = [l for l in logs if l.details and l.details.get("trigger_type") == "deadline_reminder"] assert len(deadline_logs) == 1 def test_trigger_log_details(self, db, deadline_trigger, task_with_deadline): """Test that trigger logs contain proper details.""" logs = TriggerSchedulerService.execute_deadline_reminders(db) db.commit() assert len(logs) == 1 log = logs[0] assert log.trigger_id == deadline_trigger.id assert log.task_id == task_with_deadline.id assert log.status == "success" assert log.details is not None assert log.details["trigger_name"] == deadline_trigger.name assert log.details["task_title"] == task_with_deadline.title assert "due_date" in log.details def test_inactive_trigger_not_executed(self, db, deadline_trigger, task_with_deadline): """Test that inactive triggers are not executed.""" deadline_trigger.is_active = False db.commit() logs = TriggerSchedulerService.execute_deadline_reminders(db) assert len(logs) == 0 # ============================================================================ # Tests: Template Formatting # ============================================================================ class TestTemplateFormatting: """Tests for message template formatting.""" def test_format_deadline_template_basic( self, db, deadline_trigger, task_with_deadline ): """Test basic deadline template formatting.""" template = "Task '{task_title}' is due in {reminder_days} days" result = TriggerSchedulerService._format_deadline_template( template, deadline_trigger, task_with_deadline, 3 ) assert task_with_deadline.title in result assert "3" in result def test_format_deadline_template_all_variables( self, db, deadline_trigger, task_with_deadline ): """Test template with all available variables.""" template = ( "Trigger: {trigger_name}, Task: {task_title}, " "Due: {due_date}, Days: {reminder_days}, Project: {project_name}" ) result = TriggerSchedulerService._format_deadline_template( template, deadline_trigger, task_with_deadline, 3 ) assert deadline_trigger.name in result assert task_with_deadline.title in result assert "3" in result def test_format_scheduled_trigger_template(self, db, cron_trigger): """Test scheduled trigger template formatting.""" template = "Trigger '{trigger_name}' fired for project '{project_name}'" result = TriggerSchedulerService._format_template( template, cron_trigger, cron_trigger.project ) assert cron_trigger.name in result assert cron_trigger.project.title in result