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
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// useGetRefreshFlowsQuery hook tests — the queryFn writes the global flows
// store as a side effect, so a superseded (aborted) fetch must NEVER reach
// setFlows: a stale pre-creation list landing after a create+navigate makes
// FlowPage's existence guard bounce the user back to the list.

const mockApiGet = jest.fn();
const mockSetFlows = jest.fn();
const mockSetErrorData = jest.fn();
const mockTypesSetState = jest.fn();

let capturedQueryFn:
| ((context: { signal?: AbortSignal }) => Promise<unknown>)
| null = null;

jest.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock("@/stores/flowsManagerStore", () => ({
__esModule: true,
default: jest.fn((selector: (state: unknown) => unknown) =>
selector({ setFlows: mockSetFlows }),
),
}));

jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: jest.fn((selector: (state: unknown) => unknown) =>
selector({ setErrorData: mockSetErrorData }),
),
}));

jest.mock("@/stores/typesStore", () => ({
useTypesStore: {
setState: (...args: unknown[]) => mockTypesSetState(...args),
},
}));

jest.mock("@/utils/reactflowUtils", () => ({
processFlows: jest.fn((flows: unknown[]) => ({ data: {}, flows })),
extractSecretFieldsFromComponents: jest.fn(() => ({})),
}));

jest.mock("@/controllers/API/api", () => ({
api: { get: mockApiGet },
}));

jest.mock("@/controllers/API/helpers/constants", () => ({
getURL: jest.fn((key: string) => `/api/v1/${key.toLowerCase()}`),
}));

jest.mock("@/controllers/API/services/request-processor", () => ({
UseRequestProcessor: jest.fn(() => ({
query: jest.fn(
(_key: unknown, fn: typeof capturedQueryFn, _options: unknown) => {
capturedQueryFn = fn;
return { data: undefined, isLoading: false, error: null };
},
),
queryClient: {},
})),
}));

import axios, { AxiosError } from "axios";
import { useGetRefreshFlowsQuery } from "../use-get-refresh-flows-query";

const FLOWS = [{ id: "flow-1" }, { id: "flow-2" }];

