mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 18:38:46 -05:00
8b24059b3d
Remove obsolete skips across admin, calendar, invoices, payments, PDF, uploads, and related tests. Use /login and passwords consistent with conftest; fix team chat setup in silent-exception tests; assert expense currency with locale-tolerant amount matching; align admin logo tests with admin_authenticated_client credentials.
583 lines
20 KiB
Python
583 lines
20 KiB
Python
"""Tests for PDF layout customization functionality."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from app import db
|
|
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client
|
|
from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, InvoiceItemFactory
|
|
from flask import url_for
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user(app):
|
|
"""Create an admin user for testing."""
|
|
user = UserFactory(username="admin", role="admin", email="admin@test.com")
|
|
user.is_active = True
|
|
user.set_password("password123")
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_user(app):
|
|
"""Create a regular user for testing."""
|
|
user = UserFactory(username="regular", role="user", email="regular@test.com")
|
|
user.is_active = True
|
|
user.set_password("password123")
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_invoice(app, admin_user):
|
|
"""Create a sample invoice for testing."""
|
|
# Create a client
|
|
client = ClientFactory(name="Test Client", email="client@test.com")
|
|
db.session.commit()
|
|
|
|
# Create a project
|
|
project = ProjectFactory(
|
|
client_id=client.id,
|
|
name="Test Project",
|
|
description="Test project for PDF",
|
|
billable=True,
|
|
hourly_rate=Decimal("100.00"),
|
|
)
|
|
db.session.commit()
|
|
|
|
# Create invoice
|
|
invoice = InvoiceFactory(
|
|
invoice_number="INV-2024-001",
|
|
project_id=project.id,
|
|
client_name="Test Client",
|
|
client_email="client@test.com",
|
|
client_address="123 Test St",
|
|
due_date=date.today() + timedelta(days=30),
|
|
created_by=admin_user.id,
|
|
client_id=client.id,
|
|
tax_rate=Decimal("10.00"),
|
|
status="draft",
|
|
notes="Test notes",
|
|
terms="Test terms",
|
|
)
|
|
db.session.commit()
|
|
|
|
# Add invoice item
|
|
item = InvoiceItemFactory(
|
|
invoice_id=invoice.id, description="Test Service", quantity=Decimal("5.00"), unit_price=Decimal("100.00")
|
|
)
|
|
db.session.commit()
|
|
|
|
return invoice
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_page_requires_admin(client, regular_user):
|
|
"""Test that PDF layout page requires admin access."""
|
|
with client:
|
|
# Login as regular user
|
|
client.post("/auth/login", data={"username": "regular", "password": "password123"})
|
|
|
|
# Try to access PDF layout page
|
|
response = client.get("/admin/pdf-layout")
|
|
|
|
# Should redirect or show forbidden
|
|
assert response.status_code in [302, 403]
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_page_accessible_to_admin(admin_authenticated_client):
|
|
"""Test that PDF layout page is accessible to admin."""
|
|
# Access PDF layout page
|
|
response = admin_authenticated_client.get("/admin/pdf-layout")
|
|
|
|
assert response.status_code == 200
|
|
assert b"PDF Layout Editor" in response.data or b"pdf" in response.data.lower()
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_save_custom_template(admin_authenticated_client, app):
|
|
"""Test saving custom PDF layout templates."""
|
|
from app.models import InvoicePDFTemplate
|
|
|
|
custom_html = '<div class="custom-invoice"><h1>{{ invoice.invoice_number }}</h1></div>'
|
|
custom_css = ".custom-invoice { color: red; }"
|
|
|
|
# Save custom template (A4 is default)
|
|
response = admin_authenticated_client.post(
|
|
"/admin/pdf-layout",
|
|
data={"invoice_pdf_template_html": custom_html, "invoice_pdf_template_css": custom_css, "page_size": "A4"},
|
|
follow_redirects=True,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Verify settings were saved (app may normalize CSS with @page rule; require our custom part)
|
|
with app.app_context():
|
|
settings = Settings.get_settings()
|
|
assert settings.invoice_pdf_template_html == custom_html
|
|
assert custom_css in (settings.invoice_pdf_template_css or "")
|
|
|
|
# Also check InvoicePDFTemplate
|
|
template = InvoicePDFTemplate.get_template("A4")
|
|
assert template.template_html == custom_html
|
|
assert custom_css in (template.template_css or "")
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_reset_to_defaults(admin_authenticated_client, app):
|
|
"""Test resetting PDF layout to defaults."""
|
|
# First, set custom templates
|
|
settings = Settings.get_settings()
|
|
settings.invoice_pdf_template_html = "<div>Custom HTML</div>"
|
|
settings.invoice_pdf_template_css = "body { color: blue; }"
|
|
db.session.commit()
|
|
|
|
# Reset to defaults
|
|
response = admin_authenticated_client.post("/admin/pdf-layout/reset", follow_redirects=True)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Verify templates were cleared
|
|
settings = Settings.get_settings()
|
|
assert settings.invoice_pdf_template_html == ""
|
|
assert settings.invoice_pdf_template_css == ""
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_get_defaults(admin_authenticated_client):
|
|
"""Test getting default PDF layout templates."""
|
|
# Get default templates
|
|
response = admin_authenticated_client.get("/admin/pdf-layout/default")
|
|
|
|
assert response.status_code == 200
|
|
assert response.is_json
|
|
|
|
data = response.get_json()
|
|
assert "html" in data
|
|
assert "css" in data
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_preview(admin_authenticated_client, sample_invoice):
|
|
"""Test PDF layout preview functionality."""
|
|
# Preview requires a saved template; save a minimal one first
|
|
admin_authenticated_client.post(
|
|
"/admin/pdf-layout",
|
|
data={
|
|
"invoice_pdf_template_html": "<h1>Test Invoice {{ invoice.invoice_number }}</h1>",
|
|
"invoice_pdf_template_css": "h1 { color: red; }",
|
|
"page_size": "A4",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
|
|
response = admin_authenticated_client.post(
|
|
"/admin/pdf-layout/preview",
|
|
data={
|
|
"html": "<h1>Test Invoice {{ invoice.invoice_number }}</h1>",
|
|
"css": "h1 { color: red; }",
|
|
"invoice_id": sample_invoice.id,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Should return HTML content (invoice number or heading)
|
|
assert b"Test Invoice" in response.data or b"INV-2024-001" in response.data
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_preview_prefers_form_template_json_over_database(
|
|
admin_authenticated_client, app, sample_invoice
|
|
):
|
|
"""Issue #600: preview must use template_json from the POST body when present, not only the DB."""
|
|
from app.models import InvoicePDFTemplate
|
|
|
|
db_template = {
|
|
"page": {
|
|
"size": "A4",
|
|
"margin": {"top": 20, "right": 20, "bottom": 20, "left": 20},
|
|
"width": 210,
|
|
"height": 297,
|
|
},
|
|
"elements": [
|
|
{
|
|
"type": "text",
|
|
"x": 50,
|
|
"y": 50,
|
|
"text": "DB_PREVIEW_MARKER_XYZ",
|
|
"width": 400,
|
|
"style": {"font": "Helvetica", "size": 12, "color": "#000000", "align": "left"},
|
|
}
|
|
],
|
|
"styles": {"default": {"font": "Helvetica", "size": 10, "color": "#000000"}},
|
|
}
|
|
form_template = {
|
|
"page": {
|
|
"size": "A4",
|
|
"margin": {"top": 20, "right": 20, "bottom": 20, "left": 20},
|
|
"width": 210,
|
|
"height": 297,
|
|
},
|
|
"elements": [
|
|
{
|
|
"type": "text",
|
|
"x": 50,
|
|
"y": 50,
|
|
"text": "FORM_PREVIEW_MARKER_XYZ",
|
|
"width": 400,
|
|
"style": {"font": "Helvetica", "size": 12, "color": "#000000", "align": "left"},
|
|
}
|
|
],
|
|
"styles": {"default": {"font": "Helvetica", "size": 10, "color": "#000000"}},
|
|
}
|
|
|
|
with app.app_context():
|
|
t = InvoicePDFTemplate.get_template("A4")
|
|
t.template_json = json.dumps(db_template)
|
|
db.session.commit()
|
|
|
|
response = admin_authenticated_client.post(
|
|
"/admin/pdf-layout/preview",
|
|
data={
|
|
"html": "<div></div>",
|
|
"css": "",
|
|
"template_json": json.dumps(form_template),
|
|
"page_size": "A4",
|
|
"invoice_id": sample_invoice.id,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "FORM_PREVIEW_MARKER_XYZ" in body
|
|
assert "DB_PREVIEW_MARKER_XYZ" not in body
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_preview_with_mock_invoice(admin_authenticated_client, app):
|
|
"""Test PDF layout preview with mock invoice when no real invoice exists."""
|
|
# Delete all invoices
|
|
Invoice.query.delete()
|
|
db.session.commit()
|
|
|
|
# Test preview should still work with mock invoice
|
|
response = admin_authenticated_client.post(
|
|
"/admin/pdf-layout/preview",
|
|
data={"html": "<h1>{{ invoice.invoice_number }}</h1>", "css": "h1 { color: blue; }"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.models
|
|
def test_settings_pdf_template_fields_exist(app):
|
|
"""Test that Settings model has PDF template fields."""
|
|
settings = Settings.get_settings()
|
|
|
|
assert hasattr(settings, "invoice_pdf_template_html")
|
|
assert hasattr(settings, "invoice_pdf_template_css")
|
|
|
|
|
|
@pytest.mark.models
|
|
def test_settings_pdf_template_defaults(app):
|
|
"""Test that PDF template fields have proper defaults."""
|
|
settings = Settings.get_settings()
|
|
|
|
# Should default to empty strings
|
|
if not settings.invoice_pdf_template_html:
|
|
assert settings.invoice_pdf_template_html == "" or settings.invoice_pdf_template_html is None
|
|
if not settings.invoice_pdf_template_css:
|
|
assert settings.invoice_pdf_template_css == "" or settings.invoice_pdf_template_css is None
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_pdf_generation_with_custom_template(app, sample_invoice):
|
|
"""Test PDF generation uses custom templates when available."""
|
|
from app.utils.pdf_generator import InvoicePDFGenerator
|
|
|
|
# Set custom template
|
|
settings = Settings.get_settings()
|
|
settings.invoice_pdf_template_html = """
|
|
<div class="custom-wrapper">
|
|
<h1>Custom Invoice: {{ invoice.invoice_number }}</h1>
|
|
<p>Client: {{ invoice.client_name }}</p>
|
|
</div>
|
|
"""
|
|
settings.invoice_pdf_template_css = """
|
|
.custom-wrapper { padding: 20px; }
|
|
h1 { color: #333; }
|
|
"""
|
|
db.session.commit()
|
|
|
|
# Generate PDF
|
|
generator = InvoicePDFGenerator(sample_invoice, settings)
|
|
pdf_bytes = generator.generate_pdf()
|
|
|
|
# Should generate valid PDF
|
|
assert pdf_bytes is not None
|
|
assert len(pdf_bytes) > 0
|
|
# PDF files start with %PDF
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_pdf_generation_with_default_template(app, sample_invoice):
|
|
"""Test PDF generation uses default template when no custom template set."""
|
|
from app.utils.pdf_generator import InvoicePDFGenerator
|
|
|
|
# Clear any custom templates
|
|
settings = Settings.get_settings()
|
|
settings.invoice_pdf_template_html = ""
|
|
settings.invoice_pdf_template_css = ""
|
|
db.session.commit()
|
|
|
|
# Generate PDF
|
|
generator = InvoicePDFGenerator(sample_invoice, settings)
|
|
pdf_bytes = generator.generate_pdf()
|
|
|
|
# Should generate valid PDF
|
|
assert pdf_bytes is not None
|
|
assert len(pdf_bytes) > 0
|
|
# PDF files start with %PDF
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_navigation_link_exists(admin_authenticated_client, app):
|
|
"""Test that PDF layout link exists in admin navigation."""
|
|
# Access admin dashboard or any admin page
|
|
response = admin_authenticated_client.get("/admin/settings")
|
|
|
|
assert response.status_code == 200
|
|
# Should contain link to PDF layout page
|
|
# The link might be in the navigation or as a menu item
|
|
html = response.get_data(as_text=True)
|
|
# Check for PDF layout link - it's in a dropdown menu
|
|
with app.app_context():
|
|
pdf_layout_url = url_for("admin.pdf_layout")
|
|
# Check for various possible indicators of the PDF layout link
|
|
assert (
|
|
"admin.pdf_layout" in html
|
|
or "pdf-layout" in html
|
|
or "PDF Templates" in html
|
|
or "pdf templates" in html.lower()
|
|
or pdf_layout_url in html
|
|
or "/admin/pdf-layout" in html
|
|
or "Invoice PDF" in html
|
|
)
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_form_csrf_protection(admin_authenticated_client):
|
|
"""Test that PDF layout form has CSRF protection."""
|
|
# Get the PDF layout page
|
|
response = admin_authenticated_client.get("/admin/pdf-layout")
|
|
|
|
assert response.status_code == 200
|
|
# Should contain CSRF token
|
|
assert b"csrf_token" in response.data or b'name="csrf_token"' in response.data
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_pdf_layout_jinja_variable_rendering(app, sample_invoice):
|
|
"""Test that Jinja variables are properly rendered in custom templates."""
|
|
from app.utils.pdf_generator import InvoicePDFGenerator
|
|
|
|
# Set custom template with various Jinja variables
|
|
settings = Settings.get_settings()
|
|
settings.invoice_pdf_template_html = """
|
|
<div>
|
|
<h1>Invoice: {{ invoice.invoice_number }}</h1>
|
|
<p>Client: {{ invoice.client_name }}</p>
|
|
<p>Company: {{ settings.company_name }}</p>
|
|
<p>Total: {{ format_money(invoice.total_amount) }}</p>
|
|
</div>
|
|
"""
|
|
db.session.commit()
|
|
|
|
# Generate PDF
|
|
generator = InvoicePDFGenerator(sample_invoice, settings)
|
|
pdf_bytes = generator.generate_pdf()
|
|
|
|
# Should generate valid PDF without errors
|
|
assert pdf_bytes is not None
|
|
assert len(pdf_bytes) > 0
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_rate_limiting(admin_authenticated_client):
|
|
"""Test that PDF layout endpoints have rate limiting."""
|
|
# Make multiple rapid requests to preview endpoint
|
|
for i in range(65): # Exceeds the 60 per minute limit
|
|
response = admin_authenticated_client.post(
|
|
"/admin/pdf-layout/preview", data={"html": "<h1>Test</h1>", "css": "h1 { color: red; }"}
|
|
)
|
|
|
|
# After 60 requests, should be rate limited
|
|
if i >= 60:
|
|
assert response.status_code == 429 # Too Many Requests
|
|
break
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_pdf_layout_with_invoice_items_loop(app, sample_invoice):
|
|
"""Test custom template with loop over invoice items."""
|
|
from app.utils.pdf_generator import InvoicePDFGenerator
|
|
|
|
# Set custom template with items loop
|
|
settings = Settings.get_settings()
|
|
settings.invoice_pdf_template_html = """
|
|
<div>
|
|
<h1>Invoice: {{ invoice.invoice_number }}</h1>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th>Quantity</th>
|
|
<th>Price</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in invoice.items %}
|
|
<tr>
|
|
<td>{{ item.description }}</td>
|
|
<td>{{ item.quantity }}</td>
|
|
<td>{{ format_money(item.total_amount) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"""
|
|
db.session.commit()
|
|
|
|
# Generate PDF
|
|
generator = InvoicePDFGenerator(sample_invoice, settings)
|
|
pdf_bytes = generator.generate_pdf()
|
|
|
|
# Should generate valid PDF
|
|
assert pdf_bytes is not None
|
|
assert len(pdf_bytes) > 0
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.admin
|
|
def test_pdf_layout_save_and_restore_tables(app):
|
|
"""Test that a layout with items table and expenses table in design_json/template_json persists and loads correctly."""
|
|
import json
|
|
from app.models import InvoicePDFTemplate
|
|
|
|
# Minimal Konva stage design_json with items-table and expenses-table group names (as saved by the editor fix)
|
|
design_json = {
|
|
"attrs": {"width": 595, "height": 842},
|
|
"className": "Stage",
|
|
"children": [
|
|
{
|
|
"attrs": {},
|
|
"className": "Layer",
|
|
"children": [
|
|
{
|
|
"attrs": {"x": 40, "y": 350, "name": "items-table"},
|
|
"className": "Group",
|
|
"children": [],
|
|
},
|
|
{
|
|
"attrs": {"x": 40, "y": 450, "name": "expenses-table"},
|
|
"className": "Group",
|
|
"children": [],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
# Minimal ReportLab template_json with two table elements
|
|
template_json = {
|
|
"page": {"size": "A4", "width": 595, "height": 842, "margin": {"top": 20, "right": 20, "bottom": 20, "left": 20}},
|
|
"elements": [
|
|
{
|
|
"type": "table",
|
|
"x": 40,
|
|
"y": 350,
|
|
"width": 515,
|
|
"columns": [
|
|
{"width": 250, "header": "Description", "field": "description", "align": "left"},
|
|
{"width": 70, "header": "Qty", "field": "quantity", "align": "center"},
|
|
{"width": 110, "header": "Unit Price", "field": "unit_price", "align": "right"},
|
|
{"width": 110, "header": "Total", "field": "total_amount", "align": "right"},
|
|
],
|
|
"data": "{{ invoice.all_line_items }}",
|
|
"row_template": {
|
|
"description": "{{ item.description }}",
|
|
"quantity": "{{ item.quantity }}",
|
|
"unit_price": "{{ format_money(item.unit_price) }}",
|
|
"total_amount": "{{ format_money(item.total_amount) }}",
|
|
},
|
|
},
|
|
{
|
|
"type": "table",
|
|
"x": 40,
|
|
"y": 450,
|
|
"width": 515,
|
|
"columns": [
|
|
{"width": 200, "header": "Expense", "field": "title", "align": "left"},
|
|
{"width": 100, "header": "Date", "field": "expense_date", "align": "center"},
|
|
{"width": 105, "header": "Category", "field": "category", "align": "left"},
|
|
{"width": 110, "header": "Amount", "field": "total_amount", "align": "right"},
|
|
],
|
|
"data": "{{ invoice.expenses }}",
|
|
"row_template": {
|
|
"title": "{{ expense.title }}",
|
|
"expense_date": "{{ expense.expense_date }}",
|
|
"category": "{{ expense.category }}",
|
|
"total_amount": "{{ format_money(expense.total_amount) }}",
|
|
},
|
|
},
|
|
],
|
|
"styles": {"default": {"font": "Helvetica", "size": 10, "color": "#000000"}},
|
|
}
|
|
|
|
# Persist template with table design and template JSON (simulates save)
|
|
with app.app_context():
|
|
template = InvoicePDFTemplate.get_template("A4")
|
|
template.design_json = json.dumps(design_json)
|
|
template.template_json = json.dumps(template_json)
|
|
template.template_html = "<div class=\"invoice-wrapper\"><h1>Test</h1></div>"
|
|
template.template_css = "@page { size: A4; }"
|
|
db.session.commit()
|
|
|
|
# Verify stored template has design_json and template_json with table names / table elements
|
|
with app.app_context():
|
|
template = InvoicePDFTemplate.get_template("A4")
|
|
assert template.design_json, "design_json should be persisted"
|
|
assert template.template_json, "template_json should be persisted"
|
|
design = json.loads(template.design_json)
|
|
layer = design.get("children", [{}])[0] if design.get("children") else {}
|
|
names = []
|
|
for c in layer.get("children", []):
|
|
name = (c.get("attrs") or {}).get("name")
|
|
if name in ("items-table", "expenses-table"):
|
|
names.append(name)
|
|
assert "items-table" in names, "design_json should contain items-table group name for restore"
|
|
assert "expenses-table" in names, "design_json should contain expenses-table group name for restore"
|
|
tpl = json.loads(template.template_json)
|
|
table_elements = [e for e in tpl.get("elements", []) if e.get("type") == "table"]
|
|
assert len(table_elements) >= 2, "template_json should contain at least two table elements for export"
|