Files
Odoo-Modules/fusion_helpdesk/tests/test_utils.py
gsinghpal d7ec91b0f1 feat(fusion_helpdesk): Critical flag, KPI cards, colored stage pills
Three coordinated changes on top of the section grouping:

1. **Mark as Critical** — a red chip on the New tab sets priority='3'
   when submitted. The central post-create hook auto-applies a "Critical"
   helpdesk.tag (shipped via fusion_helpdesk_central data XML, noupdate=1
   so support can recolor without losing it on upgrade), giving support
   a kanban-groupable signal that doesn't rely on remembering what
   priority='3' means. Scoped to in-app-channel tickets only, so a
   support agent manually setting Urgent on their own ticket isn't
   silently tagged.

2. **KPI cards above the sections** — Total / Open / Closed / Critical
   in a 4-up grid (auto-collapses to 2x2 under 540px). Each card uses
   its own saturated gradient so it reads on both light and dark mode —
   the dialog backdrop is irrelevant because the gradient brings its
   own background. Counts are computed in JS from state.tickets so they
   always match what's rendered below.

3. **Colored stage pills** — red Critical, green Solved, dark-yellow New,
   orange Cancelled, blue for In Progress / Testing / On Hold. Critical
   priority gets a *separate* red pill alongside the stage pill so you
   keep stage info even on escalated tickets. Stage matching is
   substring-based (lowercased) so a renamed "Resolved" or "Done" stage
   on central still maps to the green pill.

Tests cover the new is_critical=True → priority='3' wiring and the
default omission so SLA / stage defaults keep working for normal
tickets. Bumps fusion_helpdesk to 19.0.1.7.0 and
fusion_helpdesk_central to 19.0.1.2.0. End-to-end smoke test verified
live: priority=3 + x_fc_client_label triggers the Critical tag.
2026-05-27 11:21:11 -04:00

183 lines
8.1 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,
bucket_ticket,
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'])
def test_is_critical_sets_urgent_priority(self):
# The "Mark as Critical" toggle on the dialog turns into priority='3'
# in the create vals — that's the load-bearing signal both for the
# inbox's Critical bucket AND the central auto-tag.
vals = build_ticket_vals(
kind='bug', subject='X', body_html='<p>b</p>', team_id=1,
client_label='ENTECH', reporter_name='X', reporter_email='x@x.com',
company_name='X Inc', is_critical=True,
)
self.assertEqual(vals.get('priority'), '3')
def test_is_critical_default_omits_priority(self):
# Default path must NOT set priority so the central stage / SLA default
# keeps working — only the explicit toggle escalates.
vals = build_ticket_vals(
kind='bug', subject='X', body_html='<p>b</p>', team_id=1,
client_label='ENTECH', reporter_name='X', reporter_email='x@x.com',
company_name='X Inc',
)
self.assertNotIn('priority', vals)
@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'))
@tagged('post_install', '-at_install', 'fusion_helpdesk')
class TestBucketTicket(TransactionCase):
"""Bucketing pins how the My Tickets inbox sections work — getting this
wrong used to scatter Solved tickets in with New ones, defeating the
whole point of the grouped view."""
def test_folded_stage_goes_to_solved(self):
# Folded wins even if priority is high — closed tickets aren't
# actionable so they belong in Solved regardless.
self.assertEqual(bucket_ticket(True, '0'), 'solved')
self.assertEqual(bucket_ticket(True, '3'), 'solved')
def test_open_high_or_urgent_promotes_to_critical(self):
self.assertEqual(bucket_ticket(False, '2'), 'critical')
self.assertEqual(bucket_ticket(False, '3'), 'critical')
def test_open_low_or_normal_is_just_open(self):
self.assertEqual(bucket_ticket(False, '0'), 'open')
self.assertEqual(bucket_ticket(False, '1'), 'open')
self.assertEqual(bucket_ticket(False, None), 'open') # missing -> normal
self.assertEqual(bucket_ticket(False, ''), 'open')
def test_controller_namespace_resolves_bucket_ticket(self):
# Regression: the inbox controller imports bucket_ticket — if the
# name disappears from utils.py, every list call would 500.
from odoo.addons.fusion_helpdesk.controllers import main
self.assertTrue(hasattr(main, 'bucket_ticket'))