38686-vm/core/tests.py
Konrad du Plessis b0aa35661b Fix overtime_needs_pricing flag + add regression tests
The helper used log.overtime (which doesn't exist on WorkLog); the
correct field is overtime_amount. Combined with a defensive
`getattr(..., None) or 0`, the bug made the flag permanently False,
which would have silently hidden the 'Price now' banner in Tasks 3
and 4. Now reads overtime_amount directly (it's non-nullable with a
0.00 default, so no defensive shim is needed).

Adds 4 regression tests:
- test_overtime_needs_pricing_flag: the bug that just got fixed
- test_query_count_is_bounded: N+1 guard (4 queries regardless of worker count)
- test_empty_log_returns_zero_totals: log with no workers attached
- test_log_without_team_has_no_pay_period: log whose team became NULL

Also removes unused `reverse` import from tests.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:21:34 +02:00

159 lines
7.0 KiB
Python

# === TESTS FOR WORK LOG PAYROLL CROSS-LINK ===
# Covers the _build_work_log_payroll_context helper — the core logic that
# determines, for each worker on a log, whether they were paid for it.
import datetime
from decimal import Decimal
from django.contrib.auth.models import User
from django.test import TestCase
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context
class WorkLogPayrollContextTests(TestCase):
"""Tests for the helper that builds the payroll-status view of a work log."""
def setUp(self):
# Minimal scenario: 1 admin, 1 project, 1 team, 3 workers, 1 log.
# Worker A has been paid for the log; Worker B is priced-not-paid;
# Worker C is unpaid.
self.admin = User.objects.create_user(username='admin', is_staff=True)
self.project = Project.objects.create(name='Test Project')
self.team = Team.objects.create(name='Team X', supervisor=self.admin)
self.worker_a = Worker.objects.create(name='Alice', id_number='A1', monthly_salary=Decimal('4000'))
self.worker_b = Worker.objects.create(name='Bob', id_number='B1', monthly_salary=Decimal('4000'))
self.worker_c = Worker.objects.create(name='Carol', id_number='C1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=self.project,
team=self.team,
supervisor=self.admin,
)
self.log.workers.add(self.worker_a, self.worker_b, self.worker_c)
# Worker A has a PayrollRecord linking them and this log — "Paid".
self.record_a = PayrollRecord.objects.create(
worker=self.worker_a,
amount_paid=Decimal('200.00'),
date=datetime.date(2026, 4, 15),
)
self.record_a.work_logs.add(self.log)
# Worker B appears in priced_workers but has no PayrollRecord — "Priced, not paid".
self.log.priced_workers.add(self.worker_b)
# Worker C has neither — "Unpaid".
def test_returns_log_and_worker_rows(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['log'], self.log)
self.assertEqual(len(ctx['worker_rows']), 3)
def test_paid_worker_has_payslip_link(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_a.id)
self.assertEqual(row['status'], 'Paid')
self.assertEqual(row['payroll_record'], self.record_a)
self.assertGreater(row['earned'], 0)
def test_priced_but_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_b.id)
self.assertEqual(row['status'], 'Priced, not paid')
self.assertIsNone(row['payroll_record'])
def test_totally_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_c.id)
self.assertEqual(row['status'], 'Unpaid')
self.assertIsNone(row['payroll_record'])
def test_totals(self):
ctx = _build_work_log_payroll_context(self.log)
# Paid = Alice's daily_rate (one record exists for this log+worker).
self.assertEqual(ctx['total_paid'], self.worker_a.daily_rate)
# Outstanding = Bob + Carol each at their daily_rate.
expected = self.worker_b.daily_rate + self.worker_c.daily_rate
self.assertEqual(ctx['total_outstanding'], expected)
def test_adjustments_linked_to_log(self):
adj = PayrollAdjustment.objects.create(
worker=self.worker_a,
project=self.project,
type='Overtime',
amount=Decimal('50.00'),
date=datetime.date(2026, 4, 10),
description='Extra hour',
work_log=self.log,
)
ctx = _build_work_log_payroll_context(self.log)
self.assertIn(adj, ctx['adjustments'])
def test_pay_period_absent_if_no_schedule(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
def test_pay_period_present_when_schedule_configured(self):
self.team.pay_frequency = 'weekly'
self.team.pay_start_date = datetime.date(2026, 1, 5) # A Monday
self.team.save()
ctx = _build_work_log_payroll_context(self.log)
start, end = ctx['pay_period']
self.assertIsNotNone(start)
self.assertIsNotNone(end)
self.assertLessEqual(start, self.log.date)
self.assertGreaterEqual(end, self.log.date)
def test_overtime_needs_pricing_flag(self):
"""Flag fires when log has OT hours but no priced_workers yet."""
# Start: no OT, no priced workers -> flag False
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Add OT hours but keep priced_workers empty -> flag True
self.log.overtime_amount = Decimal('0.50')
self.log.priced_workers.clear()
self.log.save()
self.assertTrue(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Price the OT -> flag False again
self.log.priced_workers.add(self.worker_a)
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
def test_query_count_is_bounded(self):
"""Helper should not issue per-worker queries — guards against N+1."""
# 4 queries: payroll_records, priced_workers, workers.all, adjustments
# (plus whichever assertNumQueries overhead Django's test client adds).
# We assert a tight upper bound; regressions that add per-worker queries
# will push this well above the bound and fail the test.
with self.assertNumQueries(4):
_build_work_log_payroll_context(self.log)
def test_empty_log_returns_zero_totals(self):
"""Log with no workers: helper returns empty rows and zero totals."""
empty_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 11),
project=self.project,
team=self.team,
supervisor=self.admin,
)
ctx = _build_work_log_payroll_context(empty_log)
self.assertEqual(ctx['worker_rows'], [])
self.assertEqual(ctx['total_earned'], Decimal('0.00'))
self.assertEqual(ctx['total_paid'], Decimal('0.00'))
self.assertEqual(ctx['total_outstanding'], Decimal('0.00'))
self.assertEqual(ctx['adjustments'], [])
def test_log_without_team_has_no_pay_period(self):
"""Log whose team was later soft-deleted to NULL still works."""
self.log.team = None
self.log.save()
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
# The rest of the context should still populate correctly.
self.assertEqual(len(ctx['worker_rows']), 3)