Adds a consolidated regression test to WorkLogPayrollAjaxTests that exercises: paid worker serialization shape, null team branch, OT flag in JSON, full_page_url value, and adjustment payslip-link serialization. Closes the 'Important' coverage gap flagged in Task 3's quality review. Also appends a 'Shipped' block to the design doc summarising QA status and capturing all five deferred nits (admin-gate consistency, template branch tests, |default:0 redundancy, admin-gate expression readability, background vs background-color) so they survive the merge into project history. All 19 tests pass. manage.py check clean. No migrations needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
14 KiB
Python
315 lines
14 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 django.urls import reverse
|
|
|
|
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)
|
|
|
|
|
|
# === TESTS FOR THE WORK LOG PAYROLL AJAX ENDPOINT ===
|
|
# These cover the JSON endpoint that the Task-5 modal will consume.
|
|
# The endpoint must: return JSON to admins, forbid supervisors/anons,
|
|
# and 404 on unknown logs.
|
|
|
|
class WorkLogPayrollAjaxTests(TestCase):
|
|
"""Tests for the JSON AJAX endpoint that powers the modal."""
|
|
|
|
def setUp(self):
|
|
# One admin, one non-admin supervisor, and a simple log with one worker.
|
|
self.admin = User.objects.create_user(
|
|
username='admin', password='pass', is_staff=True
|
|
)
|
|
self.supervisor = User.objects.create_user(
|
|
username='sup', password='pass', is_staff=False
|
|
)
|
|
project = Project.objects.create(name='P')
|
|
team = Team.objects.create(name='T', supervisor=self.admin)
|
|
worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('4000'))
|
|
self.log = WorkLog.objects.create(
|
|
date=datetime.date(2026, 4, 10),
|
|
project=project, team=team, supervisor=self.admin,
|
|
)
|
|
self.log.workers.add(worker)
|
|
|
|
def test_admin_sees_200_json(self):
|
|
# Admin hits the endpoint and gets a well-formed JSON body.
|
|
self.client.login(username='admin', password='pass')
|
|
url = reverse('work_log_payroll_ajax', args=[self.log.id])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
data = resp.json()
|
|
self.assertEqual(data['log_id'], self.log.id)
|
|
self.assertEqual(len(data['worker_rows']), 1)
|
|
self.assertEqual(data['worker_rows'][0]['status'], 'Unpaid')
|
|
|
|
def test_supervisor_forbidden(self):
|
|
# A non-admin user (even if authenticated) gets 403 JSON.
|
|
self.client.login(username='sup', password='pass')
|
|
url = reverse('work_log_payroll_ajax', args=[self.log.id])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 403)
|
|
|
|
def test_anonymous_redirected_to_login(self):
|
|
# @login_required intercepts before our view ever runs — 302 to /accounts/login/.
|
|
url = reverse('work_log_payroll_ajax', args=[self.log.id])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 302)
|
|
|
|
def test_missing_log_is_404(self):
|
|
# get_object_or_404 returns a 404 if the log_id doesn't exist.
|
|
self.client.login(username='admin', password='pass')
|
|
resp = self.client.get('/history/99999/payroll/ajax/')
|
|
self.assertEqual(resp.status_code, 404)
|
|
|
|
def test_full_payload_with_paid_worker_null_team_and_ot(self):
|
|
"""One scenario exercising: paid worker, null team, OT flag, full URL."""
|
|
# Create a paid worker on a separate log with no team (null team edge case)
|
|
# and with overtime hours but no priced_workers (unpriced OT flag).
|
|
project = Project.objects.create(name='P-full')
|
|
paid_worker = Worker.objects.create(
|
|
name='Paula', id_number='P1', monthly_salary=Decimal('4000')
|
|
)
|
|
log = WorkLog.objects.create(
|
|
date=datetime.date(2026, 4, 11),
|
|
project=project,
|
|
team=None, # null team
|
|
supervisor=self.admin,
|
|
overtime_amount=Decimal('1.50'), # > 0 and no priced_workers -> flag fires
|
|
)
|
|
log.workers.add(paid_worker)
|
|
|
|
# Link a PayrollRecord so Paula shows as Paid
|
|
record = PayrollRecord.objects.create(
|
|
worker=paid_worker,
|
|
amount_paid=Decimal('250.00'),
|
|
date=datetime.date(2026, 4, 16),
|
|
)
|
|
record.work_logs.add(log)
|
|
|
|
# Link an adjustment to the same log
|
|
adj = PayrollAdjustment.objects.create(
|
|
worker=paid_worker,
|
|
project=project,
|
|
type='Overtime',
|
|
amount=Decimal('75.00'),
|
|
date=datetime.date(2026, 4, 11),
|
|
description='OT pricing',
|
|
work_log=log,
|
|
payroll_record=record,
|
|
)
|
|
|
|
self.client.login(username='admin', password='pass')
|
|
resp = self.client.get(reverse('work_log_payroll_ajax', args=[log.id]))
|
|
self.assertEqual(resp.status_code, 200)
|
|
data = resp.json()
|
|
|
|
# Null team branch
|
|
self.assertIsNone(data['team'])
|
|
# Project is still present
|
|
self.assertEqual(data['project']['name'], 'P-full')
|
|
# Paid worker serialization shape
|
|
paula_row = next(r for r in data['worker_rows'] if r['worker_name'] == 'Paula')
|
|
self.assertEqual(paula_row['status'], 'Paid')
|
|
self.assertEqual(paula_row['payroll_record_id'], record.pk)
|
|
self.assertEqual(paula_row['paid_date'], '2026-04-16')
|
|
# OT flag fires (overtime > 0, no priced_workers)
|
|
self.assertTrue(data['overtime_needs_pricing'])
|
|
# full_page_url is the reverse of work_log_payroll_detail
|
|
self.assertEqual(data['full_page_url'], f'/history/{log.id}/')
|
|
# Adjustment serialized with payslip link
|
|
self.assertEqual(len(data['adjustments']), 1)
|
|
self.assertEqual(data['adjustments'][0]['type'], 'Overtime')
|
|
self.assertEqual(data['adjustments'][0]['payroll_record_id'], record.pk)
|
|
|
|
|
|
# === TESTS FOR THE WORK LOG PAYROLL FULL-PAGE VIEW ===
|
|
# These cover the HTML page at /history/<id>/ that shares the same context
|
|
# builder as the AJAX endpoint. Admin sees a 200 HTML page; supervisor 403.
|
|
|
|
class WorkLogPayrollDetailTests(TestCase):
|
|
"""Tests for the full-page /history/<id>/ view."""
|
|
|
|
def setUp(self):
|
|
self.admin = User.objects.create_user(
|
|
username='admin', password='pass', is_staff=True
|
|
)
|
|
self.supervisor = User.objects.create_user(
|
|
username='sup', password='pass', is_staff=False
|
|
)
|
|
project = Project.objects.create(name='P2')
|
|
team = Team.objects.create(name='T2', supervisor=self.admin)
|
|
worker = Worker.objects.create(name='Wanda', id_number='X', monthly_salary=Decimal('4000'))
|
|
self.log = WorkLog.objects.create(
|
|
date=datetime.date(2026, 4, 10),
|
|
project=project, team=team, supervisor=self.admin,
|
|
)
|
|
self.log.workers.add(worker)
|
|
|
|
def test_admin_gets_full_page(self):
|
|
self.client.login(username='admin', password='pass')
|
|
url = reverse('work_log_payroll_detail', args=[self.log.id])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertContains(resp, 'FoxFitt')
|
|
self.assertContains(resp, 'History')
|
|
self.assertContains(resp, 'Wanda')
|
|
|
|
def test_supervisor_forbidden(self):
|
|
self.client.login(username='sup', password='pass')
|
|
url = reverse('work_log_payroll_detail', args=[self.log.id])
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 403)
|