Files
Odoo-Modules/fusion_helpdesk_central/tests/test_utils.py
gsinghpal 396170b438 feat(fusion_helpdesk): owner-approval engagement flow + AI summary + reporting
Ships the design spec at docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md.

What's new on central (fusion_helpdesk_central 19.0.1.2.0 -> 19.0.2.0.0):

- Engagement model: 8 new fields on helpdesk.ticket (state, snapshotted
  owner email/name, single-use UUID4 token, sent/reminded/decided
  timestamps, AI summary, stored-computed turnaround hours).
- Wizard: single + bulk modes on one fusion.helpdesk.engagement.wizard
  TransientModel with a child wizard.line for per-ticket bulk summaries.
  default_get pulls the OpenAI summary on open; AI fan-out for bulk is
  parallel via ThreadPoolExecutor (max 5 workers, 30s overall cap).
- OpenAI client in utils.py — stdlib urllib, 15s per-call timeout, every
  failure collapses to '' so the wizard's manual-summary fallback fires.
- Public portal: /fusion_helpdesk/engagement/<token>/<decision> GET +
  POST, four branded standalone QWeb pages (confirm/done/invalid/error).
  Token is single-use, cleared on confirm. Decision posts a public
  comment attributed to the resolved owner partner; chatter propagates
  to the employee's My Tickets thread per the "fully visible" UX choice.
- Mail templates (single + bulk) with magic-link buttons. Bulk template
  renders one card per ticket, each with its own approve/reject URL.
- Reminder cron: daily, single-shot per engagement, configurable via
  fusion_helpdesk_central.engagement_reminder_days ICP (default 3, 0
  disables).
- Reporting dashboard: pivot/graph/list/kanban over helpdesk.ticket
  filtered to engaged ones, with avg-turnaround measure. Menu lives
  under Helpdesk > Reporting > Owner Engagements.
- Client_key extended with owner_email/owner_name fields; ticket.create
  upserts them from the client-side piggyback (no new sync endpoint).
- 100% coverage on utils + integration tests on wizard, controllers,
  re-engagement, cron, computed turnaround. OpenAI mocked in CI.

What's new on client (fusion_helpdesk 19.0.1.7.1 -> 19.0.2.0.0):

- Two new ICP settings: fusion_helpdesk.owner_email / .owner_name with
  a new "Owner Approval" block in Settings > Fusion Helpdesk.
- controllers/main.py::submit piggybacks both keys on every ticket
  payload so central keeps client_key.owner_email/name fresh
  automatically.

Verified live end-to-end on entech -> nexa: payload upsert, wizard with
mocked AI, action_send, portal GET/POST/GET-again cycle, second click
hits the friendly invalid-token page. Token entropy = 122 bits (UUID4).
2026-05-27 13:03:23 -04:00

