diff --git a/src/Frontend/test/preconditions/failedMessageCounts.ts b/src/Frontend/test/preconditions/failedMessageCounts.ts new file mode 100644 index 000000000..d888d294b --- /dev/null +++ b/src/Frontend/test/preconditions/failedMessageCounts.ts @@ -0,0 +1,17 @@ +import { FailedMessageStatus } from "@/resources/FailedMessage"; +import type { SetupFactoryOptions } from "../driver"; + +export const hasFailedMessageCounts = + ({ failed = 0, deleted = 0 }: { failed?: number; deleted?: number }) => + ({ driver }: SetupFactoryOptions) => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const counts: Record = { + [FailedMessageStatus.Unresolved]: failed, + [FailedMessageStatus.Archived]: deleted, + }; + + driver.mockEndpointDynamic(`${serviceControlUrl}errors`, "get", (url) => { + const count = counts[url.searchParams.get("status") ?? ""] ?? 0; + return Promise.resolve({ body: [], headers: { "Total-Count": String(count) } }); + }); + }; diff --git a/src/Frontend/test/preconditions/failedMessageGroups.ts b/src/Frontend/test/preconditions/failedMessageGroups.ts new file mode 100644 index 000000000..c93bd3b6a --- /dev/null +++ b/src/Frontend/test/preconditions/failedMessageGroups.ts @@ -0,0 +1,101 @@ +import dayjs from "@/utils/dayjs"; +import type GroupOperation from "@/resources/GroupOperation"; +import type { SetupFactoryOptions } from "../driver"; + +export interface FailedMessageGroupOptions { + title?: string; + type?: string; + count?: number; + comment?: string; + firstFailedDaysAgo?: number; + lastFailedDaysAgo?: number; + lastRetriedDaysAgo?: number; +} + +export const createFailedMessageGroup = ( + id: string, + { title = `Failed group ${id}`, type = "Exception Type and Stack Trace", count = 1, comment = "", firstFailedDaysAgo = 5, lastFailedDaysAgo = 1, lastRetriedDaysAgo }: FailedMessageGroupOptions = {} +): GroupOperation => { + const group: GroupOperation = { + id, + title, + type, + count, + comment, + first: dayjs().subtract(firstFailedDaysAgo, "day").toISOString(), + last: dayjs().subtract(lastFailedDaysAgo, "day").toISOString(), + operation_status: "None", + operation_progress: 0, + need_user_acknowledgement: false, + }; + + if (lastRetriedDaysAgo !== undefined) { + group.operation_completion_time = dayjs().subtract(lastRetriedDaysAgo, "day").toISOString(); + group.last_operation_completion_time = group.operation_completion_time; + } + + return group; +}; + +export interface FailedMessageGroupsTestBed { + groups: GroupOperation[]; + requestedClassifiers: string[]; + retriedGroupIds: string[]; + deletedGroupIds: string[]; + removedNoteGroupIds: string[]; +} + +export const hasFailedMessageGroups = + ({ groups = [] }: { groups?: GroupOperation[] } = {}) => + ({ driver }: SetupFactoryOptions): FailedMessageGroupsTestBed => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const table = [...groups]; + const bed: FailedMessageGroupsTestBed = { groups: table, requestedClassifiers: [], retriedGroupIds: [], deletedGroupIds: [], removedNoteGroupIds: [] }; + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups{/:classifier}?`, "get", (_url, params) => { + bed.requestedClassifiers.push(params.classifier ? decodeURIComponent(String(params.classifier)) : ""); + return Promise.resolve({ body: table.map((group) => ({ ...group })) }); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/id/:groupId`, "get", (_url, params) => { + const group = table.find((candidate) => candidate.id === String(params.groupId)); + return Promise.resolve({ body: group ?? {}, status: group ? 200 : 404 }); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors`, "get", () => Promise.resolve({ body: [], headers: { "Total-Count": "0" } })); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/comment`, "delete", (_url, params) => { + const groupId = String(params.groupId); + const group = table.find((candidate) => candidate.id === groupId); + if (group) { + group.comment = ""; + } + bed.removedNoteGroupIds.push(groupId); + return Promise.resolve({ body: {} }); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors/retry`, "post", (_url, params) => { + const groupId = String(params.groupId); + const index = table.findIndex((group) => group.id === groupId); + if (index >= 0) { + table.splice(index, 1); + } + bed.retriedGroupIds.push(groupId); + return Promise.resolve({ body: {} }); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors/archive`, "post", (_url, params) => { + const groupId = String(params.groupId); + const group = table.find((candidate) => candidate.id === groupId); + if (group) { + group.operation_status = "ArchiveCompleted"; + group.operation_progress = 1; + group.need_user_acknowledgement = true; + group.operation_completion_time = dayjs().toISOString(); + } + bed.deletedGroupIds.push(groupId); + return Promise.resolve({ body: {} }); + }); + + return bed; + }; diff --git a/src/Frontend/test/preconditions/hasNoErrors.ts b/src/Frontend/test/preconditions/hasNoErrors.ts index 870d0fa9b..60d26430d 100644 --- a/src/Frontend/test/preconditions/hasNoErrors.ts +++ b/src/Frontend/test/preconditions/hasNoErrors.ts @@ -1,6 +1,6 @@ import type { SetupFactoryOptions } from "../driver"; -const content = JSON.stringify([]); +const content: never[] = []; export const errorsDefaultHandler = ({ driver }: SetupFactoryOptions) => { const serviceControlInstanceUrl = window.defaultConfig.service_control_url; diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index 5017ff767..5c0890ccb 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -17,6 +17,9 @@ export { serviceControlWithMonitoring } from "./serviceControlWithMonitoring"; export * from "./recoverability"; export * from "./deletedMessages"; export * from "./deletedMessageGroups"; +export * from "./failedMessageGroups"; +export * from "./recoverabilityHistory"; +export * from "./failedMessageCounts"; export * from "./deletableFailedMessage"; export * from "./licensing"; export { hasLicensingReportAvailable } from "../preconditions/hasLicensingReportAvailable"; diff --git a/src/Frontend/test/preconditions/recoverabilityHistory.ts b/src/Frontend/test/preconditions/recoverabilityHistory.ts new file mode 100644 index 000000000..909ec4dec --- /dev/null +++ b/src/Frontend/test/preconditions/recoverabilityHistory.ts @@ -0,0 +1,49 @@ +import dayjs from "@/utils/dayjs"; +import type HistoricRetryOperation from "@/resources/HistoricRetryOperation"; +import type RecoverabilityHistoryResponse from "@/resources/RecoverabilityHistoryResponse"; +import { RetryType } from "@/resources/RetryType"; +import type { SetupFactoryOptions } from "../driver"; + +export interface HistoricRetryOperationOptions { + originator?: string; + retryType?: RetryType; + messagesSent?: number; + startedMinutesAgo?: number; + completedMinutesAgo?: number; +} + +export const createHistoricRetryOperation = ( + id: string, + { originator = `Retried group ${id}`, retryType = RetryType.FailureGroup, messagesSent = 1, startedMinutesAgo = 30, completedMinutesAgo = 20 }: HistoricRetryOperationOptions = {} +): HistoricRetryOperation => ({ + request_id: id, + retry_type: retryType, + start_time: dayjs().subtract(startedMinutesAgo, "minute").toISOString(), + completion_time: dayjs().subtract(completedMinutesAgo, "minute").toISOString(), + originator, + failed: false, + number_of_messages_processed: messagesSent, +}); + +export interface CompletedRetryRequestsTestBed { + historyRequestCount: number; +} + +export const hasCompletedRetryRequests = + ({ operations = [] }: { operations?: HistoricRetryOperation[] } = {}) => + ({ driver }: SetupFactoryOptions): CompletedRetryRequestsTestBed => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const bed: CompletedRetryRequestsTestBed = { historyRequestCount: 0 }; + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/history`, "get", () => { + bed.historyRequestCount++; + return Promise.resolve({ + body: { + historic_operations: operations, + unacknowledged_operations: [], + }, + }); + }); + + return bed; + }; diff --git a/src/Frontend/test/specs/failedmessages/actions/failedMessageGroupActions.ts b/src/Frontend/test/specs/failedmessages/actions/failedMessageGroupActions.ts new file mode 100644 index 000000000..518be3b6b --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/failedMessageGroupActions.ts @@ -0,0 +1,36 @@ +import userEvent from "@testing-library/user-event"; +import { screen, within } from "@testing-library/vue"; +import { getFailedMessageGroupRow } from "../questions/failedMessageGroupRows"; +import { getConfirmationDialog } from "../questions/failedMessageGroupsView"; + +export async function clickGroupAction(groupTitle: string, action: RegExp): Promise { + const row = getFailedMessageGroupRow(groupTitle); + if (!row) { + throw new Error(`Cannot click ${action}: no failed message group row titled "${groupTitle}" is listed`); + } + await userEvent.click(within(row.element).getByRole("button", { name: action })); +} + +export async function confirmDialog(heading: RegExp): Promise { + await userEvent.click(within(getConfirmationDialog(heading)).getByRole("button", { name: "Yes" })); +} + +async function openMenu(label: string): Promise { + const menu = screen.getByText(label).closest(".msg-group-menu"); + const toggle = menu?.querySelector("button.dropdown-toggle"); + if (!menu || !toggle) { + throw new Error(`Cannot open menu: the '${label}' menu is not shown`); + } + await userEvent.click(toggle); + return menu; +} + +export async function selectGrouping(classifier: string): Promise { + const menu = await openMenu("Group by:"); + await userEvent.click(within(menu).getByText(classifier, { selector: "a" })); +} + +export async function selectSort(sortOption: RegExp): Promise { + const menu = await openMenu("Sort by:"); + await userEvent.click(within(menu).getByRole("button", { name: sortOption })); +} diff --git a/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessageGroup.ts b/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessageGroup.ts new file mode 100644 index 000000000..379e17fbd --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessageGroup.ts @@ -0,0 +1,10 @@ +import userEvent from "@testing-library/user-event"; +import { getFailedMessageGroupRow } from "../questions/failedMessageGroupRows"; + +export async function hoverFailedMessageGroup(groupTitle: string): Promise { + const row = getFailedMessageGroupRow(groupTitle); + if (!row) { + throw new Error(`Failed message group "${groupTitle}" is not displayed`); + } + await userEvent.hover(row.element); +} diff --git a/src/Frontend/test/specs/failedmessages/actions/toggleCompletedRetryRequests.ts b/src/Frontend/test/specs/failedmessages/actions/toggleCompletedRetryRequests.ts new file mode 100644 index 000000000..fcdb35db5 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/toggleCompletedRetryRequests.ts @@ -0,0 +1,6 @@ +import userEvent from "@testing-library/user-event"; +import { completedRetryRequestsHeading } from "../questions/completedRetryRequests"; + +export async function toggleCompletedRetryRequests(): Promise { + await userEvent.click(completedRetryRequestsHeading()); +} diff --git a/src/Frontend/test/specs/failedmessages/grouping-failed-messages.spec.ts b/src/Frontend/test/specs/failedmessages/grouping-failed-messages.spec.ts index 43696db6d..ebdf9884d 100644 --- a/src/Frontend/test/specs/failedmessages/grouping-failed-messages.spec.ts +++ b/src/Frontend/test/specs/failedmessages/grouping-failed-messages.spec.ts @@ -1,134 +1,331 @@ +import { expect } from "vitest"; +import { waitFor } from "@testing-library/vue"; +import { flushPromises } from "@vue/test-utils"; import { test, describe } from "../../drivers/vitest/driver"; +import type { Driver } from "../../driver"; +import * as precondition from "../../preconditions"; +import type { FailedMessageGroupOptions, FailedMessageGroupsTestBed } from "../../preconditions/failedMessageGroups"; +import routeLinks from "@/router/routeLinks"; +import type HistoricRetryOperation from "@/resources/HistoricRetryOperation"; +import { getCompletedRetryRequestRows, isCompletedRetryRequestsListExpanded, isNoCompletedRetryRequestsMessageVisible, visibleCompletedRetryRequestsSummary } from "./questions/completedRetryRequests"; +import { toggleCompletedRetryRequests } from "./actions/toggleCompletedRetryRequests"; +import { tabBadge } from "./questions/failedMessagesTabs"; +import { getFailedMessageGroupRow, getFailedMessageGroupRowCount, getFailedMessageGroupRows } from "./questions/failedMessageGroupRows"; +import { isConfirmationDialogVisible, isDeleteCompletedVisible, isDismissButtonVisible, isEmptyGroupsMessageVisible, selectedGrouping, selectedSort } from "./questions/failedMessageGroupsView"; +import { clickGroupAction, confirmDialog, selectGrouping, selectSort } from "./actions/failedMessageGroupActions"; +import { hoverFailedMessageGroup } from "./actions/hoverFailedMessageGroup"; +import { selectFailedMessageGroup } from "./actions/selectFailedMessageGroup"; +import { openTab } from "./actions/openTab"; + +const FAILED_MESSAGE_GROUPS = routeLinks.failedMessage.failedMessagesGroups.link; +const ALL_FAILED_MESSAGES = routeLinks.failedMessage.failedMessages.link; +const DELETED_MESSAGE_GROUPS = routeLinks.failedMessage.deletedMessagesGroup.link; + +const RETRY_GROUP_CONFIRMATION = /retry this group/i; +const DELETE_GROUP_CONFIRMATION = /delete this group/i; +const DELETE_NOTE_CONFIRMATION = /delete this note/i; + +async function givenFailedMessageGroupsAreShown(driver: Driver, groups: { title: string; options?: FailedMessageGroupOptions }[]): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + const bed = await driver.setUp(precondition.hasFailedMessageGroups({ groups: groups.map(({ title, options }, index) => precondition.createFailedMessageGroup(`group-${index + 1}`, { title, ...options })) })); + + await driver.goTo(FAILED_MESSAGE_GROUPS); + await waitFor(() => expect(getFailedMessageGroupRowCount()).toBe(groups.length), { timeout: 5000 }); + + return bed; +} + +async function givenCompletedRetryRequests(driver: Driver, operations: HistoricRetryOperation[]): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasFailedMessageGroups({ groups: [precondition.createFailedMessageGroup("group-1", { title: "Payments failures" })] })); + const bed = await driver.setUp(precondition.hasCompletedRetryRequests({ operations })); + + await driver.goTo(FAILED_MESSAGE_GROUPS); + await waitFor(() => expect(getFailedMessageGroupRowCount()).toBe(1), { timeout: 5000 }); + // The list starts empty, so an empty list alone doesn't prove the history response was rendered + await waitFor(() => expect(bed.historyRequestCount).toBeGreaterThan(0)); + await flushPromises(); + await waitFor(() => expect(getCompletedRetryRequestRows()).toHaveLength(operations.length)); +} + +async function givenFailedMessageCounts(driver: Driver, counts: { failed?: number; deleted?: number }): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasFailedMessageGroups({ groups: [] })); + await driver.setUp(precondition.hasFailedMessageCounts(counts)); + + await driver.goTo(FAILED_MESSAGE_GROUPS); +} + +function currentRoute(): string { + return window.location.hash.replace(/^#/, ""); +} describe("FEATURE: Failed Message Groups", () => { describe("RULE: Failed Message Groups view should shows all current failed messages, grouped by the selected grouping and sorted by the selected sort", () => { - test.todo("EXAMPLE: A message should be shown when there are no failed messages"); + test("EXAMPLE: A message should be shown when there are no failed messages", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasFailedMessageGroups({ groups: [] })); + await driver.goTo(FAILED_MESSAGE_GROUPS); - /* SCENARIO - Given there are no Failed Messages - Then the "Failed Message Groups" tab will display a message indicating the fact - */ + await waitFor(() => expect(isEmptyGroupsMessageVisible()).toBe(true)); + expect(getFailedMessageGroupRowCount()).toBe(0); + }); }); + describe("RULE: Overview information of each group should be displayed without having to navigate to the Failed Message Group details", () => { - test.todo("EXAMPLE: The number of deleted messages in a group should be shown"); - test.todo("EXAMPLE: The time period from the first failed message should be shown"); - test.todo("EXAMPLE: The time period of the last failed message should be shown"); - test.todo("EXAMPLE: The time period of when the group was last retried should be shown"); - test.todo("EXAMPLE: A deleted message group that has not been retried should show N/A for the last retry time"); - - /* SCENARIO - A group with failures that occurred in different times that has also been retried - - Given the following groups of failed messages - |name | number of failures | first failed time | last failure time | Las retried time| - |MessageX | 50 | 13:00 | 13:51 | 14:05 | - And the current time is 15:00 - When the list of failed messages groups is loaded - Then a row with the following information is included in the list - TODO: insert table with expected data to be visualized - - Previous more generic scenario draft: - Given there are 1 or more groups shown on the "Failed Message Groups" tab - Then the group row will display the current grouping name in bold - and the group will display the number of messages in the group - and the group will display a time period indicating how long ago the first failure happened - and the group will display a time period indicating how long ago the last failure happened - and the group will display a time period indicating how long ago the group was last retried, or N/A if never retried - */ + test("EXAMPLE: The number of deleted messages in a group should be shown", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { count: 7 } }]); + + expect(getFailedMessageGroupRow("Payments failures")?.messageCount).toBe("7 messages"); + }); + + test("EXAMPLE: The time period from the first failed message should be shown", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { firstFailedDaysAgo: 5 } }]); + + expect(getFailedMessageGroupRow("Payments failures")?.firstFailed).toBe("5 days ago"); + }); + + test("EXAMPLE: The time period of the last failed message should be shown", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { lastFailedDaysAgo: 2 } }]); + + expect(getFailedMessageGroupRow("Payments failures")?.lastFailed).toBe("2 days ago"); + }); + + test("EXAMPLE: The time period of when the group was last retried should be shown", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { lastRetriedDaysAgo: 3 } }]); + + expect(getFailedMessageGroupRow("Payments failures")?.lastRetried).toBe("3 days ago"); + }); + + test("EXAMPLE: A deleted message group that has not been retried should show N/A for the last retry time", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures" }]); + + expect(getFailedMessageGroupRow("Payments failures")?.lastRetried).toMatch(/n\/a/i); + }); }); + describe("RULE: Ability to select a given group should be hinted ", () => { - test.todo("EXAMPLE: Hovering the cursor over a group should indicate that it is active and selectable"); + test("EXAMPLE: Hovering the cursor over a group should indicate that it is active and selectable", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { count: 3 } }]); - /* SCENARIO - Mouse hovering a group + const before = getFailedMessageGroupRow("Payments failures"); + expect(before?.isHovered).toBe(false); + expect(before?.hasHoverCue).toBe(false); - Given there are 1 or more groups show in the "Failed Message Groups" tab - and the user hovers over a Failed Message Group row - Then the row indicates that it is active (hover state) - and that it is selectable (cursor/underlining) - */ + await hoverFailedMessageGroup("Payments failures"); + + const row = getFailedMessageGroupRow("Payments failures"); + expect(row?.isHovered).toBe(true); + expect(row?.hasHoverCue).toBe(true); + expect(row?.isSelectable).toBe(true); + }); }); + describe("RULE: Routing should place routes in browser history", () => { - test.todo("EXAMPLE: Navigating away from and then back to a detail view should return to the same detail view"); - - /* SCENARIO - Navigating back after selecting a group - - Given I start from the "Failed Message Groups" view - and I select a group to view - and the "Failed Messages" view is shown - When I navigate back in the browser - Then I should be returned to the "Failed Message Groups" view - (and subsequently navigating forwards should return me to the same "Failed Messages" view) - */ - - test.todo("EXAMPLE: Forward and backward navigation should navigate through the selected tabs in order"); - /* SCENARIO - Navigating between tabs of "Failed Messages" area - - Given I am in the "Failed Messages" area - and I click between tabs - Then the browser navigation forward/backward should navigate me through the selected tabs, in order - */ + test("EXAMPLE: Navigating away from and then back to a detail view should return to the same detail view", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures" }]); + + await selectFailedMessageGroup("Payments failures"); + await waitFor(() => expect(currentRoute()).toBe(routeLinks.failedMessage.group.link("group-1"))); + + window.history.back(); + await waitFor(() => expect(currentRoute()).toBe(FAILED_MESSAGE_GROUPS)); + await waitFor(() => expect(getFailedMessageGroupRow("Payments failures")).toBeDefined()); + + window.history.forward(); + await waitFor(() => expect(currentRoute()).toBe(routeLinks.failedMessage.group.link("group-1"))); + }); + + test("EXAMPLE: Forward and backward navigation should navigate through the selected tabs in order", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures" }]); + + await openTab("All Failed Messages"); + await waitFor(() => expect(currentRoute()).toBe(ALL_FAILED_MESSAGES)); + await openTab("Deleted Message Groups"); + await waitFor(() => expect(currentRoute()).toBe(DELETED_MESSAGE_GROUPS)); + + window.history.back(); + await waitFor(() => expect(currentRoute()).toBe(ALL_FAILED_MESSAGES)); + window.history.back(); + await waitFor(() => expect(currentRoute()).toBe(FAILED_MESSAGE_GROUPS)); + + window.history.forward(); + await waitFor(() => expect(currentRoute()).toBe(ALL_FAILED_MESSAGES)); + window.history.forward(); + await waitFor(() => expect(currentRoute()).toBe(DELETED_MESSAGE_GROUPS)); + }); }); + describe("RULE: Selected Grouping and Sort Order should remain selected when returning to Failed Message Groups tab", () => { - test.todo("EXAMPLE: The selected grouping and sorting should be restored when returning to the tab"); - - /* SCENARIO - Given I have selected a particular grouping and sorting on the "Failed Message Groups" tab - and I have navigated away from the tab, or I have refreshed the application, or I have closed the application and re-opened it - When I return to the "Failed Message Groups" tab - Then the grouping and sorting that I have previously selected are restored - */ + test("EXAMPLE: The selected grouping and sorting should be restored when returning to the tab", async ({ driver }) => { + const bed = await givenFailedMessageGroupsAreShown(driver, [ + { title: "Billing failures", options: { count: 1 } }, + { title: "Payments failures", options: { count: 5 } }, + { title: "Shipping failures", options: { count: 3 } }, + ]); + + await selectGrouping("Message Type"); + await selectSort(/^number of messages\s*\(descending\)$/i); + + await openTab("All Failed Messages"); + await waitFor(() => expect(currentRoute()).toBe(ALL_FAILED_MESSAGES)); + await openTab("Failed Message Groups"); + await waitFor(() => expect(getFailedMessageGroupRowCount()).toBe(3), { timeout: 5000 }); + + expect(selectedGrouping()).toBe("Message Type"); + expect(selectedSort()).toBe("Number of messages (Descending)"); + await waitFor(() => expect(bed.requestedClassifiers.at(-1)).toBe("Message Type")); + await waitFor(() => expect(getFailedMessageGroupRows().map((row) => row.title)).toEqual(["Payments failures", "Shipping failures", "Billing failures"])); + }); }); - describe("RULE: Something to do with last 10 completed retry requests", () => { - test.todo("Not implemented"); + + describe("RULE: Completed group retry requests should be listed on the Failed Message Groups tab", () => { + test("EXAMPLE: The list of completed retry requests should be collapsed by default", async ({ driver }) => { + await givenCompletedRetryRequests(driver, [precondition.createHistoricRetryOperation("request-1"), precondition.createHistoricRetryOperation("request-2")]); + + expect(isCompletedRetryRequestsListExpanded()).toBe(false); + expect(getCompletedRetryRequestRows().every((row) => !row.isVisible)).toBe(true); + }); + + test("EXAMPLE: Selecting the heading should expand and then collapse the list of completed retry requests", async ({ driver }) => { + await givenCompletedRetryRequests(driver, [precondition.createHistoricRetryOperation("request-1")]); + + await toggleCompletedRetryRequests(); + expect(isCompletedRetryRequestsListExpanded()).toBe(true); + expect(getCompletedRetryRequestRows().every((row) => row.isVisible)).toBe(true); + + await toggleCompletedRetryRequests(); + expect(isCompletedRetryRequestsListExpanded()).toBe(false); + }); + + test("EXAMPLE: A completed retry request should show the group name, the number of messages sent, and when it started and completed", async ({ driver }) => { + await givenCompletedRetryRequests(driver, [precondition.createHistoricRetryOperation("request-1", { originator: "Payments failures", messagesSent: 12, startedMinutesAgo: 30, completedMinutesAgo: 20 })]); + + await toggleCompletedRetryRequests(); + + expect(getCompletedRetryRequestRows()).toEqual([{ title: "Payments failures", messagesSent: "12", started: "30 minutes ago", completed: "20 minutes ago", isVisible: true }]); + }); + + test("EXAMPLE: A message should be shown when no group retry requests have been completed", async ({ driver }) => { + await givenCompletedRetryRequests(driver, []); + + await toggleCompletedRetryRequests(); + + await waitFor(() => expect(isNoCompletedRetryRequestsMessageVisible()).toBe(true)); + expect(getCompletedRetryRequestRows()).toHaveLength(0); + }); + + [ + { count: 1, summary: "There is only 1 completed group retry" }, + { count: 3, summary: "There are only 3 completed group retries" }, + ].forEach(({ count, summary }) => { + test(`EXAMPLE: The list should note when there are fewer than ten completed retry requests (${count})`, async ({ driver }) => { + await givenCompletedRetryRequests( + driver, + Array.from({ length: count }, (_, index) => precondition.createHistoricRetryOperation(`request-${index + 1}`)) + ); + + await toggleCompletedRetryRequests(); + + expect(visibleCompletedRetryRequestsSummary()).toBe(summary); + }); + }); + + test("EXAMPLE: No note should be shown when there are ten completed retry requests", async ({ driver }) => { + await givenCompletedRetryRequests( + driver, + Array.from({ length: 10 }, (_, index) => precondition.createHistoricRetryOperation(`request-${index + 1}`)) + ); + + await toggleCompletedRetryRequests(); + + expect(getCompletedRetryRequestRows()).toHaveLength(10); + expect(visibleCompletedRetryRequestsSummary()).toBeNull(); + }); }); - describe("RULE: Something to do with the badge on the tab header", () => { - test.todo("Not implemented"); + + describe("RULE: The Failed Messages tab headers should show badges for failed and deleted messages", () => { + test("EXAMPLE: The All Failed Messages tab should show the number of failed messages", async ({ driver }) => { + await givenFailedMessageCounts(driver, { failed: 7 }); + + await waitFor(() => expect(tabBadge("All Failed Messages")).toBe("7")); + }); + + test("EXAMPLE: The All Deleted Messages tab should show the number of deleted messages", async ({ driver }) => { + await givenFailedMessageCounts(driver, { deleted: 4 }); + + await waitFor(() => expect(tabBadge("All Deleted Messages")).toBe("4")); + }); + + test("EXAMPLE: The Failed Message Groups tab should show an indicator when there are failed messages", async ({ driver }) => { + await givenFailedMessageCounts(driver, { failed: 7 }); + + await waitFor(() => expect(tabBadge("Failed Message Groups")).toBe("!")); + }); + + test("EXAMPLE: The Deleted Message Groups tab should show an indicator when there are deleted messages", async ({ driver }) => { + await givenFailedMessageCounts(driver, { deleted: 4 }); + + await waitFor(() => expect(tabBadge("Deleted Message Groups")).toBe("!")); + }); }); + describe("RULE: Actions on groups should be conditional on the state of the group", () => { - test.todo("EXAMPLE: Adding a note should be possible when there are no notes on a group"); - /* SCENARIO - Given there are 1 or more groups shown on the "Failed Message Groups" tab - and no note has been added to the group - Then "Add Note" is shown as an available action on the group - */ - - test.todo("EXAMPLE: Editing a note should be possible when there is a note on a group"); - /* SCENARIO - Given there are 1 or more groups shown on the "Failed Message Groups" tab - and a note has been added to the group - Then the note contents are shown on the group row - and "Edit Note" is shown as an available action on the group - and "Remove Note" is shown as an available action on the group - */ - - test.todo("EXAMPLE: Removing a note should be possible when there is a note on a group"); - /* SCENARIO - Given there is a group shown on the "Failed Message Groups" tab - and a note has been added to the group - When the user clicks the "Remove Note" action - and clicks "Yes" on the action confirmation modal - Then the note is removed from the group - */ - - test.todo("EXAMPLE: Requesting a retry should be possible when there are failed messages in a group"); - /* SCENARIO - Given there are 1 or more groups shown on the "Failed Message Groups" tab - When the user clicks the "Request Retry" action - and clicks "Yes" on the action confirmation modal - Then all the messages in the selected group are scheduled for retry - and the list refreshes with the retried group removed - */ - - test.todo("EXAMPLE: Deleting a group should be possible when there are failed messages in a group"); - /* SCENARIO - Given there are 1 or more groups shown on the "Failed Message Groups" tab - When the user clicks the "Delete Group" action - and clicks "Yes" on the action confirmation modal - Then all the messages in the selected group are deleted according to the ServiceControl retention policy - and the list refreshes with progress/confirmation of the successful deletion - */ + test("EXAMPLE: Adding a note should be possible when there are no notes on a group", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures" }]); + + const row = getFailedMessageGroupRow("Payments failures"); + expect(row?.note).toBeNull(); + expect(row?.actions).toContain("Add note"); + }); + + test("EXAMPLE: Editing a note should be possible when there is a note on a group", async ({ driver }) => { + await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { comment: "Waiting on the payment provider fix" } }]); + + const row = getFailedMessageGroupRow("Payments failures"); + expect(row?.note).toBe("Waiting on the payment provider fix"); + expect(row?.actions).toContain("Edit note"); + expect(row?.actions).toContain("Remove note"); + }); + + test("EXAMPLE: Removing a note should be possible when there is a note on a group", async ({ driver }) => { + const bed = await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { comment: "Waiting on the payment provider fix" } }]); + + await clickGroupAction("Payments failures", /remove note/i); + expect(isConfirmationDialogVisible(DELETE_NOTE_CONFIRMATION)).toBe(true); + await confirmDialog(DELETE_NOTE_CONFIRMATION); + + await waitFor(() => expect(bed.removedNoteGroupIds).toEqual(["group-1"])); + await waitFor(() => expect(getFailedMessageGroupRow("Payments failures")?.note).toBeNull()); + }); + + test("EXAMPLE: Requesting a retry should be possible when there are failed messages in a group", async ({ driver }) => { + const bed = await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { count: 3 } }, { title: "Shipping failures" }]); + + await clickGroupAction("Payments failures", /request retry/i); + expect(isConfirmationDialogVisible(RETRY_GROUP_CONFIRMATION)).toBe(true); + await confirmDialog(RETRY_GROUP_CONFIRMATION); + + await waitFor(() => expect(bed.retriedGroupIds).toEqual(["group-1"])); + await waitFor(() => expect(getFailedMessageGroupRow("Payments failures")).toBeUndefined(), { timeout: 5000 }); + expect(getFailedMessageGroupRow("Shipping failures")).toBeDefined(); + }); + + test("EXAMPLE: Deleting a group should be possible when there are failed messages in a group", async ({ driver }) => { + const bed = await givenFailedMessageGroupsAreShown(driver, [{ title: "Payments failures", options: { count: 3 } }]); + + await clickGroupAction("Payments failures", /delete group/i); + expect(isConfirmationDialogVisible(DELETE_GROUP_CONFIRMATION)).toBe(true); + await confirmDialog(DELETE_GROUP_CONFIRMATION); + + await waitFor(() => expect(bed.deletedGroupIds).toEqual(["group-1"])); + await waitFor( + () => { + expect(isDeleteCompletedVisible()).toBe(true); + expect(isDismissButtonVisible()).toBe(true); + }, + { timeout: 5000 } + ); + }); }); }); diff --git a/src/Frontend/test/specs/failedmessages/questions/completedRetryRequests.ts b/src/Frontend/test/specs/failedmessages/questions/completedRetryRequests.ts new file mode 100644 index 000000000..4513a25d8 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/completedRetryRequests.ts @@ -0,0 +1,55 @@ +import { screen } from "@testing-library/vue"; +import { labelledValue, normalise } from "./domText"; + +const LIST_SELECTOR = ".lasttenoperations"; + +export interface CompletedRetryRequestRow { + title: string; + messagesSent: string; + started: string; + completed: string; + isVisible: boolean; +} + +function isDisplayed(element: HTMLElement | null): boolean { + for (let current = element; current; current = current.parentElement) { + if (getComputedStyle(current).display === "none") { + return false; + } + } + return element !== null; +} + +export function completedRetryRequestsHeading(): HTMLElement { + return screen.getByText(/last 10 completed retry requests/i); +} + +export function getCompletedRetryRequestRows(): CompletedRetryRequestRow[] { + // .box-no-click is the NoData message box, not a retry request + return Array.from(document.querySelectorAll(`${LIST_SELECTOR} .row.box:not(.box-no-click)`)).map((element) => { + const spans = Array.from(element.querySelectorAll("p.metadata > span.metadata")).map((span) => normalise(span.textContent ?? "")); + + return { + title: normalise(element.querySelector("p.lead")?.textContent ?? ""), + messagesSent: labelledValue(spans, "Messages sent:"), + started: labelledValue(spans, "Retry request started:"), + completed: labelledValue(spans, "Retry request completed:"), + isVisible: isDisplayed(element), + }; + }); +} + +export function isCompletedRetryRequestsListExpanded(): boolean { + const list = document.querySelector(`${LIST_SELECTOR} .no-mobile-side-padding`); + return isDisplayed(list); +} + +export function isNoCompletedRetryRequestsMessageVisible(): boolean { + const message = screen.queryByText(/no group retry requests have ever been completed/i); + return message !== null && message.getAttribute("role") === "status" && isDisplayed(message); +} + +export function visibleCompletedRetryRequestsSummary(): string | null { + const summary = Array.from(document.querySelectorAll(`${LIST_SELECTOR} .short-group-history`)).find((element) => isDisplayed(element)); + return summary ? normalise(summary.textContent ?? "") : null; +} diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupRows.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupRows.ts new file mode 100644 index 000000000..452099c6b --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupRows.ts @@ -0,0 +1,64 @@ +import { labelledValue, normalise } from "./domText"; + +const ROW_SELECTOR = ".row.box.box-group.failed-message-group"; + +export interface FailedMessageGroupRow { + title: string; + messageCount: string; + firstFailed: string; + lastFailed: string; + /** "n/a" for a group that has never been retried. */ + lastRetried: string; + /** The note text without the "NOTE:" prefix, or null when the group has no note. */ + note: string | null; + actions: string[]; + isHovered: boolean; + /** The title highlight the component toggles on mouseenter/mouseleave. */ + hasHoverCue: boolean; + isSelectable: boolean; + element: HTMLElement; +} + +function messageCountValue(spans: string[]): string { + return spans.find((span) => /^\d+ messages?$/.test(span)) ?? ""; +} + +function noteValue(row: HTMLElement): string | null { + const note = row.querySelector(".note"); + return note ? normalise((note.textContent ?? "").replace(/^\s*NOTE:/, "")) : null; +} + +function queryRows(): FailedMessageGroupRow[] { + return Array.from(document.querySelectorAll(ROW_SELECTOR)).map((element) => { + const titleElement = element.querySelector("p.lead"); + const spans = Array.from(element.querySelectorAll("p.metadata > span.metadata")).map((span) => normalise(span.textContent ?? "")); + + return { + title: normalise(titleElement?.textContent ?? ""), + messageCount: messageCountValue(spans), + firstFailed: labelledValue(spans, "First failed:"), + lastFailed: labelledValue(spans, "Last failed:"), + lastRetried: labelledValue(spans, "Last retried:"), + note: noteValue(element), + actions: Array.from(element.querySelectorAll("button")).map((button) => normalise(button.textContent ?? "")), + isHovered: element.matches(":hover"), + // The row's own :hover styling lives in main.css, which jsdom doesn't apply, so check the class the component adds instead + hasHoverCue: titleElement?.classList.contains("msg-type-hover") ?? false, + // The row is a div, so Vue renders :disabled as the string "true"/"false" rather than toggling the attribute + isSelectable: element.getAttribute("disabled") !== "true", + element, + }; + }); +} + +export function getFailedMessageGroupRows(): FailedMessageGroupRow[] { + return queryRows(); +} + +export function getFailedMessageGroupRowCount(): number { + return queryRows().length; +} + +export function getFailedMessageGroupRow(title: string): FailedMessageGroupRow | undefined { + return queryRows().find((row) => row.title === title); +} diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupsView.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupsView.ts new file mode 100644 index 000000000..67b94ff3b --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessageGroupsView.ts @@ -0,0 +1,41 @@ +import { screen } from "@testing-library/vue"; +import { normalise } from "./domText"; + +export function noGroupedMessagesMessage(): string | null { + const message = screen.queryByText(/there are currently no grouped message failures/i); + return message && message.getAttribute("role") === "status" ? normalise(message.textContent ?? "") : null; +} + +export function isEmptyGroupsMessageVisible(): boolean { + return noGroupedMessagesMessage() !== null; +} + +function menuSelection(label: string): string | null { + const menu = screen.queryByText(label)?.closest(".msg-group-menu"); + const toggle = menu?.querySelector("button.dropdown-toggle"); + return toggle ? normalise(toggle.textContent ?? "") : null; +} + +export function selectedGrouping(): string | null { + return menuSelection("Group by:"); +} + +export function selectedSort(): string | null { + return menuSelection("Sort by:"); +} + +export function getConfirmationDialog(heading: RegExp): HTMLElement { + return screen.getByRole("dialog", { name: heading }) as HTMLElement; +} + +export function isConfirmationDialogVisible(heading: RegExp): boolean { + return screen.queryByRole("dialog", { name: heading }) !== null; +} + +export function isDeleteCompletedVisible(): boolean { + return screen.queryByText(/delete request completed/i) !== null; +} + +export function isDismissButtonVisible(): boolean { + return screen.queryByRole("button", { name: /^dismiss$/i }) !== null; +} diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessagesTabs.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessagesTabs.ts new file mode 100644 index 000000000..14b52930d --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessagesTabs.ts @@ -0,0 +1,17 @@ +import { within } from "@testing-library/vue"; +import { normalise } from "./domText"; + +function tabHeader(tabName: string): HTMLElement | null { + const tabs = document.querySelector(".tabs"); + if (!tabs) { + return null; + } + const link = within(tabs).queryByRole("link", { name: new RegExp(`^${tabName}`, "i") }); + return link?.closest("h5") ?? null; +} + +/** The badge text shown next to a Failed Messages tab, or null when no badge is shown. */ +export function tabBadge(tabName: string): string | null { + const badge = tabHeader(tabName)?.querySelector(".badge"); + return badge ? normalise(badge.textContent ?? "") : null; +}