#!/usr/bin/env python3
"""End-to-end PDF test: generate a document for every layout and verify output.
Tests cover:
- All template layouts render without errors
- All document types (invoice, proposal, statement, work_order, delivery_note, contract)
- Field mapping transforms raw submission data correctly
- PDF generation returns valid PDF bytes (proper header)
- Logo variants (with/without logo) don't break rendering
"""
import base64
import json
import os
import sys
from datetime import datetime
from pathlib import Path
import pytest
# Ensure we can import app modules
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.documents import (
render_document_html,
generate_pdf,
TEMPLATE_LAYOUTS,
DEFAULT_STYLE,
DOCUMENT_TYPES,
apply_field_mapping,
)
from app.services.document_builders import (
build_invoice_data,
build_proposal_data,
build_statement_data,
build_work_order_data,
build_delivery_note_data,
build_contract_data,
)
# ─── Minimal test data fixtures ───────────────────────────────────────────────
SAMPLE_DATA = {
"document_number": "INV-2026-0001",
"issue_date": "2026-06-15",
"due_date": "2026-07-15",
"from": {
"name": "Acme Creative Studio",
"email": "billing@acmestudio.io",
"phone": "+1 (555) 012-3456",
"address": "123 Design Lane\nSan Francisco, CA 94102",
"tax_id": "US-12-3456789",
},
"to": {
"name": "Global Tech Corp",
"email": "ap@globaltech.com",
"phone": "+1 (555) 987-6543",
"address": "456 Enterprise Blvd\nAustin, TX 78701",
},
"line_items": [
{
"description": "Brand Identity Design",
"quantity": 1,
"unit_price": 4500.00,
"amount": 4500.00,
},
{
"description": "Website Design & Development",
"quantity": 1,
"unit_price": 8200.00,
"amount": 8200.00,
},
{
"description": "Monthly Content Strategy",
"quantity": 3,
"unit_price": 750.00,
"amount": 2250.00,
},
],
"subtotal": 14950.00,
"tax_rate": 0.0825,
"tax": 1233.38,
"total": 16183.38,
"currency": "USD",
"payment_terms": "Net 30",
"notes": "Please reference invoice #INV-2026-0001 on all payments.",
"payment_link": "https://pay.example.com/INV-2026-0001",
"payment_link_text": "Pay Now",
"status": "pending",
"discount": 0.0,
}
SAMPLE_STYLE = {
**DEFAULT_STYLE,
"primary_color": "#2563eb",
"logo_url": "",
}
SAMPLE_STYLE_WITH_LOGO = {
**SAMPLE_STYLE,
"logo_url": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='40' viewBox='0 0 120 40'><rect fill='%232563eb' width='120' height='40' rx='4'/><text x='60' y='25' text-anchor='middle' fill='white' font-size='16' font-family='sans-serif' font-weight='bold'>ACME</text></svg>",
}
# ─── Helper ───────────────────────────────────────────────────────────────────
def assert_valid_pdf(pdf_bytes: bytes):
"""Assert that bytes are a valid PDF."""
assert pdf_bytes, "PDF bytes are empty"
assert len(pdf_bytes) > 100, f"PDF too small ({len(pdf_bytes)} bytes) — likely broken"
assert pdf_bytes[:5] == b"%PDF-", f"Invalid PDF header: {pdf_bytes[:20]!r}"
# ─── Test: All layouts render ────────────────────────────────────────────────
class TestAllLayouts:
"""Test that every template layout renders and generates valid PDFs."""
@pytest.mark.parametrize("layout", TEMPLATE_LAYOUTS)
def test_layout_renders(self, layout):
"""Each layout template renders HTML without error."""
html = render_document_html("invoice", layout, SAMPLE_DATA, SAMPLE_STYLE)
assert html, f"Layout '{layout}' produced empty HTML"
assert len(html) > 100, f"Layout '{layout}' produced suspiciously short HTML"
assert "<html" in html.lower() or "<!doctype" in html.lower(), (
f"Layout '{layout}' HTML doesn't look like HTML"
)
@pytest.mark.parametrize("layout", TEMPLATE_LAYOUTS)
def test_layout_pdf_valid(self, layout):
"""Each layout generates valid PDF bytes."""
html = render_document_html("invoice", layout, SAMPLE_DATA, SAMPLE_STYLE)
pdf_bytes = generate_pdf(html, SAMPLE_STYLE)
assert_valid_pdf(pdf_bytes)
# ─── Test: Document type rendering ────────────────────────────────────────────
class TestDocumentTypes:
"""Test that each document type renders with appropriate content."""
@pytest.fixture
def doc_data(self):
"""Base document data shared across document type tests."""
return SAMPLE_DATA.copy()
def test_invoice_rendering(self, doc_data):
"""Invoice renders with expected fields."""
html = render_document_html("invoice", "classic", doc_data, SAMPLE_STYLE)
assert "INVOICE" in html.upper() or "invoice" in html.lower()
assert "Acme Creative Studio" in html
assert "Global Tech Corp" in html
assert "4500" in html or "4,500" in html # Line item price
def test_proposal_rendering(self, doc_data):
"""Proposal renders with expected proposal-specific fields."""
proposal_data = {
**doc_data,
"executive_summary": "We propose a comprehensive digital transformation.",
"scope_of_work": [
{"title": "Phase 1: Discovery", "description": "Requirements gathering"},
{"title": "Phase 2: Design", "description": "UI/UX design"},
],
"timeline": [
{"phase": "Discovery", "start": "2026-07-01", "end": "2026-07-14"},
],
"terms_conditions": "Proposal valid for 30 days.",
}
html = render_document_html("proposal", "proposal", proposal_data, SAMPLE_STYLE)
assert html, "Proposal HTML is empty"
assert_valid_pdf(generate_pdf(html, SAMPLE_STYLE))
def test_statement_rendering(self, doc_data):
"""Statement renders with transaction history."""
statement_data = {
**doc_data,
"opening_balance": 1000.00,
"period_start": "2026-06-01",
"period_end": "2026-06-30",
"transactions": [
{"date": "2026-06-05", "description": "Payment received", "debit": 0, "credit": 500.00, "reference": "PAY-001"},
{"date": "2026-06-15", "description": "Invoice paid", "debit": 300.00, "credit": 0, "reference": "INV-001"},
],
"closing_balance": 1200.00,
}
html = render_document_html("statement", "statement", statement_data, SAMPLE_STYLE)
assert html, "Statement HTML is empty"
assert_valid_pdf(generate_pdf(html, SAMPLE_STYLE))
def test_work_order_rendering(self, doc_data):
"""Work order renders with job details."""
wo_data = {
**doc_data,
"work_order_number": "WO-2026-0042",
"job_description": "HVAC maintenance and repair",
"scheduled_date": "2026-07-15",
"priority": "High",
"job_category": "Maintenance",
"technician": {
"name": "John Smith",
"phone": "+1 (555) 111-2222",
"license": "TECH-12345",
},
"tasks": [
{"title": "Inspect HVAC unit", "completed": False},
{"title": "Replace air filter", "completed": True},
],
"materials": [
{"description": "Air filter 20x20x1", "quantity": 2, "unit_price": 25.00},
],
"time_in": "09:00",
"time_out": "11:30",
"labor_hours": 2.5,
"labor_rate": 85.00,
"signature_required": True,
}
html = render_document_html("work_order", "work_order", wo_data, SAMPLE_STYLE)
assert html, "Work order HTML is empty"
assert "WO-2026-0042" in html or "Acme Creative Studio" in html
assert_valid_pdf(generate_pdf(html, SAMPLE_STYLE))
def test_delivery_note_rendering(self, doc_data):
"""Delivery note renders with shipping info."""
dn_data = {
**doc_data,
"delivery_number": "DN-2026-0150",
"delivery_date": "2026-07-10",
"po_number": "PO-2026-0088",
"carrier": "FedEx Ground",
"tracking_number": "FX123456789",
"delivery_address": "789 Warehouse Rd, Austin, TX 78701",
"items": [
{"description": "Widget A", "sku": "WGT-A-001", "quantity": 10, "unit": "ea"},
{"description": "Widget B", "sku": "WGT-B-002", "quantity": 5, "unit": "ea"},
],
"total_items": 15,
"signature_required": True,
}
html = render_document_html("delivery_note", "delivery_note", dn_data, SAMPLE_STYLE)
assert html, "Delivery note HTML is empty"
assert "FedEx" in html
assert_valid_pdf(generate_pdf(html, SAMPLE_STYLE))
def test_contract_rendering(self, doc_data):
"""Contract renders with parties and clauses."""
contract_data = {
**doc_data,
"contract_number": "CTR-2026-0001",
"contract_title": "Software Development Agreement",
"effective_date": "2026-07-01",
"expiration_date": "2027-06-30",
"preamble": "This Agreement is entered into by and between the parties below.",
"party_a": {
"name": "Acme Creative Studio",
"email": "billing@acmestudio.io",
"phone": "+1 (555) 012-3456",
"address": "123 Design Lane, San Francisco, CA 94102",
"title": "Provider",
},
"party_b": {
"name": "Global Tech Corp",
"email": "ap@globaltech.com",
"phone": "+1 (555) 987-6543",
"address": "456 Enterprise Blvd, Austin, TX 78701",
"title": "Client",
},
"clauses": [
{"title": "Scope of Services", "content": "Provider shall develop software as specified."},
{"title": "Payment Terms", "content": "Client shall pay within 30 days of invoice."},
{"title": "Confidentiality", "content": "Both parties agree to maintain confidentiality."},
{"title": "Termination", "content": "Either party may terminate with 60 days notice."},
],
"financial_terms": {
"amount": 150000.00,
"currency": "USD",
"payment_schedule": "Monthly installments",
},
"governing_law": "State of California",
"signatures": {
"party_a_name": "Acme Creative Studio",
"party_a_title": "Provider",
"party_b_name": "Global Tech Corp",
"party_b_title": "Client",
},
}
html = render_document_html("contract", "contract", contract_data, SAMPLE_STYLE)
assert html, "Contract HTML is empty"
assert "Software Development Agreement" in html
assert_valid_pdf(generate_pdf(html, SAMPLE_STYLE))
# ─── Test: Field mapping ──────────────────────────────────────────────────────
class TestFieldMapping:
"""Test that apply_field_mapping transforms raw data correctly."""
def test_basic_field_mapping(self):
"""Basic field mapping extracts customer info and line items."""
submission = {
"customer_name": "Test Customer",
"customer_email": "test@example.com",
"customer_phone": "+1 555 000 0000",
"data": {
"item_desc": "Consulting service",
"item_qty": "10",
"item_price": "150",
},
}
field_mapping = {
"business_name": "My Business",
"business_email": "info@mybusiness.com",
"business_phone": "+1 555 111 2222",
"business_address": "1 Business St",
"tax_rate": 0.10,
"line_items": [
{"description": "item_desc", "quantity": "item_qty", "unit_price": "item_price"},
],
"payment_terms": "Net 15",
}
result = apply_field_mapping(submission, field_mapping)
assert result["from"]["name"] == "My Business"
assert result["from"]["email"] == "info@mybusiness.com"
assert result["to"]["name"] == "Test Customer"
assert result["to"]["email"] == "test@example.com"
assert result["to"]["phone"] == "+1 555 000 0000"
assert len(result["line_items"]) == 1
assert result["line_items"][0]["description"] == "Consulting service"
assert result["line_items"][0]["quantity"] == 10.0
assert result["line_items"][0]["unit_price"] == 150.0
assert result["line_items"][0]["amount"] == 1500.0
assert result["subtotal"] == 1500.0
assert result["tax"] == 150.0
assert result["total"] == 1650.0
assert result["payment_terms"] == "Net 15"
def test_empty_field_mapping(self):
"""Empty field mapping produces valid but empty document data."""
submission = {"data": {}}
result = apply_field_mapping(submission, {})
assert "from" in result
assert "to" in result
assert "line_items" in result
assert result["line_items"] == []
assert result["subtotal"] == 0
assert result["total"] == 0
def test_custom_fields_mapping(self):
"""Custom fields are passed through correctly."""
submission = {
"data": {
"custom_field_1": "value1",
"custom_field_2": "value2",
"item_desc": "Widget",
"item_qty": "1",
"item_price": "10",
},
}
field_mapping = {
"line_items": [
{"description": "item_desc", "quantity": "item_qty", "unit_price": "item_price"},
],
"custom_fields": {
"custom_1": "custom_field_1",
"custom_2": "custom_field_2",
},
}
result = apply_field_mapping(submission, field_mapping)
assert result["custom_1"] == "value1"
assert result["custom_2"] == "value2"
def test_from_to_dict_merge(self):
"""from/to dict fields in data merge with base mapping."""
submission = {
"data": {
"from": {
"name": "Override Business",
"tax_id": "US-99-9999999",
},
"to": {
"phone": "+1 555 999 8888",
},
},
}
field_mapping = {
"business_name": "Default Business",
}
result = apply_field_mapping(submission, field_mapping)
# from dict in data overrides the mapped value
assert result["from"]["name"] == "Override Business"
assert result["from"]["tax_id"] == "US-99-9999999"
assert result["to"]["phone"] == "+1 555 999 8888"
# ─── Test: Data builders ──────────────────────────────────────────────────────
class TestDataBuilders:
"""Test the build_*_data functions produce correct structured data."""
def test_build_invoice_data(self):
"""build_invoice_data produces complete invoice structure."""
template = {
"id": 1,
"field_mapping": {"business_name": "Test Corp", "business_email": "test@corp.com"},
"style_config": DEFAULT_STYLE,
}
submission = {
"customer_name": "Client Co",
"customer_email": "client@co.com",
"tax_rate": 8.5,
}
line_items = [
{"description": "Service", "quantity": 2, "unit_price": 100},
]
result = build_invoice_data(template, submission, line_items, "USD")
assert result["document_type"] == "Invoice"
assert result["from"]["name"] == "Test Corp"
assert result["to"]["name"] == "Client Co"
assert len(result["line_items"]) == 1
assert result["line_items"][0]["amount"] == 200.0
assert result["subtotal"] == 200.0
assert result["tax_amount"] == pytest.approx(17.0) # 200 * 8.5 / 100
assert result["total"] == pytest.approx(217.0)
def test_build_proposal_data(self):
"""build_proposal_data produces complete proposal structure."""
template = {
"field_mapping": {"business_name": "Agency Inc"},
}
submission = {
"customer_name": "Prospect Co",
"executive_summary": "We can help you.",
"timeline": [{"phase": "Design", "start": "2026-07-01"}],
}
line_items = [
{"description": "Design package", "quantity": 1, "unit_price": 5000},
]
result = build_proposal_data(template, submission, line_items, "USD")
assert result["document_type"] == "proposal"
assert result["executive_summary"] == "We can help you."
assert len(result["timeline"]) == 1
assert result["from"]["name"] == "Agency Inc"
def test_build_statement_data(self):
"""build_statement_data calculates running balance correctly."""
template = {"field_mapping": {"business_name": "Bank Corp"}}
submission = {
"customer_name": "Account Holder",
"opening_balance": 1000,
"period_start": "2026-06-01",
"period_end": "2026-06-30",
}
transactions = [
{"date": "2026-06-05", "description": "Deposit", "debit": 0, "credit": 500, "reference": "D001"},
{"date": "2026-06-15", "description": "Withdrawal", "debit": 200, "credit": 0, "reference": "W001"},
]
result = build_statement_data(template, submission, transactions, "USD")
assert result["document_type"] == "statement"
assert len(result["transactions"]) == 2
# Running balance: 1000 + 500 - 200 = 1300
assert result["transactions"][1]["balance"] == 1300.0
assert result["closing_balance"] == 1300.0
def test_build_work_order_data(self):
"""build_work_order_data includes technician and task info."""
template = {"field_mapping": {"business_name": "Service Co"}}
submission = {
"customer_name": "Site Owner",
"customer_phone": "+1 555 123 4567",
"job_description": "AC repair",
"scheduled_date": "2026-07-15",
"priority": "Urgent",
"technician": {"name": "Tech John", "phone": "+1 555 999 0000", "license": "LIC-001"},
"tasks": [{"title": "Diagnose issue", "completed": True}],
}
result = build_work_order_data(template, submission, [], "USD")
assert result["document_type"] == "work_order"
assert result["technician"]["name"] == "Tech John"
assert result["job_description"] == "AC repair"
assert result["priority"] == "Urgent"
assert len(result["tasks"]) == 1
def test_build_delivery_note_data(self):
"""build_delivery_note_data includes shipping info."""
template = {"field_mapping": {"business_name": "Ship Co"}}
submission = {
"customer_name": "Receiver Inc",
"delivery_address": "123 Ship St",
"carrier": "UPS",
"tracking_number": "1Z999AA10123456784",
}
line_items = [
{"description": "Box of widgets", "sku": "BX-001", "quantity": 5, "unit": "box", "weight": "10kg"},
]
result = build_delivery_note_data(template, submission, line_items, "USD")
assert result["document_type"] == "delivery_note"
assert result["carrier"] == "UPS"
assert len(result["items"]) == 1
assert result["items"][0]["sku"] == "BX-001"
assert result["total_items"] == 5.0
def test_build_contract_data(self):
"""build_contract_data includes parties and clauses."""
template = {"field_mapping": {"business_name": "Provider LLC"}}
submission = {
"customer_name": "Client Corp",
"contract_title": "Service Agreement",
"effective_date": "2026-07-01",
"expiration_date": "2027-06-30",
"clauses": [
{"title": "Scope", "content": "Provider delivers services."},
{"title": "Payment", "content": "Client pays $10k/month."},
],
"contract_amount": 120000,
"governing_law": "Delaware",
}
result = build_contract_data(template, submission, line_items=[], currency="USD")
assert result["document_type"] == "contract"
assert result["contract_title"] == "Service Agreement"
assert result["party_a"]["name"] == "Provider LLC"
assert result["party_b"]["name"] == "Client Corp"
assert len(result["clauses"]) == 2
assert result["financial_terms"]["amount"] == 120000.0
assert result["governing_law"] == "Delaware"
# ─── Test: PDF generation ─────────────────────────────────────────────────────
class TestPDFGeneration:
"""Test that PDF generation produces valid output."""
def test_pdf_header_valid(self):
"""Generated PDF starts with %PDF- header."""
html = render_document_html("invoice", "classic", SAMPLE_DATA, SAMPLE_STYLE)
pdf_bytes = generate_pdf(html, SAMPLE_STYLE)
assert pdf_bytes[:5] == b"%PDF-", f"Invalid PDF header: {pdf_bytes[:20]!r}"
def test_pdf_size_reasonable(self):
"""Generated PDF has reasonable size (> 1KB)."""
html = render_document_html("invoice", "modern", SAMPLE_DATA, SAMPLE_STYLE)
pdf_bytes = generate_pdf(html, SAMPLE_STYLE)
assert len(pdf_bytes) > 1024, f"PDF too small: {len(pdf_bytes)} bytes"
def test_pdf_with_logo(self):
"""PDF generation works with inline SVG logo."""
html = render_document_html("invoice", "modern", SAMPLE_DATA, SAMPLE_STYLE_WITH_LOGO)
pdf_bytes = generate_pdf(html, SAMPLE_STYLE_WITH_LOGO)
assert_valid_pdf(pdf_bytes)
def test_pdf_empty_data(self):
"""PDF generation handles minimal/empty data gracefully."""
minimal_data = {
"from": {"name": "Empty Corp"},
"to": {"name": "Empty Client"},
"line_items": [],
"subtotal": 0,
"tax": 0,
"tax_rate": 0,
"total": 0,
}
html = render_document_html("invoice", "classic", minimal_data, SAMPLE_STYLE)
pdf_bytes = generate_pdf(html, SAMPLE_STYLE)
assert_valid_pdf(pdf_bytes)
# ─── Test: Config validation ──────────────────────────────────────────────────
class TestConfigValidation:
"""Test that config validation catches missing env vars."""
def test_validate_with_all_vars(self):
"""validate() passes when all required vars are set."""
from app.config import validate
# Required vars are already set by conftest.py
validate() # Should not raise
def test_required_vars_defined(self):
"""Check that required vars list is properly defined."""
from app.config import _REQUIRED_VARS, _OPTIONAL_VARS
assert len(_REQUIRED_VARS) >= 2 # At least SECRET_KEY and ENCRYPTION_KEY
assert len(_OPTIONAL_VARS) >= 3 # Stripe + Redis
required_names = {v["name"] for v in _REQUIRED_VARS}
assert "SECRET_KEY" in required_names
assert "ENCRYPTION_KEY" in required_names
optional_names = {v["name"] for v in _OPTIONAL_VARS}
assert "STRIPE_SECRET_KEY" in optional_names
assert "REDIS_URL" in optional_names
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])