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).
This commit is contained in:
193
fusion_helpdesk_central/tests/test_utils.py
Normal file
193
fusion_helpdesk_central/tests/test_utils.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# -*- 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('<script>', 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('<b>', 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)
|
||||
Reference in New Issue
Block a user