Files
TimeTracker/tests/test_favorite_projects.py
Dries Peeters 18d9808d5e feat: add user favorite projects functionality with CSV export enhancements
Features:
Add favorite projects feature allowing users to star/bookmark frequently used projects
New UserFavoriteProject association model with user-project relationships
Star icons in project list for one-click favorite toggling via AJAX
Filter to display only favorite projects
Per-user favorites with proper isolation and cascade delete behavior
Activity logging for favorite/unfavorite actions
Database:
Add user_favorite_projects table with migration (023_add_user_favorite_projects.py)
Foreign keys to users and projects with CASCADE delete
Unique constraint preventing duplicate favorites
Indexes on user_id and project_id for query optimization
Models:
User model: Add favorite_projects relationship with helper methods
add_favorite_project() - add project to favorites
remove_favorite_project() - remove from favorites
is_project_favorite() - check favorite status
get_favorite_projects() - retrieve favorites with status filter
Project model: Add is_favorited_by() method and include favorite status in to_dict()
Export UserFavoriteProject model in app/models/__init__.py
Routes:
Add /projects/<id>/favorite POST endpoint to favorite a project
Add /projects/<id>/unfavorite POST endpoint to unfavorite a project
Update /projects GET route to support favorites=true query parameter
Fix status filtering to work correctly with favorites JOIN query
Add /reports/export/form GET endpoint for enhanced CSV export form
Templates:
Update projects/list.html:
Add favorites filter dropdown to filter form (5-column grid)
Add star icon column with Font Awesome icons (filled/unfilled)
Add JavaScript toggleFavorite() function for AJAX favorite toggling
Improve hover states and transitions for better UX
Pass favorite_project_ids and favorites_only to template
Update reports/index.html:
Update CSV export link to point to new export form
Add icon and improve hover styling
Reports:
Enhance CSV export functionality with dedicated form page
Add filter options for users, projects, clients, and date ranges
Set default date range to last 30 days
Import Client model and or_ operator for advanced filtering
Testing:
Comprehensive test suite in tests/test_favorite_projects.py (550+ lines)
Model tests for UserFavoriteProject creation and validation
User/Project method tests for favorite operations
Route tests for favorite/unfavorite endpoints
Filtering tests for favorites-only view
Relationship tests for cascade delete behavior
Smoke tests for complete workflows
Coverage for edge cases and error handling
Documentation:
Add comprehensive feature documentation in docs/FAVORITE_PROJECTS_FEATURE.md
User guide with step-by-step instructions
Technical implementation details
API documentation for new endpoints
Migration guide and troubleshooting
Performance and security considerations
Template Cleanup:
Remove duplicate templates from root templates/ directory
Admin templates (dashboard, users, settings, OIDC debug, etc.)
Client CRUD templates
Error page templates
Invoice templates
Project templates
Report templates
Timer templates
All templates now properly located in app/templates/
Breaking Changes:
None - fully backward compatible
Migration Required:
Run alembic upgrade head to create user_favorite_projects table
2025-10-23 21:15:16 +02:00

553 lines
20 KiB
Python

