Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/Frontend/test/preconditions/failedMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import dayjs from "@/utils/dayjs";
import { FailedMessageStatus, type FailedMessage } from "@/resources/FailedMessage";
import type GroupOperation from "@/resources/GroupOperation";
import type { SetupFactoryOptions } from "../driver";

const RETRY_SETTLE_DELAY = 1500;

export interface FailedMessageOptions {
messageType?: string;
endpoint?: string;
machine?: string;
failedHoursAgo?: number;
retryFailures?: number;
exceptionMessage?: string;
}

export const createFailedMessage = (
id: string,
{ messageType = `Sales.OrderFailed.${id}`, endpoint = "Sales.Service", machine = "MACHINE-B", failedHoursAgo = 2, retryFailures = 0, exceptionMessage = `Order ${id} could not be processed` }: FailedMessageOptions = {}
): FailedMessage => {
const timeOfFailure = dayjs().subtract(failedHoursAgo, "hour");

return {
id,
message_type: messageType,
time_sent: timeOfFailure.subtract(1, "minute").toISOString(),
is_system_message: false,
exception: {
exception_type: "System.InvalidOperationException",
message: exceptionMessage,
source: "Sales.Service",
stack_trace: " at Sales.OrderHandler.Handle(OrderFailed message, IMessageHandlerContext context)",
},
message_id: `message-${id}`,
number_of_processing_attempts: retryFailures + 1,
status: FailedMessageStatus.Unresolved,
sending_endpoint: { name: "Sales.Client", host_id: "host-sender", host: "MACHINE-A" },
receiving_endpoint: { name: endpoint, host_id: "host-receiver", host: machine },
queue_address: endpoint,
time_of_failure: timeOfFailure.toISOString(),
last_modified: timeOfFailure.toISOString(),
edited: false,
edit_of: "",
};
};

export interface FailedMessageGroup {
id: string;
title: string;
messageIds: string[];
}

export interface FailedMessagesTestBed {
failedMessages: FailedMessage[];
retriedIds: string[];
}

export const hasFailedMessages =
({ messages = [], groups = [] }: { messages?: FailedMessage[]; groups?: FailedMessageGroup[] } = {}) =>
({ driver }: SetupFactoryOptions): FailedMessagesTestBed => {
const serviceControlUrl = window.defaultConfig.service_control_url;
const table = [...messages];
const membership = new Map(groups.map((group) => [group.id, new Set(group.messageIds)]));
const titles = new Map(groups.map((group) => [group.id, group.title]));
const retriedIds: string[] = [];

const groupOperations = (): GroupOperation[] =>
groups.map((group) => ({
id: group.id,
title: group.title,
type: "Endpoint Name",
count: membership.get(group.id)?.size ?? 0,
comment: "",
operation_status: "none",
operation_progress: 0,
need_user_acknowledgement: false,
}));

const respondWith = (rows: FailedMessage[]) => ({ body: rows, headers: { "Total-Count": String(rows.length) } });

const unresolvedRows = () => table.filter((message) => message.status === FailedMessageStatus.Unresolved);

const sortRows = (rows: FailedMessage[], url: URL) => {
const sort = url.searchParams.get("sort");
if (sort !== "time_of_failure" && sort !== "message_type") {
return rows;
}
const direction = url.searchParams.get("direction") === "asc" ? 1 : -1;
return [...rows].sort((a, b) => (sort === "time_of_failure" ? a.time_of_failure.localeCompare(b.time_of_failure) : (a.message_type ?? "").localeCompare(b.message_type ?? "")) * direction);
};

driver.mockEndpointDynamic(`${serviceControlUrl}errors`, "get", (url) => {
if (url.searchParams.get("status") !== FailedMessageStatus.Unresolved) {
return Promise.resolve(respondWith([]));
}
return Promise.resolve(respondWith(sortRows(unresolvedRows(), url)));
});

driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors`, "get", (url, params) => {
const memberIds = membership.get(String(params.groupId));
if (!memberIds) {
return Promise.resolve(respondWith([]));
}
const rows = unresolvedRows().filter((message) => memberIds.has(message.id));
return Promise.resolve(respondWith(sortRows(rows, url)));
});

driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/id/:groupId`, "get", (_url, params) => {
const groupId = String(params.groupId);
const memberIds = membership.get(groupId);
return Promise.resolve({ body: { id: groupId, title: titles.get(groupId) ?? "Unknown group", type: "Endpoint Name", count: memberIds?.size ?? 0 } });
});

driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/`, "get", () => Promise.resolve({ body: groupOperations() }));

driver.mockEndpointDynamic(`${serviceControlUrl}errors/retry`, "post", async (_url, _params, request) => {
const ids = ((await request.json()) as string[]) ?? [];
ids.forEach((id) => retriedIds.push(id));
setTimeout(() => {
ids.forEach((id) => {
const message = table.find((candidate) => candidate.id === id);
if (message) {
message.status = FailedMessageStatus.RetryIssued;
}
membership.forEach((memberIds) => memberIds.delete(id));
});
}, RETRY_SETTLE_DELAY);
return { body: {} };
});

return { failedMessages: table, retriedIds };
};
1 change: 1 addition & 0 deletions src/Frontend/test/preconditions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from "../preconditions/hasHeartbeatEndpoints";
export { serviceControlWithMonitoring } from "./serviceControlWithMonitoring";
export * from "./recoverability";
export * from "./deletedMessages";
export * from "./failedMessages";
export * from "./deletedMessageGroups";
export * from "./deletableFailedMessage";
export * from "./licensing";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/vue";

function toolbar(): HTMLElement {
const element = document.querySelector<HTMLElement>(".btn-toolbar");
if (!element) {
throw new Error("The failed messages toolbar is not displayed");
}
return element;
}

export async function selectAllFailedMessages(): Promise<void> {
await userEvent.click(within(toolbar()).getByRole("button", { name: /^select all$/i }));
}

export async function clearFailedMessageSelection(): Promise<void> {
await userEvent.click(within(toolbar()).getByRole("button", { name: /clear selection/i }));
}

export async function selectFailedMessage(messageId: string): Promise<void> {
const checkbox = document.querySelector<HTMLInputElement>(`#checkbox${messageId}`);
if (!checkbox) {
throw new Error(`Failed message ${messageId} is not displayed`);
}
await userEvent.click(checkbox);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import userEvent from "@testing-library/user-event";

function rowElement(messageId: string): HTMLElement {
const row = document.querySelector(`.row.box.repeat-item.failed-message #checkbox${messageId}`)?.closest<HTMLElement>(".failed-message");
if (!row) {
throw new Error(`Failed message ${messageId} is not displayed`);
}
return row;
}

export async function hoverFailedMessage(messageId: string): Promise<void> {
await userEvent.hover(rowElement(messageId));
}
16 changes: 16 additions & 0 deletions src/Frontend/test/specs/failedmessages/actions/requestRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import userEvent from "@testing-library/user-event";
import { hoverFailedMessage } from "./hoverFailedMessage";

function retryButton(messageId: string): HTMLElement {
const row = document.querySelector(`.row.box.repeat-item.failed-message #checkbox${messageId}`)?.closest(".failed-message");
const button = row?.querySelector<HTMLElement>('button[name="retryMessage"]');
if (!button) {
throw new Error(`The 'Request retry' action for failed message ${messageId} is not displayed`);
}
return button;
}

export async function requestRetryFor(messageId: string): Promise<void> {
await hoverFailedMessage(messageId);
await userEvent.click(retryButton(messageId));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/vue";
import { normalise } from "../questions/domText";

function dropdownMenu(): HTMLElement {
const toggle = document.querySelector<HTMLElement>(".msg-group-menu .btn.dropdown-toggle");
if (toggle) {
toggle.click(); //Bootstrap toggles the menu open; the option buttons live inside .dropdown-menu
}
const menu = document.querySelector<HTMLElement>(".msg-group-menu .dropdown-menu");
if (!menu) {
throw new Error("The sort-by dropdown is not displayed");
}
return menu;
}

function optionButton(description: string, descending: boolean): HTMLElement {
const buttons = within(dropdownMenu()).getAllByRole("button");
const match = buttons.find((button) => {
const text = normalise(button.textContent ?? "");
return descending ? text === `${description} (Descending)` : text === description;
});
if (!match) {
throw new Error(`The '${descending ? `${description} (Descending)` : description}' sort option is not displayed`);
}
return match;
}

export async function sortBy(description: string): Promise<void> {
await userEvent.click(optionButton(description, false));
}

export async function sortByDescending(description: string): Promise<void> {
await userEvent.click(optionButton(description, true));
}
Loading
Loading