From a164ca017960f0dfef4a7ea45740a01b1ed6ded4 Mon Sep 17 00:00:00 2001 From: Connor Forde Date: Wed, 1 Apr 2026 15:30:55 +0100 Subject: [PATCH 1/4] fix(HOTE-871): prevent revisiting check-your-answers via browser back --- ui/src/__tests__/routes/HomePage.test.tsx | 36 ++++++++++ .../CheckYourAnswersPage.test.tsx | 68 +++++++++++++++++-- ui/src/routes/HomePage.tsx | 5 +- .../CheckYourAnswersPage.tsx | 35 ++++++++-- 4 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 ui/src/__tests__/routes/HomePage.test.tsx diff --git a/ui/src/__tests__/routes/HomePage.test.tsx b/ui/src/__tests__/routes/HomePage.test.tsx new file mode 100644 index 00000000..c61d9be2 --- /dev/null +++ b/ui/src/__tests__/routes/HomePage.test.tsx @@ -0,0 +1,36 @@ +import { render, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { RoutePath } from "@/lib/models/route-paths"; +import HomePage from "@/routes/HomePage"; + +const mockNavigate = jest.fn(); + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe("HomePage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("redirects to the self-test start page with replace", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { + replace: true, + }); + }); + }); +}); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx index 1f3c2cfd..0ce69d8e 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx @@ -1,4 +1,12 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { RoutePath } from "@/lib/models/route-paths"; +import orderService from "@/lib/services/order-service"; +import sessionService from "@/lib/services/session-service"; import { TestErrorBoundary } from "@/lib/test-utils/TestErrorBoundary"; +import CheckYourAnswersPage from "@/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage"; import { AuthProvider, AuthUser, @@ -6,16 +14,25 @@ import { useAuth, useCreateOrderContext, } from "@/state"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import CheckYourAnswersPage from "@/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage"; -import { MemoryRouter } from "react-router-dom"; -import orderService from "@/lib/services/order-service"; -import { useEffect } from "react"; +const mockNavigate = jest.fn(); +const mockClearAddresses = jest.fn(); +let mockNavigationType = "PUSH"; + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + + return { + ...actual, + useNavigate: () => mockNavigate, + useNavigationType: () => mockNavigationType, + }; +}); const mockGoToStep = jest.fn(); const mockSetReturnToStep = jest.fn(); const mockGoBack = jest.fn(); +const clearJourneyNavigationSpy = jest.spyOn(sessionService, "clearJourneyNavigation"); jest.mock("@/state", () => { const actual = jest.requireActual("@/state"); @@ -31,6 +48,17 @@ jest.mock("@/state", () => { clearHistory: jest.fn(), setReturnToStep: mockSetReturnToStep, }), + usePostcodeLookup: () => ({ + postcode: "", + addresses: [], + selectedAddress: null, + isLoading: false, + lookupResultsStatus: "idle", + error: null, + lookupPostcode: jest.fn(), + setSelectedAddress: jest.fn(), + clearAddresses: mockClearAddresses, + }), }; }); @@ -139,6 +167,7 @@ describe("CheckYourAnswersPage", () => { beforeEach(() => { jest.clearAllMocks(); + mockNavigationType = "PUSH"; }); describe("Component Rendering", () => { @@ -308,6 +337,35 @@ describe("CheckYourAnswersPage", () => { }); describe("Submit Order", () => { + it("clears state and redirects to start when revisited via browser back after submission", async () => { + mockNavigationType = "POP"; + + render( + <> + + + , + { + wrapper: (props) => ( + + ), + }, + ); + + await waitFor(() => { + expect(mockClearAddresses).toHaveBeenCalled(); + expect(clearJourneyNavigationSpy).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(RoutePath.HomePage, { replace: true }); + expect(screen.getByTestId("order-reference")).toHaveTextContent(""); + }); + }); + it("shows error when submitting without consent", async () => { render(, { wrapper: TestWrapper }); diff --git a/ui/src/routes/HomePage.tsx b/ui/src/routes/HomePage.tsx index 37e4eb8f..aab0e112 100644 --- a/ui/src/routes/HomePage.tsx +++ b/ui/src/routes/HomePage.tsx @@ -1,12 +1,13 @@ -import { RoutePath } from "@/lib/models/route-paths"; import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { RoutePath } from "@/lib/models/route-paths"; + export default function HomePage() { const navigate = useNavigate(); useEffect(() => { - navigate(RoutePath.GetSelfTestKitPage); + navigate(RoutePath.GetSelfTestKitPage, { replace: true }); }, [navigate]); return
; diff --git a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx index 71762a9b..854cbba2 100644 --- a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx +++ b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx @@ -1,16 +1,22 @@ "use client"; import { Button, Checkboxes, ErrorSummary, SummaryList } from "nhsuk-react-components"; -import React, { useState } from "react"; +import React, { useLayoutEffect, useState } from "react"; +import { useNavigate, useNavigationType } from "react-router-dom"; import { useAsyncErrorHandler, useContent } from "@/hooks"; import FormPageLayout from "@/layouts/FormPageLayout"; -import { JourneyStepNames } from "@/lib/models/route-paths"; +import { JourneyStepNames, RoutePath } from "@/lib/models/route-paths"; import orderService, { OrderServiceRequest } from "@/lib/services/order-service"; -import { useAuth, useCreateOrderContext, useJourneyNavigationContext } from "@/state"; +import sessionService from "@/lib/services/session-service"; +import { + useAuth, + useCreateOrderContext, + useJourneyNavigationContext, + usePostcodeLookup, +} from "@/state"; // TODO: update to dynamically render supplier based on API (probably stored in state) -// TODO: add order reference number to state when order is submitted (orderAnswers.orderReferenceNumber) function formatAddress(address: { addressLine1?: string; @@ -39,10 +45,14 @@ function formatUserName(user?: { givenName: string; familyName: string } | null) } export default function CheckYourAnswersPage() { - const { orderAnswers, updateOrderAnswers } = useCreateOrderContext(); + const navigate = useNavigate(); + const navigationType = useNavigationType(); + const { orderAnswers, updateOrderAnswers, reset } = useCreateOrderContext(); const { goToStep, goBack, stepHistory, setReturnToStep } = useJourneyNavigationContext(); + const { clearAddresses } = usePostcodeLookup(); const { user } = useAuth(); const { commonContent, "check-your-answers": content } = useContent(); + const hasSubmittedOrder = orderAnswers.orderReferenceNumber != null; const [consentChecked, setConsentChecked] = useState( orderAnswers.consentCheckboxChecked ?? false, @@ -51,6 +61,17 @@ export default function CheckYourAnswersPage() { const supplierName = orderAnswers.supplier?.[0]?.name || "[Supplier]"; + useLayoutEffect(() => { + if (!hasSubmittedOrder || navigationType !== "POP") { + return; + } + + reset(); + clearAddresses(); + sessionService.clearJourneyNavigation(); + navigate(RoutePath.HomePage, { replace: true }); + }, [clearAddresses, hasSubmittedOrder, navigate, navigationType, reset]); + const handleChangeClick = (field: "address" | "mobile" | "comfort") => { setReturnToStep(JourneyStepNames.CheckYourAnswers); @@ -140,6 +161,10 @@ export default function CheckYourAnswersPage() { ? formatAddress(orderAnswers.deliveryAddress) : []; + if (hasSubmittedOrder && navigationType === "POP") { + return null; + } + return ( Date: Thu, 2 Apr 2026 10:54:42 +0100 Subject: [PATCH 2/4] refactor(ui): replace HomePage with declarative Navigate redirect --- ui/package-lock.json | 24 ++++++++++++- ui/src/__tests__/routes/HomePage.test.tsx | 36 ------------------- .../CheckYourAnswersPage.test.tsx | 2 +- ui/src/app.tsx | 5 ++- ui/src/routes/HomePage.tsx | 14 -------- .../CheckYourAnswersPage.tsx | 2 +- 6 files changed, 27 insertions(+), 56 deletions(-) delete mode 100644 ui/src/__tests__/routes/HomePage.test.tsx delete mode 100644 ui/src/routes/HomePage.tsx diff --git a/ui/package-lock.json b/ui/package-lock.json index 8012c199..8ff7db7f 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -683,6 +684,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -706,6 +708,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2233,6 +2236,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2519,6 +2523,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2529,6 +2534,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2539,6 +2545,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2619,6 +2626,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -3099,6 +3107,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3526,6 +3535,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3642,6 +3652,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4534,6 +4545,7 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -4680,6 +4692,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7393,6 +7406,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -7882,6 +7896,7 @@ "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.4.2.tgz", "integrity": "sha512-DYa7E/jwWtQPKqzeF9eB9nVcTKHpjMYf+SydKao379qQapIkblfS2BNvKsVKuWpI0w+QgI8XSDNNOUTQEGRb1w==", "license": "MIT", + "peer": true, "engines": { "node": "^20.9.0 || ^22.11.0 || >= 24.11.0" }, @@ -8325,6 +8340,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8559,6 +8575,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8568,6 +8585,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9716,6 +9734,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9794,7 +9813,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -9916,6 +9936,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10529,6 +10550,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/src/__tests__/routes/HomePage.test.tsx b/ui/src/__tests__/routes/HomePage.test.tsx deleted file mode 100644 index c61d9be2..00000000 --- a/ui/src/__tests__/routes/HomePage.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; - -import { RoutePath } from "@/lib/models/route-paths"; -import HomePage from "@/routes/HomePage"; - -const mockNavigate = jest.fn(); - -jest.mock("react-router-dom", () => { - const actual = jest.requireActual("react-router-dom"); - - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); - -describe("HomePage", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("redirects to the self-test start page with replace", async () => { - render( - - - , - ); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { - replace: true, - }); - }); - }); -}); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx index 0ce69d8e..5ab43afc 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx @@ -361,7 +361,7 @@ describe("CheckYourAnswersPage", () => { await waitFor(() => { expect(mockClearAddresses).toHaveBeenCalled(); expect(clearJourneyNavigationSpy).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(RoutePath.HomePage, { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { replace: true }); expect(screen.getByTestId("order-reference")).toHaveTextContent(""); }); }); diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 957c65b0..ffe9e644 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -1,12 +1,11 @@ import * as React from "react"; -import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"; import ErrorRedirect from "./components/ErrorRedirect"; import JourneyLayout from "./layouts/JourneyLayout"; import MainLayout from "./layouts/MainLayout"; import { JourneyStepNames, RoutePath } from "./lib/models/route-paths"; import CallbackPage from "./routes/CallbackPage"; -import HomePage from "./routes/HomePage"; import HomeTestPrivacyPolicyPage from "./routes/HomeTestPrivacyPolicyPage"; import HomeTestTermsOfUsePage from "./routes/HomeTestTermsOfUsePage"; import LoginPage from "./routes/LoginPage"; @@ -75,7 +74,7 @@ const router = createBrowserRouter([ children: [ { path: RoutePath.HomePage, - element: , + element: , }, { path: RoutePath.OrderTrackingPage, diff --git a/ui/src/routes/HomePage.tsx b/ui/src/routes/HomePage.tsx deleted file mode 100644 index aab0e112..00000000 --- a/ui/src/routes/HomePage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; - -import { RoutePath } from "@/lib/models/route-paths"; - -export default function HomePage() { - const navigate = useNavigate(); - - useEffect(() => { - navigate(RoutePath.GetSelfTestKitPage, { replace: true }); - }, [navigate]); - - return
; -} diff --git a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx index 854cbba2..1ddb2ebb 100644 --- a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx +++ b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx @@ -69,7 +69,7 @@ export default function CheckYourAnswersPage() { reset(); clearAddresses(); sessionService.clearJourneyNavigation(); - navigate(RoutePath.HomePage, { replace: true }); + navigate(RoutePath.GetSelfTestKitPage, { replace: true }); }, [clearAddresses, hasSubmittedOrder, navigate, navigationType, reset]); const handleChangeClick = (field: "address" | "mobile" | "comfort") => { From 16857ef8f47cdf38156b79b0c2deeba36aa749a3 Mon Sep 17 00:00:00 2001 From: Connor Forde Date: Thu, 2 Apr 2026 12:33:41 +0100 Subject: [PATCH 3/4] chore: revert accidental changes to ui/package-lock.json --- ui/package-lock.json | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 8ff7db7f..8012c199 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -96,7 +96,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -684,7 +683,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -708,7 +706,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2236,7 +2233,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2523,7 +2519,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2534,7 +2529,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2545,7 +2539,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2626,7 +2619,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -3107,7 +3099,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3535,7 +3526,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3652,7 +3642,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4545,7 +4534,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -4692,7 +4680,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7406,7 +7393,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -7896,7 +7882,6 @@ "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.4.2.tgz", "integrity": "sha512-DYa7E/jwWtQPKqzeF9eB9nVcTKHpjMYf+SydKao379qQapIkblfS2BNvKsVKuWpI0w+QgI8XSDNNOUTQEGRb1w==", "license": "MIT", - "peer": true, "engines": { "node": "^20.9.0 || ^22.11.0 || >= 24.11.0" }, @@ -8340,7 +8325,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8575,7 +8559,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8585,7 +8568,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9734,7 +9716,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9813,8 +9794,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -9936,7 +9916,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10550,7 +10529,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 93bf47142c3e1166525c799c28d94f41f89e31e4 Mon Sep 17 00:00:00 2001 From: billwirz1 Date: Thu, 2 Apr 2026 15:23:54 +0100 Subject: [PATCH 4/4] chore: clear-in-memory-state-and-session-for-navigation --- .../components/FormBackLink.test.tsx | 6 +- .../CannotUseServiceUnder18Page.test.tsx | 2 + .../CheckYourAnswersPage.test.tsx | 13 +-- .../OrderSubmittedPage.test.tsx | 8 +- .../SelectDeliveryAddressPage.test.tsx | 15 +-- .../state/NavigationContext.test.tsx | 91 +++++++++++++++++++ .../CheckYourAnswersPage.tsx | 12 +-- ui/src/state/NavigationContext.tsx | 19 ++++ 8 files changed, 142 insertions(+), 24 deletions(-) create mode 100644 ui/src/__tests__/state/NavigationContext.test.tsx diff --git a/ui/src/__tests__/components/FormBackLink.test.tsx b/ui/src/__tests__/components/FormBackLink.test.tsx index 4e3d6917..2a78c174 100644 --- a/ui/src/__tests__/components/FormBackLink.test.tsx +++ b/ui/src/__tests__/components/FormBackLink.test.tsx @@ -1,5 +1,4 @@ import "@testing-library/jest-dom"; - import { fireEvent, render, screen } from "@testing-library/react"; import { FormBackLink } from "@/components/FormBackLink"; @@ -12,6 +11,9 @@ const mockNavigationContext = { goBack: jest.fn(), canGoBack: jest.fn(() => true), clearHistory: jest.fn(), + resetNavigation: jest.fn(), + returnToStep: null, + setReturnToStep: jest.fn(), }; jest.mock("@/state", () => ({ @@ -109,7 +111,7 @@ describe("FormBackLink", () => { // BackLink should still be rendered but with empty text // Check for the back link container class - expect(document.querySelector('.nhsuk-back-link')).toBeInTheDocument(); + expect(document.querySelector(".nhsuk-back-link")).toBeInTheDocument(); }); }); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx index fce5955b..1c542d63 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CannotUseServiceUnder18Page.test.tsx @@ -46,6 +46,7 @@ jest.mock("@/hooks", () => ({ const goBackMock = jest.fn(); const goToStepMock = jest.fn(); +const resetNavigationMock = jest.fn(); const TestProvider = ({ children, @@ -77,6 +78,7 @@ const TestProvider = ({ goToStep: goToStepMock, canGoBack: () => history.length > 1, clearHistory: jest.fn(), + resetNavigation: resetNavigationMock, setReturnToStep: jest.fn(), }} > diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx index 5ab43afc..fe9e378e 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.test.tsx @@ -4,7 +4,6 @@ import { MemoryRouter } from "react-router-dom"; import { RoutePath } from "@/lib/models/route-paths"; import orderService from "@/lib/services/order-service"; -import sessionService from "@/lib/services/session-service"; import { TestErrorBoundary } from "@/lib/test-utils/TestErrorBoundary"; import CheckYourAnswersPage from "@/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage"; import { @@ -32,7 +31,7 @@ jest.mock("react-router-dom", () => { const mockGoToStep = jest.fn(); const mockSetReturnToStep = jest.fn(); const mockGoBack = jest.fn(); -const clearJourneyNavigationSpy = jest.spyOn(sessionService, "clearJourneyNavigation"); +const mockResetNavigation = jest.fn(); jest.mock("@/state", () => { const actual = jest.requireActual("@/state"); @@ -46,6 +45,7 @@ jest.mock("@/state", () => { goBack: mockGoBack, canGoBack: () => true, clearHistory: jest.fn(), + resetNavigation: mockResetNavigation, setReturnToStep: mockSetReturnToStep, }), usePostcodeLookup: () => ({ @@ -107,10 +107,10 @@ function StateSeeder({ function AuthSeeder({ children, user = defaultAuthUser, -}: { +}: Readonly<{ children: React.ReactNode; user?: AuthUser; -}) { +}>) { const { setUser } = useAuth(); useEffect(() => { @@ -360,8 +360,9 @@ describe("CheckYourAnswersPage", () => { await waitFor(() => { expect(mockClearAddresses).toHaveBeenCalled(); - expect(clearJourneyNavigationSpy).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { replace: true }); + expect(mockResetNavigation).toHaveBeenCalledWith(RoutePath.GetSelfTestKitPage, { + replace: true, + }); expect(screen.getByTestId("order-reference")).toHaveTextContent(""); }); }); diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx index 31e0807f..c6fde76a 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage.test.tsx @@ -1,13 +1,14 @@ -import { CreateOrderProvider, JourneyNavigationProvider, useCreateOrderContext } from "@/state"; import { render, screen } from "@testing-library/react"; - +import { useEffect } from "react"; import { MemoryRouter } from "react-router-dom"; + import OrderSubmittedPage from "@/routes/get-self-test-kit-for-HIV-journey/OrderSubmittedPage"; -import { useEffect } from "react"; +import { CreateOrderProvider, JourneyNavigationProvider, useCreateOrderContext } from "@/state"; const mockGoToStep = jest.fn(); const mockSetReturnToStep = jest.fn(); const mockGoBack = jest.fn(); +const mockResetNavigation = jest.fn(); jest.mock("@/state", () => { const actual = jest.requireActual("@/state"); @@ -21,6 +22,7 @@ jest.mock("@/state", () => { goBack: mockGoBack, canGoBack: () => false, clearHistory: jest.fn(), + resetNavigation: mockResetNavigation, setReturnToStep: mockSetReturnToStep, }), }; diff --git a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx index 7466ba75..5faceb6a 100644 --- a/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx +++ b/ui/src/__tests__/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage.test.tsx @@ -1,4 +1,11 @@ import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { JourneyStepNames } from "@/lib/models/route-paths"; +import laLookupService from "@/lib/services/la-lookup-service"; +import SelectDeliveryAddressPage from "@/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage"; import { AuthContext, AuthUser, @@ -7,12 +14,6 @@ import { PostcodeLookupProvider, useCreateOrderContext, } from "@/state"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { JourneyStepNames } from "@/lib/models/route-paths"; -import { MemoryRouter } from "react-router-dom"; -import SelectDeliveryAddressPage from "@/routes/get-self-test-kit-for-HIV-journey/SelectDeliveryAddressPage"; -import laLookupService from "@/lib/services/la-lookup-service"; -import { useEffect } from "react"; const FIXED_TODAY = new Date(2026, 2, 4); // March 4, 2026 @@ -26,6 +27,7 @@ const mockNavigationContext: { goBack: jest.Mock; canGoBack: jest.Mock; clearHistory: jest.Mock; + resetNavigation: jest.Mock; stepHistory: string[]; returnToStep: string | null; setReturnToStep: jest.Mock; @@ -35,6 +37,7 @@ const mockNavigationContext: { goBack: jest.fn(), canGoBack: jest.fn(() => true), clearHistory: jest.fn(), + resetNavigation: jest.fn(), stepHistory: ["enter-delivery-address", "select-delivery-address"], returnToStep: null, setReturnToStep: jest.fn(), diff --git a/ui/src/__tests__/state/NavigationContext.test.tsx b/ui/src/__tests__/state/NavigationContext.test.tsx new file mode 100644 index 00000000..b22cbf5b --- /dev/null +++ b/ui/src/__tests__/state/NavigationContext.test.tsx @@ -0,0 +1,91 @@ +import "@testing-library/jest-dom"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { JourneyStepNames, RoutePath } from "@/lib/models/route-paths"; +import { SESSION_STORAGE_KEYS } from "@/lib/services/session-service"; +import { JourneyNavigationProvider, useJourneyNavigationContext } from "@/state/NavigationContext"; + +function TestWrapper({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} + +describe("NavigationContext", () => { + beforeEach(() => { + globalThis.sessionStorage.clear(); + }); + + describe("JourneyNavigationProvider", () => { + it("provides the current step as the initial history", () => { + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + expect(result.current.currentStep).toBe(JourneyStepNames.CheckYourAnswers); + expect(result.current.stepHistory).toEqual([JourneyStepNames.CheckYourAnswers]); + expect(result.current.returnToStep).toBeNull(); + }); + + it("rehydrates persisted navigation and appends the current step when needed", () => { + globalThis.sessionStorage.setItem( + SESSION_STORAGE_KEYS.journeyNavigation, + JSON.stringify({ + stepHistory: [JourneyStepNames.EnterMobileNumber], + returnToStep: JourneyStepNames.CheckYourAnswers, + }), + ); + + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + expect(result.current.stepHistory).toEqual([ + JourneyStepNames.EnterMobileNumber, + JourneyStepNames.CheckYourAnswers, + ]); + expect(result.current.returnToStep).toBe(JourneyStepNames.CheckYourAnswers); + }); + + it("resetNavigation clears in-memory navigation and storage for a redirect target", async () => { + const { result } = renderHook(() => useJourneyNavigationContext(), { + wrapper: TestWrapper, + }); + + act(() => { + result.current.setReturnToStep(JourneyStepNames.CheckYourAnswers); + }); + + await waitFor(() => { + expect( + globalThis.sessionStorage.getItem(SESSION_STORAGE_KEYS.journeyNavigation), + ).not.toBeNull(); + }); + + act(() => { + result.current.resetNavigation(RoutePath.GetSelfTestKitPage); + }); + + await waitFor(() => { + expect(result.current.currentStep).toBe(RoutePath.GetSelfTestKitPage); + }); + + expect(result.current.returnToStep).toBeNull(); + expect(result.current.stepHistory).toEqual([RoutePath.GetSelfTestKitPage]); + expect(globalThis.sessionStorage.getItem(SESSION_STORAGE_KEYS.journeyNavigation)).toBeNull(); + }); + }); + + describe("useJourneyNavigationContext", () => { + it("throws when used outside provider", () => { + expect(() => { + renderHook(() => useJourneyNavigationContext()); + }).toThrow("useJourneyNavigationContext must be used within a JourneyNavigationProvider"); + }); + }); +}); diff --git a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx index 1ddb2ebb..62a5a7ac 100644 --- a/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx +++ b/ui/src/routes/get-self-test-kit-for-HIV-journey/CheckYourAnswersPage.tsx @@ -2,13 +2,12 @@ import { Button, Checkboxes, ErrorSummary, SummaryList } from "nhsuk-react-components"; import React, { useLayoutEffect, useState } from "react"; -import { useNavigate, useNavigationType } from "react-router-dom"; +import { useNavigationType } from "react-router-dom"; import { useAsyncErrorHandler, useContent } from "@/hooks"; import FormPageLayout from "@/layouts/FormPageLayout"; import { JourneyStepNames, RoutePath } from "@/lib/models/route-paths"; import orderService, { OrderServiceRequest } from "@/lib/services/order-service"; -import sessionService from "@/lib/services/session-service"; import { useAuth, useCreateOrderContext, @@ -45,10 +44,10 @@ function formatUserName(user?: { givenName: string; familyName: string } | null) } export default function CheckYourAnswersPage() { - const navigate = useNavigate(); const navigationType = useNavigationType(); const { orderAnswers, updateOrderAnswers, reset } = useCreateOrderContext(); - const { goToStep, goBack, stepHistory, setReturnToStep } = useJourneyNavigationContext(); + const { goToStep, goBack, stepHistory, resetNavigation, setReturnToStep } = + useJourneyNavigationContext(); const { clearAddresses } = usePostcodeLookup(); const { user } = useAuth(); const { commonContent, "check-your-answers": content } = useContent(); @@ -68,9 +67,8 @@ export default function CheckYourAnswersPage() { reset(); clearAddresses(); - sessionService.clearJourneyNavigation(); - navigate(RoutePath.GetSelfTestKitPage, { replace: true }); - }, [clearAddresses, hasSubmittedOrder, navigate, navigationType, reset]); + resetNavigation(RoutePath.GetSelfTestKitPage, { replace: true }); + }, [clearAddresses, hasSubmittedOrder, navigationType, reset, resetNavigation]); const handleChangeClick = (field: "address" | "mobile" | "comfort") => { setReturnToStep(JourneyStepNames.CheckYourAnswers); diff --git a/ui/src/state/NavigationContext.tsx b/ui/src/state/NavigationContext.tsx index 31ffeb07..febe3c50 100644 --- a/ui/src/state/NavigationContext.tsx +++ b/ui/src/state/NavigationContext.tsx @@ -51,6 +51,7 @@ export interface JourneyNavigationContextType { goBack: () => void; canGoBack: () => boolean; clearHistory: () => void; + resetNavigation: (step?: Step, options?: { replace?: boolean }) => void; setReturnToStep: (step: Step | null) => void; } @@ -134,6 +135,22 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea })); }, [currentStep]); + const resetNavigation = useCallback( + (step: Step = currentStep, options?: { replace?: boolean }) => { + setNavigation({ + stepHistory: [step], + returnToStep: null, + }); + + sessionService.clearJourneyNavigation(); + + if (step !== currentStep || options?.replace === true) { + navigate(getPathForStep(step), { replace: options?.replace }); + } + }, + [currentStep, navigate], + ); + const setReturnToStep = useCallback((step: Step | null) => { setNavigation((previousNavigation) => ({ ...previousNavigation, @@ -150,6 +167,7 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea goBack, canGoBack, clearHistory, + resetNavigation, setReturnToStep, }), [ @@ -160,6 +178,7 @@ export function JourneyNavigationProvider({ children }: Readonly<{ children: Rea goBack, canGoBack, clearHistory, + resetNavigation, setReturnToStep, ], );