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>
This commit is contained in:
454
tests/test_period_cli_commands.py
Normal file
454
tests/test_period_cli_commands.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Tests for MarkiTect period management CLI commands.
|
||||
|
||||
This module tests the command-line interface for period management including:
|
||||
- Period creation, listing, and status management
|
||||
- Period calculation and lifecycle operations
|
||||
- CLI error handling and validation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from click.testing import CliRunner
|
||||
|
||||
from markitect.finance.cli import cost_commands
|
||||
from markitect.finance.models import FinanceModels
|
||||
from markitect.finance.period_manager import PeriodManager
|
||||
from markitect.finance.cost_manager import CostItemManager, CostItem
|
||||
|
||||
|
||||
class TestPeriodCLICommands:
|
||||
"""Test suite for period management CLI commands."""
|
||||
|
||||
@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 setup_test_data(self, temp_db):
|
||||
"""Setup test database with sample period data."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
period_manager = PeriodManager(temp_db)
|
||||
|
||||
# Create sample period
|
||||
period_id = period_manager.create_period(
|
||||
period_start=date(2025, 1, 1),
|
||||
period_end=date(2025, 1, 31),
|
||||
period_type='monthly'
|
||||
)
|
||||
|
||||
return temp_db, period_id
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""Create Click test runner."""
|
||||
return CliRunner()
|
||||
|
||||
def test_period_create_success(self, runner, temp_db):
|
||||
"""Test period creation via CLI."""
|
||||
# Initialize database first
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-02-01',
|
||||
'--end-date', '2025-02-28',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "📅 Period: 2025-02-01 to 2025-02-28" in result.output
|
||||
assert "📊 Type: monthly" in result.output
|
||||
|
||||
def test_period_create_with_loss_forward(self, runner, temp_db):
|
||||
"""Test period creation with loss carried forward."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-03-01',
|
||||
'--end-date', '2025-03-31',
|
||||
'--loss-forward', '15.75',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "💸 Loss carried forward: €15.7500" in result.output
|
||||
|
||||
def test_period_create_invalid_dates(self, runner, temp_db):
|
||||
"""Test period creation with invalid date format."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', 'invalid-date',
|
||||
'--end-date', '2025-02-28',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Dates must be in YYYY-MM-DD format" in result.output
|
||||
|
||||
def test_period_create_overlapping_fails(self, runner, setup_test_data):
|
||||
"""Test that creating overlapping periods fails."""
|
||||
temp_db, existing_period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-01-15', # Overlaps with existing period
|
||||
'--end-date', '2025-02-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
assert "overlaps" in result.output.lower()
|
||||
|
||||
def test_period_list_all(self, runner, setup_test_data):
|
||||
"""Test listing all periods."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "📅 Calculation Periods" in result.output
|
||||
assert "2025-01-01" in result.output
|
||||
assert "2025-01-31" in result.output
|
||||
assert "Total: 1 periods" in result.output
|
||||
|
||||
def test_period_list_with_status_filter(self, runner, setup_test_data):
|
||||
"""Test listing periods with status filter."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
# Create second period and close it
|
||||
period_manager = PeriodManager(temp_db)
|
||||
period_id2 = period_manager.create_period(
|
||||
period_start=date(2025, 2, 1),
|
||||
period_end=date(2025, 2, 28)
|
||||
)
|
||||
period_manager.close_period(period_id2)
|
||||
|
||||
# Filter by open status
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--status', 'open',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-01-01" in result.output # First period should be shown
|
||||
assert "2025-02-01" not in result.output # Second period should be filtered out
|
||||
|
||||
# Filter by closed status
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--status', 'closed',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-02-01" in result.output # Second period should be shown
|
||||
assert "2025-01-01" not in result.output # First period should be filtered out
|
||||
|
||||
def test_period_list_with_date_filters(self, runner, temp_db):
|
||||
"""Test listing periods with date range filters."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
period_manager = PeriodManager(temp_db)
|
||||
|
||||
# Create periods in different months
|
||||
jan_period = period_manager.create_period(date(2025, 1, 1), date(2025, 1, 31))
|
||||
feb_period = period_manager.create_period(date(2025, 2, 1), date(2025, 2, 28))
|
||||
|
||||
# Filter by start date
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--start-from', '2025-02-01',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "2025-02-01" in result.output
|
||||
assert "2025-01-01" not in result.output
|
||||
|
||||
def test_period_list_empty(self, runner, temp_db):
|
||||
"""Test listing periods when none exist."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No periods found matching criteria" in result.output
|
||||
|
||||
def test_period_show_details(self, runner, setup_test_data):
|
||||
"""Test showing period details."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"📅 Period #{period_id} Details" in result.output
|
||||
assert "Start Date: 2025-01-01" in result.output
|
||||
assert "End Date: 2025-01-31" in result.output
|
||||
assert "Type: monthly" in result.output
|
||||
assert "Status: open" in result.output
|
||||
|
||||
def test_period_show_nonexistent(self, runner, temp_db):
|
||||
"""Test showing non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Period #999 not found" in result.output
|
||||
|
||||
def test_period_calculate(self, runner, setup_test_data):
|
||||
"""Test period cost calculation."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
# Add some cost items for calculation
|
||||
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('25.00'),
|
||||
starting_from_date=date(2025, 1, 1)
|
||||
)
|
||||
cost_manager.create_cost_item(cost_item)
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'calculate', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"📊 Period #{period_id} Cost Calculation" in result.output
|
||||
assert "Period: 2025-01-01 to 2025-01-31" in result.output
|
||||
assert "Monthly Recurring: €25.00" in result.output
|
||||
assert "Total Period Cost: €25.00" in result.output
|
||||
|
||||
def test_period_calculate_nonexistent(self, runner, temp_db):
|
||||
"""Test calculating costs for non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'calculate', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error calculating period:" in result.output
|
||||
|
||||
def test_period_status_update(self, runner, setup_test_data):
|
||||
"""Test period status update."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', str(period_id), 'calculating',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"✅ Period #{period_id} status updated to 'calculating'" in result.output
|
||||
|
||||
# Verify the status was actually updated
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert "Status: calculating" in result.output
|
||||
|
||||
def test_period_status_update_invalid_status(self, runner, setup_test_data):
|
||||
"""Test period status update with invalid status."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', str(period_id), 'invalid',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 2 # Click validation error
|
||||
assert "Invalid value" in result.output
|
||||
|
||||
def test_period_status_update_nonexistent(self, runner, temp_db):
|
||||
"""Test status update for non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'status', '999', 'calculating',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_period_close(self, runner, setup_test_data):
|
||||
"""Test period closure."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'close', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"✅ Period #{period_id} has been closed" in result.output
|
||||
assert "💰 Final total cost:" in result.output
|
||||
|
||||
# Verify the period is actually closed
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'show', str(period_id),
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert "Status: closed" in result.output
|
||||
|
||||
def test_period_close_nonexistent(self, runner, temp_db):
|
||||
"""Test closing non-existent period."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'close', '999',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in result.output
|
||||
|
||||
def test_period_current_exists(self, runner, setup_test_data):
|
||||
"""Test finding current period when it exists."""
|
||||
temp_db, period_id = setup_test_data
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', '2025-01-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "📅 Current Active Period" in result.output
|
||||
assert f"Period #{period_id}" in result.output
|
||||
assert "Dates: 2025-01-01 to 2025-01-31" in result.output
|
||||
|
||||
def test_period_current_not_found(self, runner, temp_db):
|
||||
"""Test finding current period when none exists."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', '2025-03-15',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No active period found for 2025-03-15" in result.output
|
||||
|
||||
def test_period_current_default_to_today(self, runner, temp_db):
|
||||
"""Test current period defaults to today."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No active period found for today" in result.output
|
||||
assert "💡 Create one with:" in result.output
|
||||
assert "markitect cost period create" in result.output
|
||||
|
||||
def test_period_current_invalid_date(self, runner, temp_db):
|
||||
"""Test current period with invalid date format."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'current',
|
||||
'--date', 'invalid-date',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: Date must be in YYYY-MM-DD format" in result.output
|
||||
|
||||
def test_period_help_commands(self, runner):
|
||||
"""Test help output for period commands."""
|
||||
# Test main period help
|
||||
result = runner.invoke(cost_commands, ['period', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Manage calculation periods and lifecycle" in result.output
|
||||
|
||||
# Test create help
|
||||
result = runner.invoke(cost_commands, ['period', 'create', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "Create a new calculation period" in result.output
|
||||
|
||||
# Test list help
|
||||
result = runner.invoke(cost_commands, ['period', 'list', '--help'])
|
||||
assert result.exit_code == 0
|
||||
assert "List calculation periods with optional filtering" in result.output
|
||||
|
||||
def test_period_commands_missing_database(self, runner):
|
||||
"""Test period commands without database specification."""
|
||||
# These should use default config path and still work or show appropriate error
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'list'
|
||||
])
|
||||
|
||||
# Should succeed with default database configuration
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_period_create_quarterly_type(self, runner, temp_db):
|
||||
"""Test creating quarterly period type."""
|
||||
finance_models = FinanceModels(temp_db)
|
||||
finance_models.initialize_finance_schema()
|
||||
|
||||
result = runner.invoke(cost_commands, [
|
||||
'period', 'create',
|
||||
'--start-date', '2025-04-01',
|
||||
'--end-date', '2025-06-30',
|
||||
'--type', 'quarterly',
|
||||
'--database', temp_db
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created period #" in result.output
|
||||
assert "📊 Type: quarterly" in result.output
|
||||
489
tests/test_period_manager.py
Normal file
489
tests/test_period_manager.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user