The old flow fired OpenAI on wizard open with just ticket + chatter,
so the AI summary was just a paraphrase of what the user originally
reported — your engineering analysis (scope, limitations, recommended
approach) never made it to the owner. Restructure to a two-step flow:
1. Open wizard → empty findings + empty summary, NO OpenAI call
2. You write findings: scope / effort / approach / risk
3. Click 'Generate Summary from Findings' → OpenAI runs with
ticket + chatter + findings, where the prompt explicitly tells
the model to weight findings MORE THAN the original report
4. Review/edit, then Send
Bulk wizard mirrors the flow per line: each row gets its own
findings + summary, one 'Generate All Summaries' button fans out
parallel OpenAI calls using each line's own findings.
Updated SUMMARY_PROMPT to:
- Tell the model the support engineer's findings are authoritative
- Emit a bullet structure that leads with the recommendation, not
the user's restated ask
- Side with findings over the original report when they conflict
New tests cover:
- default_get does NOT fire OpenAI (regression guard for auto-AI)
- Findings text actually reaches the OpenAI prompt
- Send works with a manually-typed summary (no AI in the loop)
- Existing bulk + validation paths still pass with the new shape
Also folds in the deferred code-review #7: ThreadPoolExecutor now
explicitly cancels pending futures on timeout via
shutdown(wait=False, cancel_futures=True) so a slow OpenAI day can't
hold the wizard open for ceil(N/workers)*15s.
Bumps fusion_helpdesk_central to 19.0.2.3.0.
Smoke-tested live on nexa: opening the wizard makes zero OpenAI calls;
clicking Generate with findings='My findings: scope is XL, ~8h' makes
exactly one call and the findings text is verifiably in the prompt
body received by call_openai_chat.
595 lines
24 KiB
Python
595 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Integration-ish tests for the owner-approval engagement flow.
|
|
|
|
These need an env (helpdesk.ticket, client_key, wizard, portal controller),
|
|
so they run as TransactionCase. OpenAI is mocked at the utils boundary —
|
|
no live API calls in CI. HTTP requests use the standard Odoo test client.
|
|
"""
|
|
from datetime import timedelta
|
|
from unittest.mock import patch
|
|
|
|
from odoo import fields
|
|
from odoo.exceptions import UserError
|
|
from odoo.tests import TransactionCase, HttpCase, tagged
|
|
|
|
|
|
def _patch_openai(return_value='• summary bullet one\n• bullet two'):
|
|
"""Mock the OpenAI client used by the wizard, returning a deterministic
|
|
summary so tests don't depend on network or API keys."""
|
|
return patch(
|
|
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
|
|
'call_openai_chat',
|
|
return_value=return_value,
|
|
)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestEngagementBase(TransactionCase):
|
|
"""Shared fixtures: a client_key with an owner, an ENTECH ticket."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
# client_key.create() requires an actual bot user; set bot login
|
|
# to the admin so the model can find it during setUp.
|
|
cls.env['ir.config_parameter'].sudo().set_param(
|
|
'fusion_helpdesk.bot_login', cls.env.user.login,
|
|
)
|
|
cls.client_key = cls.env['fusion.helpdesk.client.key'].create({
|
|
'client_label': 'TESTCLIENT',
|
|
'owner_email': 'owner@testclient.com',
|
|
'owner_name': 'Test Owner',
|
|
})
|
|
cls.team = cls.env['helpdesk.team'].create({
|
|
'name': 'Test Team for engagement',
|
|
})
|
|
|
|
def _make_ticket(self, **overrides):
|
|
vals = {
|
|
'name': '[TESTCLIENT] Bug Report: Test ticket for engagement',
|
|
'team_id': self.team.id,
|
|
'x_fc_client_label': 'TESTCLIENT',
|
|
'description': '<p>Steps to reproduce: do X, see Y.</p>',
|
|
}
|
|
vals.update(overrides)
|
|
return self.env['helpdesk.ticket'].create(vals)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestOwnerContactSync(TestEngagementBase):
|
|
|
|
def test_sync_owner_contacts_from_payload(self):
|
|
# Simulate the client-side submit piggyback: x_fc_owner_email +
|
|
# x_fc_owner_name on create vals. Central must consume them
|
|
# (pop from vals) and upsert the client_key row.
|
|
self._make_ticket(
|
|
x_fc_owner_email='newowner@testclient.com',
|
|
x_fc_owner_name='New Owner',
|
|
)
|
|
self.client_key.invalidate_recordset()
|
|
self.assertEqual(self.client_key.owner_email, 'newowner@testclient.com')
|
|
self.assertEqual(self.client_key.owner_name, 'New Owner')
|
|
|
|
def test_sync_no_owner_payload_leaves_client_key_alone(self):
|
|
# No piggyback keys → existing client_key contacts must NOT be
|
|
# nuked. (We had a bug like this in the customer-followup ship.)
|
|
original_email = self.client_key.owner_email
|
|
self._make_ticket()
|
|
self.client_key.invalidate_recordset()
|
|
self.assertEqual(self.client_key.owner_email, original_email)
|
|
|
|
def test_sync_unknown_client_label_is_silently_skipped(self):
|
|
# If a ticket arrives for a client_label we don't have a row for,
|
|
# we must not create one (would bypass API-key issuance). Just
|
|
# log and move on without raising.
|
|
self._make_ticket(
|
|
x_fc_client_label='UNKNOWN_CLIENT',
|
|
x_fc_owner_email='wat@example.com',
|
|
)
|
|
# No client_key row was created for UNKNOWN_CLIENT
|
|
unknown = self.env['fusion.helpdesk.client.key'].search(
|
|
[('client_label', '=', 'UNKNOWN_CLIENT')])
|
|
self.assertFalse(unknown)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestEngagementReset(TestEngagementBase):
|
|
|
|
def test_reset_engagement_sets_all_fields(self):
|
|
t = self._make_ticket()
|
|
t._fc_reset_engagement('o@x.com', 'Owner', 'Summary text')
|
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
|
self.assertEqual(t.x_fc_engagement_email, 'o@x.com')
|
|
self.assertEqual(t.x_fc_engagement_name, 'Owner')
|
|
self.assertEqual(t.x_fc_ai_summary, 'Summary text')
|
|
self.assertTrue(t.x_fc_engagement_token)
|
|
self.assertTrue(t.x_fc_engagement_sent_at)
|
|
self.assertFalse(t.x_fc_engagement_reminded_at)
|
|
self.assertFalse(t.x_fc_engagement_decided_at)
|
|
|
|
def test_re_engagement_rotates_token_and_clears_decision(self):
|
|
t = self._make_ticket()
|
|
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 1')
|
|
original_token = t.x_fc_engagement_token
|
|
# Simulate the owner having decided…
|
|
t.write({
|
|
'x_fc_engagement_state': 'rejected',
|
|
'x_fc_engagement_decided_at': fields.Datetime.now(),
|
|
'x_fc_engagement_reminded_at': fields.Datetime.now(),
|
|
})
|
|
# …then re-engage. State must reset, token must rotate.
|
|
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 2')
|
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
|
self.assertNotEqual(t.x_fc_engagement_token, original_token)
|
|
self.assertFalse(t.x_fc_engagement_reminded_at)
|
|
self.assertFalse(t.x_fc_engagement_decided_at)
|
|
|
|
def test_token_is_unique_per_call(self):
|
|
t = self._make_ticket()
|
|
tokens = set()
|
|
for _ in range(20):
|
|
t._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
tokens.add(t.x_fc_engagement_token)
|
|
self.assertEqual(len(tokens), 20)
|
|
|
|
def test_finalize_posts_chatter_and_clears_token(self):
|
|
t = self._make_ticket()
|
|
t._fc_reset_engagement('o@x.com', 'Owner', 's')
|
|
partner = self.env['res.partner'].create({
|
|
'name': 'Owner', 'email': 'o@x.com',
|
|
})
|
|
before_count = self.env['mail.message'].search_count(
|
|
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
|
|
t._fc_finalize_engagement('approved', partner, comment='LGTM')
|
|
after_count = self.env['mail.message'].search_count(
|
|
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
|
|
self.assertGreater(after_count, before_count)
|
|
self.assertEqual(t.x_fc_engagement_state, 'approved')
|
|
self.assertFalse(t.x_fc_engagement_token)
|
|
self.assertTrue(t.x_fc_engagement_decided_at)
|
|
|
|
def test_turnaround_hours_computed(self):
|
|
t = self._make_ticket()
|
|
now = fields.Datetime.now()
|
|
t.write({
|
|
'x_fc_engagement_sent_at': now - timedelta(hours=5),
|
|
'x_fc_engagement_decided_at': now,
|
|
})
|
|
self.assertAlmostEqual(
|
|
t.x_fc_engagement_turnaround_hours, 5.0, places=1,
|
|
)
|
|
|
|
def test_turnaround_zero_when_not_decided(self):
|
|
t = self._make_ticket()
|
|
t.write({
|
|
'x_fc_engagement_sent_at': fields.Datetime.now() - timedelta(hours=2),
|
|
})
|
|
self.assertEqual(t.x_fc_engagement_turnaround_hours, 0.0)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestEngagementWizard(TestEngagementBase):
|
|
|
|
def test_default_get_does_not_fire_openai(self):
|
|
# Two-step flow: opening the wizard must NOT call OpenAI — the user
|
|
# has to write findings first, then click Generate. Wrap call_openai_chat
|
|
# in a mock that fails the test if invoked.
|
|
from unittest.mock import patch
|
|
t = self._make_ticket()
|
|
called = {'n': 0}
|
|
def _spy(*a, **kw):
|
|
called['n'] += 1
|
|
return 'should-not-appear'
|
|
with patch(
|
|
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
|
|
'call_openai_chat',
|
|
side_effect=_spy,
|
|
):
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
self.assertEqual(called['n'], 0,
|
|
'default_get must not auto-fire OpenAI.')
|
|
self.assertEqual(wizard.ai_summary, '')
|
|
self.assertEqual(wizard.findings, '')
|
|
|
|
def test_single_send_via_wizard(self):
|
|
t = self._make_ticket()
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
active_id=t.id,
|
|
active_model='helpdesk.ticket',
|
|
).create({})
|
|
self.assertEqual(wizard.mode, 'single')
|
|
# User writes findings, clicks Generate Summary
|
|
wizard.findings = 'Scope is small; ~2 hours of work.'
|
|
with _patch_openai():
|
|
wizard.action_generate_summary()
|
|
self.assertIn('summary bullet', wizard.ai_summary)
|
|
wizard.personal_note = 'please review'
|
|
result = wizard.action_send()
|
|
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
|
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
|
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
|
|
self.assertTrue(t.x_fc_engagement_token)
|
|
|
|
def test_send_with_manual_summary_no_ai(self):
|
|
# Skipping Generate altogether: user types the summary by hand.
|
|
# Send should work without ever invoking OpenAI.
|
|
t = self._make_ticket()
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
wizard.ai_summary = 'Manually written brief; no AI involved.'
|
|
wizard.action_send()
|
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
|
self.assertEqual(t.x_fc_ai_summary,
|
|
'Manually written brief; no AI involved.')
|
|
|
|
def test_generate_summary_passes_findings_to_ai(self):
|
|
# Pin the prompt-construction wiring: findings field must reach
|
|
# build_summary_prompt, which must reach call_openai_chat.
|
|
from unittest.mock import patch
|
|
t = self._make_ticket()
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
wizard.findings = 'UNIQUE-FINDINGS-MARKER-XYZ'
|
|
captured = {}
|
|
def _spy(api_key, model, prompt, timeout=15):
|
|
captured['prompt'] = prompt
|
|
return '• generated'
|
|
with patch(
|
|
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
|
|
'call_openai_chat',
|
|
side_effect=_spy,
|
|
):
|
|
wizard.action_generate_summary()
|
|
self.assertIn('UNIQUE-FINDINGS-MARKER-XYZ', captured['prompt'])
|
|
|
|
def test_single_send_uses_current_client_key_owner(self):
|
|
# Send must read the FRESH owner contact from client_key, not a
|
|
# stale snapshot from default_get.
|
|
t = self._make_ticket()
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
wizard.ai_summary = 'manual summary'
|
|
self.client_key.owner_email = 'changed@testclient.com'
|
|
wizard.action_send()
|
|
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
|
|
|
|
def test_wizard_rejects_ticket_without_client_label(self):
|
|
t = self._make_ticket(x_fc_client_label=False)
|
|
with self.assertRaises(UserError):
|
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
|
|
def test_wizard_rejects_when_owner_contact_missing(self):
|
|
self.client_key.write({'owner_email': False, 'owner_name': False})
|
|
t = self._make_ticket()
|
|
with self.assertRaises(UserError):
|
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
|
|
def test_generate_marks_ai_unavailable_when_empty(self):
|
|
t = self._make_ticket()
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_id=t.id,
|
|
).create({})
|
|
with _patch_openai(return_value=''):
|
|
wizard.action_generate_summary()
|
|
self.assertTrue(wizard.ai_unavailable)
|
|
self.assertEqual(wizard.ai_summary, '')
|
|
|
|
def test_bulk_send_creates_one_engagement_per_ticket(self):
|
|
ts = self.env['helpdesk.ticket']
|
|
for i in range(3):
|
|
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
|
|
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_ids=ts.ids,
|
|
active_ids=ts.ids,
|
|
active_model='helpdesk.ticket',
|
|
fhc_bulk=True,
|
|
).create({})
|
|
self.assertEqual(wizard.mode, 'bulk')
|
|
self.assertEqual(len(wizard.line_ids), 3)
|
|
# User fills findings + generates summaries (or writes by hand)
|
|
for line in wizard.line_ids:
|
|
line.findings = 'Scope: small. Effort: low.'
|
|
with _patch_openai():
|
|
wizard.action_generate_all_summaries()
|
|
for line in wizard.line_ids:
|
|
self.assertIn('summary bullet', line.ai_summary)
|
|
wizard.action_send()
|
|
for t in ts:
|
|
self.assertEqual(t.x_fc_engagement_state, 'pending')
|
|
self.assertTrue(t.x_fc_engagement_token)
|
|
tokens = {t.x_fc_engagement_token for t in ts}
|
|
self.assertEqual(len(tokens), 3)
|
|
|
|
def test_bulk_rejects_mixed_clients(self):
|
|
t1 = self._make_ticket()
|
|
self.env['fusion.helpdesk.client.key'].create({
|
|
'client_label': 'OTHERCLIENT',
|
|
'owner_email': 'other@x.com', 'owner_name': 'Other',
|
|
})
|
|
t2 = self._make_ticket(
|
|
name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT')
|
|
with self.assertRaises(UserError):
|
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_ids=[t1.id, t2.id],
|
|
fhc_bulk=True,
|
|
).create({})
|
|
|
|
def test_bulk_rejects_already_pending_in_selection(self):
|
|
t1 = self._make_ticket()
|
|
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
|
|
t2 = self._make_ticket(name='[TESTCLIENT] B')
|
|
with self.assertRaises(UserError):
|
|
self.env['fusion.helpdesk.engagement.wizard'].with_context(
|
|
default_ticket_ids=[t1.id, t2.id],
|
|
fhc_bulk=True,
|
|
).create({})
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestOwnerAsFollower(TestEngagementBase):
|
|
"""The one-click 'add owner as follower' button — independent of the
|
|
engagement flow, just loops the owner into the chatter."""
|
|
|
|
def test_owner_display_renders_name_and_email(self):
|
|
t = self._make_ticket()
|
|
self.assertEqual(
|
|
t.x_fc_owner_display, 'Test Owner <owner@testclient.com>',
|
|
)
|
|
self.assertEqual(t.x_fc_owner_email_resolved, 'owner@testclient.com')
|
|
self.assertFalse(t.x_fc_owner_is_follower)
|
|
|
|
def test_owner_display_blank_when_no_client_label(self):
|
|
t = self._make_ticket(x_fc_client_label=False)
|
|
self.assertFalse(t.x_fc_owner_display)
|
|
self.assertFalse(t.x_fc_owner_email_resolved)
|
|
|
|
def test_owner_display_blank_when_client_key_has_no_owner(self):
|
|
self.client_key.write({'owner_email': False, 'owner_name': False})
|
|
t = self._make_ticket()
|
|
self.assertFalse(t.x_fc_owner_display)
|
|
|
|
def test_action_creates_partner_and_subscribes(self):
|
|
t = self._make_ticket()
|
|
# Pre-condition: no res.partner with that email exists.
|
|
existing = self.env['res.partner'].search(
|
|
[('email', '=ilike', 'owner@testclient.com')])
|
|
existing.unlink()
|
|
t.action_add_owner_as_follower()
|
|
# Partner created
|
|
partner = self.env['res.partner'].search(
|
|
[('email', '=ilike', 'owner@testclient.com')], limit=1)
|
|
self.assertTrue(partner)
|
|
self.assertEqual(partner.email, 'owner@testclient.com')
|
|
# And subscribed
|
|
self.assertIn(partner.id, t.message_partner_ids.ids)
|
|
self.assertTrue(t.x_fc_owner_is_follower)
|
|
|
|
def test_action_reuses_existing_partner(self):
|
|
t = self._make_ticket()
|
|
existing = self.env['res.partner'].create({
|
|
'name': 'Pre-existing Owner',
|
|
'email': 'owner@testclient.com',
|
|
})
|
|
t.action_add_owner_as_follower()
|
|
# No duplicate partner created
|
|
count = self.env['res.partner'].search_count(
|
|
[('email', '=ilike', 'owner@testclient.com')])
|
|
self.assertEqual(count, 1)
|
|
self.assertIn(existing.id, t.message_partner_ids.ids)
|
|
|
|
def test_action_is_idempotent_when_already_following(self):
|
|
t = self._make_ticket()
|
|
t.action_add_owner_as_follower()
|
|
followers_before = t.message_partner_ids.ids
|
|
t.action_add_owner_as_follower()
|
|
followers_after = t.message_partner_ids.ids
|
|
# Second call must not duplicate or re-trigger the subscribe
|
|
self.assertEqual(followers_before, followers_after)
|
|
|
|
def test_action_raises_when_no_owner_configured(self):
|
|
self.client_key.write({'owner_email': False})
|
|
t = self._make_ticket()
|
|
with self.assertRaises(UserError):
|
|
t.action_add_owner_as_follower()
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestReminderCron(TestEngagementBase):
|
|
|
|
def test_reminder_fires_for_stale_pending_only(self):
|
|
# 1 stale (should be reminded), 1 recent (no reminder), 1 already
|
|
# reminded (no second reminder), 1 already-decided (no reminder).
|
|
old = fields.Datetime.now() - timedelta(days=10)
|
|
recent = fields.Datetime.now() - timedelta(hours=2)
|
|
|
|
stale = self._make_ticket()
|
|
stale._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
stale.x_fc_engagement_sent_at = old
|
|
|
|
too_recent = self._make_ticket(name='[TESTCLIENT] too recent')
|
|
too_recent._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
too_recent.x_fc_engagement_sent_at = recent
|
|
|
|
already_reminded = self._make_ticket(name='[TESTCLIENT] already')
|
|
already_reminded._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
already_reminded.write({
|
|
'x_fc_engagement_sent_at': old,
|
|
'x_fc_engagement_reminded_at': old,
|
|
})
|
|
|
|
decided = self._make_ticket(name='[TESTCLIENT] decided')
|
|
decided._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
decided.write({
|
|
'x_fc_engagement_sent_at': old,
|
|
'x_fc_engagement_state': 'approved',
|
|
})
|
|
|
|
# Default ICP is 3 days, so >=10 days qualifies.
|
|
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
|
|
self.assertEqual(sent, 1)
|
|
self.assertTrue(stale.x_fc_engagement_reminded_at)
|
|
self.assertFalse(too_recent.x_fc_engagement_reminded_at)
|
|
# already_reminded's reminded_at must not have moved
|
|
self.assertEqual(
|
|
already_reminded.x_fc_engagement_reminded_at, old,
|
|
)
|
|
|
|
def test_reminder_disabled_when_days_zero(self):
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'fusion_helpdesk_central.engagement_reminder_days', '0')
|
|
t = self._make_ticket()
|
|
t._fc_reset_engagement('o@x.com', 'Owner', '')
|
|
t.x_fc_engagement_sent_at = fields.Datetime.now() - timedelta(days=30)
|
|
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
|
|
self.assertEqual(sent, 0)
|
|
self.assertFalse(t.x_fc_engagement_reminded_at)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
|
class TestEngagementPortal(HttpCase):
|
|
"""HTTP-layer tests for the public approve/reject portal pages."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'fusion_helpdesk.bot_login', self.env.user.login,
|
|
)
|
|
self.client_key = self.env['fusion.helpdesk.client.key'].create({
|
|
'client_label': 'PORTALCLIENT',
|
|
'owner_email': 'owner@portalclient.com',
|
|
'owner_name': 'Portal Owner',
|
|
})
|
|
self.team = self.env['helpdesk.team'].create({
|
|
'name': 'Test team portal',
|
|
})
|
|
|
|
def _make_pending_ticket(self):
|
|
t = self.env['helpdesk.ticket'].create({
|
|
'name': '[PORTALCLIENT] Bug Report: portal smoke',
|
|
'team_id': self.team.id,
|
|
'x_fc_client_label': 'PORTALCLIENT',
|
|
'description': '<p>nothing fancy</p>',
|
|
})
|
|
t._fc_reset_engagement('owner@portalclient.com', 'Portal Owner', 'sm')
|
|
# Make sure cursor sees it for the public request
|
|
self.env.cr.commit()
|
|
return t
|
|
|
|
def test_get_with_valid_token_renders_confirm(self):
|
|
t = self._make_pending_ticket()
|
|
try:
|
|
r = self.url_open(
|
|
'/fusion_helpdesk/engagement/%s/approve' % t.x_fc_engagement_token,
|
|
timeout=10,
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertIn('Confirm Approval', r.text)
|
|
self.assertIn(t.name, r.text)
|
|
finally:
|
|
t.unlink()
|
|
self.env.cr.commit()
|
|
|
|
def test_get_with_bad_token_renders_invalid(self):
|
|
r = self.url_open(
|
|
'/fusion_helpdesk/engagement/bogus-token/approve', timeout=10,
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertIn('Link no longer valid', r.text)
|
|
|
|
def test_get_with_bad_decision_renders_invalid(self):
|
|
t = self._make_pending_ticket()
|
|
try:
|
|
r = self.url_open(
|
|
'/fusion_helpdesk/engagement/%s/sideways'
|
|
% t.x_fc_engagement_token, timeout=10,
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertIn('Link no longer valid', r.text)
|
|
finally:
|
|
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
|
|
try:
|
|
r = self.url_open(
|
|
'/fusion_helpdesk/engagement/%s/approve' % token,
|
|
data={'comment': 'looks good'}, timeout=10,
|
|
)
|
|
self.assertEqual(r.status_code, 200)
|
|
self.assertIn('Approval recorded', r.text)
|
|
t.invalidate_recordset()
|
|
self.assertEqual(t.x_fc_engagement_state, 'approved')
|
|
self.assertFalse(t.x_fc_engagement_token)
|
|
# Second click on the same URL must now show the invalid page.
|
|
r2 = self.url_open(
|
|
'/fusion_helpdesk/engagement/%s/approve' % token, timeout=10,
|
|
)
|
|
self.assertIn('Link no longer valid', r2.text)
|
|
finally:
|
|
t.unlink()
|
|
self.env.cr.commit()
|