# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Regression tests for the missed-clock-out ("pending reason") nag and the new owner/attendance-exemption. Root cause these tests pin down: * The `x_fclk_pending_reason` flag was set by the absence + auto-clock-out crons but ONLY cleared by the systray reason dialog. The kiosk / NFC clock paths (how Entech actually clocks in) never cleared it, so a stale flag nagged employees forever -- even while currently clocked in. * Owners work but are not "on the clock"; they must be exempt from absence flagging, auto-clock-out nags and the reason dialog. """ import json from datetime import date, timedelta from odoo import fields from odoo.tests import tagged from odoo.tests.common import HttpCase, TransactionCase try: from freezegun import freeze_time except ImportError: # freezegun may be absent on the runtime image freeze_time = None MON = date(2026, 6, 1) # Monday TUE = date(2026, 6, 2) # Tuesday @tagged('-at_install', 'post_install', 'fusion_clock') class TestAttendanceExemptHelper(TransactionCase): """`hr.employee._fclk_is_attendance_exempt()` truth table.""" @classmethod def setUpClass(cls): super().setUpClass() cls.Employee = cls.env['hr.employee'] cls.owner_group = cls.env.ref('fusion_clock.group_fusion_clock_owner') def test_plain_employee_not_exempt(self): emp = self.Employee.create({'name': 'Plain', 'x_fclk_enable_clock': True}) self.assertFalse(emp._fclk_is_attendance_exempt()) def test_checkbox_makes_exempt(self): emp = self.Employee.create({ 'name': 'Flagged', 'x_fclk_enable_clock': True, 'x_fclk_exempt_from_attendance': True, }) self.assertTrue(emp._fclk_is_attendance_exempt()) def test_owner_group_makes_exempt(self): user = self.env['res.users'].create({ 'name': 'Olivia Owner', 'login': 'olivia-owner-test', 'group_ids': [(4, self.owner_group.id)], }) emp = self.Employee.create({ 'name': 'Olivia Owner', 'x_fclk_enable_clock': True, 'user_id': user.id, }) self.assertTrue(emp._fclk_is_attendance_exempt()) def test_owner_group_implies_manager(self): """The Owner role must carry full Manager permissions.""" user = self.env['res.users'].create({ 'name': 'Manager-by-owner', 'login': 'owner-implies-mgr', 'group_ids': [(4, self.owner_group.id)], }) self.assertTrue(user.has_group('fusion_clock.group_fusion_clock_manager')) @tagged('-at_install', 'post_install', 'fusion_clock') class TestCronsRespectExemptAndPending(TransactionCase): """Absence + auto-clock-out crons: no more pending nag, owners skipped.""" @classmethod def setUpClass(cls): super().setUpClass() cls.Employee = cls.env['hr.employee'] cls.Schedule = cls.env['fusion.clock.schedule'] cls.Attendance = cls.env['hr.attendance'] cls.Log = cls.env['fusion.clock.activity.log'] cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_auto_clockout', 'True') cls.ICP.set_param('fusion_clock.max_shift_hours', '16') def _post(self, emp, day): return self.Schedule.create({ 'employee_id': emp.id, 'schedule_date': day, 'state': 'posted', 'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0, }) def test_absence_does_not_set_pending_reason(self): if freeze_time is None: self.skipTest("freezegun not available") emp = self.Employee.create({'name': 'NoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC'}) self._post(emp, MON) with freeze_time("2026-06-02 09:00:00"): # yesterday = scheduled Monday self.Attendance._cron_fusion_check_absences() # Absence is still logged ... self.assertEqual(self.Log.search_count([ ('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 1) # ... but it must NOT raise the missed-clock-out reason nag. self.assertFalse(emp.x_fclk_pending_reason) def test_absence_skips_exempt_employee(self): if freeze_time is None: self.skipTest("freezegun not available") emp = self.Employee.create({ 'name': 'OwnerNoShow', 'x_fclk_enable_clock': True, 'tz': 'UTC', 'x_fclk_exempt_from_attendance': True, }) self._post(emp, MON) with freeze_time("2026-06-02 09:00:00"): self.Attendance._cron_fusion_check_absences() self.assertEqual(self.Log.search_count([ ('employee_id', '=', emp.id), ('log_type', '=', 'absent')]), 0) self.assertFalse(emp.x_fclk_pending_reason) def test_auto_clockout_skips_exempt_employee(self): emp = self.Employee.create({ 'name': 'OwnerStale', 'x_fclk_enable_clock': True, 'tz': 'UTC', 'x_fclk_exempt_from_attendance': True, }) now = fields.Datetime.now() stale = self.Attendance.create({ 'employee_id': emp.id, 'check_in': now - timedelta(hours=20), }) self.Attendance._cron_fusion_auto_clock_out() self.assertFalse(stale.check_out, "Exempt employee must not be auto-clocked-out.") self.assertFalse(emp.x_fclk_pending_reason) def test_auto_clockout_still_flags_normal_employee(self): emp = self.Employee.create({'name': 'Forgetful', 'x_fclk_enable_clock': True, 'tz': 'UTC'}) now = fields.Datetime.now() stale = self.Attendance.create({ 'employee_id': emp.id, 'check_in': now - timedelta(hours=20), }) self.Attendance._cron_fusion_auto_clock_out() self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.") self.assertTrue(emp.x_fclk_pending_reason, "Forgotten clock-out still asks for a reason.") @tagged('-at_install', 'post_install', 'fusion_clock') class TestKioskClearsPendingReason(HttpCase): """Clocking in via either kiosk clears a stale pending-reason flag.""" @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_kiosk', 'True') cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False') cls.location = cls.env['fusion.clock.location'].create({ 'name': 'Clear Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.env['res.users'].create({ 'name': 'Clear Op', 'login': 'clear-op', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.pin_emp = cls.env['hr.employee'].create({ 'name': 'Pat Pending', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234', 'x_fclk_pending_reason': True, }) cls.nfc_emp = cls.env['hr.employee'].create({ 'name': 'Nina Pending', 'x_fclk_enable_clock': True, 'x_fclk_nfc_card_uid': '04:A2:B5:62:CC:01', 'x_fclk_pending_reason': True, }) def setUp(self): super().setUp() from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_mod nfc_mod._recent_taps.clear() def _post(self, route, params): self.authenticate('clear-op', 'kioskpass123') resp = self.url_open(route, data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': params, }), headers={'Content-Type': 'application/json'}) return resp.json().get('result', {}) def test_pin_kiosk_clock_in_clears_pending(self): res = self._post('/fusion_clock/kiosk/clock', {'employee_id': self.pin_emp.id}) self.assertEqual(res.get('action'), 'clock_in') self.pin_emp.invalidate_recordset() self.assertFalse(self.pin_emp.x_fclk_pending_reason) def test_nfc_tap_clock_in_clears_pending(self): res = self._post('/fusion_clock/kiosk/nfc/tap', {'card_uid': '04:A2:B5:62:CC:01'}) self.assertEqual(res.get('action'), 'clock_in') self.nfc_emp.invalidate_recordset() self.assertFalse(self.nfc_emp.x_fclk_pending_reason) @tagged('-at_install', 'post_install', 'fusion_clock') class TestGetStatusPendingReason(HttpCase): """get_status must never raise the dialog for a clocked-in or exempt user.""" @classmethod def setUpClass(cls): super().setUpClass() cls.user = cls.env['res.users'].create({ 'name': 'Status User', 'login': 'status-user', 'password': 'statuspass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_user').id)], }) cls.emp = cls.env['hr.employee'].create({ 'name': 'Status User', 'x_fclk_enable_clock': True, 'tz': 'UTC', 'user_id': cls.user.id, 'x_fclk_pending_reason': True, }) def _status(self): self.authenticate('status-user', 'statuspass123') resp = self.url_open('/fusion_clock/get_status', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {}, }), headers={'Content-Type': 'application/json'}) return resp.json().get('result', {}) def test_pending_hidden_while_checked_in(self): self.env['hr.attendance'].create({ 'employee_id': self.emp.id, 'check_in': fields.Datetime.now() - timedelta(hours=1), }) self.emp.invalidate_recordset() res = self._status() self.assertTrue(res.get('is_checked_in')) self.assertFalse(res.get('pending_reason'), "A currently clocked-in employee must never be nagged.") def test_pending_hidden_for_exempt(self): self.emp.write({'x_fclk_exempt_from_attendance': True}) res = self._status() self.assertFalse(res.get('is_checked_in')) self.assertFalse(res.get('pending_reason'), "An exempt (owner) employee must never be nagged.") def test_pending_shown_for_normal_not_checked_in(self): """Sanity: the dialog still works for a genuine forgotten clock-out.""" res = self._status() self.assertFalse(res.get('is_checked_in')) self.assertTrue(res.get('pending_reason'))