Files
TimeTracker/tests/test_uploads_persistence.py
Dries Peeters 20b7401891 feat: Add invoice expenses, enhanced PDF editor with Konva.js, and uploads persistence
Major Features:
- Invoice Expenses: Allow linking billable expenses to invoices with automatic total calculations
  - Add expenses to invoices via "Generate from Time/Costs" workflow
  - Display expenses in invoice view, edit forms, and PDF exports
  - Track expense states (approved, invoiced, reimbursed) with automatic unlinking on invoice deletion
  - Update PDF generator and CSV exports to include expense line items

- Enhanced PDF Invoice Editor: Complete redesign using Konva.js for visual drag-and-drop layout design
  - Add 40+ draggable elements (company info, invoice data, shapes, text, advanced elements)
  - Implement comprehensive properties panel for precise element customization (position, fonts, colors, opacity)
  - Add canvas toolbar with alignment tools, zoom controls, and layer management
  - Support keyboard shortcuts (copy/paste, duplicate, arrow key positioning)
  - Save designs as JSON for editing and generate clean HTML/CSS for rendering
  - Add real-time preview with live data

- Uploads Persistence: Implement Docker volume persistence for user-uploaded files
  - Add app_uploads volume to all Docker Compose configurations
  - Ensure company logos and avatars persist across container rebuilds and restarts
  - Create migration script for existing installations
  - Update directory structure with proper permissions (755 for dirs, 644 for files)

Database & Backend:
- Add invoice_pdf_design_json column to settings table via Alembic migration
- Extend Invoice model with expenses relationship
- Update admin routes for PDF layout designer endpoints
- Enhance invoice routes to handle expense linking/unlinking

Frontend & UI:
- Redesign PDF layout editor template with Konva.js canvas (2484 lines, major overhaul)
- Update invoice edit/view templates to display and manage expenses
- Add expense sections to invoice forms with unlink functionality
- Enhance UI components with keyboard shortcuts support
- Update multiple templates for consistency and accessibility

Testing & Documentation:
- Add comprehensive test suites for invoice expenses, PDF layouts, and uploads persistence
- Create detailed documentation for all new features (5 new docs)
- Include migration guides and troubleshooting sections

Infrastructure:
- Update docker-compose files (main, example, remote, remote-dev, local-test) with uploads volume
- Configure pytest for new test modules
- Add template filters for currency formatting and expense display

This update significantly enhances TimeTracker's invoice management capabilities,
improves the PDF customization experience, and ensures uploaded files persist
reliably across deployments.
2025-10-29 15:03:01 +01:00

530 lines
18 KiB
Python

