# === 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// 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// 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)