""" Tests for MarkiTect cost item management system. This module tests the complete cost item management functionality including: - Cost item lifecycle (create, update, deactivate) - Category management - Business rule validation - Period-based cost calculations - Integration with database models """ import pytest import tempfile import os from datetime import date, datetime from decimal import Decimal from markitect.finance.cost_manager import CostItemManager, CostItem, CostCategory from markitect.finance.models import FinanceModels class TestCostItemManager: """Test suite for cost item 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 cost_manager(self, temp_db): """Create CostItemManager instance with initialized database.""" finance_models = FinanceModels(temp_db) finance_models.initialize_finance_schema() return CostItemManager(temp_db) @pytest.fixture def sample_category_id(self, cost_manager): """Create a sample category for testing.""" return cost_manager.create_category("Test Category", "For testing purposes") def test_create_cost_item_valid(self, cost_manager, sample_category_id): """Test creating a valid cost item.""" cost_item = CostItem( category_id=sample_category_id, name="Test Server", description="Monthly hosting", cost_type="monthly", amount_eur=Decimal('25.50'), starting_from_date=date(2025, 1, 1) ) cost_item_id = cost_manager.create_cost_item(cost_item) assert cost_item_id is not None # Verify item was created retrieved = cost_manager.get_cost_item(cost_item_id) assert retrieved['name'] == "Test Server" assert float(retrieved['amount_eur']) == 25.50 assert retrieved['cost_type'] == "monthly" assert retrieved['is_active'] == 1 # SQLite stores booleans as integers def test_create_cost_item_validation_errors(self, cost_manager, sample_category_id): """Test cost item validation errors.""" # Missing name with pytest.raises(ValueError, match="name is required"): cost_item = CostItem( category_id=sample_category_id, name="", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_manager.create_cost_item(cost_item) # Invalid cost type with pytest.raises(ValueError, match="must be 'monthly' or 'one_time'"): cost_item = CostItem( category_id=sample_category_id, name="Test Item", cost_type="invalid", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_manager.create_cost_item(cost_item) # Negative amount with pytest.raises(ValueError, match="must be non-negative"): cost_item = CostItem( category_id=sample_category_id, name="Test Item", cost_type="monthly", amount_eur=Decimal('-10.00'), starting_from_date=date(2025, 1, 1) ) cost_manager.create_cost_item(cost_item) # Invalid date range with pytest.raises(ValueError, match="must be after starting date"): cost_item = CostItem( category_id=sample_category_id, name="Test Item", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 15), ending_date=date(2025, 1, 10) ) cost_manager.create_cost_item(cost_item) # Inactive without ending date with pytest.raises(ValueError, match="must have an ending date"): cost_item = CostItem( category_id=sample_category_id, name="Test Item", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1), is_active=False ) cost_manager.create_cost_item(cost_item) def test_update_cost_item(self, cost_manager, sample_category_id): """Test updating cost item.""" # Create initial cost item cost_item = CostItem( category_id=sample_category_id, name="Original Name", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_item_id = cost_manager.create_cost_item(cost_item) # Update the cost item updates = { 'name': 'Updated Name', 'amount_eur': Decimal('15.50'), 'description': 'Updated description' } success = cost_manager.update_cost_item(cost_item_id, updates) assert success is True # Verify updates updated = cost_manager.get_cost_item(cost_item_id) assert updated['name'] == 'Updated Name' assert float(updated['amount_eur']) == 15.50 assert updated['description'] == 'Updated description' def test_update_nonexistent_cost_item(self, cost_manager): """Test updating non-existent cost item.""" with pytest.raises(ValueError, match="not found"): cost_manager.update_cost_item(99999, {'name': 'New Name'}) def test_deactivate_cost_item(self, cost_manager, sample_category_id): """Test deactivating cost item.""" # Create cost item cost_item = CostItem( category_id=sample_category_id, name="Test Item", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_item_id = cost_manager.create_cost_item(cost_item) # Deactivate with specific ending date ending_date = date(2025, 6, 30) success = cost_manager.deactivate_cost_item(cost_item_id, ending_date) assert success is True # Verify deactivation updated = cost_manager.get_cost_item(cost_item_id) assert updated['is_active'] == 0 # SQLite stores False as 0 assert updated['ending_date'] == ending_date.isoformat() def test_list_cost_items_filtering(self, cost_manager, sample_category_id): """Test listing cost items with filtering.""" # Create multiple cost items items = [ CostItem( category_id=sample_category_id, name="Monthly Item 1", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ), CostItem( category_id=sample_category_id, name="One-time Item", cost_type="one_time", amount_eur=Decimal('50.00'), starting_from_date=date(2025, 1, 1) ), CostItem( category_id=sample_category_id, name="Inactive Item", cost_type="monthly", amount_eur=Decimal('5.00'), starting_from_date=date(2025, 1, 1), ending_date=date(2025, 1, 31), is_active=False ) ] for item in items: cost_manager.create_cost_item(item) # Test filtering by active only active_items = cost_manager.list_cost_items(active_only=True) assert len(active_items) == 2 assert all(item['is_active'] == 1 for item in active_items) # Test filtering by cost type monthly_items = cost_manager.list_cost_items(cost_type="monthly") assert len(monthly_items) == 1 # Only active monthly items assert monthly_items[0]['cost_type'] == "monthly" # Test including inactive items all_items = cost_manager.list_cost_items(active_only=False) assert len(all_items) == 3 def test_get_active_costs_for_period(self, cost_manager, sample_category_id): """Test retrieving active costs for specific period.""" # Create cost items with different date ranges items = [ CostItem( category_id=sample_category_id, name="Active Throughout", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2024, 12, 1) ), CostItem( category_id=sample_category_id, name="Starts Mid-Period", cost_type="monthly", amount_eur=Decimal('15.00'), starting_from_date=date(2025, 1, 15) ), CostItem( category_id=sample_category_id, name="Ends Mid-Period", cost_type="monthly", amount_eur=Decimal('20.00'), starting_from_date=date(2024, 12, 1), ending_date=date(2025, 1, 15) ), CostItem( category_id=sample_category_id, name="Outside Period", cost_type="monthly", amount_eur=Decimal('25.00'), starting_from_date=date(2025, 2, 1) ) ] for item in items: cost_manager.create_cost_item(item) # Get active costs for January 2025 period_start = date(2025, 1, 1) period_end = date(2025, 1, 31) active_costs = cost_manager.get_active_costs_for_period(period_start, period_end) # Should include first 3 items but not the fourth assert len(active_costs) == 3 names = [item['name'] for item in active_costs] assert "Active Throughout" in names assert "Starts Mid-Period" in names assert "Ends Mid-Period" in names assert "Outside Period" not in names def test_calculate_period_costs(self, cost_manager, sample_category_id): """Test period cost calculations.""" # Create another category other_category_id = cost_manager.create_category("Other Category") # Create cost items in different categories items = [ CostItem( category_id=sample_category_id, name="Monthly Cost 1", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ), CostItem( category_id=sample_category_id, name="Monthly Cost 2", cost_type="monthly", amount_eur=Decimal('15.00'), starting_from_date=date(2025, 1, 1) ), CostItem( category_id=other_category_id, name="One-time Cost", cost_type="one_time", amount_eur=Decimal('100.00'), starting_from_date=date(2025, 1, 1) ) ] for item in items: cost_manager.create_cost_item(item) # Calculate costs for January 2025 period_start = date(2025, 1, 1) period_end = date(2025, 1, 31) calculations = cost_manager.calculate_period_costs(period_start, period_end) assert calculations['total_monthly'] == 25.00 assert calculations['total_one_time'] == 100.00 assert calculations['total_period'] == 125.00 assert calculations['active_cost_items'] == 3 # Check category breakdown assert 'Test Category' in calculations['category_breakdown'] assert 'Other Category' in calculations['category_breakdown'] assert calculations['category_breakdown']['Test Category']['monthly'] == 25.00 assert calculations['category_breakdown']['Other Category']['one_time'] == 100.00 def test_category_management(self, cost_manager): """Test category creation and management.""" # Create category with unique name category_id = cost_manager.create_category("Custom Infrastructure", "Custom server costs") assert category_id is not None # Retrieve category category = cost_manager.get_category(category_id) assert category['name'] == "Custom Infrastructure" assert category['description'] == "Custom server costs" # Test duplicate category with pytest.raises(ValueError, match="already exists"): cost_manager.create_category("Custom Infrastructure") # List categories categories = cost_manager.list_categories() category_names = [cat['name'] for cat in categories] assert "Custom Infrastructure" in category_names # Should also include default categories from schema initialization assert len(categories) >= 9 # 8 default + 1 created # Get category by name found_category = cost_manager.get_category_by_name("Custom Infrastructure") assert found_category['id'] == category_id def test_cost_item_with_category_validation(self, cost_manager): """Test cost item creation with category validation.""" # Try to create cost item with non-existent category with pytest.raises(ValueError, match="does not exist"): cost_item = CostItem( category_id=99999, name="Test Item", cost_type="monthly", amount_eur=Decimal('10.00'), starting_from_date=date(2025, 1, 1) ) cost_manager.create_cost_item(cost_item) def test_precision_handling(self, cost_manager, sample_category_id): """Test decimal precision in cost calculations.""" # Create cost item with precise decimal cost_item = CostItem( category_id=sample_category_id, name="Precise Cost", cost_type="monthly", amount_eur=Decimal('10.99'), starting_from_date=date(2025, 1, 1) ) cost_item_id = cost_manager.create_cost_item(cost_item) # Verify precision is maintained retrieved = cost_manager.get_cost_item(cost_item_id) assert float(retrieved['amount_eur']) == 10.99 # Test in period calculations calculations = cost_manager.calculate_period_costs(date(2025, 1, 1), date(2025, 1, 31)) assert calculations['total_monthly'] == 10.99 def test_empty_database_operations(self, cost_manager): """Test operations on empty database.""" # List items in empty database items = cost_manager.list_cost_items() assert len(items) == 0 # Get non-existent item item = cost_manager.get_cost_item(99999) assert item is None # Calculate costs for empty period calculations = cost_manager.calculate_period_costs(date(2025, 1, 1), date(2025, 1, 31)) assert calculations['total_monthly'] == 0.00 assert calculations['total_one_time'] == 0.00 assert calculations['active_cost_items'] == 0