"""
Comprehensive tests for Favorite Projects functionality.
This module tests:
- UserFavoriteProject model creation and validation
- Relationships between User and Project models
- Favorite/unfavorite routes and API endpoints
- Filtering projects by favorites
- User permissions and access control
"""
import pytest
from datetime import datetime
from decimal import Decimal
from app import create_app, db
from app.models import User, Project, Client, UserFavoriteProject
@pytest.fixture
def app():
"""Create and configure a test application instance."""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'test-secret-key-do-not-use-in-production',
'SERVER_NAME': 'localhost:5000',
'APPLICATION_ROOT': '/',
'PREFERRED_URL_SCHEME': 'http',
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client_fixture(app):
"""Create a test Flask client."""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user."""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user.id
@pytest.fixture
def test_admin(app):
"""Create a test admin user."""
with app.app_context():
admin = User(username='admin', role='admin')
db.session.add(admin)
db.session.commit()
return admin.id
@pytest.fixture
def test_client(app):
"""Create a test client."""
with app.app_context():
client = Client(name='Test Client', description='A test client')
db.session.add(client)
db.session.commit()
return client.id
@pytest.fixture
def test_project(app, test_client):
"""Create a test project."""
with app.app_context():
project = Project(
name='Test Project',
client_id=test_client,
description='A test project',
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.add(project)
db.session.commit()
return project.id
@pytest.fixture
def test_project_2(app, test_client):
"""Create a second test project."""
with app.app_context():
project = Project(
name='Test Project 2',
client_id=test_client,
description='Another test project',
billable=True,
hourly_rate=Decimal('150.00')
)
db.session.add(project)
db.session.commit()
return project.id
# Model Tests
class TestUserFavoriteProjectModel:
"""Test UserFavoriteProject model creation, validation, and basic operations."""
def test_create_favorite(self, app, test_user, test_project):
"""Test creating a favorite project entry."""
with app.app_context():
favorite = UserFavoriteProject()
favorite.user_id = test_user
favorite.project_id = test_project
db.session.add(favorite)
db.session.commit()
assert favorite.id is not None
assert favorite.user_id == test_user
assert favorite.project_id == test_project
assert favorite.created_at is not None
assert isinstance(favorite.created_at, datetime)
def test_favorite_unique_constraint(self, app, test_user, test_project):
"""Test that a user cannot favorite the same project twice."""
with app.app_context():
# Create first favorite
favorite1 = UserFavoriteProject()
favorite1.user_id = test_user
favorite1.project_id = test_project
db.session.add(favorite1)
db.session.commit()
# Try to create duplicate
favorite2 = UserFavoriteProject()
favorite2.user_id = test_user
favorite2.project_id = test_project
db.session.add(favorite2)
# Should raise IntegrityError
with pytest.raises(Exception): # SQLAlchemy will raise IntegrityError
db.session.commit()
def test_favorite_to_dict(self, app, test_user, test_project):
"""Test favorite project to_dict method."""
with app.app_context():
favorite = UserFavoriteProject()
favorite.user_id = test_user
favorite.project_id = test_project
db.session.add(favorite)
db.session.commit()
data = favorite.to_dict()
assert 'id' in data
assert 'user_id' in data
assert 'project_id' in data
assert 'created_at' in data
assert data['user_id'] == test_user
assert data['project_id'] == test_project
class TestUserFavoriteProjectMethods:
"""Test User model methods for managing favorite projects."""
def test_add_favorite_project(self, app, test_user, test_project):
"""Test adding a project to user's favorites."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify it was added
assert user.is_project_favorite(project)
assert project in user.favorite_projects.all()
def test_add_favorite_project_idempotent(self, app, test_user, test_project):
"""Test that adding a favorite twice doesn't cause errors."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add twice
user.add_favorite_project(project)
user.add_favorite_project(project)
# Should still only have one favorite entry
favorites = UserFavoriteProject.query.filter_by(
user_id=test_user,
project_id=test_project
).all()
assert len(favorites) == 1
def test_remove_favorite_project(self, app, test_user, test_project):
"""Test removing a project from user's favorites."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add then remove
user.add_favorite_project(project)
assert user.is_project_favorite(project)
user.remove_favorite_project(project)
assert not user.is_project_favorite(project)
def test_is_project_favorite_with_id(self, app, test_user, test_project):
"""Test checking if project is favorite using project ID."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Not a favorite yet
assert not user.is_project_favorite(test_project)
# Add to favorites
user.add_favorite_project(project)
# Check with ID
assert user.is_project_favorite(test_project)
def test_get_favorite_projects(self, app, test_user, test_project, test_project_2):
"""Test getting user's favorite projects."""
with app.app_context():
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
project2 = db.session.get(Project, test_project_2)
# Add both to favorites
user.add_favorite_project(project1)
user.add_favorite_project(project2)
# Get favorites
favorites = user.get_favorite_projects()
assert len(favorites) == 2
assert project1 in favorites
assert project2 in favorites
def test_get_favorite_projects_filtered_by_status(self, app, test_user, test_project, test_project_2):
"""Test getting favorite projects filtered by status."""
with app.app_context():
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
project2 = db.session.get(Project, test_project_2)
# Set different statuses
project1.status = 'active'
project2.status = 'archived'
db.session.commit()
# Add both to favorites
user.add_favorite_project(project1)
user.add_favorite_project(project2)
# Get only active favorites
active_favorites = user.get_favorite_projects(status='active')
assert len(active_favorites) == 1
assert project1 in active_favorites
assert project2 not in active_favorites
class TestProjectFavoriteMethods:
"""Test Project model methods for favorite functionality."""
def test_is_favorited_by_user(self, app, test_user, test_project):
"""Test checking if project is favorited by a specific user."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Not favorited yet
assert not project.is_favorited_by(user)
# Add to favorites
user.add_favorite_project(project)
# Now should be favorited
assert project.is_favorited_by(user)
def test_is_favorited_by_user_id(self, app, test_user, test_project):
"""Test checking if project is favorited using user ID."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Check with user ID
assert project.is_favorited_by(test_user)
def test_project_to_dict_with_favorite_status(self, app, test_user, test_project):
"""Test project to_dict includes favorite status when user provided."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Without user, no is_favorite field
data = project.to_dict()
assert 'is_favorite' not in data
# With user, includes is_favorite
data_with_user = project.to_dict(user=user)
assert 'is_favorite' in data_with_user
assert data_with_user['is_favorite'] is False
# Add to favorites
user.add_favorite_project(project)
# Now should be True
data_favorited = project.to_dict(user=user)
assert data_favorited['is_favorite'] is True
# Route Tests
class TestFavoriteProjectRoutes:
"""Test favorite project routes and endpoints."""
def test_favorite_project_route(self, app, client_fixture, test_user, test_project):
"""Test favoriting a project via POST route."""
with app.app_context():
# Login as test user
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Favorite the project
response = client_fixture.post(
f'/projects/{test_project}/favorite',
headers={'X-Requested-With': 'XMLHttpRequest'}
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify in database
user = db.session.get(User, test_user)
assert user.is_project_favorite(test_project)
def test_unfavorite_project_route(self, app, client_fixture, test_user, test_project):
"""Test unfavoriting a project via POST route."""
with app.app_context():
# Setup: Add to favorites first
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
user.add_favorite_project(project)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Unfavorite the project
response = client_fixture.post(
f'/projects/{test_project}/unfavorite',
headers={'X-Requested-With': 'XMLHttpRequest'}
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify in database
user = db.session.get(User, test_user)
assert not user.is_project_favorite(test_project)
def test_favorite_nonexistent_project(self, app, client_fixture, test_user):
"""Test favoriting a non-existent project returns 404."""
with app.app_context():
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
response = client_fixture.post('/projects/99999/favorite')
assert response.status_code == 404
def test_favorite_project_requires_login(self, app, client_fixture, test_project):
"""Test that favoriting requires authentication."""
with app.app_context():
response = client_fixture.post(f'/projects/{test_project}/favorite')
# Should redirect to login
assert response.status_code in [302, 401]
class TestFavoriteProjectFiltering:
"""Test filtering projects by favorites."""
def test_list_projects_with_favorites_filter(self, app, client_fixture, test_user, test_project, test_project_2):
"""Test listing only favorite projects."""
with app.app_context():
# Setup: Favorite only one project
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
user.add_favorite_project(project1)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Request favorites only
response = client_fixture.get('/projects?favorites=true')
assert response.status_code == 200
# Check that the response contains the favorite project
assert b'Test Project' in response.data
def test_list_all_projects_without_filter(self, app, client_fixture, test_user, test_project, test_project_2):
"""Test listing all projects without favorites filter."""
with app.app_context():
# Setup: Favorite only one project
user = db.session.get(User, test_user)
project1 = db.session.get(Project, test_project)
user.add_favorite_project(project1)
# Login
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(test_user)
# Request all projects
response = client_fixture.get('/projects')
assert response.status_code == 200
# Both projects should be in response
assert b'Test Project' in response.data
# Relationship Tests
class TestFavoriteProjectRelationships:
"""Test database relationships and cascade behavior."""
def test_delete_user_cascades_favorites(self, app, test_user, test_project):
"""Test that deleting a user removes their favorite entries."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify favorite exists
favorite_count = UserFavoriteProject.query.filter_by(user_id=test_user).count()
assert favorite_count == 1
# Delete user
db.session.delete(user)
db.session.commit()
# Favorite should be deleted
favorite_count = UserFavoriteProject.query.filter_by(user_id=test_user).count()
assert favorite_count == 0
def test_delete_project_cascades_favorites(self, app, test_user, test_project):
"""Test that deleting a project removes related favorite entries."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Add to favorites
user.add_favorite_project(project)
# Verify favorite exists
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 1
# Delete project
db.session.delete(project)
db.session.commit()
# Favorite should be deleted
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 0
def test_multiple_users_favorite_same_project(self, app, test_user, test_admin, test_project):
"""Test that multiple users can favorite the same project."""
with app.app_context():
user = db.session.get(User, test_user)
admin = db.session.get(User, test_admin)
project = db.session.get(Project, test_project)
# Both favorite the same project
user.add_favorite_project(project)
admin.add_favorite_project(project)
# Verify both have it as favorite
assert user.is_project_favorite(project)
assert admin.is_project_favorite(project)
# Verify database has 2 entries
favorite_count = UserFavoriteProject.query.filter_by(project_id=test_project).count()
assert favorite_count == 2
# Smoke Tests
class TestFavoriteProjectsSmoke:
"""Smoke tests to verify basic favorite projects functionality."""
def test_complete_favorite_workflow(self, app, test_user, test_project):
"""Test complete workflow: add favorite, check status, remove favorite."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Initially not favorited
assert not user.is_project_favorite(project)
# Add to favorites
user.add_favorite_project(project)
assert user.is_project_favorite(project)
# Get favorites list
favorites = user.get_favorite_projects()
assert len(favorites) == 1
assert project in favorites
# Remove from favorites
user.remove_favorite_project(project)
assert not user.is_project_favorite(project)
# Favorites list should be empty
favorites = user.get_favorite_projects()
assert len(favorites) == 0
def test_favorite_with_archived_projects(self, app, test_user, test_project):
"""Test that favoriting works with archived projects."""
with app.app_context():
user = db.session.get(User, test_user)
project = db.session.get(Project, test_project)
# Favorite an active project
user.add_favorite_project(project)
# Archive the project
project.status = 'archived'
db.session.commit()
# Should still be favorited
assert user.is_project_favorite(project)
# But won't appear in active favorites
active_favorites = user.get_favorite_projects(status='active')
assert len(active_favorites) == 0
# Will appear in archived favorites
archived_favorites = user.get_favorite_projects(status='archived')
assert len(archived_favorites) == 1