Merge pull request #3812 from RitvikSardana/test/api-coverage

test(api): cover untested API modules and fix what they found
This commit is contained in:
RitvikSardana
2026-09-30 00:02:46 +05:30
committed by GitHub
34 changed files with 1734 additions and 67 deletions

View File

@@ -216,6 +216,8 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
**Data Query Patterns**:
- Always prefer `frappe.get_list()` over `frappe.get_all()` for new code. It handles perms out of the box and is more flexible.
- `frappe.get_all()` is fine for child tables (e.g. `Has Role`, `HD Customer Member`), which have no permissions of their own.
- When `frappe.get_all()` is used on a parent DocType, make sure the caller may see the result some other way, for example a `frappe.has_permission(...)` check first, or `@agent_only` / `@agent_manager_only` (`helpdesk/utils.py`) on the endpoint.
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
- Example query patterns from helpdesk context

View File

@@ -262,6 +262,10 @@ export const emailIcon = {
SparkPost: LogoSparkpost,
Yahoo: LogoYahoo,
Yandex: LogoYandex,
// service names Frappe stores for these providers
"Outlook.com": LogoOutlook,
"Yahoo Mail": LogoYahoo,
"Yandex.Mail": LogoYandex,
"Frappe Mail": LogoFrappeMail,
Custom: "",
};

View File

@@ -67,6 +67,13 @@ export class TicketList {
await this.page.getByRole("menuitem", { name: action }).click();
}
/** Clear every filter from the Filter popover, the way an agent resets the list. */
async clearFilters() {
await this.page.getByRole("button", { name: "Filter" }).click();
await this.page.getByRole("dialog", { name: "Filter" }).getByRole("button", { name: "Clear all" }).click();
await this.page.keyboard.press("Escape");
}
async addFilter(field: string, value: string) {
await this.page.getByRole("button", { name: "Filter" }).click();
const popover = this.page.getByRole("dialog", { name: "Filter" });
@@ -85,5 +92,10 @@ export async function resetDefaultTicketView(api: Api, user: string) {
filters: { user, dt: "HD Ticket", is_default: 1 },
});
views.push(...(await api.list("HD View", { filters: { label: ["like", "E2E view %"] } })));
for (const view of views) await api.delete("HD View", view.name);
// the list may still be auto saving the default view, which locks it for a moment
for (const view of views) {
await expect(async () => {
if (await api.exists("HD View", { name: view.name })) await api.delete("HD View", view.name);
}).toPass();
}
}

View File

