feat: implement cost report template generator with Claude session tracking (issue #119)
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled

Comprehensive cost tracking system implementation including:

- Cost report generator with multiple formats (summary, detailed, audit)
- Full CLI integration with cost management commands
- Claude session cost tracking and estimation
- Professional markdown reports with frontmatter/contentmatter
- Automatic cost note generation for issue implementations
- Complete test coverage (33 test cases)
- Database integration with finance schema initialization

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-04 01:31:36 +02:00
parent 59814d84d8
commit dab6b9fdef
14 changed files with 4179 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
"""
Tests for MarkiTect cost tracking CLI commands.
This module tests the command-line interface for cost management including:
- Cost report generation commands
- Cost item management commands
- Category management commands
- Period cost calculations
"""
import pytest
import tempfile
import os
import json
from datetime import date
from decimal import Decimal
from click.testing import CliRunner
from markitect.finance.cli import cost_commands
from markitect.finance.cost_manager import CostItemManager, CostItem
from markitect.finance.models import FinanceModels
class TestCostCLICommands:
"""Test suite for cost tracking 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 cost data."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
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 sample cost items
cost_items = [
CostItem(
category_id=infra_cat['id'],
name='Test Server',
cost_type='monthly',
amount_eur=Decimal('25.00'),
starting_from_date=date(2025, 1, 1)
),
CostItem(
category_id=software_cat['id'],
name='Test Software',
cost_type='one_time',
amount_eur=Decimal('50.00'),
starting_from_date=date(2025, 1, 15)
)
]
for item in cost_items:
cost_manager.create_cost_item(item)
return temp_db
@pytest.fixture
def runner(self):
"""Create Click test runner."""
return CliRunner()
def test_cost_report_generate_summary(self, runner, setup_test_data):
"""Test cost report generate command with summary format."""
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', '2025-01',
'--format', 'summary',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Cost Summary Report - January 2025" in result.output
assert "€75.00" in result.output # 25 + 50
assert "frontmatter" not in result.output.lower() # Should be properly formatted
def test_cost_report_generate_detailed(self, runner, setup_test_data):
"""Test cost report generate command with detailed format."""
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', '2025-01',
'--format', 'detailed',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Detailed Cost Report - January 2025" in result.output
assert "Infrastructure" in result.output
assert "Software" in result.output
assert "Test Server" in result.output
assert "Test Software" in result.output
def test_cost_report_generate_audit(self, runner, setup_test_data):
"""Test cost report generate command with audit format."""
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', '2025-01',
'--format', 'audit',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Cost Audit Report - January 2025" in result.output
assert "Audit Summary" in result.output
assert "Transaction History" in result.output
def test_cost_report_generate_with_output_file(self, runner, setup_test_data):
"""Test saving report to output file."""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.md') as f:
output_path = f.name
try:
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', '2025-01',
'--output', output_path,
'--database', setup_test_data
])
assert result.exit_code == 0
assert f"Report saved to: {output_path}" in result.output
# Verify file was created
assert os.path.exists(output_path)
with open(output_path, 'r') as f:
content = f.read()
assert "Cost Summary Report" in content
finally:
if os.path.exists(output_path):
os.unlink(output_path)
def test_cost_report_generate_invalid_period(self, runner, setup_test_data):
"""Test report generation with invalid period format."""
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', 'invalid-period',
'--database', setup_test_data
])
assert result.exit_code == 1
assert "Period must be in YYYY-MM format" in result.output
def test_cost_report_generate_default_database(self, runner):
"""Test report generation with default database path from config."""
result = runner.invoke(cost_commands, [
'report', 'generate',
'--period', '2025-01'
])
# Should succeed with default config and empty database
assert result.exit_code == 0
assert "Cost Summary Report - January 2025" in result.output
assert "€0.00" in result.output # Empty database shows zero costs
def test_cost_report_template_show(self, runner):
"""Test cost report template show command."""
result = runner.invoke(cost_commands, [
'report', 'template', '--show'
])
assert result.exit_code == 0
assert "Summary Report Template" in result.output
assert "Description" in result.output
assert "Frontmatter Fields" in result.output
def test_cost_report_template_different_formats(self, runner):
"""Test template show for different formats."""
formats = ['summary', 'detailed', 'audit']
for format_type in formats:
result = runner.invoke(cost_commands, [
'report', 'template', '--show', '--format', format_type
])
assert result.exit_code == 0
assert f"{format_type.title()} Report Template" in result.output
def test_cost_item_add(self, runner, temp_db):
"""Test adding new cost item via CLI."""
# Initialize database
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
result = runner.invoke(cost_commands, [
'item', 'add', 'Test Item',
'--category', 'Infrastructure',
'--amount', '15.50',
'--type', 'monthly',
'--database', temp_db
])
assert result.exit_code == 0
assert "✅ Created cost item 'Test Item'" in result.output
# Verify item was created
cost_manager = CostItemManager(temp_db)
items = cost_manager.list_cost_items()
assert len(items) == 1
assert items[0]['name'] == 'Test Item'
assert float(items[0]['amount_eur']) == 15.50
def test_cost_item_add_with_description_and_date(self, runner, temp_db):
"""Test adding cost item with description and start date."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
result = runner.invoke(cost_commands, [
'item', 'add', 'Test Item',
'--category', 'Software',
'--amount', '99.99',
'--type', 'one_time',
'--description', 'Test description',
'--start-date', '2025-01-15',
'--database', temp_db
])
assert result.exit_code == 0
assert "✅ Created cost item 'Test Item'" in result.output
def test_cost_item_add_invalid_category(self, runner, temp_db):
"""Test adding item with non-existent category."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
result = runner.invoke(cost_commands, [
'item', 'add', 'Test Item',
'--category', 'NonExistent',
'--amount', '10.00',
'--type', 'monthly',
'--database', temp_db
])
assert result.exit_code == 1
assert "Category 'NonExistent' not found" in result.output
assert "Available categories:" in result.output
def test_cost_item_list(self, runner, setup_test_data):
"""Test listing cost items."""
result = runner.invoke(cost_commands, [
'item', 'list',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Test Server" in result.output
assert "Test Software" in result.output
assert "€25.00" in result.output
assert "€50.00" in result.output
def test_cost_item_list_with_filters(self, runner, setup_test_data):
"""Test listing cost items with filters."""
# Filter by category
result = runner.invoke(cost_commands, [
'item', 'list',
'--category', 'Infrastructure',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Test Server" in result.output
assert "Test Software" not in result.output
# Filter by type
result = runner.invoke(cost_commands, [
'item', 'list',
'--type', 'monthly',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Test Server" in result.output
assert "Test Software" not in result.output
def test_cost_category_list(self, runner, setup_test_data):
"""Test listing cost categories."""
result = runner.invoke(cost_commands, [
'category', 'list',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Infrastructure" in result.output
assert "Software" in result.output
assert "Total: 8 categories" in result.output # Default categories
def test_cost_category_add(self, runner, temp_db):
"""Test adding new cost category."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
result = runner.invoke(cost_commands, [
'category', 'add', 'Custom Category',
'--description', 'Custom test category',
'--database', temp_db
])
assert result.exit_code == 0
assert "✅ Created category 'Custom Category'" in result.output
# Verify category was created
cost_manager = CostItemManager(temp_db)
categories = cost_manager.list_categories()
category_names = [cat['name'] for cat in categories]
assert 'Custom Category' in category_names
def test_cost_calculate(self, runner, setup_test_data):
"""Test cost calculation command."""
result = runner.invoke(cost_commands, [
'calculate',
'--period', '2025-01',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Cost Calculation - January 2025" in result.output
assert "Monthly Recurring: €25.00" in result.output
assert "One-time Expenses: €50.00" in result.output
assert "Total Period Cost: €75.00" in result.output
assert "Active Cost Items: 2" in result.output
def test_cost_calculate_current_month(self, runner, setup_test_data):
"""Test cost calculation for current month (default)."""
result = runner.invoke(cost_commands, [
'calculate',
'--database', setup_test_data
])
assert result.exit_code == 0
assert "Cost Calculation" in result.output
# Should default to current month
def test_cost_calculate_invalid_period(self, runner, setup_test_data):
"""Test cost calculation with invalid period."""
result = runner.invoke(cost_commands, [
'calculate',
'--period', 'invalid',
'--database', setup_test_data
])
assert result.exit_code == 1
assert "Period must be in YYYY-MM format" in result.output
def test_cost_item_add_invalid_date_format(self, runner, temp_db):
"""Test adding item with invalid date format."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
result = runner.invoke(cost_commands, [
'item', 'add', 'Test Item',
'--category', 'Infrastructure',
'--amount', '10.00',
'--type', 'monthly',
'--start-date', 'invalid-date',
'--database', temp_db
])
assert result.exit_code == 1
assert "Start date must be in YYYY-MM-DD format" in result.output
def test_help_commands(self, runner):
"""Test help output for cost commands."""
# Test main cost help
result = runner.invoke(cost_commands, ['--help'])
assert result.exit_code == 0
assert "Cost tracking and financial reporting commands" in result.output
# Test report help
result = runner.invoke(cost_commands, ['report', '--help'])
assert result.exit_code == 0
assert "Generate cost reports" in result.output
# Test item help
result = runner.invoke(cost_commands, ['item', '--help'])
assert result.exit_code == 0
assert "Manage cost items" in result.output
# Test category help
result = runner.invoke(cost_commands, ['category', '--help'])
assert result.exit_code == 0
assert "Manage cost categories" in result.output

398
tests/test_cost_manager.py Normal file
View File

@@ -0,0 +1,398 @@
"""
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

View File

@@ -0,0 +1,357 @@
"""
Tests for MarkiTect cost report template generator.
This module tests the complete cost report generation functionality including:
- Report generation in different formats (summary, detailed, audit)
- Markdown output with frontmatter and contentmatter
- CLI integration and command functionality
- Template structure validation
"""
import pytest
import tempfile
import os
import json
from datetime import date, datetime
from decimal import Decimal
from markitect.finance.cost_manager import CostItemManager, CostItem
from markitect.finance.report_generator import CostReportGenerator, ReportConfig
from markitect.finance.models import FinanceModels
class TestCostReportGenerator:
"""Test suite for cost report generation 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 setup_test_data(self, temp_db):
"""Setup test database with sample cost data."""
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
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 sample cost items
cost_items = [
CostItem(
category_id=infra_cat['id'],
name='Hosteurope Server',
description='Monthly server hosting',
cost_type='monthly',
amount_eur=Decimal('10.00'),
starting_from_date=date(2025, 1, 1)
),
CostItem(
category_id=software_cat['id'],
name='Bubble.io Plan',
description='No-code platform subscription',
cost_type='monthly',
amount_eur=Decimal('32.00'),
starting_from_date=date(2025, 1, 1)
),
CostItem(
category_id=infra_cat['id'],
name='SSL Certificate',
description='Annual SSL certificate',
cost_type='one_time',
amount_eur=Decimal('45.00'),
starting_from_date=date(2025, 1, 15)
)
]
for item in cost_items:
cost_manager.create_cost_item(item)
return temp_db
@pytest.fixture
def report_generator(self, setup_test_data):
"""Create report generator with test data."""
return CostReportGenerator(setup_test_data)
def test_report_config_creation(self):
"""Test ReportConfig dataclass creation."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31),
currency="EUR"
)
assert config.format == "summary"
assert config.period_start == date(2025, 1, 1)
assert config.period_end == date(2025, 1, 31)
assert config.currency == "EUR"
assert config.include_inactive is False
assert config.output_path is None
def test_generate_summary_report(self, report_generator):
"""Test generation of summary cost report."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Check that it's valid markdown with frontmatter
assert report.startswith("---")
assert "Cost Summary Report - January 2025" in report
assert "total_costs: 87.0" in report
assert "report_type: \"cost_summary\"" in report
# Check contentmatter is present
assert "contentmatter:" in report
assert "cost_data" in report
# Verify total costs are correct (10 + 32 + 45 = 87)
assert "€87.00" in report
def test_generate_detailed_report(self, report_generator):
"""Test generation of detailed cost report."""
config = ReportConfig(
format="detailed",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Check report structure
assert "Detailed Cost Report - January 2025" in report
assert "Executive Summary" in report
assert "report_type: \"cost_detailed\"" in report
# Check category sections are present
assert "Infrastructure" in report
assert "Software" in report
# Check individual items are listed
assert "Hosteurope Server" in report
assert "Bubble.io Plan" in report
assert "SSL Certificate" in report
# Check table format
assert "| Name | Type | Amount | Status | Start Date |" in report
def test_generate_audit_report(self, report_generator):
"""Test generation of audit trail report."""
config = ReportConfig(
format="audit",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Check report structure
assert "Cost Audit Report - January 2025" in report
assert "Audit Summary" in report
assert "report_type: \"cost_audit\"" in report
assert "audit_trail: True" in report
# Check audit sections
assert "Cost Verification" in report
assert "Active Cost Items" in report
assert "Transaction History" in report
assert "Audit Trail" in report
# Check contentmatter includes audit data
assert "audit_data" in report
def test_generate_period_report_convenience_method(self, report_generator):
"""Test convenience method for generating monthly reports."""
report = report_generator.generate_period_report(2025, 1, "summary")
assert "Cost Summary Report - January 2025" in report
assert "2025-01-01" in report
assert "2025-01-31" in report
def test_invalid_report_format_raises_error(self, report_generator):
"""Test that invalid report format raises ValueError."""
config = ReportConfig(
format="invalid",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
with pytest.raises(ValueError, match="Unknown report format"):
report_generator.generate_report(config)
def test_frontmatter_structure(self, report_generator):
"""Test frontmatter structure in generated reports."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Extract frontmatter (between first two ---)
lines = report.split('\n')
frontmatter_lines = []
in_frontmatter = False
for line in lines:
if line.strip() == "---":
if not in_frontmatter:
in_frontmatter = True
continue
else:
break
if in_frontmatter:
frontmatter_lines.append(line)
frontmatter_text = '\n'.join(frontmatter_lines)
# Check required frontmatter fields
assert 'report_type:' in frontmatter_text
assert 'period_start:' in frontmatter_text
assert 'period_end:' in frontmatter_text
assert 'total_costs:' in frontmatter_text
assert 'currency:' in frontmatter_text
assert 'generated_at:' in frontmatter_text
def test_contentmatter_structure(self, report_generator):
"""Test contentmatter structure in generated reports."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Extract contentmatter (JSON in HTML comment)
assert "<!--" in report
assert "contentmatter:" in report
assert "-->" in report
# Find and extract JSON
start = report.find("contentmatter:\n") + len("contentmatter:\n")
end = report.find("\n-->")
json_text = report[start:end].strip()
# Parse JSON to verify structure
contentmatter = json.loads(json_text)
assert "cost_data" in contentmatter
assert "total_monthly" in contentmatter["cost_data"]
assert "total_one_time" in contentmatter["cost_data"]
assert "categories" in contentmatter["cost_data"]
assert "active_items" in contentmatter["cost_data"]
# Verify totals
assert contentmatter["cost_data"]["total_monthly"] == 42.0
assert contentmatter["cost_data"]["total_one_time"] == 45.0
def test_save_report_to_file(self, report_generator, temp_db):
"""Test saving report to file."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Save to temporary file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.md') as f:
output_path = f.name
try:
report_generator.save_report(report, output_path)
# Verify file was created and contains expected content
with open(output_path, 'r', encoding='utf-8') as f:
saved_content = f.read()
assert saved_content == report
assert "Cost Summary Report" in saved_content
finally:
os.unlink(output_path)
def test_empty_database_report(self, temp_db):
"""Test report generation with empty database."""
# Initialize empty database
finance_models = FinanceModels(temp_db)
finance_models.initialize_finance_schema()
report_generator = CostReportGenerator(temp_db)
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31)
)
report = report_generator.generate_report(config)
# Should still generate valid report with zero costs
assert "total_costs: 0.0" in report
assert "€0.00" in report
def test_different_currency(self, report_generator):
"""Test report generation with different currency."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31),
currency="USD"
)
report = report_generator.generate_report(config)
assert 'currency: "USD"' in report
# Note: amounts are still in EUR from database, currency is just metadata
def test_report_with_inactive_items(self, setup_test_data):
"""Test report behavior with inactive cost items."""
cost_manager = CostItemManager(setup_test_data)
# Deactivate one item
items = cost_manager.list_cost_items()
if items:
cost_manager.deactivate_cost_item(items[0]['id'], date(2025, 1, 15))
report_generator = CostReportGenerator(setup_test_data)
config = ReportConfig(
format="detailed",
period_start=date(2025, 1, 1),
period_end=date(2025, 1, 31),
include_inactive=False
)
report = report_generator.generate_report(config)
# Should still generate valid report, potentially with fewer active items
assert "Detailed Cost Report" in report
assert "contentmatter:" in report
def test_cross_month_period(self, report_generator):
"""Test report generation across multiple months."""
config = ReportConfig(
format="summary",
period_start=date(2025, 1, 15),
period_end=date(2025, 2, 15)
)
report = report_generator.generate_report(config)
assert "2025-01-15" in report
assert "2025-02-15" in report
# Should include items active during this period

View File

@@ -0,0 +1,430 @@
"""
Tests for MarkiTect finance models and database schema.
This module tests the complete finance schema including:
- Database table creation and relationships
- Data integrity constraints
- Index performance
- Schema validation
- Migration functionality
"""
import pytest
import tempfile
import os
from datetime import date, datetime
from decimal import Decimal
from markitect.finance.models import FinanceModels
class TestFinanceModels:
"""Test suite for finance database models."""
@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 finance_models(self, temp_db):
"""Create FinanceModels instance with temporary database."""
return FinanceModels(temp_db)
def test_initialize_finance_schema(self, finance_models):
"""Test complete finance schema initialization."""
# Initialize schema
finance_models.initialize_finance_schema()
# Validate schema was created
assert finance_models.validate_schema()
# Check all required tables exist
schema_info = finance_models.get_schema_info()
expected_tables = [
'cost_categories',
'cost_items',
'cost_periods',
'cost_transactions',
'issue_cost_allocations',
'issue_activity_log'
]
for table in expected_tables:
assert table in schema_info['tables']
assert len(schema_info['tables'][table]['columns']) > 0
def test_cost_categories_table(self, finance_models):
"""Test cost categories table structure and data."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Test default categories were inserted
cursor.execute('SELECT COUNT(*) FROM cost_categories')
count = cursor.fetchone()[0]
assert count >= 8 # At least 8 default categories
# Test unique constraint
with pytest.raises(Exception): # Should violate unique constraint
cursor.execute('''
INSERT INTO cost_categories (name, description)
VALUES ('Infrastructure', 'Duplicate category')
''')
conn.close()
def test_cost_items_table(self, finance_models):
"""Test cost items table constraints and relationships."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Insert test category
cursor.execute('''
INSERT INTO cost_categories (name, description)
VALUES ('Test Category', 'For testing')
''')
category_id = cursor.lastrowid
# Test valid cost item insertion
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (?, 'Test Server', 'monthly', 10.50, '2025-01-01')
''', (category_id,))
# Test cost_type constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (?, 'Invalid Type', 'invalid', 10.00, '2025-01-01')
''', (category_id,))
# Test negative amount constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (?, 'Negative Cost', 'monthly', -10.00, '2025-01-01')
''', (category_id,))
# Test date range constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date, ending_date)
VALUES (?, 'Invalid Dates', 'monthly', 10.00, '2025-01-01', '2024-12-31')
''', (category_id,))
conn.close()
def test_cost_periods_table(self, finance_models):
"""Test cost periods table constraints."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Test valid period insertion
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-01-01', '2025-01-31')
''')
# Test period date constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-01-31', '2025-01-01')
''')
# Test status constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end, status)
VALUES ('2025-02-01', '2025-02-28', 'invalid_status')
''')
# Test unique period constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-01-01', '2025-01-31')
''')
conn.close()
def test_cost_transactions_table(self, finance_models):
"""Test cost transactions table and audit trail."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Create test data
cursor.execute('''
INSERT INTO cost_categories (name) VALUES ('Test Category')
''')
category_id = cursor.lastrowid
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (?, 'Test Item', 'monthly', 10.00, '2025-01-01')
''', (category_id,))
cost_item_id = cursor.lastrowid
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-01-01', '2025-01-31')
''')
period_id = cursor.lastrowid
# Test valid transaction
cursor.execute('''
INSERT INTO cost_transactions
(period_id, cost_item_id, transaction_type, amount_eur, transaction_date)
VALUES (?, ?, 'cost_incurred', 10.00, '2025-01-15')
''', (period_id, cost_item_id))
# Test transaction type constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_transactions
(period_id, cost_item_id, transaction_type, amount_eur, transaction_date)
VALUES (?, ?, 'invalid_type', 10.00, '2025-01-15')
''', (period_id, cost_item_id))
conn.close()
def test_issue_cost_allocations_table(self, finance_models):
"""Test issue cost allocations table."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Create test period
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-01-01', '2025-01-31')
''')
period_id = cursor.lastrowid
# Test valid allocation
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (123, ?, 5.50, '2025-01-31')
''', (period_id,))
# Test positive amount constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (124, ?, -1.00, '2025-01-31')
''', (period_id,))
# Test unique issue-period constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (123, ?, 3.00, '2025-01-31')
''', (period_id,))
conn.close()
def test_issue_activity_log_table(self, finance_models):
"""Test issue activity log table."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Test valid activity log entry
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date)
VALUES (123, 'created', '2025-01-15')
''')
# Test activity type constraint
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date)
VALUES (124, 'invalid_activity', '2025-01-15')
''')
conn.close()
def test_foreign_key_constraints(self, finance_models):
"""Test foreign key relationships are enforced."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Test cost_items references cost_categories
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (999, 'Invalid Category', 'monthly', 10.00, '2025-01-01')
''')
# Test cost_transactions references cost_periods
with pytest.raises(Exception):
cursor.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, transaction_date)
VALUES (999, 'cost_incurred', 10.00, '2025-01-15')
''')
conn.close()
def test_indexes_created(self, finance_models):
"""Test that performance indexes are created."""
finance_models.initialize_finance_schema()
schema_info = finance_models.get_schema_info()
index_names = [idx['name'] for idx in schema_info['indexes']]
# Check critical indexes exist
expected_indexes = [
'idx_cost_items_active',
'idx_cost_items_type',
'idx_cost_periods_status',
'idx_cost_transactions_period',
'idx_issue_allocations_issue'
]
for index in expected_indexes:
assert index in index_names
def test_schema_validation(self, finance_models):
"""Test schema validation functionality."""
# Before initialization
assert not finance_models.validate_schema()
# After initialization
finance_models.initialize_finance_schema()
assert finance_models.validate_schema()
def test_drop_finance_schema(self, finance_models):
"""Test schema cleanup functionality."""
# Initialize schema
finance_models.initialize_finance_schema()
assert finance_models.validate_schema()
# Drop schema
finance_models.drop_finance_schema()
assert not finance_models.validate_schema()
def test_database_integration(self, temp_db):
"""Test integration with existing DatabaseManager."""
from markitect.database import DatabaseManager
# Initialize standard database
db_manager = DatabaseManager(temp_db)
db_manager.initialize_database()
# Verify finance tables were also created
finance_models = FinanceModels(temp_db)
assert finance_models.validate_schema()
# Verify existing tables still exist
conn = finance_models.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT name FROM sqlite_master
WHERE type='table' AND name IN ('markdown_files', 'schemas')
''')
existing_tables = [row[0] for row in cursor.fetchall()]
assert 'markdown_files' in existing_tables
assert 'schemas' in existing_tables
conn.close()
def test_decimal_precision(self, finance_models):
"""Test decimal precision for financial calculations."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Insert test category
cursor.execute('''
INSERT INTO cost_categories (name) VALUES ('Test Category')
''')
category_id = cursor.lastrowid
# Test precise decimal amounts
test_amounts = [10.50, 99.99, 0.01, 1234.56]
for amount in test_amounts:
cursor.execute('''
INSERT INTO cost_items
(category_id, name, cost_type, amount_eur, starting_from_date)
VALUES (?, ?, 'monthly', ?, '2025-01-01')
''', (category_id, f'Test Item {amount}', amount))
# Verify precision is maintained
cursor.execute('SELECT amount_eur FROM cost_items ORDER BY id')
stored_amounts = [float(row[0]) for row in cursor.fetchall()]
assert stored_amounts == test_amounts
conn.close()
def test_example_cost_data(self, finance_models):
"""Test insertion of example cost data from issue description."""
finance_models.initialize_finance_schema()
conn = finance_models.get_connection()
cursor = conn.cursor()
# Get category IDs
cursor.execute('SELECT id, name FROM cost_categories')
categories = {name: id for id, name in cursor.fetchall()}
# Insert example costs from issue #88
example_costs = [
('Infrastructure', 'Hosteurope Server', 'Monthly server hosting', 10.00),
('Software', 'Bubble.io Plan', 'No-code platform subscription', 32.00),
('Domain & DNS', 'Coulomb.social Domain', 'Domain registration', 5.00),
('Development Tools', 'Claude Code Plan', 'AI coding assistant', 20.00),
('AI & ML Services', 'Gemini Plan', 'LLM API for specifications', 20.00)
]
for category_name, name, description, amount in example_costs:
category_id = categories.get(category_name)
assert category_id is not None
cursor.execute('''
INSERT INTO cost_items
(category_id, name, description, cost_type, amount_eur, starting_from_date)
VALUES (?, ?, ?, 'monthly', ?, '2025-01-01')
''', (category_id, name, description, amount))
# Verify total monthly costs
cursor.execute('''
SELECT SUM(amount_eur) FROM cost_items
WHERE cost_type = 'monthly' AND is_active = TRUE
''')
total_monthly = float(cursor.fetchone()[0])
assert total_monthly == 87.00 # €87/month as described in issue
conn.close()