194 lines
7.5 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure-helper tests for fusion_helpdesk_central.utils.
No Odoo env, no network — these run in any environment with `markupsafe`
installed. Mirrors the pattern in fusion_helpdesk/tests/test_utils.py.
"""
from unittest.mock import patch
from odoo.tests import TransactionCase, tagged
from odoo.addons.fusion_helpdesk_central.utils import (
SUMMARY_PROMPT,
OPENAI_PROMPT_MAX_CHARS,
build_summary_prompt,
call_openai_chat,
format_engagement_chatter,
truncate_for_openai,
)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestBuildSummaryPrompt(TransactionCase):
def test_includes_title_description_and_messages(self):
prompt = build_summary_prompt(
'My ticket',
'Detailed description here',
[{'author': 'Alice', 'date': '2026-05-27 10:00:00',
'body_plain': 'first reply'}],
)
self.assertIn('My ticket', prompt)
self.assertIn('Detailed description here', prompt)
self.assertIn('Alice', prompt)
self.assertIn('first reply', prompt)
def test_empty_messages_becomes_explicit_marker(self):
# The model needs to know "no replies yet" — a blank section would
# invite hallucination.
prompt = build_summary_prompt('t', 'd', [])
self.assertIn('(no replies yet)', prompt)
def test_empty_title_and_description_use_fallbacks(self):
prompt = build_summary_prompt('', '', [])
self.assertIn('(untitled)', prompt)
self.assertIn('(no description)', prompt)
def test_empty_message_body_is_marked_not_dropped(self):
prompt = build_summary_prompt('t', 'd', [
{'author': 'A', 'date': 'X', 'body_plain': ''},
])
# An empty body must still produce a line so author + date context
# survives; '(empty)' marker keeps the model honest.
self.assertIn('(empty)', prompt)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestTruncateForOpenAI(TransactionCase):
def test_no_truncation_when_under_limit(self):
prompt = 'x' * 100
self.assertEqual(truncate_for_openai(prompt, max_chars=200), prompt)
def test_truncation_appends_marker_and_respects_cap(self):
prompt = 'x' * 500
out = truncate_for_openai(prompt, max_chars=200)
self.assertLessEqual(len(out), 200)
self.assertIn('truncated', out.lower())
def test_default_cap_is_8000(self):
# Regression guard — flipping this default has real $$ implications.
self.assertEqual(OPENAI_PROMPT_MAX_CHARS, 8000)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestFormatEngagementChatter(TransactionCase):
def test_approve_without_comment(self):
out = str(format_engagement_chatter('approved', 'Kris'))
self.assertIn('Approved', out)
self.assertIn('Kris', out)
self.assertIn('', out)
# No blockquote when no comment.
self.assertNotIn('blockquote', out)
def test_reject_with_comment(self):
out = str(format_engagement_chatter(
'rejected', 'Kris', comment='not in scope this quarter'))
self.assertIn('Rejected', out)
self.assertIn('', out)
self.assertIn('blockquote', out)
self.assertIn('not in scope this quarter', out)
def test_comment_html_escaped(self):
# XSS guard: a malicious comment must not inject script tags.
out = str(format_engagement_chatter(
'approved', 'Kris', comment='<script>alert(1)</script>'))
self.assertNotIn('<script>', out)
self.assertIn('&lt;script&gt;', out)
def test_owner_name_html_escaped(self):
# Same XSS guard on the owner name path.
out = str(format_engagement_chatter(
'approved', '<b>Pwn</b>'))
self.assertNotIn('<b>Pwn</b>', out)
self.assertIn('&lt;b&gt;', out)
def test_missing_owner_name_falls_back(self):
out = str(format_engagement_chatter('approved', None))
self.assertIn('the owner', out)
def test_invalid_decision_raises(self):
# Typo upstream should fail loud, not silently post a malformed line.
with self.assertRaises(ValueError):
format_engagement_chatter('maybe', 'Kris')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestCallOpenAIChat(TransactionCase):
"""OpenAI client unit tests — mock urlopen, never hit the network."""
def test_empty_api_key_returns_empty_string(self):
# No key configured? Don't even try to call OpenAI.
self.assertEqual(call_openai_chat('', 'gpt-4o-mini', 'hi'), '')
def test_empty_prompt_returns_empty_string(self):
self.assertEqual(call_openai_chat('sk-x', 'gpt-4o-mini', ''), '')
def test_happy_path_returns_message_content(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: (
b'{"choices":[{"message":{"content":"bullet one\\nbullet two"}}]}'
)
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
out = call_openai_chat('sk-x', 'gpt-4o-mini', 'prompt')
self.assertEqual(out, 'bullet one\nbullet two')
def test_network_error_returns_empty(self):
# All exceptions must collapse to '' so the wizard's fallback fires.
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.URLError('boom'),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_http_error_returns_empty(self):
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.HTTPError(
'url', 429, 'rate limited', {}, None,
),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_malformed_json_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'not json at all'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_empty_choices_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'{"choices":[]}'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestPromptConstant(TransactionCase):
def test_summary_prompt_has_required_placeholders(self):
# The wizard calls .format(name=, description_plain=, messages_plain=)
# — silently dropping any of these would yield a useless prompt.
for placeholder in ('{name}', '{description_plain}', '{messages_plain}'):
self.assertIn(placeholder, SUMMARY_PROMPT)