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).
194 lines
7.5 KiB
Python
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('<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)
|