bouquin/tests/test_invoices.py
Miguel Jacq 81878c63d9
All checks were successful
CI / test (push) Successful in 7m5s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 25s
Invoicing
2025-12-08 20:34:11 +11:00

1348 lines
42 KiB
Python

import pytest
from datetime import date, timedelta
from PySide6.QtCore import Qt, QDate
from PySide6.QtWidgets import QMessageBox
from bouquin.invoices import (
InvoiceDetailMode,
InvoiceLineItem,
_invoice_due_reminder_text,
InvoiceDialog,
InvoicesDialog,
_INVOICE_REMINDER_TIME,
)
from bouquin.reminders import Reminder, ReminderType
# ============================================================================
# Tests for InvoiceDetailMode enum
# ============================================================================
def test_invoice_detail_mode_enum_values(app):
"""Test InvoiceDetailMode enum has expected values."""
assert InvoiceDetailMode.DETAILED == "detailed"
assert InvoiceDetailMode.SUMMARY == "summary"
def test_invoice_detail_mode_is_string(app):
"""Test InvoiceDetailMode enum inherits from str."""
assert isinstance(InvoiceDetailMode.DETAILED, str)
assert isinstance(InvoiceDetailMode.SUMMARY, str)
# ============================================================================
# Tests for InvoiceLineItem dataclass
# ============================================================================
def test_invoice_line_item_creation(app):
"""Test creating an InvoiceLineItem instance."""
item = InvoiceLineItem(
description="Development work",
hours=5.5,
rate_cents=10000,
amount_cents=55000,
)
assert item.description == "Development work"
assert item.hours == 5.5
assert item.rate_cents == 10000
assert item.amount_cents == 55000
def test_invoice_line_item_with_zero_values(app):
"""Test InvoiceLineItem with zero values."""
item = InvoiceLineItem(
description="",
hours=0.0,
rate_cents=0,
amount_cents=0,
)
assert item.description == ""
assert item.hours == 0.0
assert item.rate_cents == 0
assert item.amount_cents == 0
# ============================================================================
# Tests for _invoice_due_reminder_text helper function
# ============================================================================
def test_invoice_due_reminder_text_normal(app):
"""Test reminder text generation with normal inputs."""
result = _invoice_due_reminder_text("Project Alpha", "INV-001")
assert result == "Invoice INV-001 for Project Alpha is due"
def test_invoice_due_reminder_text_with_whitespace(app):
"""Test reminder text strips whitespace from inputs."""
result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ")
assert result == "Invoice INV-002 for Project Beta is due"
def test_invoice_due_reminder_text_empty_project(app):
"""Test reminder text with empty project name."""
result = _invoice_due_reminder_text("", "INV-003")
assert result == "Invoice INV-003 for (no project) is due"
def test_invoice_due_reminder_text_empty_invoice_number(app):
"""Test reminder text with empty invoice number."""
result = _invoice_due_reminder_text("Project Gamma", "")
assert result == "Invoice ? for Project Gamma is due"
def test_invoice_due_reminder_text_both_empty(app):
"""Test reminder text with both inputs empty."""
result = _invoice_due_reminder_text("", "")
assert result == "Invoice ? for (no project) is due"
# ============================================================================
# Tests for InvoiceDialog
# ============================================================================
@pytest.fixture
def invoice_dialog_setup(qtbot, fresh_db):
"""Set up a project with time logs for InvoiceDialog testing."""
# Create a project
proj_id = fresh_db.add_project("Test Project")
# Create an activity
act_id = fresh_db.add_activity("Development")
# Set billing info
fresh_db.upsert_project_billing(
proj_id,
hourly_rate_cents=15000, # $150/hr
currency="USD",
tax_label="VAT",
tax_rate_percent=20.0,
client_name="John Doe",
client_company="Acme Corp",
client_address="123 Main St",
client_email="john@acme.com",
)
# Create some time logs
today = date.today()
start_date = (today - timedelta(days=7)).isoformat()
end_date = today.isoformat()
# Add time logs for testing (2.5 hours = 150 minutes)
for i in range(3):
log_date = (today - timedelta(days=i)).isoformat()
fresh_db.add_time_log(
log_date,
proj_id,
act_id,
150, # 2.5 hours in minutes
f"Note {i}",
)
time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date)
return {
"db": fresh_db,
"proj_id": proj_id,
"act_id": act_id,
"start_date": start_date,
"end_date": end_date,
"time_rows": time_rows,
}
def test_invoice_dialog_init(qtbot, invoice_dialog_setup):
"""Test InvoiceDialog initialization."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
assert dialog._db is setup["db"]
assert dialog._project_id == setup["proj_id"]
assert dialog._start == setup["start_date"]
assert dialog._end == setup["end_date"]
assert len(dialog._time_rows) == 3
def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup):
"""Test InvoiceDialog initialization without explicit time_rows."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
)
qtbot.addWidget(dialog)
# Should fetch time rows from DB
assert len(dialog._time_rows) == 3
def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup):
"""Test that InvoiceDialog loads billing defaults from project."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
assert dialog.currency_edit.text() == "USD"
assert dialog.rate_spin.value() == 150.0
assert dialog.client_name_edit.text() == "John Doe"
assert dialog.client_company_combo.currentText() == "Acme Corp"
def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db):
"""Test InvoiceDialog with project that has no billing info."""
proj_id = fresh_db.add_project("Test Project No Billing")
today = date.today()
start = (today - timedelta(days=1)).isoformat()
end = today.isoformat()
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
qtbot.addWidget(dialog)
# Should use defaults
assert dialog.currency_edit.text() == "AUD"
assert dialog.rate_spin.value() == 0.0
assert dialog.client_name_edit.text() == ""
def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup):
"""Test _project_name method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
project_name = dialog._project_name()
assert project_name == "Test Project"
def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup):
"""Test _suggest_invoice_number method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
invoice_number = dialog._suggest_invoice_number()
# Should be in format YYYY-001 for first invoice (3 digits)
current_year = date.today().year
assert invoice_number.startswith(str(current_year))
assert invoice_number.endswith("-001")
def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup):
"""Test that invoice number suggestions increment."""
setup = invoice_dialog_setup
# Create an invoice first
dialog1 = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog1)
# Save an invoice to increment the counter
invoice_number_1 = dialog1._suggest_invoice_number()
setup["db"].create_invoice(
project_id=setup["proj_id"],
invoice_number=invoice_number_1,
issue_date=date.today().isoformat(),
due_date=(date.today() + timedelta(days=14)).isoformat(),
currency="USD",
tax_label=None,
tax_rate_percent=None,
detail_mode=InvoiceDetailMode.DETAILED,
line_items=[],
time_log_ids=[],
)
# Create another dialog and check the number increments
dialog2 = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog2)
invoice_number_2 = dialog2._suggest_invoice_number()
current_year = date.today().year
assert invoice_number_2 == f"{current_year}-002"
def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup):
"""Test _populate_detailed_rows method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog._populate_detailed_rows(15000) # $150/hr in cents
# Check that table has rows
assert dialog.table.rowCount() == 3
# Check that hours are displayed (COL_HOURS uses cellWidget, not item)
for row in range(3):
hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS)
assert hours_widget is not None
assert hours_widget.value() == 2.5
def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup):
"""Test _total_hours_from_table method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog._populate_detailed_rows(15000)
total_hours = dialog._total_hours_from_table()
# 3 rows * 2.5 hours = 7.5 hours
assert total_hours == 7.5
def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup):
"""Test _detail_line_items method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(150.0)
dialog._populate_detailed_rows(15000)
line_items = dialog._detail_line_items()
assert len(line_items) == 3
for item in line_items:
assert isinstance(item, InvoiceLineItem)
assert item.hours == 2.5
assert item.rate_cents == 15000
assert item.amount_cents == 37500 # 2.5 * 15000
def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup):
"""Test _summary_line_items method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(150.0)
dialog._populate_detailed_rows(15000)
line_items = dialog._summary_line_items()
assert len(line_items) == 1 # Summary should have one line
item = line_items[0]
assert isinstance(item, InvoiceLineItem)
# The description comes from summary_desc_edit which has a localized default
# Just check it's not empty
assert len(item.description) > 0
assert item.hours == 7.5 # Total of 3 * 2.5
assert item.rate_cents == 15000
assert item.amount_cents == 112500 # 7.5 * 15000
def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup):
"""Test _recalc_amounts method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog._populate_detailed_rows(15000)
dialog.rate_spin.setValue(200.0) # Change rate to $200/hr
dialog._recalc_amounts()
# Check that amounts were recalculated
for row in range(3):
amount_item = dialog.table.item(row, dialog.COL_AMOUNT)
assert amount_item is not None
# 2.5 hours * $200 = $500
assert amount_item.text() == "500.00"
def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup):
"""Test _recalc_totals method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(100.0)
dialog._populate_detailed_rows(10000)
# Enable tax
dialog.tax_checkbox.setChecked(True)
dialog.tax_rate_spin.setValue(10.0)
dialog._recalc_totals()
# 7.5 hours * $100 = $750
# Tax: $750 * 10% = $75
# Total: $750 + $75 = $825
assert "750.00" in dialog.subtotal_label.text()
assert "75.00" in dialog.tax_label_total.text()
assert "825.00" in dialog.total_label.text()
def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup):
"""Test _on_tax_toggled method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.show()
# Initially unchecked (from fixture setup with tax)
dialog.tax_checkbox.setChecked(False)
dialog._on_tax_toggled(False)
# Tax fields should be hidden
assert not dialog.tax_label.isVisible()
assert not dialog.tax_label_edit.isVisible()
assert not dialog.tax_rate_label.isVisible()
assert not dialog.tax_rate_spin.isVisible()
# Check the box
dialog.tax_checkbox.setChecked(True)
dialog._on_tax_toggled(True)
# Tax fields should be visible
assert dialog.tax_label.isVisible()
assert dialog.tax_label_edit.isVisible()
assert dialog.tax_rate_label.isVisible()
assert dialog.tax_rate_spin.isVisible()
def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup):
"""Test _on_client_company_changed method for autofill."""
setup = invoice_dialog_setup
# Create another project with different client
proj_id_2 = setup["db"].add_project("Project 2")
setup["db"].upsert_project_billing(
proj_id_2,
hourly_rate_cents=20000,
currency="EUR",
tax_label="GST",
tax_rate_percent=15.0,
client_name="Jane Smith",
client_company="Tech Industries",
client_address="456 Oak Ave",
client_email="jane@tech.com",
)
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
# Initially should have first project's client
assert dialog.client_name_edit.text() == "John Doe"
# Change to second company
dialog.client_company_combo.setCurrentText("Tech Industries")
dialog._on_client_company_changed("Tech Industries")
# Should autofill with second client's info
assert dialog.client_name_edit.text() == "Jane Smith"
assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave"
assert dialog.client_email_edit.text() == "jane@tech.com"
def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup):
"""Test _create_due_date_reminder method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
due_date = (date.today() + timedelta(days=14)).isoformat()
invoice_number = "INV-TEST-001"
invoice_id = 999 # Fake invoice ID for testing
dialog._create_due_date_reminder(invoice_id, invoice_number, due_date)
# Check that reminder was created
reminders = setup["db"].get_all_reminders()
assert len(reminders) > 0
# Find our reminder
expected_text = _invoice_due_reminder_text("Test Project", invoice_number)
matching_reminders = [r for r in reminders if r.text == expected_text]
assert len(matching_reminders) == 1
reminder = matching_reminders[0]
assert reminder.reminder_type == ReminderType.ONCE
assert reminder.date_iso == due_date
assert reminder.time_str == _INVOICE_REMINDER_TIME
# ============================================================================
# Tests for InvoicesDialog
# ============================================================================
@pytest.fixture
def invoices_dialog_setup(qtbot, fresh_db):
"""Set up projects with invoices for InvoicesDialog testing."""
# Create projects
proj_id_1 = fresh_db.add_project("Project Alpha")
proj_id_2 = fresh_db.add_project("Project Beta")
# Create invoices for project 1
today = date.today()
for i in range(3):
issue_date = (today - timedelta(days=i * 7)).isoformat()
due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat()
paid_at = today.isoformat() if i == 0 else None # First one is paid
fresh_db.create_invoice(
project_id=proj_id_1,
invoice_number=f"ALPHA-{i+1}",
issue_date=issue_date,
due_date=due_date,
currency="USD",
tax_label="VAT",
tax_rate_percent=20.0,
detail_mode=InvoiceDetailMode.DETAILED,
line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr
time_log_ids=[],
)
# Update paid_at separately if needed
if paid_at:
invoice_rows = fresh_db.get_all_invoices(proj_id_1)
if invoice_rows:
inv_id = invoice_rows[0]["id"]
fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at)
# Create invoices for project 2
for i in range(2):
issue_date = (today - timedelta(days=i * 10)).isoformat()
due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat()
fresh_db.create_invoice(
project_id=proj_id_2,
invoice_number=f"BETA-{i+1}",
issue_date=issue_date,
due_date=due_date,
currency="EUR",
tax_label=None,
tax_rate_percent=None,
detail_mode=InvoiceDetailMode.SUMMARY,
line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr
time_log_ids=[],
)
return {
"db": fresh_db,
"proj_id_1": proj_id_1,
"proj_id_2": proj_id_2,
}
def test_invoices_dialog_init(qtbot, invoices_dialog_setup):
"""Test InvoicesDialog initialization."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"])
qtbot.addWidget(dialog)
assert dialog._db is setup["db"]
assert dialog.project_combo.count() >= 2 # 2 projects
def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup):
"""Test InvoicesDialog initialization with specific project."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Should select the specified project
current_proj = dialog._current_project()
assert current_proj == setup["proj_id_1"]
def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup):
"""Test _reload_projects method."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"])
qtbot.addWidget(dialog)
initial_count = dialog.project_combo.count()
assert initial_count >= 2 # Should have 2 projects from setup
# Create a new project
setup["db"].add_project("Project Gamma")
# Reload projects
dialog._reload_projects()
# Should have one more project
assert dialog.project_combo.count() == initial_count + 1
def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup):
"""Test _current_project method when specific project is selected."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
current_proj = dialog._current_project()
assert current_proj == setup["proj_id_1"]
def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup):
"""Test _reload_invoices with first project selected by default."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"])
qtbot.addWidget(dialog)
# First project should be selected by default (Project Alpha with 3 invoices)
# The exact project depends on creation order, so just check we have some invoices
assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2)
def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup):
"""Test _reload_invoices with single project selected."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
dialog._reload_invoices()
# Should show only 3 invoices from proj1
assert dialog.table.rowCount() == 3
def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup):
"""Test _on_project_changed method."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"])
qtbot.addWidget(dialog)
# Start with project 2 (2 invoices)
assert dialog.table.rowCount() == 2
# Find the index of project 1
for i in range(dialog.project_combo.count()):
if dialog.project_combo.itemData(i) == setup["proj_id_1"]:
dialog.project_combo.setCurrentIndex(i)
break
dialog._on_project_changed(dialog.project_combo.currentIndex())
# Should now show 3 invoices from proj1
assert dialog.table.rowCount() == 3
def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup):
"""Test _remove_invoice_due_reminder method."""
setup = invoices_dialog_setup
# Create a reminder for an invoice
due_date = (date.today() + timedelta(days=7)).isoformat()
invoice_number = "TEST-REMINDER-001"
project_name = "Project Alpha"
reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
reminder = Reminder(
id=None,
text=reminder_text,
time_str=_INVOICE_REMINDER_TIME,
reminder_type=ReminderType.ONCE,
date_iso=due_date,
active=True,
)
reminder.id = setup["db"].save_reminder(reminder)
# Verify reminder exists
reminders = setup["db"].get_all_reminders()
assert len(reminders) == 1
# Create dialog and populate with invoices
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Manually add a row to test the removal (simulating the invoice row)
row = dialog.table.rowCount()
dialog.table.insertRow(row)
# Set the project and invoice number items
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem(project_name)
num_item = QTableWidgetItem(invoice_number)
dialog.table.setItem(row, dialog.COL_PROJECT, proj_item)
dialog.table.setItem(row, dialog.COL_NUMBER, num_item)
# Mock invoice_id
num_item.setData(Qt.ItemDataRole.UserRole, 999)
# Call the removal method
dialog._remove_invoice_due_reminder(row, 999)
# Reminder should be deleted
reminders_after = setup["db"].get_all_reminders()
assert len(reminders_after) == 0
def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup):
"""Test _on_item_changed for invoice number editing."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Get the first row's invoice ID
num_item = dialog.table.item(0, dialog.COL_NUMBER)
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
# Change the invoice number
num_item.setText("ALPHA-MODIFIED")
# Trigger the change handler
dialog._on_item_changed(num_item)
# Verify the change was saved to DB
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
assert invoice_data["invoice_number"] == "ALPHA-MODIFIED"
def test_invoices_dialog_on_item_changed_empty_invoice_number(
qtbot, invoices_dialog_setup, monkeypatch
):
"""Test _on_item_changed rejects empty invoice number."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Mock QMessageBox to auto-close
def mock_warning(*args, **kwargs):
return QMessageBox.Ok
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
# Get the first row's invoice number item
num_item = dialog.table.item(0, dialog.COL_NUMBER)
original_number = num_item.text()
# Try to set empty invoice number
num_item.setText("")
dialog._on_item_changed(num_item)
# Should be reset to original
assert num_item.text() == original_number
def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup):
"""Test _on_item_changed for issue date editing."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Get the first row
num_item = dialog.table.item(0, dialog.COL_NUMBER)
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
new_date = "2024-01-15"
issue_item.setText(new_date)
dialog._on_item_changed(issue_item)
# Verify change was saved
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date")
assert invoice_data["issue_date"] == new_date
def test_invoices_dialog_on_item_changed_invalid_date(
qtbot, invoices_dialog_setup, monkeypatch
):
"""Test _on_item_changed rejects invalid date format."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Mock QMessageBox
def mock_warning(*args, **kwargs):
return QMessageBox.Ok
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
original_date = issue_item.text()
# Try to set invalid date
issue_item.setText("not-a-date")
dialog._on_item_changed(issue_item)
# Should be reset to original
assert issue_item.text() == original_date
def test_invoices_dialog_on_item_changed_due_before_issue(
qtbot, invoices_dialog_setup, monkeypatch
):
"""Test _on_item_changed rejects due date before issue date."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Mock QMessageBox
def mock_warning(*args, **kwargs):
return QMessageBox.Ok
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
# Set issue date
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
issue_item.setText("2024-02-01")
dialog._on_item_changed(issue_item)
# Try to set due date before issue date
due_item = dialog.table.item(0, dialog.COL_DUE_DATE)
original_due = due_item.text()
due_item.setText("2024-01-01")
dialog._on_item_changed(due_item)
# Should be reset
assert due_item.text() == original_due
def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup):
"""Test _on_item_changed for currency editing."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Get the first row
num_item = dialog.table.item(0, dialog.COL_NUMBER)
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
currency_item = dialog.table.item(0, dialog.COL_CURRENCY)
currency_item.setText("gbp") # lowercase
dialog._on_item_changed(currency_item)
# Should be normalized to uppercase
assert currency_item.text() == "GBP"
# Verify change was saved
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency")
assert invoice_data["currency"] == "GBP"
def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup):
"""Test _on_item_changed for tax rate editing."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Get the first row
num_item = dialog.table.item(0, dialog.COL_NUMBER)
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
tax_rate_item.setText("15.5")
dialog._on_item_changed(tax_rate_item)
# Verify change was saved
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent")
assert invoice_data["tax_rate_percent"] == 15.5
def test_invoices_dialog_on_item_changed_invalid_tax_rate(
qtbot, invoices_dialog_setup, monkeypatch
):
"""Test _on_item_changed rejects invalid tax rate."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Mock QMessageBox
def mock_warning(*args, **kwargs):
return QMessageBox.Ok
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
original_rate = tax_rate_item.text()
# Try to set invalid tax rate
tax_rate_item.setText("not-a-number")
dialog._on_item_changed(tax_rate_item)
# Should be reset to original
assert tax_rate_item.text() == original_rate
def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup):
"""Test _on_item_changed for subtotal editing."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Get the first row
num_item = dialog.table.item(0, dialog.COL_NUMBER)
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
subtotal_item.setText("1234.56")
dialog._on_item_changed(subtotal_item)
# Verify change was saved (in cents)
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents")
assert invoice_data["subtotal_cents"] == 123456
# Should be normalized to 2 decimals
assert subtotal_item.text() == "1234.56"
def test_invoices_dialog_on_item_changed_invalid_amount(
qtbot, invoices_dialog_setup, monkeypatch
):
"""Test _on_item_changed rejects invalid amount."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Mock QMessageBox
def mock_warning(*args, **kwargs):
return QMessageBox.Ok
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
original_subtotal = subtotal_item.text()
# Try to set invalid amount
subtotal_item.setText("not-a-number")
dialog._on_item_changed(subtotal_item)
# Should be reset to original
assert subtotal_item.text() == original_subtotal
def test_invoices_dialog_on_item_changed_paid_at_removes_reminder(
qtbot, invoices_dialog_setup
):
"""Test that marking invoice as paid removes due date reminder."""
setup = invoices_dialog_setup
# Create a reminder for an invoice
due_date = (date.today() + timedelta(days=7)).isoformat()
invoice_number = "ALPHA-1"
project_name = "Project Alpha"
reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
reminder = Reminder(
id=None,
text=reminder_text,
time_str=_INVOICE_REMINDER_TIME,
reminder_type=ReminderType.ONCE,
date_iso=due_date,
active=True,
)
reminder.id = setup["db"].save_reminder(reminder)
# Verify reminder exists
reminders = setup["db"].get_all_reminders()
assert any(r.text == reminder_text for r in reminders)
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Find the ALPHA-1 invoice row
for row in range(dialog.table.rowCount()):
num_item = dialog.table.item(row, dialog.COL_NUMBER)
if num_item and num_item.text() == "ALPHA-1":
# Mark as paid
paid_item = dialog.table.item(row, dialog.COL_PAID_AT)
paid_item.setText(date.today().isoformat())
dialog._on_item_changed(paid_item)
break
# Reminder should be removed
reminders_after = setup["db"].get_all_reminders()
assert not any(r.text == reminder_text for r in reminders_after)
def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup):
"""Test that _on_item_changed is ignored during reload."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
qtbot.addWidget(dialog)
# Set reloading flag
dialog._reloading_invoices = True
# Try to change an item
num_item = dialog.table.item(0, dialog.COL_NUMBER)
original_number = num_item.text()
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
num_item.setText("SHOULD-BE-IGNORED")
dialog._on_item_changed(num_item)
# Change should not be saved to DB
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
assert invoice_data["invoice_number"] == original_number
def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup):
"""Test _update_mode_enabled method."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.show()
# Initially detailed mode should be selected
assert dialog.rb_detailed.isChecked()
# Table should be enabled in detailed mode
assert dialog.table.isEnabled()
# Switch to summary mode
dialog.rb_summary.setChecked(True)
dialog._update_mode_enabled()
# Table should be disabled in summary mode
assert not dialog.table.isEnabled()
def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db):
"""Test InvoiceDialog with project that has no time logs."""
proj_id = fresh_db.add_project("Empty Project")
today = date.today()
start = (today - timedelta(days=7)).isoformat()
end = today.isoformat()
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
qtbot.addWidget(dialog)
# Should handle empty time logs gracefully
assert len(dialog._time_rows) == 0
assert dialog.table.rowCount() == 0
def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup):
"""Test that InvoiceDialog loads existing client companies."""
setup = invoice_dialog_setup
# Create another project with a different client company
proj_id_2 = setup["db"].add_project("Project 2")
setup["db"].upsert_project_billing(
proj_id_2,
hourly_rate_cents=10000,
currency="EUR",
tax_label="VAT",
tax_rate_percent=19.0,
client_name="Jane Doe",
client_company="Beta Corp",
client_address="456 Main St",
client_email="jane@beta.com",
)
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
# Should have both companies in the combo
companies = [
dialog.client_company_combo.itemText(i)
for i in range(dialog.client_company_combo.count())
]
assert "Acme Corp" in companies
assert "Beta Corp" in companies
def test_invoice_line_item_equality(app):
"""Test InvoiceLineItem equality."""
item1 = InvoiceLineItem("Work", 5.0, 10000, 50000)
item2 = InvoiceLineItem("Work", 5.0, 10000, 50000)
item3 = InvoiceLineItem("Other", 5.0, 10000, 50000)
assert item1 == item2
assert item1 != item3
def test_invoices_dialog_empty_database(qtbot, fresh_db):
"""Test InvoicesDialog with no projects or invoices."""
dialog = InvoicesDialog(fresh_db)
qtbot.addWidget(dialog)
# Should have no projects in combo
assert dialog.project_combo.count() == 0
assert dialog.table.rowCount() == 0
def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db):
"""Test that tax fields are hidden when tax_rate_percent is None."""
proj_id = fresh_db.add_project("No Tax Project")
fresh_db.upsert_project_billing(
proj_id,
hourly_rate_cents=10000,
currency="USD",
tax_label="Tax",
tax_rate_percent=None, # No tax
client_name="Client",
client_company="Company",
client_address="Address",
client_email="email@test.com",
)
today = date.today()
start = (today - timedelta(days=1)).isoformat()
end = today.isoformat()
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
qtbot.addWidget(dialog)
dialog.show()
# Tax checkbox should be unchecked
assert not dialog.tax_checkbox.isChecked()
# Tax fields should be hidden
assert not dialog.tax_label.isVisible()
assert not dialog.tax_label_edit.isVisible()
assert not dialog.tax_rate_label.isVisible()
assert not dialog.tax_rate_spin.isVisible()
def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup):
"""Test that issue and due dates have correct default values."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
# Issue date should be today
assert dialog.issue_date_edit.date() == QDate.currentDate()
# Due date should be 14 days from today
QDate.currentDate().addDays(14)
assert dialog.issue_date_edit.date() == QDate.currentDate()
def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup):
"""Test that unchecking a line item updates the total cost."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(100.0)
dialog._populate_detailed_rows(10000)
dialog.tax_checkbox.setChecked(False)
# Initial total: 3 rows * 2.5 hours * $100 = $750
dialog._recalc_totals()
assert "750.00" in dialog.subtotal_label.text()
assert "750.00" in dialog.total_label.text()
# Uncheck the first row
include_item = dialog.table.item(0, dialog.COL_INCLUDE)
include_item.setCheckState(Qt.Unchecked)
# Wait for signal processing
qtbot.wait(10)
# New total: 2 rows * 2.5 hours * $100 = $500
assert "500.00" in dialog.subtotal_label.text()
assert "500.00" in dialog.total_label.text()
def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup):
"""Test that checkbox toggling works correctly with tax enabled."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(100.0)
dialog._populate_detailed_rows(10000)
dialog.tax_checkbox.setChecked(True)
dialog.tax_rate_spin.setValue(10.0)
# Initial: 3 rows * 2.5 hours * $100 = $750
# Tax: $750 * 10% = $75
# Total: $825
dialog._recalc_totals()
assert "750.00" in dialog.subtotal_label.text()
assert "75.00" in dialog.tax_label_total.text()
assert "825.00" in dialog.total_label.text()
# Uncheck two rows
dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
# Wait for signal processing
qtbot.wait(10)
# New total: 1 row * 2.5 hours * $100 = $250
# Tax: $250 * 10% = $25
# Total: $275
assert "250.00" in dialog.subtotal_label.text()
assert "25.00" in dialog.tax_label_total.text()
assert "275.00" in dialog.total_label.text()
def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup):
"""Test that rechecking a previously unchecked item updates totals."""
setup = invoice_dialog_setup
dialog = InvoiceDialog(
setup["db"],
setup["proj_id"],
setup["start_date"],
setup["end_date"],
setup["time_rows"],
)
qtbot.addWidget(dialog)
dialog.rate_spin.setValue(100.0)
dialog._populate_detailed_rows(10000)
dialog.tax_checkbox.setChecked(False)
# Uncheck all items
for row in range(dialog.table.rowCount()):
dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
qtbot.wait(10)
# Total should be 0
assert "0.00" in dialog.total_label.text()
# Re-check first item
dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked)
qtbot.wait(10)
# Total should be 1 row * 2.5 hours * $100 = $250
assert "250.00" in dialog.total_label.text()
def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup):
"""Test _select_initial_project method."""
setup = invoices_dialog_setup
dialog = InvoicesDialog(setup["db"])
qtbot.addWidget(dialog)
# Initially should have first project selected (either proj1 or proj2)
initial_proj = dialog._current_project()
assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]]
# Select specific project
dialog._select_initial_project(setup["proj_id_2"])
# Should now have proj_id_2 selected
assert dialog._current_project() == setup["proj_id_2"]