describe("useGetRefreshFlowsQuery", () => {
beforeEach(() => {
jest.clearAllMocks();
capturedQueryFn = null;
});

it("forwards the query's AbortSignal to both HTTP calls and sets flows on success", async () => {
mockApiGet
.mockResolvedValueOnce({ data: FLOWS })
.mockResolvedValueOnce({ data: [] });

useGetRefreshFlowsQuery({ get_all: true, header_flows: true });
const controller = new AbortController();
const result = await capturedQueryFn!({ signal: controller.signal });

expect(mockApiGet).toHaveBeenCalledTimes(2);
expect(mockApiGet.mock.calls[0][1]).toEqual({ signal: controller.signal });
expect(mockApiGet.mock.calls[1][1]).toEqual({ signal: controller.signal });
expect(mockSetFlows).toHaveBeenCalledWith(FLOWS);
expect(result).toEqual(FLOWS);
expect(mockSetErrorData).not.toHaveBeenCalled();
});

it("does not write the flows store or toast when the fetch is aborted", async () => {
mockApiGet.mockRejectedValueOnce(new axios.CanceledError("canceled"));

useGetRefreshFlowsQuery({ get_all: true, header_flows: true });
await expect(capturedQueryFn!({ signal: undefined })).rejects.toThrow();

expect(mockSetFlows).not.toHaveBeenCalled();
expect(mockSetErrorData).not.toHaveBeenCalled();
});

it("does not write the flows store or toast when the abort hits the second call", async () => {
mockApiGet
.mockResolvedValueOnce({ data: FLOWS })
.mockRejectedValueOnce(new axios.CanceledError("canceled"));

useGetRefreshFlowsQuery({ get_all: true, header_flows: true });
await expect(capturedQueryFn!({ signal: undefined })).rejects.toThrow();

expect(mockSetFlows).not.toHaveBeenCalled();
expect(mockSetErrorData).not.toHaveBeenCalled();
});

it("still toasts on a non-cancellation server error", async () => {
const serverError = new AxiosError("boom");
(serverError as AxiosError & { status?: number }).status = 500;
mockApiGet.mockRejectedValueOnce(serverError);

useGetRefreshFlowsQuery({ get_all: true, header_flows: true });
await expect(capturedQueryFn!({ signal: undefined })).rejects.toThrow();

expect(mockSetFlows).not.toHaveBeenCalled();
expect(mockSetErrorData).toHaveBeenCalledWith({
title: "errors.couldNotLoadFlows",
});
});

it("stays silent on 403 (auth-guarded fetch)", async () => {
const forbidden = new AxiosError("forbidden");
(forbidden as AxiosError & { status?: number }).status = 403;
mockApiGet.mockRejectedValueOnce(forbidden);

useGetRefreshFlowsQuery({ get_all: true, header_flows: true });
await expect(capturedQueryFn!({ signal: undefined })).rejects.toThrow();

expect(mockSetErrorData).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { UseQueryOptions } from "@tanstack/react-query";
import { AxiosError } from "axios";
import axios, { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import buildQueryStringUrl from "@/controllers/utils/create-query-param-string";
import useAlertStore from "@/stores/alertStore";
Expand Down Expand Up @@ -40,10 +40,11 @@ export const useGetRefreshFlowsQuery: useQueryFunctionType<

const getFlowsFn = async (
params: GetFlowsParams,
signal?: AbortSignal,
): Promise<FlowType[] | PaginatedFlowsType> => {
try {
const url = addQueryParams(`${getURL("FLOWS")}/`, params);
const { data: dbDataFlows } = await api.get<FlowType[]>(url);
const { data: dbDataFlows } = await api.get<FlowType[]>(url, { signal });

if (params.components_only) {
return dbDataFlows;
Expand All @@ -54,6 +55,7 @@ export const useGetRefreshFlowsQuery: useQueryFunctionType<
components_only: true,
get_all: true,
}),
{ signal },
);

if (dbDataComponents) {
Expand All @@ -77,7 +79,11 @@ export const useGetRefreshFlowsQuery: useQueryFunctionType<

return [];
} catch (e) {
if (e instanceof AxiosError && e.status !== 403) {
// Cancellation is not a failure: the fetch was superseded (e.g. a
// create fired refetchQueries) or its observers unmounted. Surfacing
// a toast for it would flash "could not load flows" on normal
// navigation.
if (e instanceof AxiosError && e.status !== 403 && !axios.isCancel(e)) {
setErrorData({
title: t("errors.couldNotLoadFlows"),
});
Expand All @@ -86,9 +92,14 @@ export const useGetRefreshFlowsQuery: useQueryFunctionType<
}
};

// Forward the query's AbortSignal into the HTTP layer. ``setFlows`` above
// is a side effect on the global flows store: without the signal, a
// superseded fetch keeps running and lands a stale (pre-mutation) flow
// list AFTER a create has already navigated to the new flow, which makes
// FlowPage's existence guard bounce the user back to the list.
const queryResult = query(
["useGetRefreshFlowsQuery", params],
() => getFlowsFn(params || {}),
({ signal }) => getFlowsFn(params || {}, signal),
options as UseQueryOptions,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type { FlowType } from "@/types/flow";
import useLoadFlowForRoute from "../use-load-flow-for-route";

const FLOW: FlowType = {
id: "new-flow",
name: "New Flow",
description: "",
data: null,
};

type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
};

const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};

const renderRouteLoader = (
options: {
id?: string;
getFlow?: jest.Mock<Promise<FlowType>, [{ id: string }]>;
} = {},
) => {
const id = "id" in options ? options.id : FLOW.id;
const getFlow = options.getFlow ?? jest.fn().mockResolvedValue(FLOW);
const applyFlowToCanvas = jest.fn();
const navigate = jest.fn();
const result = renderHook(() =>
useLoadFlowForRoute({
id,
flows: [],
currentFlowId: "",
types: { flow: "Flow" },
getFlow,
applyFlowToCanvas,
navigate,
}),
);

return { ...result, getFlow, applyFlowToCanvas, navigate };
};

describe("useLoadFlowForRoute", () => {
let consoleError: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
consoleError.mockRestore();
});

it("confirms a store-missing flow with the server and applies it", async () => {
const { getFlow, applyFlowToCanvas, navigate } = renderRouteLoader();

await waitFor(() => {
expect(getFlow).toHaveBeenCalledWith({ id: FLOW.id });
expect(applyFlowToCanvas).toHaveBeenCalledWith(FLOW);
});
expect(navigate).not.toHaveBeenCalled();
});

it("logs and redirects when the server cannot confirm the flow", async () => {
const error = new Error("network unavailable");
const getFlow = jest.fn().mockRejectedValue(error);
const { applyFlowToCanvas, navigate } = renderRouteLoader({ getFlow });

await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
`Failed to confirm flow ${FLOW.id} from the server:`,
error,
);
expect(navigate).toHaveBeenCalledWith("/all");
});
expect(applyFlowToCanvas).not.toHaveBeenCalled();
});

it("redirects without fetching when the route id is missing", async () => {
const getFlow = jest.fn().mockResolvedValue(FLOW);
const { navigate } = renderRouteLoader({ id: undefined, getFlow });

await waitFor(() => expect(navigate).toHaveBeenCalledWith("/all"));
expect(getFlow).not.toHaveBeenCalled();
});

it("does not apply a flow when the effect is cleaned up", async () => {
const pending = deferred<FlowType>();
const getFlow = jest.fn().mockReturnValue(pending.promise);
const { unmount, applyFlowToCanvas, navigate } = renderRouteLoader({
getFlow,
});

await waitFor(() => expect(getFlow).toHaveBeenCalledWith({ id: FLOW.id }));
unmount();
await act(async () => pending.resolve(FLOW));

expect(applyFlowToCanvas).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});

it("does not redirect or log when a stale confirmation fails", async () => {
const pending = deferred<FlowType>();
const getFlow = jest.fn().mockReturnValue(pending.promise);
const { unmount, navigate } = renderRouteLoader({ getFlow });

await waitFor(() => expect(getFlow).toHaveBeenCalledWith({ id: FLOW.id }));
unmount();
await act(async () => pending.reject(new Error("stale request failed")));

expect(navigate).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
});
});
66 changes: 66 additions & 0 deletions src/frontend/src/pages/FlowPage/hooks/use-load-flow-for-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect } from "react";
import type { FlowType } from "@/types/flow";

