""" Tests for MarkiTect Period Management Framework. This module tests the complete period lifecycle management system including: - Period creation, status management, and lifecycle transitions - Period overlap validation and conflict resolution - Period calculations and cost aggregation - Period closure validation and audit trails - Current period detection and auto-creation """ import pytest import tempfile import os from datetime import date, datetime, timedelta from decimal import Decimal from markitect.finance.period_manager import PeriodManager, PeriodStatus, Period from markitect.finance.models import FinanceModels from markitect.finance.cost_manager import CostItemManager, CostItem class TestPeriodManager: """Test suite for period management system.""" @pytest.fixture def temp_db(self): """Create temporary database for testing.""" fd, path = tempfile.mkstemp(suffix='.db') os.close(fd) yield path os.unlink(path) @pytest.fixture def period_manager(self, temp_db): """Create period manager with initialized database.""" finance_models = FinanceModels(temp_db) finance_models.initialize_finance_schema() return PeriodManager(temp_db) @pytest.fixture def sample_period_data(self): """Sample period data for testing.""" return { 'period_start': date(2025, 1, 1), 'period_end': date(2025, 1, 31), 'period_type': 'monthly' } def test_period_status_enum(self): """Test period status enumeration.""" assert PeriodStatus.OPEN.value == 'open' assert PeriodStatus.CALCULATING.value == 'calculating' assert PeriodStatus.CLOSED.value == 'closed' def test_period_dataclass(self): """Test Period dataclass creation.""" period = Period( id=1, period_start=date(2025, 1, 1), period_end=date(2025, 1, 31), period_type='monthly', status='open', total_costs=Decimal('100.50') ) assert period.id == 1 assert period.period_start == date(2025, 1, 1) assert period.period_end == date(2025, 1, 31) assert period.total_costs == Decimal('100.50') def test_create_period_success(self, period_manager, sample_period_data): """Test successful period creation.""" period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'], period_type=sample_period_data['period_type'] ) assert period_id is not None assert isinstance(period_id, int) # Verify period was created created_period = period_manager.get_period_by_id(period_id) assert created_period is not None assert created_period['period_start'] == sample_period_data['period_start'].isoformat() assert created_period['period_end'] == sample_period_data['period_end'].isoformat() assert created_period['status'] == PeriodStatus.OPEN.value def test_create_period_invalid_dates(self, period_manager): """Test period creation with invalid date range.""" with pytest.raises(ValueError, match="Period end date must be after start date"): period_manager.create_period( period_start=date(2025, 1, 31), period_end=date(2025, 1, 1) # End before start ) def test_create_period_with_loss_carried_forward(self, period_manager, sample_period_data): """Test period creation with loss carried forward.""" loss_amount = Decimal('25.50') period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'], loss_carried_forward=loss_amount ) created_period = period_manager.get_period_by_id(period_id) assert created_period['loss_carried_forward'] == loss_amount def test_find_overlapping_periods(self, period_manager, sample_period_data): """Test overlap detection functionality.""" # Create first period period_id1 = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Test overlapping period detection overlapping = period_manager.find_overlapping_periods( period_start=date(2025, 1, 15), # Overlaps with existing period_end=date(2025, 2, 15) ) assert len(overlapping) == 1 assert overlapping[0]['id'] == period_id1 def test_create_overlapping_period_fails(self, period_manager, sample_period_data): """Test that creating overlapping periods fails.""" # Create first period period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Try to create overlapping period with pytest.raises(ValueError, match="Period overlaps with existing periods"): period_manager.create_period( period_start=date(2025, 1, 15), # Overlaps period_end=date(2025, 2, 15) ) def test_update_period_status_valid_transition(self, period_manager, sample_period_data): """Test valid period status transitions.""" period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Transition from OPEN to CALCULATING success = period_manager.update_period_status(period_id, PeriodStatus.CALCULATING.value) assert success is True updated_period = period_manager.get_period_by_id(period_id) assert updated_period['status'] == PeriodStatus.CALCULATING.value # Transition from CALCULATING to CLOSED success = period_manager.update_period_status(period_id, PeriodStatus.CLOSED.value) assert success is True updated_period = period_manager.get_period_by_id(period_id) assert updated_period['status'] == PeriodStatus.CLOSED.value def test_update_period_status_invalid_status(self, period_manager, sample_period_data): """Test update with invalid status.""" period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) with pytest.raises(ValueError, match="Invalid status 'invalid'"): period_manager.update_period_status(period_id, 'invalid') def test_update_period_status_nonexistent_period(self, period_manager): """Test update status for non-existent period.""" with pytest.raises(ValueError, match="Period #999 not found"): period_manager.update_period_status(999, PeriodStatus.CALCULATING.value) def test_calculate_period_costs(self, period_manager, sample_period_data, temp_db): """Test period cost calculation functionality.""" # Create period period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Set up cost manager and add test data finance_models = FinanceModels(temp_db) cost_manager = CostItemManager(temp_db) # Get categories infra_cat = cost_manager.get_category_by_name('Infrastructure') software_cat = cost_manager.get_category_by_name('Software') # Create test cost items monthly_item = CostItem( category_id=infra_cat['id'], name='Monthly Server', cost_type='monthly', amount_eur=Decimal('25.00'), starting_from_date=date(2024, 12, 1) # Started before period ) one_time_item = CostItem( category_id=software_cat['id'], name='One-time License', cost_type='one_time', amount_eur=Decimal('50.00'), starting_from_date=date(2025, 1, 15) # Within period ) cost_manager.create_cost_item(monthly_item) cost_manager.create_cost_item(one_time_item) # Calculate period costs calculation_result = period_manager.calculate_period_costs(period_id) # Verify calculation results assert calculation_result['period_id'] == period_id assert calculation_result['monthly_costs'] == 25.0 assert calculation_result['one_time_costs'] == 50.0 assert calculation_result['total_costs'] == 75.0 # Verify period was updated updated_period = period_manager.get_period_by_id(period_id) assert updated_period['total_costs'] == Decimal('75.00') def test_close_period(self, period_manager, sample_period_data): """Test period closure functionality.""" period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Close the period success = period_manager.close_period(period_id) assert success is True # Verify period is closed closed_period = period_manager.get_period_by_id(period_id) assert closed_period['status'] == PeriodStatus.CLOSED.value def test_close_period_already_closed(self, period_manager, sample_period_data): """Test closing an already closed period.""" period_id = period_manager.create_period( period_start=sample_period_data['period_start'], period_end=sample_period_data['period_end'] ) # Close period first time period_manager.close_period(period_id) # Close again (should succeed without error) success = period_manager.close_period(period_id) assert success is True def test_close_nonexistent_period(self, period_manager): """Test closing non-existent period.""" with pytest.raises(ValueError, match="Period #999 not found"): period_manager.close_period(999) def test_list_periods_no_filter(self, period_manager, sample_period_data): """Test listing all periods without filters.""" # Create multiple periods period_id1 = period_manager.create_period( period_start=date(2025, 1, 1), period_end=date(2025, 1, 31) ) period_id2 = period_manager.create_period( period_start=date(2025, 2, 1), period_end=date(2025, 2, 28) ) # List all periods periods = period_manager.list_periods() assert len(periods) == 2 period_ids = [p['id'] for p in periods] assert period_id1 in period_ids assert period_id2 in period_ids def test_list_periods_with_status_filter(self, period_manager): """Test listing periods with status filter.""" # Create periods with different statuses period_id1 = period_manager.create_period( period_start=date(2025, 1, 1), period_end=date(2025, 1, 31) ) period_id2 = period_manager.create_period( period_start=date(2025, 2, 1), period_end=date(2025, 2, 28) ) # Close one period period_manager.close_period(period_id2) # Filter by open status open_periods = period_manager.list_periods(status_filter=PeriodStatus.OPEN.value) assert len(open_periods) == 1 assert open_periods[0]['id'] == period_id1 # Filter by closed status closed_periods = period_manager.list_periods(status_filter=PeriodStatus.CLOSED.value) assert len(closed_periods) == 1 assert closed_periods[0]['id'] == period_id2 def test_list_periods_with_date_filters(self, period_manager): """Test listing periods with date range filters.""" # Create periods in different months jan_period = period_manager.create_period( period_start=date(2025, 1, 1), period_end=date(2025, 1, 31) ) feb_period = period_manager.create_period( period_start=date(2025, 2, 1), period_end=date(2025, 2, 28) ) # Filter by start date periods_from_feb = period_manager.list_periods(start_date=date(2025, 2, 1)) assert len(periods_from_feb) == 1 assert periods_from_feb[0]['id'] == feb_period # Filter by end date periods_until_jan = period_manager.list_periods(end_date=date(2025, 1, 31)) assert len(periods_until_jan) == 1 assert periods_until_jan[0]['id'] == jan_period def test_get_current_period(self, period_manager): """Test getting current period for a specific date.""" # Create period covering January 2025 period_id = period_manager.create_period( period_start=date(2025, 1, 1), period_end=date(2025, 1, 31) ) # Test date within period current = period_manager.get_current_period(date(2025, 1, 15)) assert current is not None assert current['id'] == period_id # Test date outside period current = period_manager.get_current_period(date(2025, 2, 15)) assert current is None def test_get_current_period_defaults_to_today(self, period_manager): """Test that get_current_period defaults to today's date.""" today = date.today() # Create period covering today period_id = period_manager.create_period( period_start=date(today.year, today.month, 1), period_end=date(today.year, today.month, 31) if today.month != 12 else date(today.year, 12, 31) ) # Get current period without specifying date current = period_manager.get_current_period() assert current is not None assert current['id'] == period_id def test_create_monthly_period(self, period_manager): """Test convenience method for creating monthly periods.""" period_id = period_manager.create_monthly_period(2025, 3) assert period_id is not None # Verify correct dates were set period = period_manager.get_period_by_id(period_id) assert period['period_start'] == '2025-03-01' assert period['period_end'] == '2025-03-31' assert period['period_type'] == 'monthly' def test_create_monthly_period_december(self, period_manager): """Test creating monthly period for December (year boundary).""" period_id = period_manager.create_monthly_period(2025, 12) period = period_manager.get_period_by_id(period_id) assert period['period_start'] == '2025-12-01' assert period['period_end'] == '2025-12-31' def test_auto_create_period_for_date(self, period_manager): """Test automatic period creation for a given date.""" test_date = date(2025, 5, 15) # First call should create new period period_id = period_manager.auto_create_period_for_date(test_date) assert period_id is not None # Second call should return existing period period_id2 = period_manager.auto_create_period_for_date(test_date) assert period_id2 == period_id # Verify period covers the test date period = period_manager.get_period_by_id(period_id) assert period['period_start'] == '2025-05-01' assert period['period_end'] == '2025-05-31' def test_period_calculation_with_loss_carried_forward(self, period_manager, temp_db): """Test period calculation including loss carried forward.""" # Create period with loss carried forward period_id = period_manager.create_period( period_start=date(2025, 1, 1), period_end=date(2025, 1, 31), loss_carried_forward=Decimal('15.75') ) # Add a cost item cost_manager = CostItemManager(temp_db) infra_cat = cost_manager.get_category_by_name('Infrastructure') cost_item = CostItem( category_id=infra_cat['id'], name='Test Server', cost_type='monthly', amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_manager.create_cost_item(cost_item) # Calculate costs calculation = period_manager.calculate_period_costs(period_id) # Should include loss carried forward assert calculation['loss_carried_forward'] == 15.75 assert calculation['monthly_costs'] == 10.0 assert calculation['total_costs'] == 25.75 # 10.0 + 15.75 def test_period_cost_calculation_edge_cases(self, period_manager, temp_db): """Test period cost calculation with various edge cases.""" # Create period period_id = period_manager.create_period( period_start=date(2025, 3, 1), period_end=date(2025, 3, 31) ) cost_manager = CostItemManager(temp_db) infra_cat = cost_manager.get_category_by_name('Infrastructure') # Item that starts before period and ends during period item1 = CostItem( category_id=infra_cat['id'], name='Ending Item', cost_type='monthly', amount_eur=Decimal('20.00'), starting_from_date=date(2025, 1, 1), ending_date=date(2025, 3, 15) ) # Item that starts after period item2 = CostItem( category_id=infra_cat['id'], name='Future Item', cost_type='monthly', amount_eur=Decimal('30.00'), starting_from_date=date(2025, 4, 1) ) # One-time item outside period item3 = CostItem( category_id=infra_cat['id'], name='Past One-time', cost_type='one_time', amount_eur=Decimal('100.00'), starting_from_date=date(2025, 2, 15) ) cost_manager.create_cost_item(item1) cost_manager.create_cost_item(item2) cost_manager.create_cost_item(item3) # Calculate costs calculation = period_manager.calculate_period_costs(period_id) # Only item1 should be included (ends during period) assert calculation['monthly_costs'] == 20.0 assert calculation['one_time_costs'] == 0.0 assert calculation['total_costs'] == 20.0 def test_error_handling_database_errors(self, period_manager): """Test error handling for database-related issues.""" # Test with invalid period ID with pytest.raises(ValueError, match="Period #-1 not found"): period_manager.calculate_period_costs(-1) # Test getting non-existent period result = period_manager.get_period_by_id(99999) assert result is None