fix(fusion_helpdesk_central): close magic-link race + cron savepoint + avg pivot

Findings from the post-feature code review on commit 396170b4. Addresses
the two CRITICAL + one HIGH + two MEDIUM issues; rest are deferred.

CRITICAL #1 — magic-link token race:
  Two near-simultaneous POSTs on the same /engagement/<token>/approve
  could both SELECT state='pending' under READ COMMITTED, both post
  chatter, and let the last writer flip the outcome. Now the POST path
  does an atomic UPDATE helpdesk_ticket SET token=NULL WHERE token=%s
  AND state='pending' RETURNING id — the loser gets no row back and
  renders the friendly invalid-link page. Verified live: 2 concurrent
  POSTs → 1 wins, 1 loses, exactly 1 chatter row.

CRITICAL #2 — reminder cron without per-row savepoint:
  Per CLAUDE.md rule #14, a DB failure mid-loop aborts the whole
  transaction and silently kills the rest of the batch. Wrap each row's
  send_mail+write in `with self.env.cr.savepoint()`. Also corrected the
  success-count log (was len(stale), now actual sent count).

HIGH #3 — turnaround pivot summed instead of averaged:
  fields.Float defaults to SUM aggregator; meaningless for per-ticket
  decision delays. Added aggregator='avg' so the pivot reads "avg
  turnaround per ticket" not "summed wait time".

HIGH #4 — added test_concurrent_claim_only_one_wins regression test
  that fires two real HTTP POSTs against the same token and asserts
  exactly one wins + exactly one approval chatter row exists.

MEDIUM #6 — cron nextcall pinned to 09:00 tomorrow so reminders land
  in business hours regardless of when the module was last upgraded.

MEDIUM #10 — escalate failed owner-partner-create from WARNING to
  ERROR (via _logger.exception) since silent attribution to the bot
  account is a real audit-trail confusion.

Deferred (follow-up commits): #5, #7 (executor cleanup), #8, #9,
#11–#14 — none are bugs, all spec-drift or hardening.
This commit is contained in:
gsinghpal
2026-05-27 13:16:20 -04:00
parent 396170b438
commit f1a2b300f7
4 changed files with 130 additions and 22 deletions

View File

@@ -391,6 +391,56 @@ class TestEngagementPortal(HttpCase):
t.unlink()
self.env.cr.commit()
def test_concurrent_claim_only_one_wins(self):
"""Regression for the magic-link double-click race.
Two POSTs against the same token must NOT both record decisions.
The controller uses UPDATE...RETURNING with a WHERE on
state='pending' so the second call gets a NULL row back and
returns the invalid-link page. Without that atomic claim, two
worker transactions could each SELECT the same pending row and
both post chatter — last-writer-wins on state.
url_open hits live HTTP, so each call is its own request/
transaction — different from a same-transaction simulation and
the actual production race scenario.
"""
t = self._make_pending_ticket()
token = t.x_fc_engagement_token
try:
r1 = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token,
data={'comment': 'first'}, timeout=10,
)
r2 = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token,
data={'comment': 'second'}, timeout=10,
)
self.assertEqual(r1.status_code, 200)
self.assertEqual(r2.status_code, 200)
ok_count = sum(
'Approval recorded' in r.text for r in (r1, r2))
invalid_count = sum(
'Link no longer valid' in r.text for r in (r1, r2))
self.assertEqual(
ok_count, 1,
'Both clicks must not both succeed (race condition).',
)
self.assertEqual(invalid_count, 1)
t.invalidate_recordset()
approval_chatter = self.env['mail.message'].search_count([
('res_id', '=', t.id),
('model', '=', 'helpdesk.ticket'),
('body', 'ilike', 'Approved by'),
])
self.assertEqual(
approval_chatter, 1,
'Race must not produce duplicate approval chatter posts.',
)
finally:
t.unlink()
self.env.cr.commit()
def test_post_records_decision_and_invalidates_token(self):
t = self._make_pending_ticket()
token = t.x_fc_engagement_token