"""Tests for uploads persistence functionality.
This module tests that uploaded files (logos and avatars) persist correctly
across application restarts and container rebuilds when using Docker volumes.
"""
import pytest
import os
import io
import tempfile
import shutil
from pathlib import Path
from flask import url_for
from app import db
from app.models import User, Settings
from PIL import Image
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def admin_user(app):
"""Create an admin user for testing."""
user = User(username='admintest', role='admin')
db.session.add(user)
db.session.commit()
db.session.refresh(user)
return user
@pytest.fixture
def authenticated_admin_client(client, admin_user):
"""Create an authenticated admin client."""
with client.session_transaction() as sess:
sess['_user_id'] = str(admin_user.id)
return client
@pytest.fixture
def sample_logo_image():
"""Create a sample PNG image for testing."""
img = Image.new('RGB', (100, 100), color='red')
img_io = io.BytesIO()
img.save(img_io, 'PNG')
img_io.seek(0)
return img_io
@pytest.fixture
def uploads_dir(app):
"""Get the uploads directory path."""
with app.app_context():
return os.path.join(app.root_path, 'static', 'uploads')
@pytest.fixture
def cleanup_test_files(app):
"""Clean up test files after tests."""
yield
with app.app_context():
upload_folder = os.path.join(app.root_path, 'static', 'uploads', 'logos')
if os.path.exists(upload_folder):
for filename in os.listdir(upload_folder):
if filename.startswith('test_') or filename.startswith('company_logo_'):
try:
os.remove(os.path.join(upload_folder, filename))
except OSError:
pass
# ============================================================================
# Unit Tests - Directory Structure
# ============================================================================
@pytest.mark.unit
def test_uploads_directory_exists(app, uploads_dir):
"""Test that the uploads directory exists or can be created."""
with app.app_context():
# The directory should exist or be creatable
os.makedirs(uploads_dir, exist_ok=True)
assert os.path.exists(uploads_dir)
assert os.path.isdir(uploads_dir)
@pytest.mark.unit
def test_logos_subdirectory_exists(app, uploads_dir):
"""Test that the logos subdirectory exists or can be created."""
with app.app_context():
logos_dir = os.path.join(uploads_dir, 'logos')
os.makedirs(logos_dir, exist_ok=True)
assert os.path.exists(logos_dir)
assert os.path.isdir(logos_dir)
@pytest.mark.unit
def test_avatars_subdirectory_exists(app, uploads_dir):
"""Test that the avatars subdirectory exists or can be created."""
with app.app_context():
avatars_dir = os.path.join(uploads_dir, 'avatars')
os.makedirs(avatars_dir, exist_ok=True)
assert os.path.exists(avatars_dir)
assert os.path.isdir(avatars_dir)
@pytest.mark.unit
def test_uploads_directory_is_writable(app, uploads_dir):
"""Test that the uploads directory is writable."""
with app.app_context():
os.makedirs(uploads_dir, exist_ok=True)
test_file = os.path.join(uploads_dir, '.test_write_permissions')
try:
# Try to write a test file
with open(test_file, 'w') as f:
f.write('test')
# Verify it was created
assert os.path.exists(test_file)
# Clean up
os.remove(test_file)
except Exception as e:
pytest.fail(f"Uploads directory is not writable: {e}")
# ============================================================================
# Integration Tests - File Persistence
# ============================================================================
@pytest.mark.integration
def test_logo_file_persists_after_upload(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test that uploaded logo file persists on disk after upload."""
with app.app_context():
# Upload logo
data = {
'logo': (sample_logo_image, 'test_logo_persist.png', 'image/png'),
}
response = authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data',
follow_redirects=True
)
assert response.status_code == 200
# Get the filename from database
settings = Settings.get_settings()
logo_filename = settings.company_logo_filename
assert logo_filename != ''
# Verify file exists on disk
logo_path = settings.get_logo_path()
assert os.path.exists(logo_path), f"Logo file does not exist at {logo_path}"
assert os.path.isfile(logo_path), f"Logo path is not a file: {logo_path}"
# Verify file is readable
with open(logo_path, 'rb') as f:
data = f.read()
assert len(data) > 0, "Logo file is empty"
@pytest.mark.integration
def test_logo_accessible_after_simulated_restart(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test that logo remains accessible after simulated app restart."""
with app.app_context():
# Upload logo
data = {
'logo': (sample_logo_image, 'test_logo_restart.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
# Get the filename and path
settings = Settings.get_settings()
logo_filename = settings.company_logo_filename
logo_path = settings.get_logo_path()
# Verify file exists
assert os.path.exists(logo_path)
# Simulate restart by creating new app context
# (In real Docker scenario, the file would still be there via volume)
db.session.close()
# New app context simulating restart
with app.app_context():
# Verify database still has the filename
settings = Settings.get_settings()
assert settings.company_logo_filename == logo_filename
# Verify file still exists
logo_path = settings.get_logo_path()
assert os.path.exists(logo_path), "Logo file lost after simulated restart"
@pytest.mark.integration
def test_multiple_logos_in_directory(authenticated_admin_client, app, cleanup_test_files):
"""Test that multiple logos can exist in the directory (old and new)."""
with app.app_context():
logos_to_upload = []
# Create and upload multiple logos
for i in range(3):
img = Image.new('RGB', (100, 100), color=('red', 'blue', 'green')[i])
img_io = io.BytesIO()
img.save(img_io, 'PNG')
img_io.seek(0)
data = {
'logo': (img_io, f'test_logo_{i}.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
logos_to_upload.append(settings.company_logo_filename)
# Verify at least the current logo exists
settings = Settings.get_settings()
current_logo_path = settings.get_logo_path()
assert os.path.exists(current_logo_path), "Current logo does not exist"
@pytest.mark.integration
def test_logo_path_is_in_uploads_directory(authenticated_admin_client, sample_logo_image, app, uploads_dir, cleanup_test_files):
"""Test that uploaded logos are stored in the correct uploads directory."""
with app.app_context():
data = {
'logo': (sample_logo_image, 'test_logo_path.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
logo_path = settings.get_logo_path()
# Verify the logo is in the uploads/logos directory
assert 'uploads' in logo_path, f"Logo not in uploads directory: {logo_path}"
assert 'logos' in logo_path, f"Logo not in logos subdirectory: {logo_path}"
# Verify the path structure
expected_dir = os.path.join(uploads_dir, 'logos')
assert expected_dir in logo_path, f"Logo not in expected directory: {logo_path}"
# ============================================================================
# Unit Tests - Path Resolution
# ============================================================================
@pytest.mark.unit
def test_settings_logo_path_resolution(app):
"""Test that Settings model correctly resolves logo paths."""
with app.app_context():
settings = Settings.get_settings()
settings.company_logo_filename = 'test_logo.png'
db.session.commit()
logo_path = settings.get_logo_path()
assert logo_path is not None
assert 'app/static/uploads/logos' in logo_path or 'app\\static\\uploads\\logos' in logo_path
assert 'test_logo.png' in logo_path
@pytest.mark.unit
def test_settings_logo_url_format(app):
"""Test that Settings model returns correct URL format."""
with app.app_context():
settings = Settings.get_settings()
settings.company_logo_filename = 'test_logo.png'
db.session.commit()
logo_url = settings.get_logo_url()
assert logo_url == '/uploads/logos/test_logo.png'
@pytest.mark.unit
def test_settings_logo_path_none_when_no_filename(app):
"""Test that logo path is None when no filename is set."""
with app.app_context():
settings = Settings.get_settings()
settings.company_logo_filename = None
db.session.commit()
logo_path = settings.get_logo_path()
assert logo_path is None
# ============================================================================
# Integration Tests - File Operations
# ============================================================================
@pytest.mark.integration
def test_logo_file_has_correct_extension(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test that uploaded logo file has correct extension."""
with app.app_context():
data = {
'logo': (sample_logo_image, 'test_logo.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
logo_filename = settings.company_logo_filename
# Should have .png extension
assert logo_filename.endswith('.png')
@pytest.mark.integration
def test_old_logo_removed_when_new_uploaded(authenticated_admin_client, app, cleanup_test_files):
"""Test that old logo file is removed when new one is uploaded."""
with app.app_context():
# Upload first logo
img1 = Image.new('RGB', (100, 100), color='red')
img1_io = io.BytesIO()
img1.save(img1_io, 'PNG')
img1_io.seek(0)
data1 = {
'logo': (img1_io, 'test_logo1.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data1,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
old_filename = settings.company_logo_filename
old_path = settings.get_logo_path()
# Verify first logo exists
assert os.path.exists(old_path)
# Upload second logo
img2 = Image.new('RGB', (100, 100), color='blue')
img2_io = io.BytesIO()
img2.save(img2_io, 'PNG')
img2_io.seek(0)
data2 = {
'logo': (img2_io, 'test_logo2.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data2,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
new_filename = settings.company_logo_filename
new_path = settings.get_logo_path()
# Verify new logo is different
assert new_filename != old_filename
# Verify new logo exists
assert os.path.exists(new_path)
@pytest.mark.integration
def test_logo_removed_when_deleted(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test that logo file is removed when deleted via admin interface."""
with app.app_context():
# Upload logo
data = {
'logo': (sample_logo_image, 'test_logo_delete.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
logo_path = settings.get_logo_path()
# Verify logo exists
assert os.path.exists(logo_path)
# Remove logo
authenticated_admin_client.post(
'/admin/remove-logo',
follow_redirects=True
)
# Verify database field is cleared
settings = Settings.get_settings()
assert settings.company_logo_filename == '' or settings.company_logo_filename is None
# ============================================================================
# Smoke Tests
# ============================================================================
@pytest.mark.smoke
def test_uploads_directory_accessible(app, uploads_dir):
"""Smoke test: Verify uploads directory is accessible."""
with app.app_context():
# Create directory if it doesn't exist
os.makedirs(uploads_dir, exist_ok=True)
# Verify we can list directory contents
try:
contents = os.listdir(uploads_dir)
assert isinstance(contents, list)
except Exception as e:
pytest.fail(f"Cannot access uploads directory: {e}")
@pytest.mark.smoke
def test_logo_upload_and_retrieve_workflow(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Smoke test: Complete workflow of uploading and retrieving a logo."""
with app.app_context():
# 1. Upload logo
data = {
'logo': (sample_logo_image, 'test_workflow.png', 'image/png'),
}
upload_response = authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data',
follow_redirects=True
)
assert upload_response.status_code == 200
# 2. Verify logo is in database
settings = Settings.get_settings()
assert settings.company_logo_filename != ''
# 3. Verify logo file exists
logo_path = settings.get_logo_path()
assert os.path.exists(logo_path)
# 4. Retrieve logo URL
logo_url = settings.get_logo_url()
assert logo_url is not None
# 5. Access logo via HTTP
retrieve_response = authenticated_admin_client.get(logo_url)
assert retrieve_response.status_code == 200
assert retrieve_response.content_type.startswith('image/')
# ============================================================================
# Model Tests
# ============================================================================
@pytest.mark.models
def test_settings_has_logo_with_existing_file(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test Settings.has_logo() returns True when logo exists."""
with app.app_context():
# Upload logo
data = {
'logo': (sample_logo_image, 'test_has_logo.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
# has_logo() should return True
assert settings.has_logo() is True
@pytest.mark.models
def test_settings_has_logo_without_file(app):
"""Test Settings.has_logo() returns False when no logo exists."""
with app.app_context():
settings = Settings.get_settings()
settings.company_logo_filename = ''
db.session.commit()
# has_logo() should return False
assert settings.has_logo() is False
@pytest.mark.models
def test_settings_to_dict_includes_logo_info(authenticated_admin_client, sample_logo_image, app, cleanup_test_files):
"""Test Settings.to_dict() includes logo information."""
with app.app_context():
# Upload logo
data = {
'logo': (sample_logo_image, 'test_to_dict.png', 'image/png'),
}
authenticated_admin_client.post(
'/admin/upload-logo',
data=data,
content_type='multipart/form-data'
)
settings = Settings.get_settings()
settings_dict = settings.to_dict()
# Should include logo filename and/or URL
assert 'company_logo_filename' in settings_dict or 'logo_url' in settings_dict