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
7 changes: 4 additions & 3 deletions src/Frontend/src/stores/RecoverabilityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,11 @@ export const useRecoverabilityStore = defineStore("RecoverabilityStore", () => {
}

async function retryAll() {
updateDateRangeForPeriod();
let url = "pendingretries/retry";
const data: { from: string; to: string; queueaddress?: string } = {
from: new Date(0).toISOString(),
to: new Date(0).toISOString(),
from: startDate.value.toISOString(),
to: endDate.value.toISOString(),
};
if (selectedQueue.value !== "empty") {
url = "pendingretries/queues/retry";
Expand Down Expand Up @@ -273,7 +274,7 @@ export const useRecoverabilityStore = defineStore("RecoverabilityStore", () => {
deletedPeriodOptions,
retryPeriodOptions,
selectedPeriod,
selectedQueue: shallowReadonly(selectedQueue),
selectedQueue,
endpoints: shallowReadonly(endpoints),
setSort,
setPeriod,
Expand Down
1 change: 1 addition & 0 deletions src/Frontend/test/preconditions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { serviceControlWithMonitoring } from "./serviceControlWithMonitoring";
export * from "./recoverability";
export * from "./deletedMessages";
export * from "./failedMessages";
export * from "./pendingRetries";
export * from "./deletedMessageGroups";
export * from "./deletableFailedMessage";
export * from "./licensing";
Expand Down
114 changes: 114 additions & 0 deletions src/Frontend/test/preconditions/pendingRetries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import dayjs from "@/utils/dayjs";
import { FailedMessageStatus, type FailedMessage } from "@/resources/FailedMessage";
import type QueueAddress from "@/resources/QueueAddress";
import type { SetupFactoryOptions } from "../driver";

export interface PendingRetryMessageOptions {
messageType?: string;
endpoint?: string;
machine?: string;
retriedMinutesAgo?: number;
exceptionMessage?: string;
}

export const createPendingRetryMessage = (
id: string,
{ messageType = `Sales.OrderFailed.${id}`, endpoint = "Sales.Service", machine = "MACHINE-B", retriedMinutesAgo = 10, exceptionMessage = `Order ${id} could not be processed` }: PendingRetryMessageOptions = {}
): FailedMessage => {
const timeOfRetry = dayjs().subtract(retriedMinutesAgo, "minute");

return {
id,
message_type: messageType,
time_sent: timeOfRetry.subtract(1, "hour").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: 1,
status: FailedMessageStatus.RetryIssued,
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: timeOfRetry.subtract(30, "minute").toISOString(),
last_modified: timeOfRetry.toISOString(),
edited: false,
edit_of: "",
};
};

export interface RetryAllRequest {
from: string;
to: string;
queueaddress?: string;
}

export interface PendingRetriesTestBed {
pendingRetryMessages: FailedMessage[];
retryAllRequests: RetryAllRequest[];
}

export const hasPendingRetryMessages =
({ messages = [] }: { messages?: FailedMessage[] } = {}) =>
({ driver }: SetupFactoryOptions): PendingRetriesTestBed => {
const serviceControlUrl = window.defaultConfig.service_control_url;
const table = [...messages];
const retryAllRequests: RetryAllRequest[] = [];

const withinModifiedWindow = (url: URL) => {
const range = url.searchParams.get("modified");
if (!range) {
return () => true;
}
const [start, end] = range.split("...");
const from = dayjs(start).valueOf();
const until = dayjs(end).valueOf();
return (message: FailedMessage) => {
const modified = dayjs(message.last_modified).valueOf();
return modified >= from && modified <= until;
};
};

const matchingQueue = (url: URL) => {
const queue = url.searchParams.get("queueaddress");
if (!queue) {
return () => true;
}
return (message: FailedMessage) => message.queue_address === queue;
};

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

driver.mockEndpointDynamic(`${serviceControlUrl}errors`, "get", (url) => {
if (url.searchParams.get("status") !== FailedMessageStatus.RetryIssued) {
return Promise.resolve(respondWith([]));
}
return Promise.resolve(respondWith(table.filter(matchingQueue(url)).filter(withinModifiedWindow(url))));
});

driver.mockEndpointDynamic(`${serviceControlUrl}errors/queues/addresses`, "get", () => {
const addresses = [...new Set(table.map((message) => message.queue_address))];
return Promise.resolve({
body: addresses.map((address) => <QueueAddress>{ physical_address: address, failed_message_count: table.filter((message) => message.queue_address === address).length }),
});
});

driver.mockEndpointDynamic(`${serviceControlUrl}pendingretries/queues/retry`, "post", async (_url, _params, request) => {
retryAllRequests.push((await request.json()) as RetryAllRequest);
return { body: {} };
});

driver.mockEndpointDynamic(`${serviceControlUrl}pendingretries/retry`, "post", async (_url, _params, request) => {
const body = await request.json();
if (!Array.isArray(body)) {
retryAllRequests.push(body as RetryAllRequest);
}
return { body: {} };
});

return { pendingRetryMessages: table, retryAllRequests };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import userEvent from "@testing-library/user-event";
import { screen, within } from "@testing-library/vue";
import { getRetryAllConfirmation } from "../questions/pendingRetriesView";

export async function selectQueueFilter(queueAddress: string): Promise<void> {
const queueSelect = await screen.findByRole("combobox");
await userEvent.selectOptions(queueSelect, queueAddress);
}

export async function selectRetryPeriod(period: string): Promise<void> {
const periodMenu = document.querySelector<HTMLElement>(".msg-group-menu .dropdown-menu");
if (!periodMenu) {
throw new Error("Period dropdown menu not found");
}
await userEvent.click(within(periodMenu).getByText(period));
}

export async function clickRetryAll(): Promise<void> {
await userEvent.click(screen.getByRole("button", { name: /retry all/i }));
}

export async function confirmRetryAll(): Promise<void> {
await userEvent.click(within(getRetryAllConfirmation()).getByRole("button", { name: "Yes" }));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { screen } from "@testing-library/vue";
import { normalise } from "./domText";

const ROW_SELECTOR = ".row.box.repeat-item.failed-message";
const RETRY_ALL_CONFIRMATION_NAME = /confirm retry of all messages/i;
const SELECT_QUEUE_FIRST_NAME = /select a queue first/i;

export function getPendingRetryRowCount(): number {
return document.querySelectorAll(ROW_SELECTOR).length;
}

export function selectedRetryPeriod(): string {
return normalise(document.querySelector(".msg-group-menu .sp-btn-menu")?.textContent ?? "");
}

export function getRetryAllConfirmation(): HTMLElement {
return screen.getByRole("dialog", { name: RETRY_ALL_CONFIRMATION_NAME }) as HTMLElement;
}

export function isRetryAllConfirmationVisible(): boolean {
return screen.queryByRole("dialog", { name: RETRY_ALL_CONFIRMATION_NAME }) !== null;
}

export function isSelectQueueFirstNoticeVisible(): boolean {
return screen.queryByRole("dialog", { name: SELECT_QUEUE_FIRST_NAME }) !== null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect } from "vitest";
import { waitFor } from "@testing-library/vue";
import { test, describe } from "../../drivers/vitest/driver";
import * as precondition from "../../preconditions";
import type { Driver } from "../../driver";
import type { PendingRetriesTestBed, RetryAllRequest } from "../../preconditions/pendingRetries";
import routeLinks from "@/router/routeLinks";
import { getPendingRetryRowCount, isRetryAllConfirmationVisible, isSelectQueueFirstNoticeVisible } from "./questions/pendingRetriesView";
import { clickRetryAll, confirmRetryAll, selectQueueFilter, selectRetryPeriod } from "./actions/retryAllPendingRetries";

const PENDING_RETRIES = routeLinks.failedMessage.pendingRetries.link;
const SALES_QUEUE = "Sales.Service";
const HOUR_IN_MS = 60 * 60 * 1000;

async function givenPendingRetriesAreShown(driver: Driver): Promise<PendingRetriesTestBed> {
await driver.setUp(precondition.serviceControlWithMonitoring);
const bed = await driver.setUp(
precondition.hasPendingRetryMessages({
messages: [precondition.createPendingRetryMessage("msg-1", { endpoint: SALES_QUEUE }), precondition.createPendingRetryMessage("msg-2", { endpoint: SALES_QUEUE })],
})
);
await driver.goTo(PENDING_RETRIES);
await waitFor(() => expect(getPendingRetryRowCount()).toBe(2), { timeout: 5000 });
return bed;
}

function requestedRange(request: RetryAllRequest) {
return { from: new Date(request.from).getTime(), to: new Date(request.to).getTime() };
}

describe("FEATURE: Retrying all pending retries", () => {
describe("RULE: Retrying all can only be done for a single queue", () => {
test("EXAMPLE: Clicking Retry all without a queue selected explains that a queue must be selected first and requests nothing", async ({ driver }) => {
const bed = await givenPendingRetriesAreShown(driver);

await clickRetryAll();

expect(isSelectQueueFirstNoticeVisible()).toBe(true);
expect(bed.retryAllRequests).toHaveLength(0);
});
});

describe("RULE: Retrying all should cover the displayed period for the selected queue", () => {
test("EXAMPLE: With the default period, the whole period up to now is requested for the selected queue", async ({ driver }) => {
const bed = await givenPendingRetriesAreShown(driver);
await selectQueueFilter(SALES_QUEUE);

await clickRetryAll();
expect(isRetryAllConfirmationVisible()).toBe(true);
await confirmRetryAll();

await waitFor(() => expect(bed.retryAllRequests).toHaveLength(1));
const request = bed.retryAllRequests[0];
expect(request.queueaddress).toBe(SALES_QUEUE);

const { from, to } = requestedRange(request);
expect(to).toBeGreaterThan(from);
expect(Math.abs(Date.now() - to)).toBeLessThan(30 * 1000);
expect(Math.abs(to - from - 365 * 24 * HOUR_IN_MS)).toBeLessThan(2 * HOUR_IN_MS);
});

test("EXAMPLE: With 'Retried in the last 2 Hours' selected, only the last two hours are requested", async ({ driver }) => {
const bed = await givenPendingRetriesAreShown(driver);
await selectRetryPeriod("Retried in the last 2 Hours");
await selectQueueFilter(SALES_QUEUE);

await clickRetryAll();
await confirmRetryAll();

await waitFor(() => expect(bed.retryAllRequests).toHaveLength(1));
const request = bed.retryAllRequests[0];
expect(request.queueaddress).toBe(SALES_QUEUE);

const { from, to } = requestedRange(request);
expect(Math.abs(Date.now() - to)).toBeLessThan(30 * 1000);
expect(Math.abs(to - from - 2 * HOUR_IN_MS)).toBeLessThan(60 * 1000);
});
});
});
Loading