mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 19:20:21 -06:00
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.
530 lines
18 KiB
Python
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
|
|
|