1import {
2 expect,
3 skipGettingStarted,
4 test,
5 uid,
6 usePersona,
7} from "../../helpers/fixtures";
8import {
9 createThrowawayAgent,
10 openSettings,
11 snapshotSettings,
12} from "../../helpers/settings";
13import { PASSWORD, personas } from "../../helpers/personas";
14import type { Locator, Page } from "@playwright/test";
15import type { Api } from "../../helpers/api";
16
17const SLA = "OD Service Level Agreement";
18const HOLIDAYS = "OD Service Holiday List";
19
20usePersona("admin");
21
22test("an SLA policy is created with targets and working hours, then made default", async ({ page, api }) => {
23 const name = `E2E SLA ${uid()}`;
24 const previousDefault = await api.exists(SLA, { default_sla: 1 });
25 try {
26 await page.goto("/opendesk/tickets");
27 const dialog = await openSettings(page, "SLA Policies");
28 await dialog.getByRole("button", { name: "New" }).click();
29 await dialog.getByRole("textbox", { name: /^Name/ }).fill(name);
30 await dialog.getByRole("button", { name: "Save", exact: true }).click();
31
32 await expect.poll(() => api.exists(SLA, { name })).toBeTruthy();
33 const sla = await api.get(SLA, name);
34 // Not a fixed list: other runs may have added priorities.
35 const priorities = await api.list("OD Ticket Priority", { fields: ["name"], limit: 0 });
36 expect(sla.priorities.map((row) => row.priority).sort()).toEqual(priorities.map((row) => row.name).sort());
37 expect(sla.support_and_resolution).toHaveLength(5);
38
39 await dialog.getByRole("checkbox", { name: "Set as default SLA" }).check();
40 await dialog.getByRole("button", { name: "Save", exact: true }).click();
41 await expect.poll(async () => (await api.get(SLA, name)).default_sla).toBe(1);
42 if (previousDefault) expect((await api.get(SLA, previousDefault)).default_sla).toBe(0);
43 } finally {
44 if (previousDefault) await api.update(SLA, previousDefault, { default_sla: 1 });
45 if (await api.exists(SLA, { name })) await api.delete(SLA, name);
46 }
47});
48
49test("a business holiday list is created with a holiday", async ({ page, api }) => {
50 const name = `E2E Holidays ${uid()}`;
51 try {
52 await page.goto("/opendesk/tickets");
53 const dialog = await openSettings(page, "Business Holidays");
54 await dialog.getByRole("button", { name: "New" }).click();
55 await dialog.getByRole("textbox", { name: /^Name/ }).fill(name);
56 await pickDate(dialog.getByRole("combobox", { name: "From date" }), "2027-01-01");
57 await pickDate(dialog.getByRole("combobox", { name: "To date" }), "2027-12-31");
58
59 await dialog.getByRole("button", { name: "Add Holiday" }).click();
60 const modal = page.getByRole("dialog", { name: "Add Holiday" });
61 await pickDate(modal.getByRole("combobox"), "2027-01-26");
62 await modal.getByPlaceholder("National holiday, etc.").fill("Republic Day");
63 await modal.getByRole("button", { name: "Add Holiday" }).click();
64 await dialog.getByRole("button", { name: "Save", exact: true }).click();
65
66 await expect.poll(() => api.exists(HOLIDAYS, { name })).toBeTruthy();
67 const list = await api.get(HOLIDAYS, name);
68 expect([list.from_date, list.to_date]).toEqual(["2027-01-01", "2027-12-31"]);
69 expect(list.holidays.map((row) => row.holiday_date)).toContain("2027-01-26");
70 } finally {
71 if (await api.exists(HOLIDAYS, { name })) await api.delete(HOLIDAYS, name);
72 }
73});
74
75test("an assignment rule for a team auto-assigns and notifies", async ({ page, api }) => {
76 const team = `E2E Rule Team ${uid()}`;
77 const rule = `E2E Rule ${uid()}`;
78 await api.insert("OD Team", { team_name: team, users: [{ user: personas.agent.email }] });
79 let ticket: { name: string } | undefined;
80 try {
81 await page.goto("/opendesk/tickets");
82 const dialog = await openSettings(page, "Assignment Rules");
83 await dialog.getByRole("button", { name: "New" }).click();
84 await dialog.getByRole("textbox", { name: /^Name/ }).fill(rule);
85 await dialog.getByRole("textbox", { name: /^Description/ }).fill("Route E2E team tickets");
86 await dialog.getByText("Add a condition").first().click();
87 await dialog.getByRole("button", { name: "Field", exact: true }).click();
88 await page.getByRole("option", { name: "Team", exact: true }).click();
89 await dialog.getByRole("combobox", { name: "condition" }).fill(team);
90 await page.getByRole("option", { name: team }).click();
91 await dialog.getByRole("button", { name: "Add Assignee" }).click();
92 await page.getByPlaceholder("Search").last().fill("Bela");
93 await page.getByRole("option", { name: /Bela E2E/ }).click();
94 await expect(dialog.first().getByRole("button", { name: "Bela E2E" })).toBeVisible();
95 await dialog.first().getByRole("button", { name: "Save", exact: true }).click();
96 await expect.poll(() => api.exists("Assignment Rule", { name: rule })).toBeTruthy();
97
98 ticket = await api.insert("OD Ticket", { subject: `Routed ${uid()}`, description: "x", agent_group: team });
99 await expect
100 .poll(async () => (await assignees(api, ticket!.name)) || "")
101 .toContain(personas.agent2.email);
102 await expect
103 .poll(() => api.exists("Notification Log", { for_user: personas.agent2.email, document_name: ticket!.name }))
104 .toBeTruthy();
105 } finally {
106 if (await api.exists("Assignment Rule", { name: rule })) await api.delete("Assignment Rule", rule);
107 if (ticket) await api.delete("OD Ticket", ticket.name);
108 await api.delete("OD Team", team);
109 }
110});
111
112test("a field dependency narrows the child field on the new ticket form", async ({ page, api }) => {
113 const template = `E2E FD ${uid()}`;
114 const script = "Field Dependency-priority-ticket_type";
115 await api.insert("OD Ticket Template", {
116 template_name: template,
117 fields: [{ fieldname: "priority" }, { fieldname: "ticket_type" }],
118 });
119 try {
120 await page.goto("/opendesk/tickets");
121 const dialog = await openSettings(page, "Field Dependencies");
122 await dialog.getByRole("button", { name: "New" }).click();
123 await pickOption(page, dialog.getByRole("combobox", { name: "Select option" }).first(), "Priority");
124 await pickOption(page, dialog.getByRole("combobox", { name: "Select option" }).nth(1), "Ticket Type");
125 await dialog.getByRole("listitem").filter({ hasText: /^Urgent$/ }).click();
126 await dialog.getByRole("listitem").filter({ hasText: /^Bug$/ }).getByRole("checkbox").check();
127 // Leave the visibility and mandatory rules off so OD Ticket's schema stays untouched.
128 for (const rule of [/^Show Ticket Type if/, /^Make Ticket Type mandatory if/]) {
129 await dialog.getByText(rule).locator("xpath=ancestor::div[.//button[@role='switch']][1]").getByRole("switch").click();
130 }
131 await dialog.getByRole("button", { name: "Save", exact: true }).click();
132 await expect.poll(() => api.exists("OD Form Script", { name: script })).toBeTruthy();
133
134 await page.goto(`/opendesk/tickets/new/${encodeURIComponent(template)}`);
135 await pickOption(page, templateField(page, "Priority"), "Urgent");
136 await templateField(page, "Ticket Type").click();
137 await expect(page.getByRole("option")).toHaveText(["Bug"]);
138 } finally {
139 if (await api.exists("OD Form Script", { name: script })) await api.delete("OD Form Script", script);
140 await api.delete("OD Ticket Template", template);
141 }
142});
143
144test("saved replies are created and deleting one removes only that row", async ({ page, api }) => {
145 const [keep, drop] = [`E2E Keep ${uid()}`, `E2E Drop ${uid()}`];
146 await api.insert("OD Saved Reply", { title: keep, message: "<p>keep</p>", scope: "Personal" });
147 try {
148 await page.goto("/opendesk/tickets");
149 const dialog = await openSettings(page, "Saved Replies");
150 await dialog.getByRole("button", { name: "New" }).click();
151 await dialog.getByRole("textbox", { name: /^Name/ }).fill(drop);
152 await dialog.locator("[contenteditable=true]").fill("Thanks for waiting.");
153 await dialog.getByRole("button", { name: "Save", exact: true }).click();
154 await expect.poll(() => api.exists("OD Saved Reply", { title: drop })).toBeTruthy();
155
156 await dialog.getByRole("button", { name: drop }).click();
157 const row = dialog.locator("div.grid").filter({ hasText: drop });
158 await row.getByRole("button").click();
159 await page.getByRole("menuitem", { name: "Delete" }).click();
160 await page.getByRole("menuitem", { name: "Confirm Delete" }).click();
161 await expect.poll(() => api.exists("OD Saved Reply", { title: drop })).toBeFalsy();
162 expect(await api.exists("OD Saved Reply", { title: keep })).toBeTruthy();
163 } finally {
164 for (const title of [keep, drop]) {
165 const name = await api.exists("OD Saved Reply", { title });
166 if (name) await api.delete("OD Saved Reply", name);
167 }
168 }
169});
170
171test("the email account form validates, and notifications can be toggled", async ({ page, api }) => {
172 const restore = await snapshotSettings(api, ["send_acknowledgement_email"]);
173 const before = (await api.get("OD Settings", "OD Settings")).send_acknowledgement_email;
174 try {
175 await page.goto("/opendesk/tickets");
176 const dialog = await openSettings(page, "Email Notifications");
177 await dialog.getByRole("listitem").filter({ hasText: "Acknowledgement" }).click();
178 await dialog.getByRole("switch").click();
179 await dialog.getByRole("button", { name: "Save", exact: true }).click();
180 await expect
181 .poll(async () => (await api.get("OD Settings", "OD Settings")).send_acknowledgement_email)
182 .toBe(before ? 0 : 1);
183
184 await dialog.getByRole("button", { name: "Email Accounts", exact: true }).click();
185 await dialog.getByRole("button", { name: "New" }).click();
186 await dialog.getByText("Custom", { exact: true }).click();
187 await dialog.getByRole("button", { name: "Create" }).click();
188 await expect(dialog.getByText("Account name is required")).toBeVisible();
189 } finally {
190 await restore();
191 }
192});
193
194test("general settings update the brand name and ticket toggles", async ({ page, api }) => {
195 const restore = await snapshotSettings(api, ["brand_name", "is_feedback_mandatory", "enable_comment_reactions"]);
196 const brand = `E2E Desk ${uid()}`;
197 const before = await api.get("OD Settings", "OD Settings");
198 try {
199 await page.goto("/opendesk/tickets");
200 const dialog = await openSettings(page, "General");
201 await dialog.getByRole("textbox", { name: "Brand name" }).fill(brand);
202 await dialog.getByRole("button", { name: "Save", exact: true }).click();
203 await expect.poll(() => setting(api, "brand_name")).toBe(brand);
204
205 // Toggles save on change, one at a time.
206 await settingSwitch(dialog, "Make feedback mandatory").click();
207 await expect.poll(() => setting(api, "is_feedback_mandatory")).toBe(before.is_feedback_mandatory ? 0 : 1);
208 await settingSwitch(dialog, "Enable comment reactions").click();
209 await expect.poll(() => setting(api, "enable_comment_reactions")).toBe(before.enable_comment_reactions ? 0 : 1);
210
211 await page.reload();
212 await expect(page.getByRole("button", { name: new RegExp(`^${brand} `) })).toBeVisible();
213 } finally {
214 await restore();
215 }
216});
217
218// Regression: a second toggle during the first save used to fail with a 417.
219test("toggling two general settings quickly saves both", async ({ page, api }) => {
220 const restore = await snapshotSettings(api, ["is_feedback_mandatory", "enable_comment_reactions"]);
221 const before = await api.get("OD Settings", "OD Settings");
222 try {
223 await page.goto("/opendesk/tickets");
224 const dialog = await openSettings(page, "General");
225 await settingSwitch(dialog, "Make feedback mandatory").click();
226 await settingSwitch(dialog, "Enable comment reactions").click();
227 await expect.poll(() => setting(api, "enable_comment_reactions")).toBe(before.enable_comment_reactions ? 0 : 1);
228 expect(await setting(api, "is_feedback_mandatory")).toBe(before.is_feedback_mandatory ? 0 : 1);
229 } finally {
230 await restore();
231 }
232});
233
234test.describe("preferences", () => {
235 test.use({ storageState: { cookies: [], origins: [] } });
236
237 test("language and timezone preferences persist", async ({ page, api }) => {
238 const email = `e2e-prefs-${uid()}@example.com`;
239 await createThrowawayAgent(api, email, PASSWORD);
240 await page.request.post("/api/method/login", { form: { usr: email, pwd: PASSWORD } });
241 await skipGettingStarted(page, email);
242
243 await page.goto("/opendesk/tickets");
244 const dialog = await openSettings(page, "Preferences");
245 await dialog.getByRole("button", { name: "Select language" }).click();
246 await page.getByRole("option", { name: "Svenska" }).click();
247 await dialog.locator("button", { hasText: /\w+\/\w+/ }).click();
248 await page.getByRole("combobox").or(page.getByPlaceholder(/Search/)).last().fill("Europe/Berlin");
249 await page.getByRole("option", { name: "Europe/Berlin" }).click();
250 await dialog.getByRole("button", { name: "Save", exact: true }).click();
251
252 await expect.poll(async () => (await api.get("User", email)).time_zone).toBe("Europe/Berlin");
253 expect((await api.get("User", email)).language).toBe("sv");
254 });
255});
256
257async function pickDate(input: Locator, isoDate: string) {
258 await input.fill(isoDate);
259 await input.press("Enter");
260}
261
262async function pickOption(page: Page, combobox: Locator, option: string) {
263 await combobox.click();
264 await page.getByRole("option", { name: option, exact: true }).click();
265}
266
267function settingSwitch(dialog: Locator, label: string) {
268 return dialog.getByText(label, { exact: true }).locator("xpath=ancestor::div[.//button[@role='switch']][1]").getByRole("switch");
269}
270
271function templateField(page: Page, label: string) {
272 return page
273 .getByText(label, { exact: true })
274 .locator("xpath=ancestor::div[.//*[@role='combobox']][1]")
275 .getByRole("combobox");
276}
277
278async function setting(api: Api, field: string) {
279 return (await api.get("OD Settings", "OD Settings"))[field];
280}
281
282// REST reads drop `_assign`, so ask for the column directly.
283async function assignees(api: Api, ticket: string): Promise<string | undefined> {
284 const value = await api.call("frappe.client.get_value", {
285 doctype: "OD Ticket",
286 filters: { name: ticket },
287 fieldname: "_assign",
288 });
289 return value._assign;
290}
291