From c0bb3d71b3562de80c93cb659ffdfc7c9528aa6b Mon Sep 17 00:00:00 2001 From: John Simons Date: Wed, 30 Sep 2026 11:21:26 +1000 Subject: [PATCH] Fix "Retry all" on pending retries requesting an empty date range Fixes a bug where `retryAll` was sending epoch zero as the date range instead of the actual selected period. Also removes `shallowReadonly` from `selectedQueue` to allow it to be mutated from outside the store where needed. --- .../src/stores/RecoverabilityStore.ts | 7 +- src/Frontend/test/preconditions/index.ts | 1 + .../test/preconditions/pendingRetries.ts | 114 ++++++++++++++++++ .../actions/retryAllPendingRetries.ts | 24 ++++ .../questions/pendingRetriesView.ts | 26 ++++ .../retrying-all-pending-retries.spec.ts | 79 ++++++++++++ 6 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 src/Frontend/test/preconditions/pendingRetries.ts create mode 100644 src/Frontend/test/specs/failedmessages/actions/retryAllPendingRetries.ts create mode 100644 src/Frontend/test/specs/failedmessages/questions/pendingRetriesView.ts create mode 100644 src/Frontend/test/specs/failedmessages/retrying-all-pending-retries.spec.ts diff --git a/src/Frontend/src/stores/RecoverabilityStore.ts b/src/Frontend/src/stores/RecoverabilityStore.ts index b2e2ebc16..8ec2bd657 100644 --- a/src/Frontend/src/stores/RecoverabilityStore.ts +++ b/src/Frontend/src/stores/RecoverabilityStore.ts @@ -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"; @@ -273,7 +274,7 @@ export const useRecoverabilityStore = defineStore("RecoverabilityStore", () => { deletedPeriodOptions, retryPeriodOptions, selectedPeriod, - selectedQueue: shallowReadonly(selectedQueue), + selectedQueue, endpoints: shallowReadonly(endpoints), setSort, setPeriod, diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index 51dceb686..448e0f46a 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -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"; diff --git a/src/Frontend/test/preconditions/pendingRetries.ts b/src/Frontend/test/preconditions/pendingRetries.ts new file mode 100644 index 000000000..001564508 --- /dev/null +++ b/src/Frontend/test/preconditions/pendingRetries.ts @@ -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) => { 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 }; + }; diff --git a/src/Frontend/test/specs/failedmessages/actions/retryAllPendingRetries.ts b/src/Frontend/test/specs/failedmessages/actions/retryAllPendingRetries.ts new file mode 100644 index 000000000..74c99a1fd --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/retryAllPendingRetries.ts @@ -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 { + const queueSelect = await screen.findByRole("combobox"); + await userEvent.selectOptions(queueSelect, queueAddress); +} + +export async function selectRetryPeriod(period: string): Promise { + const periodMenu = document.querySelector(".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 { + await userEvent.click(screen.getByRole("button", { name: /retry all/i })); +} + +export async function confirmRetryAll(): Promise { + await userEvent.click(within(getRetryAllConfirmation()).getByRole("button", { name: "Yes" })); +} diff --git a/src/Frontend/test/specs/failedmessages/questions/pendingRetriesView.ts b/src/Frontend/test/specs/failedmessages/questions/pendingRetriesView.ts new file mode 100644 index 000000000..c551c5a56 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/pendingRetriesView.ts @@ -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; +} diff --git a/src/Frontend/test/specs/failedmessages/retrying-all-pending-retries.spec.ts b/src/Frontend/test/specs/failedmessages/retrying-all-pending-retries.spec.ts new file mode 100644 index 000000000..835240939 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/retrying-all-pending-retries.spec.ts @@ -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 { + 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); + }); + }); +});