feat(fusion_clock): NFC tap endpoint debounce + 6 error-case tests

Adds module-level 5s debounce (_is_debounced) with thread-safe dict +
GC. Inserts debounce guard in nfc_tap immediately after uid validation.
Adds TestTapEndpointErrors (6 tests): unknown_card, clock_disabled,
no_location_configured, kiosk_disabled, invalid_uid, debounce.
Adds setUp() to both tap test classes to clear _recent_taps between
tests, preventing cross-test debounce bleed. 29/29 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-14 01:06:30 -04:00
parent 148aa5cba8
commit ef885c66dc
2 changed files with 113 additions and 0 deletions

View File

@@ -4,12 +4,33 @@
import logging
import re
import time
import threading
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
_DEBOUNCE_WINDOW_SECONDS = 5.0
_recent_taps = {} # {card_uid: monotonic_ts}
_recent_taps_lock = threading.Lock()
def _is_debounced(uid):
"""Return True if this UID was tapped within the debounce window."""
now = time.monotonic()
with _recent_taps_lock:
last = _recent_taps.get(uid, 0)
if now - last < _DEBOUNCE_WINDOW_SECONDS:
return True
_recent_taps[uid] = now
# Opportunistic GC: drop entries older than 60s
stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
for k in stale_keys:
_recent_taps.pop(k, None)
return False
class FusionClockNfcKiosk(http.Controller):
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
@@ -120,6 +141,9 @@ class FusionClockNfcKiosk(http.Controller):
if not normalized:
return {'error': 'invalid_uid'}
if _is_debounced(normalized):
return {'error': 'debounce'}
company = request.env.company
location = company.x_fclk_nfc_kiosk_location_id
if not location: