1import type { Page } from "@playwright/test";
2import type { Api } from "../../../helpers/api";
3import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
4import { raiseTicket } from "../../../helpers/factories";
5import { addComment, openTicket, runTicketMethod } from "../../../helpers/ticket";
6
7usePersona("agent");
8
9const communications = (api: Api, ticket: string) =>
10 api.list("Communication", {
11 fields: ["name", "content"],
12 filters: { reference_doctype: "OD Ticket", reference_name: ticket },
13 });
14
15type Ticket = { name: string; subject: string };
16
17/** Merge `source` into `target` through the ticket's More menu. */
18async function mergeFromUi(page: Page, source: Ticket, target: Ticket) {
19 const opened = page.context().waitForEvent("page");
20 await openMergeDialog(page, source, target);
21 await page.getByRole("button", { name: `Merge with ticket #${target.name}` }).click();
22 await expect(page.getByText("Ticket merged successfully.")).toBeVisible();
23 return opened;
24}
25
26async function openMergeDialog(page: Page, source: Ticket, target: Ticket) {
27 await openTicket(page, source.name);
28 await page.getByRole("banner").getByRole("button", { name: "More actions" }).click();
29 await page.getByRole("menuitem", { name: "Merge Ticket" }).click();
30 const dialog = page.getByRole("dialog");
31 await dialog.getByRole("button", { name: "Ticket", exact: true }).click();
32 await page.getByRole("combobox", { name: "Select Ticket" }).fill(target.subject);
33 await page.getByRole("option", { name: `${target.subject} ${target.name},` }).click();
34 return dialog;
35}
36
37test("a ticket merges into another from the same customer", async ({ page, api, apiAs }) => {
38 const customer = await apiAs("customer");
39 const target = await raiseTicket(customer);
40 const source = await raiseTicket(customer);
41
42 const opened = await mergeFromUi(page, source, target);
43 expect((await opened).url()).toContain(`/opendesk/tickets/${target.name}`);
44 expect(await api.get("OD Ticket", source.name)).toMatchObject({
45 is_merged: 1,
46 merged_with: target.name,
47 status: "Closed",
48 });
49});
50
51test("a merge whose response is lost closes the dialog once the ticket shows merged", async ({
52 page,
53 api,
54 apiAs,
55}) => {
56 const customer = await apiAs("customer");
57 const target = await raiseTicket(customer);
58 const source = await raiseTicket(customer);
59 // The server merges, but the browser gets a gateway timeout instead of the reply.
60 await page.route("**/api/method/opendesk.opendesk.doctype.od_ticket.api.merge_ticket", async (route) => {
61 await route.fetch();
62 await route.fulfill({ status: 504, body: "Gateway Timeout" });
63 });
64
65 const dialog = await openMergeDialog(page, source, target);
66 await dialog.getByRole("button", { name: `Merge with ticket #${target.name}` }).click();
67 await expect(page.getByText("Could not confirm the merge. Reloading the ticket.")).toBeVisible();
68 await expect(dialog).toBeHidden();
69 expect((await api.get("OD Ticket", source.name)).is_merged).toBe(1);
70});
71
72test("merging carries the source emails over to the target", async ({ page, api, apiAs }) => {
73 const customer = await apiAs("customer");
74 const target = await raiseTicket(customer);
75 const source = await raiseTicket(customer);
76
77 await mergeFromUi(page, source, target);
78 const moved = (await communications(api, target.name)).map((c) => c.content).join();
79 expect(moved).toContain(`${source.subject} description`);
80});
81
82test("a later email splits off into a new ticket", async ({ page, api, apiAs }) => {
83 const customer = await apiAs("customer");
84 const ticket = await raiseTicket(customer);
85 await runTicketMethod(customer, ticket.name, "create_communication_via_contact", {
86 message: "<p>Also, a separate problem</p>",
87 });
88 const followUp = (await communications(api, ticket.name)).find((c) =>
89 c.content.includes("separate problem")
90 )!;
91 const subject = `Split off ${uid()}`;
92 await openTicket(page, ticket.name);
93
94 const row = page.locator(`[id="email:${followUp.name}"]`);
95 await row.getByRole("button").last().click();
96 await page.getByRole("menuitem", { name: "Split Ticket" }).click();
97 const dialog = page.getByRole("dialog", { name: "Split ticket" });
98 await dialog.getByPlaceholder("Add a subject for the new ticket").fill(subject);
99 await dialog.getByRole("button", { name: "Split into new ticket" }).click();
100
101 await expect.poll(() => api.exists("OD Ticket", { subject })).toBeTruthy();
102 const split = await api.exists("OD Ticket", { subject });
103 expect((await communications(api, split!)).map((c) => c.name)).toEqual([followUp.name]);
104 expect((await communications(api, ticket.name)).map((c) => c.name)).not.toContain(followUp.name);
105});
106
107test("next and previous ticket show that ticket's own timeline", async ({ api, apiAs, pageAs }) => {
108 const page = await pageAs("agent2");
109 const agent = await apiAs("agent");
110 const customer = await apiAs("customer");
111 const notes = new Map<string, string>();
112 for (const ticket of [await raiseTicket(customer), await raiseTicket(customer)]) {
113 notes.set(ticket.name, `Timeline of ${ticket.name} ${uid()}`);
114 await addComment(agent, ticket.name, notes.get(ticket.name)!);
115 }
116 // the newest ticket heads the default view, so it has a next one
117 const start = [...notes.keys()].at(-1)!;
118 const feed = page.getByRole("tabpanel", { name: "Activity" });
119 await openTicket(page, start);
120 await expect(feed.getByText(notes.get(start)!)).toBeVisible();
121
122 const runCommand = async (title: string) => {
123 // Desktop Chrome's user agent is Windows, where Mod is Control
124 await page.keyboard.press("Control+k");
125 await page.getByRole("combobox", { name: /Search commands/ }).fill(title);
126 await page.getByRole("option", { name: new RegExp(`^${title}`) }).click();
127 };
128 await runCommand("Next ticket");
129 await expect(page).not.toHaveURL(new RegExp(`/tickets/${start}$`));
130 const next = page.url().split("/").pop()!;
131 const nextComments = await api.list("Comment", {
132 fields: ["content"],
133 filters: { reference_name: next, comment_type: "Comment" },
134 });
135 for (const comment of nextComments) {
136 await expect(feed.getByText(comment.content.replace(/<[^>]+>/g, ""))).toBeVisible();
137 }
138 await expect(feed.getByText(notes.get(start)!)).toHaveCount(0);
139
140 await runCommand("Previous ticket");
141 await expect(page).toHaveURL(new RegExp(`/tickets/${start}$`));
142 await expect(feed.getByText(notes.get(start)!)).toBeVisible();
143 if (!notes.has(next)) return;
144 await expect(feed.getByText(notes.get(next)!)).toHaveCount(0);
145});
146
147test("activity, emails, comments and analytics tabs render", async ({ page, apiAs, ticket }) => {
148 const note = `Tab note ${uid()}`;
149 await addComment(await apiAs("agent"), ticket.name, note);
150 await openTicket(page, ticket.name);
151
152 await page.getByRole("tab", { name: "Emails" }).click();
153 await expect(page).toHaveURL(/#email$/);
154 const emails = page.getByRole("tabpanel", { name: "Emails" });
155 await expect(emails.getByText("Cora E2E")).toBeVisible();
156 await expect(emails.getByText(note)).toHaveCount(0);
157
158 await page.getByRole("tab", { name: "Comments" }).click();
159 await expect(page).toHaveURL(/#comment$/);
160 await expect(page.getByRole("tabpanel", { name: "Comments" }).getByText(note)).toBeVisible();
161
162 await page.getByRole("tab", { name: "Analytics" }).click();
163 const analytics = page.getByRole("tabpanel", { name: "Analytics" });
164 await expect(analytics.getByRole("heading", { name: "Ticket Timeline" })).toBeVisible();
165 await expect(analytics.getByRole("heading", { name: "Conversation Summary" })).toBeVisible();
166
167 await page.getByRole("tab", { name: "Activity" }).click();
168 await expect(page.getByRole("tabpanel", { name: "Activity" }).getByText(note)).toBeVisible();
169});
170
171test.describe("deleting", () => {
172 test("agents are not offered delete", async ({ page, ticket }) => {
173 await openTicket(page, ticket.name);
174 await page.getByRole("banner").getByRole("button", { name: "More actions" }).click();
175 await expect(page.getByRole("menuitem", { name: "Merge Ticket" })).toBeVisible();
176 await expect(page.getByRole("menuitem", { name: "Delete" })).toHaveCount(0);
177 });
178
179 test("an admin deletes a ticket", async ({ api, pageAs, ticket }) => {
180 const admin = await pageAs("admin");
181 await openTicket(admin, ticket.name);
182 await admin.getByRole("banner").getByRole("button", { name: "More actions" }).click();
183 await admin.getByRole("menuitem", { name: "Delete" }).click();
184 await admin.getByRole("dialog").getByRole("button", { name: "Delete" }).click();
185
186 await expect(admin.getByText("Ticket deleted successfully.")).toBeVisible();
187 await expect(admin).toHaveURL(/\/opendesk\/tickets$/);
188 expect(await api.exists("OD Ticket", { name: ticket.name })).toBeUndefined();
189 });
190});
191