type GetFlow = (payload: { id: string }) => Promise<FlowType>;

type UseLoadFlowForRouteProps = {
id?: string;
flows?: FlowType[];
currentFlowId: string;
types: Record<string, string>;
getFlow: GetFlow;
applyFlowToCanvas: (flow: FlowType) => void;
navigate: (path: string) => void;
};

export default function useLoadFlowForRoute({
id,
flows,
currentFlowId,
types,
getFlow,
applyFlowToCanvas,
navigate,
}: UseLoadFlowForRouteProps): void {
useEffect(() => {
if (!flows || currentFlowId !== "" || Object.keys(types).length === 0) {
return;
}

if (!id) {
navigate("/all");
return;
}

let cancelled = false;
const storedFlow = flows.find((flow) => flow.id === id);

const loadFlowToCanvas = async (flowId: string) => {
const flow = await getFlow({ id: flowId });
if (!cancelled) {
applyFlowToCanvas(flow);
}
};

if (storedFlow) {
void loadFlowToCanvas(storedFlow.id);
} else {
// The flows store is not authoritative here: right after
// create-then-navigate (the "New Flow" button), an in-flight list
// refetch snapshotted BEFORE the create can land now and rewrite the
// store without the new flow. Confirm with the server before giving up
// on the route.
void loadFlowToCanvas(id).catch((error) => {
if (cancelled) {
return;
}
console.error(`Failed to confirm flow ${id} from the server:`, error);
navigate("/all");
});
}

return () => {
cancelled = true;
};
}, [id, flows, currentFlowId, types, getFlow, applyFlowToCanvas, navigate]);
}
Loading
Loading