Files
Odoo-Modules/fusion_helpdesk_central/tests/test_utils.py
gsinghpal c520803c84 feat(fusion_helpdesk_central): findings-first wizard, explicit Generate button
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.
2026-05-27 13:49:02 -04:00

207 lines
8.0 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)
def test_findings_included_in_prompt(self):
# Findings is the support engineer's analysis — must reach OpenAI.
prompt = build_summary_prompt(
't', 'd', [], findings='Scope is small. ~2h of work.',
)
self.assertIn('Scope is small. ~2h of work.', prompt)
def test_findings_absent_uses_explicit_marker(self):
# Empty findings collapses to an explicit marker so the model
# doesn't read a blank section as "missing".
prompt = build_summary_prompt('t', 'd', [], findings='')
self.assertIn('none provided', 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)