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
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:
393
tests/test_cost_cli_commands.py
Normal file
393
tests/test_cost_cli_commands.py
Normal 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
398
tests/test_cost_manager.py
Normal 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
|
||||
357
tests/test_cost_report_generator.py
Normal file
357
tests/test_cost_report_generator.py
Normal 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
|
||||
430
tests/test_finance_models.py
Normal file
430
tests/test_finance_models.py
Normal 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()
|
||||
Reference in New Issue
Block a user