Files
TimeTracker/tests/test_models_comprehensive.py
Dries Peeters 90dde470da style: standardize code formatting and normalize line endings
- Normalize line endings from CRLF to LF across all files to match .editorconfig
- Standardize quote style from single quotes to double quotes
- Normalize whitespace and formatting throughout codebase
- Apply consistent code style across 372 files including:
  * Application code (models, routes, services, utils)
  * Test files
  * Configuration files
  * CI/CD workflows

This ensures consistency with the project's .editorconfig settings and
improves code maintainability.
2025-11-28 20:05:37 +01:00

769 lines
23 KiB
Python

"""
Comprehensive model testing suite.
Tests all models, relationships, properties, and business logic.
"""
import pytest
from datetime import datetime, timedelta, date
from decimal import Decimal
from app.models import User, Project, TimeEntry, Client, Settings, Invoice, InvoiceItem, Task
from factories import InvoiceFactory
from app import db
# ============================================================================
# User Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_user_creation(app, user):
"""Test basic user creation."""
assert user.id is not None
assert user.username == "testuser"
assert user.role == "user"
assert user.is_active is True
@pytest.mark.unit
@pytest.mark.models
def test_user_is_admin_property(app, admin_user):
"""Test user is_admin property."""
assert admin_user.is_admin is True
@pytest.mark.unit
@pytest.mark.models
def test_user_active_timer(app, user, active_timer):
"""Test user active_timer property."""
# Refresh user to load relationships
db.session.refresh(user)
assert user.active_timer is not None
assert user.active_timer.id == active_timer.id
@pytest.mark.unit
@pytest.mark.models
def test_user_time_entries_relationship(app, user, multiple_time_entries):
"""Test user time entries relationship."""
db.session.refresh(user)
assert len(user.time_entries.all()) == 5
@pytest.mark.unit
@pytest.mark.models
def test_user_to_dict(app, user):
"""Test user serialization to dictionary."""
user_dict = user.to_dict()
assert "id" in user_dict
assert "username" in user_dict
assert "role" in user_dict
# Should not include sensitive data
assert "password" not in user_dict
# ============================================================================
# Client Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_client_creation(app, test_client):
"""Test basic client creation."""
assert test_client.id is not None
assert test_client.name == "Test Client Corp"
assert test_client.status == "active"
assert test_client.default_hourly_rate == Decimal("85.00")
@pytest.mark.unit
@pytest.mark.models
def test_client_projects_relationship(app, test_client, multiple_projects):
"""Test client projects relationship."""
db.session.refresh(test_client)
assert len(test_client.projects.all()) == 3
@pytest.mark.unit
@pytest.mark.models
def test_client_total_projects_property(app, test_client, multiple_projects):
"""Test client total_projects property."""
db.session.refresh(test_client)
assert test_client.total_projects == 3
@pytest.mark.unit
@pytest.mark.models
def test_client_archive_activate(app, test_client):
"""Test client archive and activate methods."""
db.session.refresh(test_client)
# Archive client
test_client.archive()
db.session.commit()
assert test_client.status == "inactive"
# Activate client
test_client.activate()
db.session.commit()
assert test_client.status == "active"
@pytest.mark.unit
@pytest.mark.models
def test_client_get_active_clients(app, multiple_clients):
"""Test get_active_clients class method."""
active_clients = Client.get_active_clients()
assert len(active_clients) >= 3
@pytest.mark.unit
@pytest.mark.models
def test_client_to_dict(app, test_client):
"""Test client serialization to dictionary."""
client_dict = test_client.to_dict()
assert "id" in client_dict
assert "name" in client_dict
assert "status" in client_dict
# ============================================================================
# Project Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_project_creation(app, project):
"""Test basic project creation."""
assert project.id is not None
assert project.name == "Test Project"
assert project.billable is True
assert project.status == "active"
@pytest.mark.unit
@pytest.mark.models
def test_project_client_relationship(app, project, test_client):
"""Test project client relationship."""
db.session.refresh(project)
db.session.refresh(test_client)
assert project.client_id == test_client.id
# Check backward compatibility
if hasattr(project, "client"):
assert project.client == test_client.name
@pytest.mark.unit
@pytest.mark.models
def test_project_time_entries_relationship(app, project, multiple_time_entries):
"""Test project time entries relationship."""
db.session.refresh(project)
assert len(project.time_entries.all()) == 5
@pytest.mark.unit
@pytest.mark.models
def test_project_total_hours(app, project, multiple_time_entries):
"""Test project total_hours property."""
db.session.refresh(project)
# Each entry is 8 hours (9am to 5pm), 5 entries = 40 hours
assert project.total_hours > 0
@pytest.mark.unit
@pytest.mark.models
def test_project_estimated_cost(app, project, multiple_time_entries):
"""Test project estimated_cost property."""
db.session.refresh(project)
estimated_cost = project.estimated_cost
assert estimated_cost > 0
# Cost should be hours * hourly_rate
expected_cost = project.total_hours * float(project.hourly_rate)
assert abs(float(estimated_cost) - expected_cost) < 0.01
@pytest.mark.unit
@pytest.mark.models
def test_project_archive(app, project):
"""Test project archiving."""
db.session.refresh(project)
project.status = "archived"
db.session.commit()
assert project.status == "archived"
# ============================================================================
# Time Entry Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_time_entry_creation(app, time_entry):
"""Test basic time entry creation."""
assert time_entry.id is not None
assert time_entry.start_time is not None
assert time_entry.end_time is not None
@pytest.mark.unit
@pytest.mark.models
def test_time_entry_duration(app, time_entry):
"""Test time entry duration calculations."""
db.session.refresh(time_entry)
assert time_entry.duration_seconds > 0
assert time_entry.duration_hours > 0
assert time_entry.duration_formatted is not None
@pytest.mark.unit
@pytest.mark.models
def test_active_timer_is_active(app, active_timer):
"""Test active timer is_active property."""
db.session.refresh(active_timer)
assert active_timer.is_active is True
assert active_timer.end_time is None
@pytest.mark.unit
@pytest.mark.models
def test_stop_timer(app, active_timer):
"""Test stopping an active timer."""
db.session.refresh(active_timer)
active_timer.stop_timer()
db.session.commit()
db.session.refresh(active_timer)
assert active_timer.is_active is False
assert active_timer.end_time is not None
assert active_timer.duration_seconds > 0
@pytest.mark.unit
@pytest.mark.models
def test_time_entry_tag_list(app, test_client):
"""Test time entry tag_list property."""
from app.models import User, Project
user = User.query.first() or User(username="test", role="user")
project = Project.query.first() or Project(name="Test", client_id=test_client.id, billable=True)
if not user.id:
db.session.add(user)
if not project.id:
db.session.add(project)
db.session.commit()
from factories import TimeEntryFactory
entry = TimeEntryFactory(
user_id=user.id,
project_id=project.id,
start_time=datetime.utcnow(),
end_time=datetime.utcnow() + timedelta(hours=1),
tags="python,testing,development",
source="manual",
)
db.session.refresh(entry)
assert entry.tag_list == ["python", "testing", "development"]
# ============================================================================
# Task Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
def test_task_creation(app, task):
"""Test basic task creation."""
db.session.refresh(task)
assert task.id is not None
assert task.name == "Test Task"
assert task.status == "todo"
@pytest.mark.unit
@pytest.mark.models
def test_task_project_relationship(app, task, project):
"""Test task project relationship."""
db.session.refresh(task)
db.session.refresh(project)
assert task.project_id == project.id
@pytest.mark.unit
@pytest.mark.models
def test_task_status_transitions(app, task):
"""Test task status transitions."""
db.session.refresh(task)
# Mark as in progress
task.status = "in_progress"
db.session.commit()
assert task.status == "in_progress"
# Mark as done
task.status = "done"
db.session.commit()
assert task.status == "done"
# ============================================================================
# Invoice Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_invoice_creation(app, invoice):
"""Test basic invoice creation."""
# Invoice is already refreshed in the fixture, no need to refresh again
assert invoice.id is not None
assert invoice.invoice_number is not None
assert invoice.status == "draft"
@pytest.mark.unit
@pytest.mark.models
def test_invoice_number_generation(app):
"""Test invoice number generation."""
invoice_number = Invoice.generate_invoice_number()
assert invoice_number is not None
assert "INV-" in invoice_number
@pytest.mark.unit
@pytest.mark.models
def test_invoice_calculate_totals(app, invoice_with_items):
"""Test invoice total calculations."""
invoice, items = invoice_with_items
# Invoice is already committed and refreshed in the fixture
# 10 * 75 + 5 * 60 = 750 + 300 = 1050
assert invoice.subtotal == Decimal("1050.00")
# Tax: 20% of 1050 = 210
assert invoice.tax_amount == Decimal("210.00")
# Total: 1050 + 210 = 1260
assert invoice.total_amount == Decimal("1260.00")
@pytest.mark.unit
@pytest.mark.models
def test_invoice_payment_tracking(app, invoice_with_items):
"""Test invoice payment tracking."""
invoice, items = invoice_with_items
# Record partial payment
partial_payment = invoice.total_amount / 2
invoice.record_payment(
amount=partial_payment, payment_date=date.today(), payment_method="bank_transfer", payment_reference="TEST-123"
)
db.session.commit()
db.session.expire(invoice)
db.session.refresh(invoice)
assert invoice.payment_status == "partially_paid"
assert invoice.amount_paid == partial_payment
assert invoice.is_partially_paid is True
# Record remaining payment
remaining = invoice.outstanding_amount
invoice.record_payment(amount=remaining, payment_method="bank_transfer")
db.session.commit()
db.session.expire(invoice)
db.session.refresh(invoice)
assert invoice.payment_status == "fully_paid"
assert invoice.is_paid is True
assert invoice.outstanding_amount == Decimal("0")
@pytest.mark.unit
@pytest.mark.models
def test_invoice_overdue_status(app, user, project, test_client):
"""Test invoice overdue status."""
# Create overdue invoice
overdue_invoice = InvoiceFactory(
invoice_number=Invoice.generate_invoice_number(),
project_id=project.id,
client_id=test_client.id,
client_name="Test Client",
due_date=date.today() - timedelta(days=10),
created_by=user.id,
status="sent",
)
db.session.commit()
# Ensure status is 'sent' for overdue calculation compatibility
overdue_invoice.status = "sent"
db.session.commit()
db.session.refresh(overdue_invoice)
assert overdue_invoice.is_overdue is True
assert overdue_invoice.days_overdue == 10
# ============================================================================
# Settings Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
def test_settings_singleton(app):
"""Test settings singleton pattern."""
settings1 = Settings.get_settings()
settings2 = Settings.get_settings()
assert settings1.id == settings2.id
@pytest.mark.unit
@pytest.mark.models
def test_settings_default_values(app):
"""Test settings default values."""
settings = Settings.get_settings()
# Check that settings has expected attributes
assert hasattr(settings, "id")
# Add more default value checks based on your Settings model
# ============================================================================
# Model Relationship Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.models
@pytest.mark.database
def test_cascade_delete_user_time_entries(app, user, multiple_time_entries):
"""Test cascade delete of user time entries."""
user_id = user.id
# Get time entry count
entry_count = TimeEntry.query.filter_by(user_id=user_id).count()
assert entry_count == 5
# Delete user
db.session.delete(user)
db.session.commit()
# Check time entries are deleted or handled
remaining_entries = TimeEntry.query.filter_by(user_id=user_id).count()
# Depending on cascade settings, entries might be deleted or set to null
# For now, we just verify the operation completed without errors
assert remaining_entries >= 0 # Operation completed successfully
@pytest.mark.integration
@pytest.mark.models
@pytest.mark.database
def test_project_client_relationship_integrity(app, project, test_client):
"""Test project-client relationship integrity."""
# Verify the relationship
assert project.client_id == test_client.id
# Get project through client relationship
client_projects = Client.query.get(test_client.id).projects.all()
project_ids = [p.id for p in client_projects]
assert project.id in project_ids
# ============================================================================
# Model Validation Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
def test_project_requires_name(app, test_client):
"""Test that project requires a name."""
# Project __init__ requires name as first positional argument
# This test verifies the API enforces this requirement
with pytest.raises(TypeError):
project = Project(billable=True)
@pytest.mark.unit
@pytest.mark.models
def test_time_entry_requires_start_time(app, user, project):
"""Test that time entry requires start time."""
# TimeEntry requires start_time at database level (nullable=False)
# This test verifies the database enforces this requirement
from sqlalchemy.exc import IntegrityError
from app import db
with pytest.raises(IntegrityError):
entry = TimeEntry(user_id=user.id, project_id=project.id, source="manual")
db.session.add(entry)
db.session.commit()
# ============================================================================
# User Deletion and Cascading Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_without_relationships(app):
"""Test that a user without relationships can be deleted."""
with app.app_context():
# Create a user with no relationships
delete_user = User(username="deletable", role="user")
delete_user.is_active = True
db.session.add(delete_user)
db.session.commit()
user_id = delete_user.id
# Delete the user
db.session.delete(delete_user)
db.session.commit()
# Verify deletion
deleted = User.query.get(user_id)
assert deleted is None
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_cascades_project_costs(app, test_client):
"""Test that deleting a user cascades to project costs."""
from app.models import ProjectCost
from datetime import date
with app.app_context():
# Create user and project
user = User(username="costuser", role="user")
user.is_active = True
db.session.add(user)
project = Project(name="Cost Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Create project cost
cost = ProjectCost(
project_id=project.id,
user_id=user.id,
description="Test expense",
category="materials",
amount=Decimal("100.00"),
cost_date=date.today(),
)
db.session.add(cost)
db.session.commit()
user_id = user.id
cost_id = cost.id
# Delete user
db.session.delete(user)
db.session.commit()
# Verify user is deleted
deleted_user = User.query.get(user_id)
assert deleted_user is None
# Verify project cost is cascaded (deleted)
deleted_cost = ProjectCost.query.get(cost_id)
assert deleted_cost is None
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_cascades_time_entries(app, test_client):
"""Test that deleting a user cascades to time entries."""
with app.app_context():
# Create user and project
user = User(username="entryuser", role="user")
user.is_active = True
db.session.add(user)
project = Project(name="Entry Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Create time entry
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.utcnow(),
end_time=datetime.utcnow() + timedelta(hours=1),
description="Test entry",
)
db.session.add(entry)
db.session.commit()
user_id = user.id
entry_id = entry.id
# Delete user
db.session.delete(user)
db.session.commit()
# Verify user is deleted
deleted_user = User.query.get(user_id)
assert deleted_user is None
# Verify time entry is cascaded (deleted)
deleted_entry = TimeEntry.query.get(entry_id)
assert deleted_entry is None
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_removes_from_favorite_projects(app, test_client):
"""Test that deleting a user removes them from favorite projects."""
with app.app_context():
# Create user and project
user = User(username="favuser", role="user")
user.is_active = True
db.session.add(user)
project = Project(name="Favorite Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Add project to favorites
user.favorite_projects.append(project)
db.session.commit()
# Verify favorite was added
assert project in user.favorite_projects.all()
user_id = user.id
project_id = project.id
# Delete user
db.session.delete(user)
db.session.commit()
# Verify user is deleted
deleted_user = User.query.get(user_id)
assert deleted_user is None
# Verify project still exists (favorites are many-to-many)
remaining_project = Project.query.get(project_id)
assert remaining_project is not None
# Verify user is not in project's favorited_by
assert user_id not in [u.id for u in remaining_project.favorited_by.all()]
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_preserves_tasks_assigned_to_them(app, test_client):
"""Test that deleting a user preserves tasks but nullifies assigned_to."""
with app.app_context():
# Create users and project
creator = User(username="creator", role="user")
creator.is_active = True
assignee = User(username="assignee", role="user")
assignee.is_active = True
db.session.add_all([creator, assignee])
project = Project(name="Task Test Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Create task
task = Task(
project_id=project.id,
name="Test Task",
description="Test description",
created_by=creator.id,
assigned_to=assignee.id,
)
db.session.add(task)
db.session.commit()
assignee_id = assignee.id
task_id = task.id
# Delete assignee
db.session.delete(assignee)
db.session.commit()
# Verify assignee is deleted
deleted_user = User.query.get(assignee_id)
assert deleted_user is None
# Verify task still exists but assigned_to is nullified
remaining_task = Task.query.get(task_id)
assert remaining_task is not None
assert remaining_task.assigned_to is None
@pytest.mark.unit
@pytest.mark.models
def test_user_cannot_be_deleted_if_has_created_tasks(app, test_client):
"""Test that deleting a user who created tasks cascades properly."""
from sqlalchemy.exc import IntegrityError
with app.app_context():
# Create user and project
creator = User(username="taskcreator", role="user")
creator.is_active = True
db.session.add(creator)
project = Project(name="Task Creator Project", client_id=test_client.id, billable=True)
db.session.add(project)
db.session.commit()
# Create task
task = Task(project_id=project.id, name="Created Task", description="Test description", created_by=creator.id)
db.session.add(task)
db.session.commit()
creator_id = creator.id
# Try to delete creator - should raise IntegrityError because created_by is NOT NULL
with pytest.raises(IntegrityError):
db.session.delete(creator)
db.session.commit()
db.session.rollback()
# Verify creator still exists
still_exists = User.query.get(creator_id)
assert still_exists is not None
@pytest.mark.unit
@pytest.mark.models
def test_user_deletion_count_check(app):
"""Test that we can query user count before and after deletion."""
with app.app_context():
# Get initial count
initial_count = User.query.count()
# Create and delete a user
temp_user = User(username="tempuser", role="user")
temp_user.is_active = True
db.session.add(temp_user)
db.session.commit()
# Verify count increased
assert User.query.count() == initial_count + 1
# Delete user
db.session.delete(temp_user)
db.session.commit()
# Verify count back to initial
assert User.query.count() == initial_count