Squash-merge of feat/helpdesk-customer-followup. The billing and fusion_login_audit work from that branch is already on main (landed separately); this lands only the helpdesk feature. - Identity keystone: submit() forwards partner_email/partner_name/ x_fc_client_label so the central Helpdesk find-or-creates the customer partner and subscribes them as a follower (enables reply emails + magic link). - Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray unread badge. Defense-in-depth scope domain + _norm_email normalisation (wildcard emails cannot widen scope). - fusion_helpdesk_central: x_fc_client_label field + list/search views + branded acknowledgement email template. - Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client 19.0.1.4.1 (requires Contact Creation on the central service account). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
132 lines
5.7 KiB
Python
132 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
"""Unit tests for the pure helpers in fusion_helpdesk.utils.
|
|
|
|
These need no live central Odoo — they pin the identity keystone, the
|
|
scoping security boundary, the public-message filter and the unread
|
|
maths as plain data transformations.
|
|
"""
|
|
from odoo.tests import TransactionCase, tagged
|
|
|
|
from odoo.addons.fusion_helpdesk.utils import (
|
|
build_ticket_vals,
|
|
build_scope_domain,
|
|
is_public_message,
|
|
compute_unread_count,
|
|
_norm_email,
|
|
)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
|
class TestBuildTicketVals(TransactionCase):
|
|
|
|
def test_identity_fields_present(self):
|
|
vals = build_ticket_vals(
|
|
kind='bug', subject='X', body_html='<p>b</p>',
|
|
team_id=1, client_label='ENTECH',
|
|
reporter_name='John Doe', reporter_email='john@entech.com',
|
|
company_name='ENTECH Inc',
|
|
)
|
|
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
|
self.assertEqual(vals['partner_name'], 'John Doe')
|
|
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
|
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
|
self.assertEqual(vals['team_id'], 1)
|
|
self.assertIn('X', vals['name'])
|
|
self.assertIn('[ENTECH]', vals['name'])
|
|
|
|
def test_no_email_omits_partner_email(self):
|
|
vals = build_ticket_vals(
|
|
kind='feature', subject='Y', body_html='<p>b</p>',
|
|
team_id=False, client_label='', reporter_name='Jane',
|
|
reporter_email='', company_name='',
|
|
)
|
|
self.assertNotIn('partner_email', vals) # never send an empty email
|
|
self.assertNotIn('team_id', vals) # omit falsy team
|
|
self.assertNotIn('x_fc_client_label', vals) # omit empty label
|
|
self.assertEqual(vals['partner_name'], 'Jane')
|
|
self.assertIn('Feature Request', vals['name'])
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
|
class TestScopeDomain(TransactionCase):
|
|
|
|
def test_regular_scope_binds_email_and_label(self):
|
|
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
|
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
|
|
|
def test_admin_scope_binds_label_only(self):
|
|
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
|
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
|
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
|
|
|
def test_empty_label_never_matches_everything(self):
|
|
dom = build_scope_domain(label='', email='', is_admin=True)
|
|
# label term must be present and must NOT be an empty string
|
|
label_terms = [t for t in dom if t[0] == 'x_fc_client_label']
|
|
self.assertEqual(len(label_terms), 1)
|
|
self.assertNotEqual(label_terms[0][2], '')
|
|
|
|
def test_wildcard_email_cannot_widen_scope(self):
|
|
# IDOR guard: a self-set email of '%' must NOT become a match-all
|
|
# =ilike term — the wildcard has to be escaped to a literal.
|
|
dom = build_scope_domain(label='ENTECH', email='%', is_admin=False)
|
|
email_terms = [t for t in dom if t[0] == 'partner_email']
|
|
self.assertEqual(len(email_terms), 1)
|
|
self.assertEqual(email_terms[0][2], '\\%',
|
|
"'%' must be escaped so ILIKE matches it literally")
|
|
|
|
def test_underscore_in_real_email_is_escaped_but_preserved(self):
|
|
dom = build_scope_domain(label='ENTECH', email='john_doe@x.com', is_admin=False)
|
|
email_terms = [t for t in dom if t[0] == 'partner_email']
|
|
self.assertEqual(email_terms[0][2], 'john\\_doe@x.com')
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
|
class TestMessageFilterAndUnread(TransactionCase):
|
|
|
|
def test_internal_note_is_not_public(self):
|
|
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
|
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
|
self.assertTrue(is_public_message({})) # default visible
|
|
|
|
def test_unread_count(self):
|
|
tickets = [
|
|
{'id': 1, 'last_support_msg_id': 10}, # seen 10 -> read
|
|
{'id': 2, 'last_support_msg_id': 5}, # seen 3 -> unread
|
|
{'id': 3, 'last_support_msg_id': 0}, # no support msg
|
|
]
|
|
seen = {1: 10, 2: 3}
|
|
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
|
|
|
def test_unread_count_unseen_ticket_counts(self):
|
|
tickets = [{'id': 9, 'last_support_msg_id': 4}]
|
|
self.assertEqual(compute_unread_count(tickets, {}), 1)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
|
class TestNormEmail(TransactionCase):
|
|
|
|
def test_valid_email_is_normalised_lowercase(self):
|
|
self.assertEqual(_norm_email('John@Entech.COM'), 'john@entech.com')
|
|
|
|
def test_first_valid_candidate_wins(self):
|
|
# confirmed reply email empty -> fall back to the next valid one
|
|
self.assertEqual(_norm_email('', 'not an email', 'jane@x.com'), 'jane@x.com')
|
|
|
|
def test_wildcard_is_rejected(self):
|
|
# IDOR guard: a self-set '%' must not survive as a scope key
|
|
self.assertEqual(_norm_email('%'), '')
|
|
|
|
def test_non_email_login_falls_through_to_empty(self):
|
|
self.assertEqual(_norm_email('admin', 'also-not-email', ''), '')
|
|
|
|
def test_controller_namespace_resolves_norm_email(self):
|
|
# Regression: _norm_email was called in controllers/main.py
|
|
# (submit + _identity) but never imported/defined -> NameError on
|
|
# every inbox endpoint. Guard that the name is resolvable there.
|
|
from odoo.addons.fusion_helpdesk.controllers import main
|
|
self.assertTrue(hasattr(main, '_norm_email'))
|