Files
markitect-main/tests/test_period_manager.py
tegwick 397b607442 feat: implement comprehensive Period Management Framework (issue #112)
Complete period lifecycle management system including:

- PeriodManager class with full lifecycle operations
- Period status management (open/calculating/closed) with validation
- Period overlap detection and conflict resolution
- Comprehensive cost calculation and aggregation engine
- Loss carried forward calculations between periods
- Period closure validation with audit trails
- Current period detection and auto-creation utilities

CLI Integration:
- Complete period command suite (create, list, show, calculate, status, close, current)
- Professional CLI output with detailed formatting
- Comprehensive error handling and validation
- Date filtering and status filtering capabilities

Testing:
- 25 core PeriodManager tests covering all functionality
- 24 CLI command tests ensuring proper integration
- Edge case testing for complex scenarios
- 49 total tests passing with comprehensive coverage

Database Integration:
- Utilizes existing cost_periods schema from FinanceModels
- Full SQLite integration with proper constraints
- Performance-optimized indexes and queries
- Seamless integration with existing cost tracking system

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 01:41:58 +02:00

489 lines
19 KiB
Python

"""
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