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:
@@ -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
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ def restore_ticket_access() -> None:
|
||||
job_id="promote_all_contacts_to_managers",
|
||||
deduplicate=True,
|
||||
)
|
||||
disable_notice()
|
||||
|
||||
|
||||
def disable_notice() -> None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
47
helpdesk/tests/AGENTS.md
Normal 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.
|
||||
0
helpdesk/tests/__init__.py
Normal file
0
helpdesk/tests/__init__.py
Normal file
0
helpdesk/tests/api/__init__.py
Normal file
0
helpdesk/tests/api/__init__.py
Normal file
0
helpdesk/tests/api/agent_home/__init__.py
Normal file
0
helpdesk/tests/api/agent_home/__init__.py
Normal file
0
helpdesk/tests/api/settings/__init__.py
Normal file
0
helpdesk/tests/api/settings/__init__.py
Normal file
150
helpdesk/tests/api/settings/test_email.py
Normal file
150
helpdesk/tests/api/settings/test_email.py
Normal 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")])
|
||||
192
helpdesk/tests/api/settings/test_email_notifications.py
Normal file
192
helpdesk/tests/api/settings/test_email_notifications.py
Normal 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)
|
||||
124
helpdesk/tests/api/test_article.py
Normal file
124
helpdesk/tests/api/test_article.py
Normal 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])
|
||||
61
helpdesk/tests/api/test_assignment_rule.py
Normal file
61
helpdesk/tests/api/test_assignment_rule.py
Normal 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()
|
||||
129
helpdesk/tests/api/test_customer_portal_notice.py
Normal file
129
helpdesk/tests/api/test_customer_portal_notice.py
Normal 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)
|
||||
199
helpdesk/tests/api/test_dashboard.py
Normal file
199
helpdesk/tests/api/test_dashboard.py
Normal 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"]}
|
||||
123
helpdesk/tests/api/test_doc_fields.py
Normal file
123
helpdesk/tests/api/test_doc_fields.py
Normal 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]
|
||||
219
helpdesk/tests/api/test_doc_filters.py
Normal file
219
helpdesk/tests/api/test_doc_filters.py
Normal 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")
|
||||
241
helpdesk/tests/api/test_doc_list.py
Normal file
241
helpdesk/tests/api/test_doc_list.py
Normal 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}%"]]
|
||||
Reference in New Issue
Block a user