1import type { Page } from "@playwright/test";
2import type { Api } from "../../../helpers/api";
3import { raiseTicket } from "../../../helpers/factories";
4import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
5import { personas, type PersonaKey } from "../../../helpers/personas";
6
7type PageAs = (key: PersonaKey) => Promise<Page>;
8
9usePersona("agent");
10
11test("first response counts down, then reads Fulfilled after the agent replies", async ({
12 page,
13 api,
14 apiAs,
15 pageAs,
16}) => {
17 const subject = `E2E SLA ${uid()}`;
18 const ticket = await raiseTicket(await apiAs("customer"), subject);
19 expect((await api.get("OD Ticket", ticket.name)).agreement_status).toBe(
20 "First Response Due"
21 );
22
23 await page.goto(`/opendesk/tickets/${ticket.name}`);
24 await expect(sidebarValue(page, "First Response")).toHaveText(/^Due in /);
25 const row = await listRow(api, pageAs, ticket.name);
26 await expect(row).toContainText(/\d+h \d+m|\d+ days?/);
27 await expect(row).not.toContainText("Fulfilled");
28
29 await replyAsAgent(await apiAs("agent"), ticket.name);
30
31 await page.goto(`/opendesk/tickets/${ticket.name}`);
32 await expect(sidebarValue(page, "First Response")).toHaveText(/^Fulfilled/);
33 await expect(await listRow(api, pageAs, ticket.name)).toContainText("Fulfilled");
34});
35
36test("a ticket on hold stays Paused after its deadlines pass", async ({
37 api,
38 apiAs,
39 pageAs,
40}) => {
41 const subject = `E2E SLA hold ${uid()}`;
42 const ticket = await raiseTicket(await apiAs("customer"), subject);
43 await api.update("OD Ticket", ticket.name, { status: "Replied" });
44
45 // Rewind the clock: SLA started a week ago, answered and held 10 minutes in.
46 const weekAgo = Date.now() - 7 * 24 * 3600 * 1000;
47 const heldAt = await siteDatetime(api, weekAgo + 10 * 60 * 1000);
48 await api.update("OD Ticket", ticket.name, {
49 service_level_agreement_creation: await siteDatetime(api, weekAgo),
50 first_responded_on: heldAt,
51 on_hold_since: heldAt,
52 });
53 await api.update("OD Ticket", ticket.name, { subject: `${subject} edited` });
54
55 const saved = await api.get("OD Ticket", ticket.name);
56 expect(saved.resolution_by < (await siteDatetime(api, Date.now()))).toBe(true);
57 expect(saved.agreement_status).toBe("Paused");
58 await expect(await listRow(api, pageAs, ticket.name)).toContainText("Paused");
59});
60
61test("analytics tab counts the opening message once", async ({ page, apiAs, ticket }) => {
62 await replyAsAgent(await apiAs("agent"), ticket.name);
63
64 await page.goto(`/opendesk/tickets/${ticket.name}`);
65 await page.getByRole("tab", { name: "Analytics" }).click();
66
67 await expect(page.getByText("Created", { exact: true }).first()).toBeVisible();
68 await expect(summaryValue(page, "Customer messages")).toHaveText("1");
69 await expect(summaryValue(page, "Agent replies")).toHaveText("1");
70});
71
72async function replyAsAgent(agent: Api, ticket: string) {
73 await agent.call("run_doc_method", {
74 dt: "OD Ticket",
75 dn: ticket,
76 method: "reply_via_agent",
77 args: { message: "<p>E2E agent reply</p>" },
78 });
79}
80
81function sidebarValue(page: Page, label: string) {
82 return page
83 .locator("div.min-h-7")
84 .filter({ has: page.getByText(label, { exact: true }) })
85 .locator("span.truncate");
86}
87
88function summaryValue(page: Page, label: string) {
89 return page
90 .locator("div.justify-between")
91 .filter({ has: page.getByText(label, { exact: true }) })
92 .locator("span")
93 .last();
94}
95
96/** The ticket's list row, seen by the manager with their saved view reset. */
97async function listRow(api: Api, pageAs: PageAs, ticket: string) {
98 const saved = await api.list("OD View", {
99 filters: { user: personas.manager.email, dt: "OD Ticket", is_default: 1 },
100 });
101 for (const view of saved) await api.delete("OD View", view.name);
102 const page = await pageAs("manager");
103 const filters = encodeURIComponent(JSON.stringify({ name: ticket }));
104 await page.goto(`/opendesk/tickets?filters=${filters}`);
105 return page.getByRole("link", { name: new RegExp(`^${ticket} `) });
106}
107
108/** Frappe stores naive datetimes in the site's timezone. */
109async function siteDatetime(api: Api, epoch: number) {
110 const settings = await api.get("System Settings", "System Settings");
111 return new Date(epoch)
112 .toLocaleString("sv-SE", { timeZone: settings.time_zone })
113 .slice(0, 19);
114}
115