# -*- coding: utf-8 -*- from odoo.tests.common import HttpCase, tagged @tagged('-at_install', 'post_install', 'fusion_clock') class TestNfcKioskController(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.location = cls.env['fusion.clock.location'].create({ 'name': 'Test Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.kiosk_user = cls.env['res.users'].create({ 'name': 'NFC Kiosk User', 'login': 'nfc-kiosk-test', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) def test_kiosk_page_redirects_when_disabled(self): self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False') self.authenticate('nfc-kiosk-test', 'kioskpass123') response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False) self.assertIn(response.status_code, (301, 302, 303)) def test_kiosk_page_renders_when_enabled(self): self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') self.authenticate('nfc-kiosk-test', 'kioskpass123') response = self.url_open('/fusion_clock/kiosk/nfc') self.assertEqual(response.status_code, 200) self.assertIn('nfc_kiosk_root', response.text) from odoo.tests.common import TransactionCase from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk @tagged('-at_install', 'post_install', 'fusion_clock') class TestUidNormalization(TransactionCase): def test_lowercase_input_uppercased(self): self.assertEqual( FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'), '04:A2:B5:62:C1:80', ) def test_no_separator_input_gets_colons(self): self.assertEqual( FusionClockNfcKiosk._normalize_uid('04A2B562C180'), '04:A2:B5:62:C1:80', ) def test_dash_separator_replaced(self): self.assertEqual( FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'), '04:A2:B5:62:C1:80', ) def test_whitespace_stripped(self): self.assertEqual( FusionClockNfcKiosk._normalize_uid(' 04:A2:B5:62:C1:80 '), '04:A2:B5:62:C1:80', ) def test_empty_input_returns_none(self): self.assertIsNone(FusionClockNfcKiosk._normalize_uid('')) self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None)) def test_invalid_chars_returns_none(self): self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid')) self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80')) def test_odd_length_returns_none(self): self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18')) import json @tagged('-at_install', 'post_install', 'fusion_clock') class TestEnrollEndpoint(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234') cls.kiosk_user = cls.env['res.users'].create({ 'name': 'Enroll Kiosk User', 'login': 'nfc-kiosk-enroll', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True}) cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True}) def _call(self, payload): self.authenticate('nfc-kiosk-enroll', 'kioskpass123') response = self.url_open( '/fusion_clock/kiosk/nfc/enroll', data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}), headers={'Content-Type': 'application/json'}, ) return response.json().get('result', {}) def test_enroll_success(self): result = self._call({ 'employee_id': self.alice.id, 'card_uid': '04:a2:b5:62:c1:80', 'enroll_password': '1234', }) self.assertTrue(result.get('success')) self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80') self.alice.invalidate_recordset() self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80') def test_enroll_wrong_password(self): result = self._call({ 'employee_id': self.alice.id, 'card_uid': '04:A2:B5:62:C1:81', 'enroll_password': 'wrong', }) self.assertEqual(result.get('error'), 'invalid_password') self.alice.invalidate_recordset() self.assertFalse(self.alice.x_fclk_nfc_card_uid) def test_enroll_card_already_assigned(self): self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82' result = self._call({ 'employee_id': self.bob.id, 'card_uid': '04:A2:B5:62:C1:82', 'enroll_password': '1234', }) self.assertEqual(result.get('error'), 'card_already_assigned') self.assertEqual(result.get('existing_employee'), 'Alice E') self.bob.invalidate_recordset() self.assertFalse(self.bob.x_fclk_nfc_card_uid) def test_enroll_invalid_uid(self): result = self._call({ 'employee_id': self.alice.id, 'card_uid': 'not-a-uid', 'enroll_password': '1234', }) self.assertEqual(result.get('error'), 'invalid_uid') @tagged('-at_install', 'post_install', 'fusion_clock') class TestTapEndpointHappyPath(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() 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': 'Tap Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.kiosk_user = cls.env['res.users'].create({ 'name': 'Tap Kiosk User', 'login': 'nfc-kiosk-tap', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.alice = cls.env['hr.employee'].create({ 'name': 'Alice T', 'x_fclk_enable_clock': True, 'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90', }) def setUp(self): super().setUp() # Clear module-level debounce cache so tests don't inherit state from other classes from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module nfc_kiosk_module._recent_taps.clear() def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''): self.authenticate('nfc-kiosk-tap', 'kioskpass123') response = self.url_open( '/fusion_clock/kiosk/nfc/tap', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'card_uid': card_uid, 'photo_b64': photo_b64}, }), headers={'Content-Type': 'application/json'}, ) return response.json().get('result', {}) def test_first_tap_clocks_in(self): result = self._tap() self.assertTrue(result.get('success')) self.assertEqual(result.get('action'), 'clock_in') self.assertEqual(result.get('employee_name'), 'Alice T') attendance = self.env['hr.attendance'].search([ ('employee_id', '=', self.alice.id), ], order='check_in desc', limit=1) self.assertTrue(attendance) self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk') self.assertEqual(attendance.x_fclk_location_id, self.location) self.assertFalse(attendance.check_out) def test_second_tap_clocks_out(self): self._tap() # Wait for debounce window (5s) to elapse import time time.sleep(6) result = self._tap() self.assertTrue(result.get('success')) self.assertEqual(result.get('action'), 'clock_out') attendance = self.env['hr.attendance'].search([ ('employee_id', '=', self.alice.id), ], order='check_in desc', limit=1) self.assertTrue(attendance.check_out) @tagged('-at_install', 'post_install', 'fusion_clock') class TestTapEndpointErrors(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() 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': 'Err Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.kiosk_user = cls.env['res.users'].create({ 'name': 'Err Kiosk User', 'login': 'nfc-kiosk-err', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.disabled_emp = cls.env['hr.employee'].create({ 'name': 'Disabled E', 'x_fclk_enable_clock': False, 'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD', }) cls.active_emp = cls.env['hr.employee'].create({ 'name': 'Active E', 'x_fclk_enable_clock': True, 'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01', }) def setUp(self): super().setUp() # Clear module-level debounce cache so tests don't bleed into each other from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module nfc_kiosk_module._recent_taps.clear() # Reset ICP to known-good defaults before each test self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') self.env.company.x_fclk_nfc_kiosk_location_id = self.location.id def _tap(self, card_uid): self.authenticate('nfc-kiosk-err', 'kioskpass123') response = self.url_open( '/fusion_clock/kiosk/nfc/tap', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'card_uid': card_uid, 'photo_b64': ''}, }), headers={'Content-Type': 'application/json'}, ) return response.json().get('result', {}) def test_unknown_card(self): result = self._tap('04:00:00:00:00:00') self.assertEqual(result.get('error'), 'card_unknown') def test_disabled_employee(self): result = self._tap('04:A2:B5:62:DE:AD') self.assertEqual(result.get('error'), 'clock_disabled') def test_no_location_configured(self): self.env.company.x_fclk_nfc_kiosk_location_id = False result = self._tap('04:A2:B5:62:AC:01') self.assertEqual(result.get('error'), 'no_location_configured') def test_kiosk_disabled(self): self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False') result = self._tap('04:A2:B5:62:AC:01') self.assertEqual(result.get('error'), 'kiosk_disabled') def test_invalid_uid(self): result = self._tap('not-a-uid') self.assertEqual(result.get('error'), 'invalid_uid') def test_debounce_silent_second_tap(self): first = self._tap('04:A2:B5:62:AC:01') self.assertTrue(first.get('success')) second = self._tap('04:A2:B5:62:AC:01') self.assertEqual(second.get('error'), 'debounce') @tagged('-at_install', 'post_install', 'fusion_clock') class TestTapPhotoHandling(HttpCase): SAMPLE_PNG_DATAURL = ( 'data:image/png;base64,' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA' 'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' ) @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') cls.location = cls.env['fusion.clock.location'].create({ 'name': 'Photo Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.kiosk_user = cls.env['res.users'].create({ 'name': 'Photo Kiosk User', 'login': 'nfc-kiosk-photo', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.emp = cls.env['hr.employee'].create({ 'name': 'Photo Emp', 'x_fclk_enable_clock': True, 'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01', }) def setUp(self): super().setUp() # Avoid debounce contamination from other test classes from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module nfc_kiosk_module._recent_taps.clear() def _tap(self, photo_b64=''): self.authenticate('nfc-kiosk-photo', 'kioskpass123') response = self.url_open( '/fusion_clock/kiosk/nfc/tap', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64}, }), headers={'Content-Type': 'application/json'}, ) return response.json().get('result', {}) def test_photo_saved_on_clock_in(self): self.ICP.set_param('fusion_clock.nfc_photo_required', 'True') result = self._tap(self.SAMPLE_PNG_DATAURL) self.assertTrue(result.get('success')) attendance = self.env['hr.attendance'].search([ ('employee_id', '=', self.emp.id), ], order='check_in desc', limit=1) self.assertTrue(attendance.x_fclk_check_in_photo) def test_photo_required_rejects_when_missing(self): self.ICP.set_param('fusion_clock.nfc_photo_required', 'True') result = self._tap(photo_b64='') self.assertEqual(result.get('error'), 'photo_required') def test_photo_optional_succeeds_without_photo(self): self.ICP.set_param('fusion_clock.nfc_photo_required', 'False') result = self._tap(photo_b64='') self.assertTrue(result.get('success')) @tagged('-at_install', 'post_install', 'fusion_clock') class TestEmployeeSearch(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') cls.kiosk_user = cls.env['res.users'].create({ 'name': 'Search Kiosk User', 'login': 'nfc-kiosk-search', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True}) def test_search_returns_matching_employees(self): self.authenticate('nfc-kiosk-search', 'kioskpass123') response = self.url_open( '/fusion_clock/kiosk/nfc/employee_search', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'query': 'Steve'}, }), headers={'Content-Type': 'application/json'}, ) result = response.json().get('result', {}) self.assertIn('employees', result) names = [e['name'] for e in result['employees']] self.assertIn('Searchable Steve', names)