@@ -80,6 +80,9 @@ test("inviting adds an existing user at once and keeps a new email pending until
await input.fill(email);
await input.press("Enter");
}
// Escape closes the open suggestions without blurring, so the picker's delayed refocus can't reopen them
await input.press("Escape");
await expect(page.getByRole("listbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "Invite", exact: true }).click();
await expect(card(page, existing.name)).toBeVisible();

View File

@@ -212,7 +212,8 @@ test("clicking an assignee avatar filters the list by that agent", async ({
await expect(list.rows()).toHaveCount(2);
await expect(list.row(`${id} other`)).toHaveCount(0);
await list.goto();
// the avatar filter is auto saved to the default view, so a reload would keep it
await list.clearFilters();
await list.searchSubject(id);
await expect(list.rows()).toHaveCount(3);
await list.row(`${id} both`).locator(`.user-avatar[data-name="${agent2.email}"]`).click();

View File

@@ -1,11 +1,13 @@
import re
import frappe
from frappe import _
from textblob import TextBlob
from textblob.exceptions import MissingCorpusError
from helpdesk.search import NUM_RESULTS
from helpdesk.search import search as hd_search
from helpdesk.utils import is_agent
def get_nouns(blob: TextBlob):
@@ -28,11 +30,26 @@ def search_with_enough_results(
out = hd_search(query, qtype=qtype)
if not out:
return prev_res, len(prev_res) == NUM_RESULTS
items = prev_res + out[0].get("items", [])
items = prev_res + filter_public_articles(out[0].get("items", []))
items = list({v["id"]: v for v in items}.values())[:NUM_RESULTS] # unique results
return items, len(items) == NUM_RESULTS
def filter_public_articles(articles: list) -> list:
"""Drop hits whose article is no longer published, the index can be stale."""
names = {article["name"].split("#")[0] for article in articles}
if not names:
return []
published = frappe.get_list(
"HD Article",
filters={"name": ("in", list(names)), "status": "Published"},
pluck="name",
)
return [
article for article in articles if article["name"].split("#")[0] in published
]
def sanitize_query(query: str) -> str:
q = query.strip().lower()
q = re.sub(r"[^a-z0-9\s]", " ", q)
@@ -43,7 +60,11 @@ def sanitize_query(query: str) -> str:
@frappe.whitelist()
def get_article_stats(article_name: str):
views = frappe.db.get_value("HD Article", article_name, "views")
views, status = frappe.db.get_value(
"HD Article", article_name, ["views", "status"]
) or (None, None)
if not is_agent() and status != "Published":
frappe.throw(_("Access denied"), frappe.PermissionError)
likes = frappe.db.count(
"HD Article Feedback",

View File

@@ -11,6 +11,7 @@ def get_assignment_rules_list():
assignment_rules = frappe.get_list(
"Assignment Rule",
filters={"document_type": "HD Ticket"},
fields=["name", "description", "disabled", "priority"],
order_by="modified desc",
)

View File

@@ -28,6 +28,7 @@ def restore_ticket_access() -> None:
job_id="promote_all_contacts_to_managers",
deduplicate=True,
)
disable_notice()
def disable_notice() -> None:

View File

@@ -3,11 +3,13 @@ from frappe import _
from frappe.desk.form.assign_to import set_status
from frappe.model import no_value_fields
from frappe.model.document import get_controller
from frappe.query_builder.functions import Date
from frappe.utils.caching import redis_cache
from pypika import Criterion
from helpdesk.api.dashboard import COUNT_NAME
from helpdesk.utils import (
agent_only,
call_log_default_columns,
check_permissions,
contact_default_columns,
@@ -45,9 +47,6 @@ def get_list_data(
label_doc = view.get("label_doc") if view else None
label_field = view.get("label_field") if view else None
handle_at_me_support(filters)
handle_assigned_on_filter(filters, doctype)
_list = get_controller(doctype)
default_rows = []
if hasattr(_list, "default_list_data"):
@@ -101,6 +100,9 @@ def get_list_data(
else:
filters.append([key, "=", value])
handle_at_me_support(filters)
handle_assigned_on_filter(filters, doctype)
if rows is None:
rows = []
@@ -244,13 +246,23 @@ def get_list_data(
@frappe.whitelist()
@redis_cache()
def get_filterable_fields(
doctype: str,
show_customer_portal_fields: bool = False,
ignore_team_restrictions: bool = False,
):
check_permissions(doctype, None)
return _get_filterable_fields(
doctype, show_customer_portal_fields, ignore_team_restrictions
)
@redis_cache()
def _get_filterable_fields(
doctype: str,
show_customer_portal_fields: bool = False,
ignore_team_restrictions: bool = False,
):
QBDocField = frappe.qb.DocType("DocField")
QBCustomField = frappe.qb.DocType("Custom Field")
allowed_fieldtypes = [
@@ -549,12 +561,8 @@ def handle_at_me_support(filters):
def _replace_at_me(container, key):
value = container[key]
if isinstance(value, list):
if "@me" in value:
value[value.index("@me")] = frappe.session.user
elif "%@me%" in value:
index = [i for i, v in enumerate(value) if v == "%@me%"]
for i in index:
value[i] = "%" + frappe.session.user + "%"
for index in range(len(value)):
_replace_at_me(value, index)
elif value == "@me":
container[key] = frappe.session.user
elif value == "%@me%":
@@ -581,8 +589,8 @@ def handle_assigned_on_filter(filters, doctype):
.where(ToDo.status == "Open")
)
# Apply date filter based on operator
query = apply_datetime_filter(query, ToDo.creation, assigned_on_filter)
# "Assigned on" is a Date filter, so compare whole days
query = apply_datetime_filter(query, Date(ToDo.creation), assigned_on_filter)
ticket_names = [row[0] for row in query.run()]
# No matching tickets results in an impossible filter
@@ -676,6 +684,7 @@ def apply_datetime_filter(query, field, filter_value):
@frappe.whitelist()
@agent_only
def remove_assignments(doctype: str, name: str, assignees: list[str]):
assignees = frappe.parse_json(assignees)

View File

@@ -3,6 +3,13 @@ from typing import Any
import frappe
from frappe import _
# the UI names these providers differently from the Email Account service options
FRAPPE_SERVICE_NAMES = {
"Outlook": "Outlook.com",
"Yahoo": "Yahoo Mail",
"Yandex": "Yandex.Mail",
}
@frappe.whitelist()
def create_email_account(data: dict[str, Any]):
@@ -19,7 +26,7 @@ def create_email_account(data: dict[str, Any]):
"doctype": "Email Account",
"email_id": data.get("email_id"),
"email_account_name": data.get("email_account_name"),
"service": service,
"service": FRAPPE_SERVICE_NAMES.get(service, service),
"enable_incoming": data.get("enable_incoming"),
"enable_outgoing": data.get("enable_outgoing"),
"default_incoming": data.get("default_incoming"),
@@ -61,8 +68,9 @@ def create_email_account(data: dict[str, Any]):
"imap_folder", {"append_to": "HD Ticket", "folder_name": "INBOX"}
)
email_doc.password = data.get("password")
# validate whether the credentials are correct
email_doc.get_incoming_server()
# validate the credentials, except for outgoing-only services
if service == "Custom" or email_doc.email_server:
email_doc.get_incoming_server()
# if correct credentials, save the email account
email_doc.save()

View File

@@ -143,6 +143,10 @@ def update_share_feedback(
content: str,
):
only_for_managers()
settings = frappe.get_single("HD Settings")
settings.enable_email_ticket_feedback = enabled
settings.send_email_feedback_on_status = ticket_status["value"]
settings.validate_send_feedback_when_ticket_closed()
common_settings = set_common_settings(
"enable_email_ticket_feedback",
enabled,

View File

@@ -1,48 +0,0 @@
import json
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, now_datetime, nowdate
from helpdesk.api.dashboard import HelpdeskDashboard
from helpdesk.test_utils import create_agent, make_sla, make_ticket
AGENT = "dashboard-sla-agent@example.com"
class TestSlaFulfilledCard(IntegrationTestCase):
def test_fulfilled_percentage_stays_within_100(self):
"""A response-only policy marks a still-open ticket Fulfilled. Counting it
in the numerator while the denominator only holds resolved tickets pushes
the card past 100%."""
create_agent(AGENT)
response_only = make_sla("Response Only", "doc.subject == 'Response only'", 1)
response_only.apply_sla_for_resolution = 0
response_only.save()
open_ticket = self.make_assigned_ticket("Response only")
self.assertEqual(open_ticket.agreement_status, "Fulfilled")
self.assertNotEqual(open_ticket.status_category, "Resolved")
resolved_ticket = self.make_assigned_ticket("Resolved one", status="Closed")
self.assertEqual(resolved_ticket.agreement_status, "Fulfilled")
card = self.get_sla_card()
self.assertEqual(card["value"], 100)
def make_assigned_ticket(self, subject: str, status: str | None = None):
"""A ticket that has been responded to, scoped to this test's agent."""
ticket = make_ticket(subject=subject)
ticket.reload()
ticket.first_responded_on = now_datetime()
if status:
ticket.status = status
ticket.save()
frappe.db.set_value("HD Ticket", ticket.name, "_assign", json.dumps([AGENT]))
return ticket
def get_sla_card(self) -> dict:
filters = frappe._dict(
from_date=add_days(nowdate(), -1), to_date=nowdate(), agent=AGENT
)
return HelpdeskDashboard(filters).get_sla_fulfilled_count()

View File

@@ -1,8 +1,10 @@
from datetime import datetime
from typing import Any
import frappe
from frappe.cache_manager import clear_doctype_map
from frappe.core.doctype.communication.test_communication import create_email_account
from frappe.tests.classes.context_managers import freeze_time
from frappe.utils import add_to_date, getdate
from helpdesk.api.settings.field_dependency import create_update_field_dependency
@@ -644,3 +646,165 @@ def make_notification_log(name: str, ticket: str, user: str, **values) -> None:
}
)
doc.db_insert()
def unique_name(prefix: str) -> str:
"""`prefix` plus a random suffix, for records that must not clash across tests."""
return f"{prefix}-{frappe.generate_hash(length=8)}"
def unique_email(prefix: str) -> str:
"""A random example.com address starting with `prefix`."""
return f"{unique_name(prefix)}@example.com"
def make_agent_manager(prefix: str = "agent-manager") -> str:
"""Create an agent with a unique email and the Agent Manager role, returning the user name."""
manager = create_agent(unique_email(prefix)).name
frappe.get_doc("User", manager).add_roles("Agent Manager")
return manager
def make_article(
title: str | None = None,
status: str = "Published",
category: str | None = None,
) -> str:
"""Insert an HD Article, titled uniquely unless `title` is given, and return its name."""
return (
frappe.get_doc(
{
"doctype": "HD Article",
"title": title or unique_name("Test Article"),
"content": "<p>Open settings and click reset.</p>",
"status": status,
"category": category,
}
)
.insert(ignore_permissions=True)
.name
)
def make_article_feedback(article: str, feedback: int) -> None:
"""Record Administrator's feedback on an article (1 like, 2 dislike)."""
frappe.get_doc(
{
"doctype": "HD Article Feedback",
"user": "Administrator",
"article": article,
"feedback": feedback,
}
).insert(ignore_permissions=True)
def make_search_hit(article: str, section: str = "intro") -> frappe._dict:
"""A search index hit shaped like helpdesk.search.search items."""
name = f"{article}#{section}"
return frappe._dict(id=f"HD Article:{name}", name=name, subject=article)
def make_search_page(*hits: frappe._dict) -> list[dict]:
"""A helpdesk.search.search result holding the given article hits."""
return [{"title": "Articles", "items": list(hits)}]
def make_assignment_rule(
name: str, document_type: str, users: list[str] | None = None
) -> str:
"""A disabled rule, so it never enters the live assignment rule cache."""
rule = frappe.get_doc(
{
"doctype": "Assignment Rule",
"name": name,
"assignment_rule_name": name,
"document_type": document_type,
"description": "Test assignment rule",
"assign_condition": "status == 'Open'",
"rule": "Round Robin",
"priority": 2,
"disabled": 1,
"assignment_days": [{"day": "Monday"}],
"users": [{"user": user} for user in users or []],
}
).insert(ignore_permissions=True)
return rule.name
def make_todo(ticket: str, user: str, creation: str | None = None) -> str:
"""Open a ToDo for `user` on `ticket`, optionally backdated to `creation`."""
todo = frappe.get_doc(
{
"doctype": "ToDo",
"allocated_to": user,
"reference_type": "HD Ticket",
"reference_name": ticket,
"description": "Assigned in test",
}
).insert(ignore_permissions=True)
if creation:
frappe.db.set_value(
"ToDo", todo.name, "creation", creation, update_modified=False
)
return todo.name
def make_default_view(
user: str, columns: list | None = None, rows: list | None = None
) -> None:
"""Replace `user`'s default HD Ticket list view with one of the given columns and rows."""
delete_default_views(user)
frappe.get_doc(
{
"doctype": "HD View",
"dt": "HD Ticket",
"user": user,
"is_default": 1,
"columns": frappe.as_json(columns or []),
"rows": frappe.as_json(rows or []),
}
).insert(ignore_permissions=True)
def delete_default_views(user: str) -> None:
"""Delete `user`'s default HD Ticket list views."""
frappe.db.delete("HD View", {"user": user, "dt": "HD Ticket", "is_default": 1})
def make_tagged_ticket(team: str, created_on: str, *tags: str):
"""A ticket for `team` created at `created_on`, carrying the given tags."""
with freeze_time(created_on):
ticket = make_ticket(subject="Tag chart", agent_group=team)
for tag in tags:
ticket.add_tag(tag)
return ticket
def make_email_account_data(service: str | None, **overrides: Any) -> dict[str, Any]:
"""The payload EmailAdd.vue sends, with a unique name and address."""
suffix = frappe.generate_hash(length=6)
return {
"email_account_name": f"Support {suffix}",
"email_id": f"support-{suffix}@example.com",
"service": service,
"password": "app-password",
"enable_incoming": 1,
"enable_outgoing": 1,
"default_incoming": 0,
"default_outgoing": 0,
**overrides,
}
def is_portal_notice_shown() -> bool:
"""Whether HD Settings still flags the customer portal notice as shown."""
from helpdesk.api.customer_portal_notice import NOTICE_FLAG
return bool(frappe.db.get_single_value("HD Settings", NOTICE_FLAG))
def get_user_roles(user: str) -> list[str]:
"""A user's Has Role rows, duplicates included (unlike frappe.get_roles)."""
return frappe.get_all(
"Has Role", filters={"parent": user, "parenttype": "User"}, pluck="role"
)

47
helpdesk/tests/AGENTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Backend tests
Python tests for the Helpdesk server code, run with Frappe's test runner against a real site.
## Layout
- `helpdesk/tests/api/` mirrors `helpdesk/api/`. `helpdesk/api/contact.py` is tested in `helpdesk/tests/api/test_contact.py`, and `helpdesk/api/settings/email.py` in `helpdesk/tests/api/settings/test_email.py`.
- DocType tests stay next to their DocType (`helpdesk/helpdesk/doctype/<name>/test_<name>.py`), as Frappe expects.
- All backend test helpers (factories, toggles, fixture builders) live in `helpdesk/test_utils.py` and are imported from there, never defined inline in a test file. Check it before writing one (`make_ticket`, `create_agent`, `create_contact`, `create_customer`, `make_sla`, `make_team` and more); if it is missing, add it there with a docstring.
- Keep a folder under 15 files. Fold a small API module into the test file of its closest neighbour rather than giving it its own file.
- Every test folder needs an `__init__.py`.
## Running
Start the bench's redis first. Without it, saves fail with `Should not fail silently in tests` from the global search queue.
```bash
bench --site <site> run-tests --app helpdesk --module helpdesk.tests.api.test_contact
bench --site <site> run-tests --app helpdesk --module helpdesk.tests.api.test_contact --test test_edit_keeps_user_photo
bench --site <site> run-tests --app helpdesk # everything, as CI does
```
The site needs `allow_tests` set. `before_tests` in `test_utils.py` seeds the SLA, holiday list and email account once per run; pass `--skip-before-tests` to reruns once that is done. CI (`.github/workflows/server-tests.yml`) runs the whole app with `--coverage`.
## Writing a test
- **Base class.** Subclass `FrappeTestCase` (`frappe.tests.utils`) or `IntegrationTestCase` (`frappe.tests`). Both give `self.set_user` and `self.freeze_time`, and both roll back after the class.
- **Call the API directly.** Import the whitelisted function and call it as Python. This skips the HTTP layer, so `@frappe.whitelist(methods=...)` and `allow_guest` are not exercised; test the permission checks inside the function instead.
- **Act as a persona.** Seed as Administrator, then switch for the call under test with `with self.set_user(agent.name):`. The context manager restores the user even when the test fails, so there is no `tearDown` to write.
- **Test who may not.** For every write endpoint, assert the role that must be refused: `with self.assertRaises(frappe.PermissionError):`. An agent calling a manager-only API is the most common bug here.
- **Assert saved state.** After the call, read back from the database (`frappe.db.get_value`, `doc.reload()`), not just the return value.
- **Unique data.** The database rolls back after each test class, not after each test, so records from one test are still there in the next. Give records unique names (`frappe.generate_hash(length=6)` in an email or title) or create them in `setUp`.
- **Settings.** Change a setting with `change_settings` from `frappe.tests.utils`: `with change_settings("HD Settings", {"field": value}):`. It restores the old value on exit.
- **Time.** Freeze the clock with `with self.freeze_time("2026-01-05 10:00:00"):` for SLA, dashboard and stats tests. Never assert against `now()` taken outside the block.
- **No commits.** Never call `frappe.db.commit()` or `rollback()` in a test. It leaks data into other tests.
- **Names.** Test classes are `Test<Thing>`, test methods say what happens: `test_agent_cannot_delete_contact`, not `test_delete_2`.
- **What to cover.** Important behaviour and edge cases (empty input, missing links, a second customer, a disabled record). Skip trivial getters, and merge small checks into one test rather than one test per assertion. If personas are involved, test each persona in its own method. If a test is long, break it into helper methods with descriptive names.
- **What not to cover.** Don't test Frappe itself, or the Python standard library. Don't test the HTTP layer, or the UI. Don't test the database engine. Don't test the email server. Don't test third-party libraries. Don't test your own code that is already tested by Frappe (e.g., `frappe.get_doc`, `frappe.db.get_value`, `frappe.sendmail`).
- **Test data.** Don't hard-code a record's name or email address. Use the factories in `test_utils.py` to create them, and give them unique names. If you need a specific name, create it in `setUp` and delete it in `tearDown`.
- **Test isolation.** Each test should be independent. Don't rely on the order of tests, or on data created by other tests. Use `setUp` to create any necessary data, and `tearDown` to clean up if needed.
- **Test coverage.** Aim for high test coverage, but don't sacrifice quality for quantity. Focus on testing the most critical and complex parts of the code. DO NOT write tests just to increase coverage numbers. DO NOT weaken assertions to make tests pass. If a test fails, investigate and fix the underlying issue. DO NOT CHANGE the code under test to make a test pass without understanding why it failed. If a test fails, it indicates a potential bug or unexpected behavior that needs to be addressed.
## When a test finds a bug
- A small, clear fix ships in the same PR as its own `fix:` commit, with the test that caught it.
- A bigger one gets reported. Mark the test `@unittest.expectedFailure` with a one-line comment naming the cause, so it starts passing loudly once fixed. Never weaken the assertion to make it pass.

View File

View File

View File

View File

@@ -0,0 +1,150 @@
import imaplib
from typing import Any
from unittest.mock import MagicMock, patch
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.settings.email import create_email_account
from helpdesk.test_utils import (
create_agent,
make_agent_manager,
make_email_account_data,
unique_email,
)
EMAIL_ACCOUNT_MODULE = "frappe.email.doctype.email_account.email_account"
class TestCreateEmailAccount(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
self.email_server: MagicMock = self.enterContext(
patch(f"{EMAIL_ACCOUNT_MODULE}.EmailServer")
)
def tearDown(self) -> None:
frappe.set_user("Administrator")
def test_gmail_account_saves_provider_servers_and_ticket_folder(self) -> None:
name = create_email_account(make_email_account_data("GMail"))
account = frappe.get_doc("Email Account", name)
self.assertEqual(account.service, "GMail")
self.assertEqual(account.email_server, "imap.gmail.com")
self.assertEqual(account.smtp_server, "smtp.gmail.com")
self.assertEqual((account.use_ssl, account.use_imap), (1, 1))
self.assertEqual(account.create_contact, 1)
self.assertEqual(account.get_password(), "app-password")
self.assert_ticket_inbox_folder(account)
self.email_server.return_value.connect.assert_called_once()
def test_invalid_credentials_are_rejected_and_nothing_saved(self) -> None:
self.email_server.return_value.connect.side_effect = imaplib.IMAP4.error(
"AUTHENTICATIONFAILED"
)
# the add form sends incoming off by default, credentials are still checked
for enable_incoming in (1, 0):
data = make_email_account_data("GMail", enable_incoming=enable_incoming)
with self.assertRaises(frappe.ValidationError):
create_email_account(data)
self.assertFalse(self.account_exists(data))
def test_unsupported_or_missing_service_is_rejected(self) -> None:
for service in ("Hotmail", "", None):
data = make_email_account_data(service)
with self.assertRaises(frappe.ValidationError):
create_email_account(data)
self.assertFalse(self.account_exists(data))
def test_invalid_email_address_is_rejected(self) -> None:
data = make_email_account_data("GMail", email_id="not-an-email")
with self.assertRaises(frappe.ValidationError):
create_email_account(data)
self.assertFalse(self.account_exists(data))
def test_custom_service_saves_given_servers_and_defaults(self) -> None:
data = make_email_account_data(
"Custom",
email_server="imap.example.com",
incoming_port="993",
smtp_server="smtp.example.com",
use_ssl=1,
)
account = frappe.get_doc("Email Account", create_email_account(data))
self.assertEqual(account.service, "")
self.assertEqual(account.email_server, "imap.example.com")
self.assertEqual(account.incoming_port, "993")
self.assertEqual(account.smtp_server, "smtp.example.com")
self.assertEqual(account.smtp_port, "587")
self.assertEqual((account.use_ssl, account.use_tls), (1, 0))
self.assertEqual(account.validate_ssl_certificate, 1)
self.assert_ticket_inbox_folder(account)
def test_frappe_mail_saves_api_credentials_without_imap(self) -> None:
data = make_email_account_data(
"Frappe Mail",
api_key="mail-key",
api_secret="mail-secret",
frappe_mail_site="https://mail.example.com",
)
with patch(
f"{EMAIL_ACCOUNT_MODULE}.EmailAccount.validate_frappe_mail_settings"
) as validate_frappe_mail:
name = create_email_account(data)
account = frappe.get_doc("Email Account", name)
self.assertEqual(account.use_imap, 0)
self.assertEqual(account.append_to, "HD Ticket")
self.assertEqual(account.api_key, "mail-key")
self.assertEqual(account.get_password("api_secret"), "mail-secret")
self.assertEqual(account.frappe_mail_site, "https://mail.example.com")
validate_frappe_mail.assert_called()
self.email_server.assert_not_called()
def test_outgoing_only_sendgrid_account_is_created(self) -> None:
data = make_email_account_data("Sendgrid", enable_incoming=0)
account = frappe.get_doc("Email Account", create_email_account(data))
self.assertEqual(account.smtp_server, "smtp.sendgrid.net")
self.assertEqual(account.enable_outgoing, 1)
self.email_server.assert_not_called()
def test_outlook_yahoo_and_yandex_accounts_are_created(self) -> None:
for service in ("Outlook", "Yahoo", "Yandex"):
data = make_email_account_data(service)
create_email_account(data)
self.assertTrue(self.account_exists(data))
def test_agent_cannot_create_email_account(self) -> None:
agent = create_agent(unique_email("email-agent"))
data = make_email_account_data("GMail")
with self.set_user(agent.name), self.assertRaises(frappe.PermissionError):
create_email_account(data)
self.assertFalse(self.account_exists(data))
def test_agent_manager_can_create_outgoing_email_account(self) -> None:
data = make_email_account_data("GMail", enable_incoming=0)
with self.set_user(make_agent_manager("email-manager")):
create_email_account(data)
self.assertTrue(self.account_exists(data))
def account_exists(self, data: dict[str, Any]) -> bool:
return bool(
frappe.db.exists("Email Account", {"email_id": data["email_id"]})
or frappe.db.exists("Email Account", data["email_account_name"])
)
def assert_ticket_inbox_folder(self, account) -> None:
folders = [(row.folder_name, row.append_to) for row in account.imap_folder]
self.assertEqual(folders, [("INBOX", "HD Ticket")])

View File

@@ -0,0 +1,192 @@
from collections.abc import Callable
import frappe
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import change_settings
from helpdesk.api.settings.email_notifications import (
get_data,
update_acknowledgement,
update_reply_to_agents,
update_reply_via_agent,
update_share_feedback,
)
from helpdesk.helpdesk.doctype.hd_settings.helpers import get_default_email_content
from helpdesk.test_utils import (
create_agent,
make_agent_manager,
make_status,
unique_email,
unique_name,
)
# notification name: (update function, enabled field, content field)
SIMPLE_NOTIFICATIONS: dict[str, tuple[Callable, str, str]] = {
"acknowledgement": (
update_acknowledgement,
"send_acknowledgement_email",
"acknowledgement_email_content",
),
"reply_to_agents": (
update_reply_to_agents,
"enable_reply_email_to_agent",
"reply_email_to_agent_content",
),
"reply_via_agent": (
update_reply_via_agent,
"enable_reply_email_via_agent",
"reply_via_agent_email_content",
),
}
FEEDBACK_FIELDS = [
"enable_email_ticket_feedback",
"send_email_feedback_on_status",
"feedback_email_content",
]
CONTENT_FIELDS = [fields[2] for fields in SIMPLE_NOTIFICATIONS.values()] + [
"feedback_email_content"
]
CUSTOM_CONTENT = "<p>Custom {{ doc.name }}</p>"
class TestEmailNotifications(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
self.enterContext(
change_settings("HD Settings", self.current_notification_settings())
)
self.resolved_status = make_status(unique_name("Done"), "Resolved").name
def tearDown(self) -> None:
frappe.set_user("Administrator")
def test_update_saves_and_get_data_reads_it_back(self) -> None:
for name, (
update,
enabled_field,
content_field,
) in SIMPLE_NOTIFICATIONS.items():
result = update(enabled=True, content=CUSTOM_CONTENT)
self.assertEqual(result, {"enabled": True, "content": CUSTOM_CONTENT})
self.assertEqual(self.saved(enabled_field), 1)
self.assertEqual(self.saved(content_field), CUSTOM_CONTENT)
data = get_data(name)
self.assertTrue(data["enabled"])
self.assertEqual(data["content"], CUSTOM_CONTENT)
self.assertEqual(data["default_content"], get_default_email_content(name))
def test_disabling_a_notification_saves_zero(self) -> None:
for name, (update, enabled_field, _) in SIMPLE_NOTIFICATIONS.items():
update(enabled=True, content=CUSTOM_CONTENT)
update(enabled=False, content=CUSTOM_CONTENT)
self.assertEqual(self.saved(enabled_field), 0)
self.assertIs(get_data(name)["enabled"], False)
def test_unconfigured_content_falls_back_to_default(self) -> None:
with change_settings("HD Settings", {field: None for field in CONTENT_FIELDS}):
for name in [*SIMPLE_NOTIFICATIONS, "share_feedback"]:
self.assertEqual(
get_data(name)["content"], get_default_email_content(name)
)
def test_blank_content_falls_back_to_default(self) -> None:
update_acknowledgement(enabled=True, content=" ")
self.assertEqual(
get_data("acknowledgement")["content"],
get_default_email_content("acknowledgement"),
)
def test_invalid_template_is_rejected_and_nothing_saved(self) -> None:
update_reply_to_agents(enabled=False, content=CUSTOM_CONTENT)
with self.assertRaises(frappe.ValidationError):
update_reply_to_agents(enabled=True, content="<p>{{ doc.name </p>")
self.assertEqual(self.saved("enable_reply_email_to_agent"), 0)
self.assertEqual(self.saved("reply_email_to_agent_content"), CUSTOM_CONTENT)
def test_unknown_notification_is_rejected(self) -> None:
with self.assertRaises(frappe.ValidationError):
get_data("unknown_notification")
def test_share_feedback_saves_status_and_returns_it(self) -> None:
status = {"label": self.resolved_status, "value": self.resolved_status}
result = update_share_feedback(status, True, CUSTOM_CONTENT)
self.assertEqual(result["ticket_status"], status)
self.assertEqual(self.saved("enable_email_ticket_feedback"), 1)
self.assertEqual(
self.saved("send_email_feedback_on_status"), self.resolved_status
)
data = get_data("share_feedback")
self.assertEqual(data["ticket_status"], status)
self.assertTrue(data["enabled"])
self.assertEqual(data["content"], CUSTOM_CONTENT)
def test_share_feedback_status_defaults_to_closed_when_unset(self) -> None:
with change_settings(
"HD Settings",
{"enable_email_ticket_feedback": 0, "send_email_feedback_on_status": None},
):
data = get_data("share_feedback")
self.assertEqual(data["ticket_status"], {"label": "Closed", "value": "Closed"})
def test_share_feedback_rejects_status_outside_resolved_category(self) -> None:
open_status = make_status(unique_name("Waiting"), "Open").name
update_share_feedback(
{"label": self.resolved_status, "value": self.resolved_status},
False,
CUSTOM_CONTENT,
)
with self.assertRaises(frappe.ValidationError):
update_share_feedback(
{"label": open_status, "value": open_status}, True, CUSTOM_CONTENT
)
self.assertEqual(self.saved("enable_email_ticket_feedback"), 0)
self.assertEqual(
self.saved("send_email_feedback_on_status"), self.resolved_status
)
def test_agent_cannot_read_notification_settings(self) -> None:
with self.set_user(create_agent(unique_email("notify-agent")).name):
for name in [*SIMPLE_NOTIFICATIONS, "share_feedback"]:
with self.assertRaises(frappe.PermissionError):
get_data(name)
def test_agent_cannot_update_notification_settings(self) -> None:
before = self.current_notification_settings()
with self.set_user(create_agent(unique_email("notify-agent")).name):
for update, _, _ in SIMPLE_NOTIFICATIONS.values():
with self.assertRaises(frappe.PermissionError):
update(enabled=True, content=CUSTOM_CONTENT)
with self.assertRaises(frappe.PermissionError):
update_share_feedback(
{"label": "Closed", "value": "Closed"}, True, CUSTOM_CONTENT
)
self.assertEqual(self.current_notification_settings(), before)
def test_agent_manager_can_read_and_update_notification_settings(self) -> None:
manager = make_agent_manager("notify-manager")
with self.set_user(manager):
update_acknowledgement(enabled=True, content=CUSTOM_CONTENT)
data = get_data("acknowledgement")
self.assertEqual(self.saved("send_acknowledgement_email"), 1)
self.assertEqual(data["content"], CUSTOM_CONTENT)
def current_notification_settings(self) -> dict:
fields = [field for _, *pair in SIMPLE_NOTIFICATIONS.values() for field in pair]
return {field: self.saved(field) for field in fields + FEEDBACK_FIELDS}
def saved(self, field: str):
return frappe.db.get_single_value("HD Settings", field, cache=False)

View File

@@ -0,0 +1,124 @@
from unittest.mock import patch
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.article import get_article_stats, search
from helpdesk.search import NUM_RESULTS
from helpdesk.test_utils import (
create_agent,
create_contact,
make_agent_manager,
make_article,
make_article_feedback,
make_search_hit,
make_search_page,
unique_email,
)
class TestGetArticleStats(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
self.published = make_article()
self.draft = make_article(status="Draft")
def test_counts_views_likes_and_dislikes_of_one_article(self) -> None:
frappe.db.set_value("HD Article", self.published, "views", 7)
for feedback in (1, 1, 2, 0):
make_article_feedback(self.published, feedback)
make_article_feedback(self.draft, 1)
stats = get_article_stats(self.published)
self.assertEqual(stats, {"views": 7, "likes": 2, "dislikes": 1})
def test_administrator_reads_stats_of_draft(self) -> None:
self.assertEqual(get_article_stats(self.draft)["likes"], 0)
def test_agent_reads_stats_of_draft(self) -> None:
with self.set_user(create_agent(unique_email("article-agent")).name):
self.assertEqual(get_article_stats(self.draft)["dislikes"], 0)
def test_agent_manager_reads_stats_of_draft(self) -> None:
user = make_agent_manager("article-manager")
with self.set_user(user):
self.assertEqual(get_article_stats(self.draft)["likes"], 0)
def test_customer_reads_stats_of_published_article(self) -> None:
make_article_feedback(self.published, 1)
customer = create_contact("Article Customer", unique_email("article-customer"))
with self.set_user(customer["user"]):
self.assertEqual(get_article_stats(self.published)["likes"], 1)
def test_customer_cannot_read_stats_of_unpublished_article(self) -> None:
archived = make_article(status="Archived")
customer = create_contact("Article Customer", unique_email("article-customer"))
for article in (self.draft, archived, "missing-article"):
with self.subTest(article=article), self.set_user(customer["user"]):
with self.assertRaises(frappe.PermissionError):
get_article_stats(article)
def test_inactive_agent_cannot_read_stats_of_draft(self) -> None:
agent = create_agent(unique_email("article-agent")).name
frappe.db.set_value("HD Agent", agent, "is_active", 0)
with self.set_user(agent), self.assertRaises(frappe.PermissionError):
get_article_stats(self.draft)
@patch("helpdesk.api.article.get_nouns", return_value=[])
@patch("helpdesk.api.article.get_noun_phrases", return_value=["password reset"])
@patch("helpdesk.api.article.hd_search")
class TestSearchArticles(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
self.articles = [make_article() for _ in range(NUM_RESULTS + 1)]
def test_full_first_page_is_returned_without_fallback(self, hd_search, *_) -> None:
hits = [make_search_hit(article) for article in self.articles[:NUM_RESULTS]]
hd_search.return_value = make_search_page(*hits)
result = search(" How do I RESET my password?! ")
self.assertEqual(result, hits)
hd_search.assert_called_once_with("how do i reset my password", qtype="and")
def test_fallback_merges_unique_hits_up_to_the_limit(self, hd_search, *_) -> None:
first, second, *rest = [make_search_hit(article) for article in self.articles]
hd_search.side_effect = [
make_search_page(first, second),
make_search_page(second, *rest),
]
result = search("reset my password")
self.assertEqual(result, [first, second, *rest][:NUM_RESULTS])
hd_search.assert_called_with("password reset", qtype="and")
def test_no_match_returns_empty_list(self, hd_search, *_) -> None:
hd_search.return_value = []
self.assertEqual(search("nothing matches this"), [])
self.assertEqual(hd_search.call_count, 3)
def test_customer_never_sees_unpublished_article_from_stale_index(
self, hd_search, *_
) -> None:
published = make_search_hit(self.articles[0])
stale = [
make_search_hit(make_article(status="Draft")),
make_search_hit(make_article(status="Archived")),
make_search_hit("deleted-article"),
]
hd_search.return_value = make_search_page(published, *stale)
customer = create_contact("Article Customer", unique_email("article-customer"))
with self.set_user(customer["user"]):
result = search("reset password")
self.assertEqual(result, [published])

View File

@@ -0,0 +1,61 @@
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.assignment_rule import get_assignment_rules_list
from helpdesk.test_utils import (
create_agent,
create_contact,
make_agent_manager,
make_assignment_rule,
unique_email,
)
class TestAssignmentRulesList(IntegrationTestCase):
"""The Assignment Rules settings page lists rules through this endpoint."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
suffix = frappe.generate_hash(length=6)
cls.manager = make_agent_manager("rule-list-manager")
cls.agent = create_agent(unique_email("rule-list-agent")).name
cls.customer = create_contact("Rule Customer", unique_email("rule-list"))
cls.staffed = make_assignment_rule(
f"Staffed {suffix}", "HD Ticket", [cls.agent]
)
cls.unstaffed = make_assignment_rule(f"Unstaffed {suffix}", "HD Ticket")
cls.contact_rule = make_assignment_rule(f"Contact {suffix}", "Contact")
def test_manager_lists_ticket_rules_newest_first(self) -> None:
with self.set_user(self.manager):
rules = get_assignment_rules_list()
names = [rule.name for rule in rules]
self.assertLess(names.index(self.unstaffed), names.index(self.staffed))
self.assertEqual(
rules[names.index(self.staffed)],
{
"name": self.staffed,
"description": "Test assignment rule",
"disabled": 1,
"priority": 2,
"users_exists": True,
},
)
self.assertFalse(rules[names.index(self.unstaffed)]["users_exists"])
def test_rules_for_other_doctypes_are_not_listed(self) -> None:
with self.set_user(self.manager):
names = [rule.name for rule in get_assignment_rules_list()]
self.assertNotIn(self.contact_rule, names)
def test_agent_cannot_list_rules(self) -> None:
with self.set_user(self.agent):
with self.assertRaises(frappe.PermissionError):
get_assignment_rules_list()
def test_customer_cannot_list_rules(self) -> None:
with self.set_user(self.customer["user"]):
with self.assertRaises(frappe.PermissionError):
get_assignment_rules_list()

View File

@@ -0,0 +1,129 @@
from unittest.mock import MagicMock, patch
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.customer_portal_notice import (
NOTICE_FLAG,
dismiss_notice,
promote_all_contacts_to_managers,
restore_ticket_access,
)
from helpdesk.test_utils import (
create_agent,
create_contact,
create_customer,
get_user_roles,
is_portal_notice_shown,
make_agent_manager,
unique_email,
)
class TestDismissNotice(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
def test_administrator_dismisses_notice_and_notifies_clients(self) -> None:
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
publish_realtime = self.dismiss_as("Administrator")
self.assertFalse(is_portal_notice_shown())
publish_realtime.assert_called_once()
event = publish_realtime.call_args.args[0]
self.assertEqual(event, "helpdesk:settings-updated")
def test_agent_manager_dismisses_notice(self) -> None:
manager = make_agent_manager("notice-manager")
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
self.dismiss_as(manager)
self.assertFalse(is_portal_notice_shown())
def test_agent_cannot_dismiss_notice(self) -> None:
agent = create_agent(unique_email("notice-agent")).name
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
with self.assertRaises(frappe.PermissionError):
self.dismiss_as(agent)
self.assertTrue(is_portal_notice_shown())
def test_customer_cannot_dismiss_notice(self) -> None:
customer = create_contact("Notice Customer", unique_email("notice-customer"))
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
with self.assertRaises(frappe.PermissionError):
self.dismiss_as(customer["user"])
self.assertTrue(is_portal_notice_shown())
def dismiss_as(self, user: str) -> MagicMock:
"""Dismiss the notice as `user`, returning the realtime publisher mock."""
with patch("frappe.publish_realtime") as publish_realtime, self.set_user(user):
dismiss_notice()
return publish_realtime
class TestRestoreTicketAccess(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
def test_administrator_queues_promotion_and_dismisses_notice(self) -> None:
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
enqueue = self.restore_as("Administrator")
self.assertFalse(is_portal_notice_shown())
enqueue.assert_called_once()
self.assertIs(enqueue.call_args.args[0], promote_all_contacts_to_managers)
self.assertTrue(enqueue.call_args.kwargs["deduplicate"])
def test_agent_manager_queues_promotion(self) -> None:
self.restore_as(make_agent_manager("notice-manager")).assert_called_once()
def test_agent_cannot_restore_access(self) -> None:
agent = create_agent(unique_email("restore-agent")).name
with self.change_settings("HD Settings", {NOTICE_FLAG: 1}):
with self.assertRaises(frappe.PermissionError):
self.restore_as(agent)
self.assertTrue(is_portal_notice_shown())
def test_customer_cannot_restore_access(self) -> None:
customer = create_contact("Restore Customer", unique_email("restore-customer"))
with self.assertRaises(frappe.PermissionError):
self.restore_as(customer["user"])
def restore_as(self, user: str) -> MagicMock:
"""Restore access as `user`, returning the enqueue mock."""
with patch("frappe.enqueue") as enqueue, self.set_user(user):
restore_ticket_access()
return enqueue
class TestPromoteAllContactsToManagers(IntegrationTestCase):
def setUp(self) -> None:
frappe.set_user("Administrator")
self.member = create_contact("Plain Member", unique_email("plain-member"))
self.manager = create_contact("Manager", unique_email("already-manager"))
self.no_user = create_contact("No User", unique_email("no-user"), user=False)
self.customer = create_customer(
f"Notice Customer {frappe.generate_hash(length=6)}",
[
{"contact_name": self.member["contact"]},
{"contact_name": self.manager["contact"], "is_manager": 1},
{"contact_name": self.no_user["contact"]},
],
)
def test_every_member_becomes_manager_with_both_roles(self) -> None:
promote_all_contacts_to_managers()
self.customer.reload()
self.assertTrue(all(row.is_manager for row in self.customer.contacts))
for user in (self.member["user"], self.manager["user"]):
with self.subTest(user=user):
self.assertIn("HD Customer Manager", get_user_roles(user))
self.assertIn("HD Customer", get_user_roles(user))
def test_running_twice_does_not_duplicate_roles(self) -> None:
promote_all_contacts_to_managers()
promote_all_contacts_to_managers()
roles = get_user_roles(self.member["user"])
self.assertEqual(roles.count("HD Customer Manager"), 1)
self.assertEqual(roles.count("HD Customer"), 1)

View File

@@ -0,0 +1,199 @@
import json
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, getdate, now_datetime, nowdate
from helpdesk.api.dashboard import HelpdeskDashboard, get_dashboard_data
from helpdesk.test_utils import (
create_agent,
create_contact,
make_agent_manager,
make_sla,
make_tagged_ticket,
make_team,
make_ticket,
unique_email,
)
AGENT = "dashboard-sla-agent@example.com"
class TestSlaFulfilledCard(IntegrationTestCase):
def test_fulfilled_percentage_stays_within_100(self):
"""A response-only policy marks a still-open ticket Fulfilled. Counting it
in the numerator while the denominator only holds resolved tickets pushes
the card past 100%."""
create_agent(AGENT)
response_only = make_sla("Response Only", "doc.subject == 'Response only'", 1)
response_only.apply_sla_for_resolution = 0
response_only.save()
open_ticket = self.make_assigned_ticket("Response only")
self.assertEqual(open_ticket.agreement_status, "Fulfilled")
self.assertNotEqual(open_ticket.status_category, "Resolved")
resolved_ticket = self.make_assigned_ticket("Resolved one", status="Closed")
self.assertEqual(resolved_ticket.agreement_status, "Fulfilled")
card = self.get_sla_card()
self.assertEqual(card["value"], 100)
def make_assigned_ticket(self, subject: str, status: str | None = None):
"""A ticket that has been responded to, scoped to this test's agent."""
ticket = make_ticket(subject=subject)
ticket.reload()
ticket.first_responded_on = now_datetime()
if status:
ticket.status = status
ticket.save()
frappe.db.set_value("HD Ticket", ticket.name, "_assign", json.dumps([AGENT]))
return ticket
def get_sla_card(self) -> dict:
filters = frappe._dict(
from_date=add_days(nowdate(), -1), to_date=nowdate(), agent=AGENT
)
return HelpdeskDashboard(filters).get_sla_fulfilled_count()
class TestTagDashboard(IntegrationTestCase):
"""Tag charts reached through `get_dashboard_data("tags", ...)`."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.manager = make_agent_manager("tag-dash-manager")
cls.agent = create_agent(unique_email("tag-dash-agent")).name
cls.member = create_agent(unique_email("tag-dash-member")).name
cls.customer = create_contact("Tag Customer", unique_email("tag-dash"))
# so no ticket below is Administrator's first, which would get a tag
make_ticket()
def setUp(self) -> None:
self.suffix = frappe.generate_hash(length=6)
self.team = make_team(f"Tag Team {self.suffix}", [self.member]).name
def test_top_tags_count_each_tag_of_a_ticket(self) -> None:
billing, refund = self.tag("billing"), self.tag("refund")
make_tagged_ticket(self.team, "2031-03-10 10:00:00", billing, refund)
make_tagged_ticket(self.team, "2031-03-11 10:00:00", billing)
make_tagged_ticket(self.team, "2031-03-11 11:00:00")
top_tags = self.get_charts("2031-03-10", "2031-03-11")["top_tags"]["data"]
# ascending, so the chart draws the biggest bar on top
self.assertEqual(
top_tags, [{"tag": refund, "Tickets": 1}, {"tag": billing, "Tickets": 2}]
)
def test_date_range_includes_whole_first_and_last_day(self) -> None:
billing = self.tag("billing")
make_tagged_ticket(self.team, "2031-04-09 23:59:59", billing)
make_tagged_ticket(self.team, "2031-04-10 00:00:00", billing)
make_tagged_ticket(self.team, "2031-04-12 23:59:59", billing)
make_tagged_ticket(self.team, "2031-04-13 00:00:00", billing)
charts = self.get_charts("2031-04-10", "2031-04-12")
self.assertEqual(self.top_tag_counts(charts), {billing: 2})
def test_tag_trend_zero_fills_each_day(self) -> None:
billing, refund = self.tag("billing"), self.tag("refund")
make_tagged_ticket(self.team, "2031-05-01 09:00:00", billing)
make_tagged_ticket(self.team, "2031-05-01 17:00:00", billing)
make_tagged_ticket(self.team, "2031-05-03 09:00:00", refund)
trend = self.get_charts("2031-05-01", "2031-05-03")["tag_trend"]
self.assertEqual(
trend["data"],
[
{"date": getdate("2031-05-01"), billing: 2, refund: 0},
{"date": getdate("2031-05-03"), billing: 0, refund: 1},
],
)
self.assertEqual(
[series["name"] for series in trend["series"]], [billing, refund]
)
def test_charts_keep_only_the_busiest_tags(self) -> None:
# tag i sits on i + 1 tickets, so the ranking has no ties
tags = [self.tag(f"t{index}") for index in range(11)]
for index in range(11):
make_tagged_ticket(self.team, "2031-06-01 10:00:00", *tags[index:])
charts = self.get_charts("2031-06-01", "2031-06-01")
top_tags = [row["tag"] for row in charts["top_tags"]["data"]]
trend_tags = [series["name"] for series in charts["tag_trend"]["series"]]
self.assertEqual(top_tags, tags[1:])
self.assertEqual(trend_tags, tags[:5:-1])
def test_untagged_tickets_give_empty_charts(self) -> None:
make_tagged_ticket(self.team, "2031-07-01 10:00:00")
charts = self.get_charts("2031-07-01", "2031-07-01")
self.assertEqual(charts["top_tags"]["data"], [])
self.assertEqual(charts["tag_trend"]["data"], [])
self.assertEqual(charts["tag_trend"]["series"], [])
def test_other_teams_tickets_are_not_counted(self) -> None:
billing = self.tag("billing")
other_team = make_team(f"Other Tag Team {self.suffix}", [self.member]).name
make_tagged_ticket(self.team, "2031-08-01 10:00:00", billing)
make_tagged_ticket(other_team, "2031-08-01 10:00:00", billing)
charts = self.get_charts("2031-08-01", "2031-08-01")
self.assertEqual(self.top_tag_counts(charts), {billing: 1})
def test_deleted_ticket_and_removed_tag_are_not_counted(self) -> None:
billing = self.tag("billing")
make_tagged_ticket(self.team, "2031-09-01 10:00:00", billing)
deleted = make_tagged_ticket(self.team, "2031-09-01 10:00:00", billing)
untagged = make_tagged_ticket(self.team, "2031-09-01 10:00:00", billing)
frappe.delete_doc("HD Ticket", deleted.name, force=True)
untagged.remove_tag(billing)
charts = self.get_charts("2031-09-01", "2031-09-01")
self.assertEqual(self.top_tag_counts(charts), {billing: 1})
def test_agent_sees_tags_of_own_tickets_only(self) -> None:
billing = self.tag("billing")
mine = make_tagged_ticket(self.team, "2031-10-01 10:00:00", billing)
make_tagged_ticket(self.team, "2031-10-01 10:00:00", billing)
frappe.db.set_value("HD Ticket", mine.name, "_assign", json.dumps([self.agent]))
filters = {
"from_date": "2031-10-01",
"to_date": "2031-10-01",
"agent": self.agent,
}
with self.set_user(self.agent):
charts = self.by_key(get_dashboard_data("tags", filters))
self.assertEqual(self.top_tag_counts(charts), {billing: 1})
def test_agent_cannot_view_team_or_org_tag_charts(self) -> None:
with self.set_user(self.agent):
with self.assertRaises(frappe.PermissionError):
get_dashboard_data("tags", {"team": self.team, "agent": self.agent})
with self.assertRaises(frappe.PermissionError):
get_dashboard_data("tags", {})
def test_customer_cannot_view_tag_charts(self) -> None:
with self.set_user(self.customer["user"]):
with self.assertRaises(frappe.PermissionError):
get_dashboard_data("tags", {"agent": self.customer["user"]})
def tag(self, label: str) -> str:
return f"{label}-{self.suffix}"
def get_charts(self, from_date: str, to_date: str) -> dict[str, dict]:
"""Tag charts for this test's team, as the manager sees them."""
filters = {"from_date": from_date, "to_date": to_date, "team": self.team}
with self.set_user(self.manager):
return self.by_key(get_dashboard_data("tags", filters))
@staticmethod
def by_key(charts: list[dict]) -> dict[str, dict]:
return {chart["key"]: chart for chart in charts}
@staticmethod
def top_tag_counts(charts: dict[str, dict]) -> dict[str, int]:
return {row["tag"]: row["Tickets"] for row in charts["top_tags"]["data"]}

View File

@@ -0,0 +1,123 @@
import frappe
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import change_settings
from helpdesk.api.doc import (
_get_filterable_fields,
get_filterable_fields,
get_quick_filters,
sort_options,
)
from helpdesk.test_utils import create_agent, create_contact, create_user, unique_email
AGENT_ONLY_FIELDS = {"_assign", "_user_tags", "agent_group", "ticket_type", "contact"}
class FieldPersonas(IntegrationTestCase):
"""An agent and a customer, created once per test class."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.agent = create_agent(unique_email("doc-fields-agent")).name
cls.customer = create_contact("DocFields", unique_email("doc-fields"))["user"]
class TestFilterableFields(FieldPersonas):
"""The filter picker lists what `get_filterable_fields` returns."""
def setUp(self) -> None:
_get_filterable_fields.clear_cache()
def test_agent_gets_ticket_and_pseudo_fields(self) -> None:
with self.set_user(self.agent):
fields = self.fieldnames(get_filterable_fields("HD Ticket"))
for field in ("subject", "agent_group", "_assign", "_user_tags", "owner"):
self.assertIn(field, fields)
self.assertIn("__assigned_on", fields)
self.assertNotIn("service_level_agreement_creation", fields)
self.assertEqual(len(fields), len(set(fields)))
def test_customer_gets_only_portal_fields(self) -> None:
with self.set_user(self.customer):
fields = self.fieldnames(get_filterable_fields("HD Ticket", True))
self.assertIn("subject", fields)
self.assertIn("status", fields)
self.assertFalse(AGENT_ONLY_FIELDS & set(fields))
def test_team_restriction_hides_team_unless_ignored(self) -> None:
with change_settings("HD Settings", {"restrict_tickets_by_agent_group": 1}):
with self.set_user(self.agent):
restricted = self.fieldnames(get_filterable_fields("HD Ticket"))
unrestricted = self.fieldnames(
get_filterable_fields("HD Ticket", ignore_team_restrictions=True)
)
self.assertNotIn("agent_group", restricted)
self.assertIn("agent_group", unrestricted)
def test_user_without_read_access_is_refused_even_when_cached(self) -> None:
get_filterable_fields("HD Agent")
outsider = create_user(unique_email("doc-fields-outsider")).name
with self.set_user(outsider):
with self.assertRaises(frappe.PermissionError):
get_filterable_fields("HD Agent")
def fieldnames(self, fields: list[dict]) -> list[str]:
return [field["fieldname"] for field in fields]
class TestSortOptions(FieldPersonas):
def test_agent_can_sort_by_any_labelled_field(self) -> None:
with self.set_user(self.agent):
values = self.values(sort_options("HD Ticket"))
for field in ("subject", "agent_group", "priority", "modified", "owner"):
self.assertIn(field, values)
def test_customer_sorts_only_by_portal_fields(self) -> None:
with self.set_user(self.customer):
values = self.values(sort_options("HD Ticket", True))
self.assertIn("subject", values)
self.assertIn("modified", values)
self.assertFalse(AGENT_ONLY_FIELDS & set(values))
def values(self, options: list[dict]) -> list[str]:
return [option["value"] for option in options]
class TestQuickFilters(FieldPersonas):
def test_agent_ticket_quick_filters(self) -> None:
with self.set_user(self.agent):
names = self.names(get_quick_filters("HD Ticket"))
self.assertEqual(names[:1], ["name"])
self.assertIn("subject", names)
self.assertIn("customer", names)
def test_customer_ticket_quick_filters_drop_customer(self) -> None:
with self.set_user(self.customer):
names = self.names(get_quick_filters("HD Ticket", True))
self.assertIn("subject", names)
self.assertNotIn("customer", names)
def test_select_filter_starts_with_an_empty_option(self) -> None:
with self.set_user(self.agent):
filters = get_quick_filters("HD Article")
status = next(f for f in filters if f["name"] == "status")
self.assertEqual(status["options"][0], {"label": "", "value": ""})
self.assertIn({"label": "Draft", "value": "Draft"}, status["options"])
self.assertNotIn("name", self.names(filters))
def test_contact_offers_only_the_id_filter(self) -> None:
with self.set_user(self.agent):
self.assertEqual(self.names(get_quick_filters("Contact")), ["name"])
def names(self, filters: list[dict]) -> list[str]:
return [f["name"] for f in filters]

View File

@@ -0,0 +1,219 @@
import frappe
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import change_settings
from helpdesk.api.doc import (
get_list_data,
handle_assigned_on_filter,
handle_at_me_support,
remove_assignments,
)
from helpdesk.test_utils import (
create_agent,
create_contact,
make_team,
make_ticket,
make_todo,
unique_email,
)
ASSIGNED_AT = {
"day_before": "2026-01-04 23:59:59",
"midnight": "2026-01-05 00:00:00",
"evening": "2026-01-05 18:30:00",
"day_after": "2026-01-06 09:00:00",
}
class TestAtMeSupport(IntegrationTestCase):
"""`@me` in a saved or typed filter means whoever is looking at the list."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.agent = create_agent(unique_email("at-me")).name
def test_list_conditions_replace_every_at_me(self) -> None:
filters = [
["owner", "=", "@me"],
["_assign", "like", "%@me%"],
["HD Ticket", "raised_by", "=", "@me"],
["owner", "in", ["x@example.com", "@me", "@me"]],
["status", "=", "Open"],
[],
]
with self.set_user(self.agent):
handle_at_me_support(filters)
me = self.agent
self.assertEqual(
filters,
[
["owner", "=", me],
["_assign", "like", f"%{me}%"],
["HD Ticket", "raised_by", "=", me],
["owner", "in", ["x@example.com", me, me]],
["status", "=", "Open"],
[],
],
)
def test_dict_filters_replace_nested_at_me(self) -> None:
filters = {
"owner": "@me",
"_assign": ["like", "%@me%"],
"raised_by": ["in", ["@me", "x@example.com"]],
}
with self.set_user(self.agent):
handle_at_me_support(filters)
me = self.agent
self.assertEqual(filters["owner"], me)
self.assertEqual(filters["_assign"], ["like", f"%{me}%"])
self.assertEqual(filters["raised_by"], ["in", [me, "x@example.com"]])
class TestAssignedOnFilter(IntegrationTestCase):
"""The "Assigned on" Date filter matches the session user's open ToDos."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.agent = create_agent(unique_email("assigned-on")).name
cls.tickets = {}
for label, at in ASSIGNED_AT.items():
cls.tickets[label] = make_ticket(subject=f"Assigned on {label}").name
make_todo(cls.tickets[label], cls.agent, at)
def test_each_operator_treats_the_value_as_a_whole_day(self) -> None:
cases = [
("=", "2026-01-05", {"midnight", "evening"}),
("!=", "2026-01-05", {"day_before", "day_after"}),
(">", "2026-01-05", {"day_after"}),
("<", "2026-01-05", {"day_before"}),
(">=", "2026-01-05", {"midnight", "evening", "day_after"}),
("<=", "2026-01-05", {"day_before", "midnight", "evening"}),
("between", ["2026-01-04", "2026-01-05"], set(ASSIGNED_AT) - {"day_after"}),
("is", "set", set(ASSIGNED_AT)),
("is", "not set", set()),
]
for operator, value, expected in cases:
with self.subTest(operator=operator, value=value):
self.assertEqual(self.matches(operator, value), expected)
def test_timespan_covers_the_full_system_day(self) -> None:
with self.freeze_time("2026-01-05 23:59:00"):
self.assertEqual(self.matches("timespan", "today"), {"midnight", "evening"})
self.assertEqual(self.matches("timespan", "yesterday"), {"day_before"})
def test_dict_filters_default_to_equals(self) -> None:
filters = {"__assigned_on": "2026-01-05"}
with self.set_user(self.agent):
handle_assigned_on_filter(filters, "HD Ticket")
self.assertNotIn("__assigned_on", filters)
self.assertEqual(self.labels(filters["name"][1]), {"midnight", "evening"})
def test_only_own_open_assignments_count(self) -> None:
other_agent = create_agent(unique_email("assigned-on-other")).name
ticket = make_ticket(subject="Assigned to someone else").name
make_todo(ticket, other_agent, ASSIGNED_AT["evening"])
cancelled = make_todo(ticket, self.agent, ASSIGNED_AT["evening"])
frappe.db.set_value("ToDo", cancelled, "status", "Cancelled")
self.assertNotIn(ticket, self.matching_names("=", "2026-01-05"))
def test_no_match_lists_no_tickets(self) -> None:
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket", filters=[["__assigned_on", "=", "2020-01-01"]]
)
self.assertEqual(result["data"], [])
self.assertEqual(result["total_count"], 0)
def test_intersects_an_existing_name_in_filter(self) -> None:
keep = [self.tickets["midnight"], self.tickets["day_before"]]
list_filters = [
["name", "in", list(keep)],
["__assigned_on", "=", "2026-01-05"],
]
dict_filters = {
"name": ["in", list(keep)],
"__assigned_on": ["=", "2026-01-05"],
}
with self.set_user(self.agent):
handle_assigned_on_filter(list_filters, "HD Ticket")
handle_assigned_on_filter(dict_filters, "HD Ticket")
self.assertEqual(list_filters, [["name", "in", [self.tickets["midnight"]]]])
self.assertEqual(dict_filters["name"], ["in", [self.tickets["midnight"]]])
def matches(self, operator: str, value: str | list) -> set[str]:
return self.labels(self.matching_names(operator, value))
def matching_names(self, operator: str, value: str | list) -> list[str]:
filters = [["__assigned_on", operator, value]]
with self.set_user(self.agent):
handle_assigned_on_filter(filters, "HD Ticket")
return filters[0][2]
def labels(self, names: list[str]) -> set[str]:
by_name = {name: label for label, name in self.tickets.items()}
return {by_name[name] for name in names if name in by_name}
class TestRemoveAssignments(IntegrationTestCase):
"""The assignee popover sends removed agents to `remove_assignments`."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.agent = create_agent(unique_email("unassigning")).name
cls.assignee = create_agent(unique_email("assignee")).name
cls.customer = create_contact("Unassign", unique_email("unassign-customer"))
def setUp(self) -> None:
self.ticket = make_ticket(raised_by=self.customer["user"]).name
self.todo = make_todo(self.ticket, self.assignee)
def test_agent_removes_assignment(self) -> None:
with self.set_user(self.agent):
remove_assignments("HD Ticket", self.ticket, [self.assignee])
self.assertEqual(frappe.db.get_value("ToDo", self.todo, "status"), "Cancelled")
assigned = frappe.db.get_value("HD Ticket", self.ticket, "_assign")
self.assertNotIn(self.assignee, frappe.parse_json(assigned or "[]"))
def test_admin_removes_assignment(self) -> None:
remove_assignments("HD Ticket", self.ticket, [self.assignee])
self.assertEqual(frappe.db.get_value("ToDo", self.todo, "status"), "Cancelled")
def test_empty_assignees_change_nothing(self) -> None:
with self.set_user(self.agent):
self.assertIsNone(remove_assignments("HD Ticket", self.ticket, []))
self.assertEqual(frappe.db.get_value("ToDo", self.todo, "status"), "Open")
def test_customer_cannot_remove_assignments(self) -> None:
with self.set_user(self.customer["user"]):
with self.assertRaises(frappe.PermissionError):
remove_assignments("HD Ticket", self.ticket, [self.assignee])
self.assertEqual(frappe.db.get_value("ToDo", self.todo, "status"), "Open")
def test_agent_outside_the_team_cannot_remove_assignments(self) -> None:
team = make_team(f"Unassign {frappe.generate_hash(length=6)}", [self.assignee])
frappe.db.set_value("HD Ticket", self.ticket, "agent_group", team.name)
with change_settings("HD Settings", {"restrict_tickets_by_agent_group": 1}):
with self.set_user(self.agent):
with self.assertRaises(frappe.PermissionError):
remove_assignments("HD Ticket", self.ticket, [self.assignee])
self.assertEqual(frappe.db.get_value("ToDo", self.todo, "status"), "Open")

View File

@@ -0,0 +1,241 @@
import frappe
from frappe.tests import IntegrationTestCase
from helpdesk.api.doc import get_list_data
from helpdesk.helpdesk.doctype.hd_ticket.hd_ticket import HDTicket
from helpdesk.test_utils import (
create_agent,
create_contact,
delete_default_views,
make_article,
make_default_view,
make_ticket,
unique_email,
)
PORTAL_FIELDS = {
"name",
"subject",
"status",
"priority",
"response_by",
"resolution_by",
"creation",
}
class TestGetListData(IntegrationTestCase):
"""`get_list_data` feeds every desk and portal list through ListViewBuilder."""
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.token = frappe.generate_hash(length=8)
cls.agent = create_agent(unique_email("doc-list-agent")).name
cls.customer = create_contact("DocList", unique_email("doc-list"))
other = create_contact("DocListOther", unique_email("doc-other"))
cls.tickets = {
suffix: make_ticket(subject=f"{cls.token} {suffix}").name
for suffix in ("a", "b", "c")
}
cls.own_ticket = make_ticket(
subject=f"{cls.token} own", raised_by=cls.customer["user"]
).name
cls.other_ticket = make_ticket(
subject=f"{cls.token} other", raised_by=other["user"]
).name
def test_filters_sort_and_pagination(self) -> None:
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket",
filters=[["name", "in", list(self.tickets.values())]],
order_by="subject asc",
page_length=2,
)
names = [row.name for row in result["data"]]
self.assertEqual(names, [self.tickets["a"], self.tickets["b"]])
self.assertEqual(result["row_count"], 2)
self.assertEqual(result["total_count"], 3)
def test_empty_filters_list_everything(self) -> None:
for empty in ({}, []):
with self.subTest(filters=empty), self.set_user(self.agent):
result = get_list_data("HD Ticket", filters=empty, page_length=1)
self.assertEqual(result["row_count"], 1)
self.assertGreaterEqual(result["total_count"], 5)
def test_at_me_filter_resolves_to_session_user(self) -> None:
filters = [["owner", "=", "@me"], *self.subject_filter(self.token)]
result = get_list_data("HD Ticket", filters=filters)
self.assertEqual(result["total_count"], 5)
def test_custom_columns_are_fetched_as_rows(self) -> None:
columns = [{"label": "Priority", "type": "Link", "key": "priority"}]
view = {"view_type": "list", "group_by_field": "agent_group"}
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket",
filters=self.subject_filter(self.token),
columns=columns,
rows=["subject"],
view=view,
)
self.assertEqual(result["columns"], columns)
row = result["data"][0]
for field in ("name", "subject", "priority", "agent_group", "_seen", "sla"):
self.assertIn(field, row)
def test_agent_default_columns_without_saved_view(self) -> None:
delete_default_views(self.agent)
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket", filters=self.subject_filter(self.token), is_default=True
)
self.assertEqual(result["columns"], HDTicket.default_list_data()["columns"])
self.assertIn("_assign", result["data"][0])
self.assertIn("agent_group", {f["value"] for f in result["fields"]})
def test_customer_gets_portal_columns_and_fields(self) -> None:
delete_default_views(self.customer["user"])
with self.set_user(self.customer["user"]):
result = get_list_data(
"HD Ticket", is_default=True, show_customer_portal_fields=True
)
portal_columns = HDTicket.default_list_data(True)["columns"]
self.assertEqual(result["columns"], portal_columns)
self.assertLessEqual({f["value"] for f in result["fields"]}, PORTAL_FIELDS)
def test_customer_sees_only_own_tickets(self) -> None:
with self.set_user(self.customer["user"]):
result = get_list_data(
"HD Ticket",
filters=self.subject_filter(self.token),
show_customer_portal_fields=True,
)
self.assertEqual([row.name for row in result["data"]], [self.own_ticket])
self.assertEqual(result["total_count"], 1)
def test_default_view_supplies_columns_and_rows(self) -> None:
columns = [{"label": "Subject", "type": "Data", "key": "subject"}]
make_default_view(self.agent, columns=columns, rows=["ticket_type"])
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket", filters=self.subject_filter(self.token), is_default=True
)
self.assertEqual(result["columns"], columns)
self.assertIn("ticket_type", result["data"][0])
def test_default_view_without_columns_falls_back_to_defaults(self) -> None:
make_default_view(self.agent, columns=[], rows=[])
with self.set_user(self.agent):
result = get_list_data(
"HD Ticket", filters=self.subject_filter(self.token), is_default=True
)
self.assertEqual(result["columns"], HDTicket.default_list_data()["columns"])
self.assertIn("resolution_date", result["rows"])
def test_default_filters_apply_only_without_filters(self) -> None:
make_default_view(self.agent)
default_filters = {"subject": ["like", f"{self.token} a"]}
with self.set_user(self.agent):
unfiltered = self.list_default(default_filters, filters=[])
filtered = self.list_default(
default_filters, self.subject_filter(f"{self.token} b")
)
self.assertEqual([row.name for row in unfiltered["data"]], [self.tickets["a"]])
self.assertEqual([row.name for row in filtered["data"]], [self.tickets["b"]])
def test_default_filters_resolve_at_me(self) -> None:
make_default_view(self.customer["user"])
with self.set_user(self.customer["user"]):
result = self.list_default({"raised_by": "@me"}, filters=[])
self.assertEqual([row.name for row in result["data"]], [self.own_ticket])
def list_default(self, default_filters: dict, filters: list) -> dict:
return get_list_data(
"HD Ticket",
filters=filters,
default_filters=default_filters,
is_default=True,
)
def subject_filter(self, text: str) -> list:
return [["subject", "like", f"%{text}%"]]
class TestGroupByView(IntegrationTestCase):
"""The knowledge base groups articles by category through a group_by view."""
VIEW = {
"view_type": "group_by",
"group_by_field": "category",
"label_doc": "HD Article Category",
"label_field": "category_name",
}
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.token = frappe.generate_hash(length=8)
categories = [
frappe.get_doc({"doctype": "HD Article Category", "category_name": label})
.insert(ignore_permissions=True)
.name
for label in ("Zeta", "Alpha")
]
general = {"category_name": "General"}
categories += [frappe.db.get_value("HD Article Category", general), None]
for category in categories:
make_article(f"{cls.token} {category}", "Draft", category)
def test_categories_sorted_by_label_with_general_first(self) -> None:
options = self.group_options(order_by="modified desc")
self.assertEqual(
[o["label"] for o in options], ["General", "", "Alpha", "Zeta"]
)
def test_descending_sort_on_group_field_keeps_general_first(self) -> None:
options = self.group_options(order_by="category desc")
self.assertEqual(
[o["label"] for o in options], ["General", "Zeta", "Alpha", ""]
)
def test_select_field_groups_by_its_options(self) -> None:
view = {"view_type": "group_by", "group_by_field": "status"}
result = get_list_data("HD Article", filters=self.title_filter(), view=view)
options = result["group_by_field"]["options"]
self.assertEqual(options, ["Published", "Draft", "Archived"])
def group_options(self, order_by: str) -> list[dict]:
result = get_list_data(
"HD Article", filters=self.title_filter(), order_by=order_by, view=self.VIEW
)
self.assertEqual(result["view_type"], "group_by")
self.assertEqual(result["group_by_field"]["name"], "category")
return result["group_by_field"]["options"]
def title_filter(self) -> list:
return [["title", "like", f"{self.token}%"]]