From 822be2825bb5beb8016d16aca3316d051e22242d Mon Sep 17 00:00:00 2001 From: Terence Sheppard <260696118+terence-sheppard-nhs@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:43:20 +0000 Subject: [PATCH 01/24] VIA-832 TS/DB Moved non-personalised vaccine content to dedicated component (cherry picked from commit 42d06f2b2509ddfb540e1284cf205b5cf65dca76) --- ...NonPersonalisedVaccinePageContent.test.tsx | 248 ++++++++++++++++++ .../NonPersonalisedVaccinePageContent.tsx | 38 +++ src/app/_components/vaccine/Vaccine.test.tsx | 238 ++++------------- src/app/_components/vaccine/Vaccine.tsx | 31 +-- 4 files changed, 340 insertions(+), 215 deletions(-) create mode 100644 src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx create mode 100644 src/app/_components/content/NonPersonalisedVaccinePageContent.tsx diff --git a/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx b/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx new file mode 100644 index 00000000..20451b87 --- /dev/null +++ b/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx @@ -0,0 +1,248 @@ +import { VaccineType } from "@project/src/models/vaccine"; +import { StyledVaccineContent } from "@project/src/services/content-api/types"; +import { mockStyledContent } from "@project/test-data/content-api/data"; +import { render, screen } from "@testing-library/react"; + +import { NonPersonalisedVaccinePageContent } from "./NonPersonalisedVaccinePageContent"; + +jest.mock("react-markdown", () => + jest.fn(function MockMarkdown(props) { + return
{props.children}
; + }), +); +jest.mock("cheerio", () => ({ + load: jest.fn(() => { + const selectorImpl = jest.fn(() => ({ + attr: jest.fn(), + })); + + return Object.assign(selectorImpl, { + html: jest.fn(() => "

HTML fragment

"), + }); + }), +})); + +describe("NonPersonalisedVaccinePageContent", () => { + describe("shows content section, when content available", () => { + const testCases = [ + { + campaignPreOpen: false, + campaignOpen: false, + }, + { + campaignPreOpen: true, + campaignOpen: false, + }, + { + campaignPreOpen: false, + campaignOpen: true, + }, + { + campaignPreOpen: true, + campaignOpen: true, + }, + ]; + + it.each(testCases)("should include overview text when %s", async ({ campaignPreOpen, campaignOpen }) => { + await renderNonPersonalisedVaccinePage( + mockStyledContent, + VaccineType.TD_IPV_3_IN_1, + campaignOpen, + campaignPreOpen, + ); + + const overviewText: HTMLElement = screen.getByText("Overview text"); + + expect(overviewText).toBeInTheDocument(); + }); + + it.each(testCases)("should include recommendation text when %s", async ({ campaignPreOpen, campaignOpen }) => { + await renderNonPersonalisedVaccinePage( + mockStyledContent, + VaccineType.FLU_IN_PREGNANCY, + campaignOpen, + campaignPreOpen, + ); + + const recommendationText: HTMLElement = screen.getByRole("heading", { + name: "Non-urgent advice: Recommendation Heading", + level: 2, + }); + + expect(recommendationText).toBeInTheDocument(); + }); + + it.each(testCases)( + "should include additionalInformation text when %s", + async ({ campaignPreOpen, campaignOpen }) => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.MMRV, campaignOpen, campaignPreOpen); + + const additionalInformation: HTMLElement = screen.getByText("Additional Information component"); + + expect(additionalInformation).toBeInTheDocument(); + }, + ); + + it.each(testCases)( + "should not include additionalInformation text when %s", + async ({ campaignPreOpen, campaignOpen }) => { + await renderNonPersonalisedVaccinePage( + { ...mockStyledContent, additionalInformation: undefined }, + VaccineType.MMRV, + campaignOpen, + campaignPreOpen, + ); + + const additionalInformation: HTMLElement | null = screen.queryByText("Additional Information component"); + + expect(additionalInformation).not.toBeInTheDocument(); + }, + ); + }); + + describe("shows content section, when content available for Vaccines that do not have campagins", () => { + const campaignPreOpen = false; + const campaignOpen = false; + + it("should not include actions", async () => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, campaignOpen, campaignPreOpen); + + const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); + + expect(actions).toBe(null); + }); + + it("should not include PreOpen actions", async () => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, campaignOpen, campaignPreOpen); + + const preOpenActions: HTMLElement | null = screen.queryByRole("button", { + name: "Book, cancel or change an appointment", + }); + + expect(preOpenActions).toBe(null); + }); + }); + + describe("shows callouts and actions for Vaccines that handle campaigns (COVID_19)", () => { + const covid19VaccineType = VaccineType.COVID_19; + + it("should include callout heading when campaign is closed", async () => { + const campaignOpen = false; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const calloutHeading: HTMLElement = screen.getByRole("heading", { name: "Important: Callout Heading" }); + + expect(calloutHeading).toBeInTheDocument(); + }); + + it("should not include callout heading when campaign is open", async () => { + const campaignOpen = true; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); + + expect(calloutHeading).toBeNull(); + }); + + it("should not include callout heading when campaign is pre-open", async () => { + const campaignOpen = false; + const campaignPreOpen = true; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); + + expect(calloutHeading).toBeNull(); + }); + + it("should include actions when campaign is open", async () => { + const campaignOpen = true; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const actions: HTMLElement = screen.getByRole("button", { name: "Continue to booking" }); + + expect(actions).toBeInTheDocument(); + }); + + it("should not include open campaign actions when campaign is pre-open", async () => { + const campaignOpen = false; + const campaignPreOpen = true; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); + + expect(actions).toBeNull(); + }); + + it("should not include actions when campaign is closed", async () => { + const campaignOpen = false; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); + + expect(actions).toBeNull(); + }); + + it("should include pre-open actions when campaign is pre-open", async () => { + const campaignOpen = false; + const campaignPreOpen = true; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const preOpenActions: HTMLElement = screen.getByRole("button", { name: "Book, cancel or change an appointment" }); + + expect(preOpenActions).toBeInTheDocument(); + }); + + it("should not include pre-open actions when campaign is open", async () => { + const campaignOpen = true; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const preOpenActions: HTMLElement | null = screen.queryByRole("button", { + name: "Book, cancel or change an appointment", + }); + + expect(preOpenActions).toBeNull(); + }); + + it("should not include pre-open actions when campaign is closed", async () => { + const campaignOpen = false; + const campaignPreOpen = false; + + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + + const preOpenActions: HTMLElement | null = screen.queryByRole("button", { + name: "Book, cancel or change an appointment", + }); + + expect(preOpenActions).toBeNull(); + }); + }); +}); + +const renderNonPersonalisedVaccinePage = async ( + styledVaccineContent: StyledVaccineContent, + vaccineType: VaccineType, + isCampaignOpen: boolean, + isCampaignPreOpen: boolean, +) => { + render( + await NonPersonalisedVaccinePageContent({ + styledVaccineContent, + vaccineType, + isCampaignOpen, + isCampaignPreOpen, + }), + ); +}; diff --git a/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx b/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx new file mode 100644 index 00000000..15fa9746 --- /dev/null +++ b/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx @@ -0,0 +1,38 @@ +import { Overview } from "@src/app/_components/content/Overview"; +import Recommendation from "@src/app/_components/content/Recommendation"; +import { EligibilityActions } from "@src/app/_components/eligibility/EligibilityActions"; +import WarningCallout from "@src/app/_components/nhs-frontend/WarningCallout"; +import { VaccineType } from "@src/models/vaccine"; +import { StyledVaccineContent } from "@src/services/content-api/types"; + +const NonPersonalisedVaccinePageContent = (props: { + styledVaccineContent: StyledVaccineContent; + vaccineType: VaccineType; + isCampaignOpen: boolean; + isCampaignPreOpen: boolean; +}) => { + return ( + <> + + + {!props.isCampaignOpen && !props.isCampaignPreOpen && ( + + )} + {props.styledVaccineContent.additionalInformation?.component && ( +
{props.styledVaccineContent.additionalInformation.component}
+ )} + {!props.isCampaignOpen && props.isCampaignPreOpen && ( + + )} + {props.isCampaignOpen && ( + + )} + + + ); +}; + +export { NonPersonalisedVaccinePageContent }; diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index c40074ba..d957f019 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -33,11 +33,25 @@ jest.mock("@src/app/_components/eligibility/EligibilityVaccinePageContent", () =
Test Eligibility Content Component
)), })); -jest.mock("react-markdown", () => - jest.fn(function MockMarkdown(props) { - return
{props.children}
; +jest.mock("@src/app/_components/content/NonPersonalisedVaccinePageContent", () => ({ + NonPersonalisedVaccinePageContent: jest.fn().mockImplementation((props) => { + if (props.isCampaignOpen) { + return ( +
Test Non-personalised Vaccine Page Content Component
+ ); + } else if (props.isCampaignPreOpen) { + return ( +
+ Test Non-personalised Vaccine Page Content Component +
+ ); + } else { + return ( +
Test Non-personalised Vaccine Page Content Component
+ ); + } }), -); +})); jest.mock("@project/auth", () => ({ auth: jest.fn(), })); @@ -47,17 +61,6 @@ jest.mock("next/headers", () => ({ })); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); jest.mock("@src/utils/config"); -jest.mock("cheerio", () => ({ - load: jest.fn(() => { - const selectorImpl = jest.fn(() => ({ - attr: jest.fn(), - })); - - return Object.assign(selectorImpl, { - html: jest.fn(() => "

HTML fragment

"), - }); - }), -})); const nhsNumber = "5123456789"; @@ -137,47 +140,12 @@ describe("Any vaccine page", () => { (getEligibilityForPerson as jest.Mock).mockResolvedValue(eligibilitySuccessResponse); }); - it("should include overview text", async () => { - await renderNamedVaccinePage(VaccineType.TD_IPV_3_IN_1); - - const overviewText: HTMLElement = screen.getByText("Overview text"); - - expect(overviewText).toBeInTheDocument(); - }); - - it("should include recommendation text", async () => { - await renderNamedVaccinePage(VaccineType.FLU_IN_PREGNANCY); + it("should display non-personalised vaccine page content", async () => { + await renderNamedVaccinePage(VaccineType.RSV); - const recommendationText: HTMLElement = screen.getByRole("heading", { - name: "Non-urgent advice: Recommendation Heading", - level: 2, - }); + const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock"); - expect(recommendationText).toBeInTheDocument(); - }); - - it("should include additionalInformation text", async () => { - await renderNamedVaccinePage(VaccineType.MMRV); - - const additionalInformation: HTMLElement = screen.getByText("Additional Information component"); - - expect(additionalInformation).toBeInTheDocument(); - }); - - it("should include callout heading", async () => { - await renderNamedVaccinePage(VaccineType.MMR); - - const calloutHeading: HTMLElement = screen.getByRole("heading", { name: "Important: Callout Heading" }); - - expect(calloutHeading).toBeInTheDocument(); - }); - - it("should include actions", async () => { - await renderNamedVaccinePage(VaccineType.COVID_19); - - const actions: HTMLElement = screen.getByRole("button", { name: "Continue to booking" }); - - expect(actions).toBeInTheDocument(); + expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); }); it("should include more information expanders", async () => { @@ -218,10 +186,9 @@ describe("Any vaccine page", () => { }); }); - describe("shows callouts and actions for Vaccines that handle campaigns (COVID_19)", () => { + describe("shows correct content for Vaccines that handle campaigns (COVID_19)", () => { const mockedConfig = config as ConfigMock; const campaigns = new Campaigns({}); - const covid19VaccineType = VaccineType.COVID_19; beforeEach(() => { (getContentForVaccine as jest.Mock).mockResolvedValue(contentSuccessResponse); @@ -230,134 +197,37 @@ describe("Any vaccine page", () => { Object.assign(mockedConfig, defaultConfig); }); - it("should include callout heading when campaign is closed", async () => { - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); - - const calloutHeading: HTMLElement = screen.getByRole("heading", { name: "Important: Callout Heading" }); - - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(calloutHeading).toBeInTheDocument(); - closedCampaignSpy.mockRestore(); - preOpenCampaignSpy.mockRestore(); - }); - - it("should not include callout heading when campaign is open", async () => { - const openCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(true); - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + it("should display non-personalised vaccine page content for PreOpen Campaign", async () => { + jest.spyOn(campaigns, "isPreOpen").mockReturnValue(true); + jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); - - expect(openCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(calloutHeading).toBeNull(); - openCampaignSpy.mockRestore(); - preOpenCampaignSpy.mockRestore(); - }); - - it("should not include callout heading when campaign is pre-open", async () => { - const openCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(true); - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); - - const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); - - expect(openCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(calloutHeading).toBeNull(); - openCampaignSpy.mockRestore(); - closedCampaignSpy.mockRestore(); - }); - - it("should include actions when campaign is open", async () => { - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - const openCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(true); - await renderNamedVaccinePage(covid19VaccineType); - - const actions: HTMLElement = screen.getByRole("button", { name: "Continue to booking" }); - - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(openCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(actions).toBeInTheDocument(); - preOpenCampaignSpy.mockRestore(); - openCampaignSpy.mockRestore(); - }); - - it("should not include open campaign actions when campaign is pre-open", async () => { - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(true); - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + await renderNamedVaccinePage(VaccineType.COVID_19); - const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); + const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock-preopen"); - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(actions).toBeNull(); - preOpenCampaignSpy.mockRestore(); - closedCampaignSpy.mockRestore(); + expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); }); - it("should not include actions when campaign is closed", async () => { - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + it("should display non-personalised vaccine page content for Open Campaign", async () => { + jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); + jest.spyOn(campaigns, "isOpen").mockReturnValue(true); - const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); - - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(actions).toBeNull(); - preOpenCampaignSpy.mockRestore(); - closedCampaignSpy.mockRestore(); - }); - - it("should include pre-open actions when campaign is pre-open", async () => { - const preOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(true); - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + await renderNamedVaccinePage(VaccineType.COVID_19); - const preOpenActions: HTMLElement = screen.getByRole("button", { name: "Book, cancel or change an appointment" }); + const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock-open"); - expect(preOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(preOpenActions).toBeInTheDocument(); - preOpenCampaignSpy.mockRestore(); - closedCampaignSpy.mockRestore(); + expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); }); - it("should not include pre-open actions when campaign is open", async () => { - const openCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(true); - const closedPreOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + it("should display non-personalised vaccine page content for Closed Campaign", async () => { + jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); + jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - const preOpenActions: HTMLElement | null = screen.queryByRole("button", { - name: "Book, cancel or change an appointment", - }); - - expect(openCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedPreOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(preOpenActions).toBeNull(); - openCampaignSpy.mockRestore(); - closedPreOpenCampaignSpy.mockRestore(); - }); - - it("should not include pre-open actions when campaign is closed", async () => { - const closedCampaignSpy = jest.spyOn(campaigns, "isOpen").mockReturnValue(false); - const closedPreOpenCampaignSpy = jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - await renderNamedVaccinePage(covid19VaccineType); + await renderNamedVaccinePage(VaccineType.COVID_19); - const preOpenActions: HTMLElement | null = screen.queryByRole("button", { - name: "Book, cancel or change an appointment", - }); + const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock"); - expect(closedCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(closedPreOpenCampaignSpy).toHaveBeenCalledWith(covid19VaccineType, expect.any(Date)); - expect(preOpenActions).toBeNull(); - closedCampaignSpy.mockRestore(); - closedPreOpenCampaignSpy.mockRestore(); + expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); }); }); @@ -367,28 +237,12 @@ describe("Any vaccine page", () => { (getEligibilityForPerson as jest.Mock).mockResolvedValue(eligibilitySuccessResponse); }); - it("should not display overview paragraph", async () => { - await renderNamedVaccinePage(VaccineType.TD_IPV_3_IN_1); - - const overviewText: HTMLElement | null = screen.queryByText("Overview text"); - - expect(overviewText).not.toBeInTheDocument(); - }); - - it("should not display callout", async () => { - await renderNamedVaccinePage(VaccineType.HPV); - - const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); - - expect(calloutHeading).not.toBeInTheDocument(); - }); - - it("should not display additionalInformation", async () => { - await renderNamedVaccinePage(VaccineType.MMRV); + it("should not display non-personalised vaccine page content", async () => { + await renderNamedVaccinePage(VaccineType.RSV); - const overviewText: HTMLElement | null = screen.queryByText("Additional Information component"); + const nonPersonalisedVaccinePageContent = screen.queryByTestId("non-personalised-content-mock"); - expect(overviewText).not.toBeInTheDocument(); + expect(nonPersonalisedVaccinePageContent).not.toBeInTheDocument(); }); it("should not display more information expanders", async () => { @@ -583,7 +437,7 @@ describe("shouldShowHowToGetSection", () => { [VaccineType.RSV_PREGNANCY, false, true, false, false], [VaccineType.FLU_FOR_SCHOOL_AGED_CHILDREN, true, false, false, false], ])( - `should decide if to show hotToGet Section for: %s, campaigns: %s, open: %s, preopen %s, expected: %s`, + `should decide if to show hotToGet Section for: %s, campaigns: %s, open: %s, PreOpen %s, expected: %s`, async (vaccineType, isSupported, isOpen, isPreOpen, expected) => { const actual = await shouldShowHowToGetSection(vaccineType, isSupported, isOpen, isPreOpen); diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 5ef4c395..27129711 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -4,11 +4,8 @@ import { auth } from "@project/auth"; import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { MoreInformation } from "@src/app/_components/content/MoreInformation"; -import { Overview } from "@src/app/_components/content/Overview"; -import Recommendation from "@src/app/_components/content/Recommendation"; -import { EligibilityActions } from "@src/app/_components/eligibility/EligibilityActions"; +import { NonPersonalisedVaccinePageContent } from "@src/app/_components/content/NonPersonalisedVaccinePageContent"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; -import WarningCallout from "@src/app/_components/nhs-frontend/WarningCallout"; import { RSVPregnancyInfo } from "@src/app/_components/vaccine-custom/RSVPregnancyInfo"; import { NhsNumber, VaccineDetails, VaccineInfo, VaccineType } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/content-service"; @@ -94,28 +91,16 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise {contentError != ContentErrorTypes.CONTENT_LOADING_ERROR && styledVaccineContent != undefined && ( - <> - - - {!isCampaignOpen && !isCampaignPreOpen && ( - - )} - {styledVaccineContent.additionalInformation?.component && ( -
{styledVaccineContent.additionalInformation.component}
- )} - {!isCampaignOpen && isCampaignPreOpen && ( - - )} - {isCampaignOpen && } - - + )} {/* Eligibility section for RSV */} - {vaccineType === VaccineType.RSV && eligibilityForPerson !== undefined && ( + {vaccineInfo.personalisedEligibilityStatusRequired && eligibilityForPerson !== undefined && ( Date: Thu, 5 Mar 2026 15:22:24 +0000 Subject: [PATCH 02/24] VIA-832 TS/DB Extract More Information section to dedicated component (cherry picked from commit 8d65b4ed00beadbfbe93fde5f9bb4657a16aaac6) --- .../_components/content/FindOutMore.test.tsx | 2 +- ....tsx => MoreInformationExpanders.test.tsx} | 48 ++++--- ...ation.tsx => MoreInformationExpanders.tsx} | 4 +- .../content/MoreInformationSection.test.tsx | 126 ++++++++++++++++++ .../content/MoreInformationSection.tsx | 31 +++++ src/app/_components/vaccine/Vaccine.test.tsx | 55 +++++--- src/app/_components/vaccine/Vaccine.tsx | 26 ++-- 7 files changed, 231 insertions(+), 61 deletions(-) rename src/app/_components/content/{MoreInformation.test.tsx => MoreInformationExpanders.test.tsx} (90%) rename src/app/_components/content/{MoreInformation.tsx => MoreInformationExpanders.tsx} (97%) create mode 100644 src/app/_components/content/MoreInformationSection.test.tsx create mode 100644 src/app/_components/content/MoreInformationSection.tsx diff --git a/src/app/_components/content/FindOutMore.test.tsx b/src/app/_components/content/FindOutMore.test.tsx index 30b71eb3..aab1b758 100644 --- a/src/app/_components/content/FindOutMore.test.tsx +++ b/src/app/_components/content/FindOutMore.test.tsx @@ -4,7 +4,7 @@ import { randomURL } from "@test-data/meta-builder"; import { render, screen } from "@testing-library/react"; import React from "react"; -it("should include 'how to get' link with url from vaccineInfo config ", async () => { +it("should include 'how to get' link with provided url ", async () => { const findOutMoreUrl = randomURL(); const vaccineType = VaccineType.RSV; diff --git a/src/app/_components/content/MoreInformation.test.tsx b/src/app/_components/content/MoreInformationExpanders.test.tsx similarity index 90% rename from src/app/_components/content/MoreInformation.test.tsx rename to src/app/_components/content/MoreInformationExpanders.test.tsx index 907a9405..d2e64e2a 100644 --- a/src/app/_components/content/MoreInformation.test.tsx +++ b/src/app/_components/content/MoreInformationExpanders.test.tsx @@ -1,4 +1,4 @@ -import { MoreInformation } from "@src/app/_components/content/MoreInformation"; +import { MoreInformationExpanders } from "@src/app/_components/content/MoreInformationExpanders"; import { VaccineType } from "@src/models/vaccine"; import { mockStyledContent, mockStyledContentWithoutWhatSection } from "@test-data/content-api/data"; import { render, screen } from "@testing-library/react"; @@ -8,7 +8,7 @@ describe("MoreInformation component for COVID", () => { it("should not show how-to-get expander section when it is not displayed", async () => { render( - { it("should show how-to-get expander section when it is displayed", async () => { render( - { it("should display whatItIsFor expander block", async () => { const vaccineType = VaccineType.RSV; render( - { it("should display whoVaccineIsFor expander block", async () => { const vaccineType = VaccineType.RSV; render( - { it("should display howToGet expander block", async () => { const vaccineType = VaccineType.TD_IPV_3_IN_1; render( - { const mockContentWithExtraDosesSchedule = { ...mockStyledContent, extraDosesSchedule }; render( - { const vaccineType = VaccineType.COVID_19; render( - { it("should display vaccineSideEffects expander block", async () => { const vaccineType = VaccineType.RSV; render( - { "should display whatItIsFor expander block for %s", async (vaccine: VaccineType) => { render( - , + , ); expectExpanderBlockToBePresent("what-heading", "What Section styled component"); }, @@ -136,7 +140,11 @@ describe("MoreInformation component ", () => { "should display whoVaccineIsFor expander block for %s", async (vaccine: VaccineType) => { render( - , + , ); expectExpanderBlockToBePresent("who-heading", "Who Section styled component"); }, @@ -145,7 +153,7 @@ describe("MoreInformation component ", () => { it("should display howToGet expander block", async () => { const vaccineType = VaccineType.FLU_IN_PREGNANCY; render( - { it("should display vaccineSideEffects expander block", async () => { const vaccineType = VaccineType.FLU_IN_PREGNANCY; render( - { const mockContentWithExtraDosesSchedule = { ...mockStyledContent, extraDosesSchedule }; render( - { const vaccineType = VaccineType.FLU_IN_PREGNANCY; render( - { it("should not include 'how to get' section for RSV_PREGNANCY ", async () => { const vaccineType = VaccineType.RSV_PREGNANCY; render( - { it("should not include 'how to get' section for RSV ", async () => { const vaccineType = VaccineType.RSV; render( - { it("should display webpage link to more information about vaccine", async () => { const vaccineType = VaccineType.RSV; render( - { it("should not display whatItIsFor section if undefined in content", async () => { const vaccineType = VaccineType.RSV; render( - { it("should display whoVaccineIsFor section even if whatItIsFor is undefined in content", async () => { const vaccineType = VaccineType.RSV; render( - ({ + MoreInformationExpanders: jest + .fn() + .mockImplementation(() =>
Test More Information Expanders
), +})); +jest.mock("@src/app/_components/content/FindOutMore", () => ({ + FindOutMoreLink: jest + .fn() + .mockImplementation(() =>
Test Find Out More Link
), +})); + +describe("MoreInformationSection", () => { + describe("when styled vaccine content is available", () => { + const styledVaccineContent = mockStyledContent; + + it("should include more information heading for vaccine", () => { + const expectedMoreInformationHeading: string = "More information about the RSV vaccine"; + + render( + , + ); + + const moreInfoHeading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: expectedMoreInformationHeading, + }); + + expect(moreInfoHeading).toBeInTheDocument(); + }); + + it("should include more information expanders", () => { + const showHowToGetSection = false; + render( + , + ); + + const moreInfoExpanders: HTMLElement = screen.getByText("Test More Information Expanders"); + + expect(moreInfoExpanders).toBeInTheDocument(); + expect(MoreInformationExpanders).toHaveBeenCalledWith( + { + styledVaccineContent: styledVaccineContent, + vaccineType: VaccineType.RSV, + showHowToGetSection: showHowToGetSection, + }, + undefined, + ); + }); + }); + + describe("when styled vaccine content is unavailable", () => { + const unavailableStyledVaccineContent = undefined; + + it("should still show more information heading for vaccine", () => { + const expectedMoreInformationHeading: string = "More information about the RSV vaccine"; + + render( + , + ); + + const moreInfoHeading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: expectedMoreInformationHeading, + }); + + expect(moreInfoHeading).toBeInTheDocument(); + }); + + it("should not display more information expanders", () => { + const showHowToGetSection = false; + render( + , + ); + + const moreInfoExpanders: HTMLElement | null = screen.queryByText("Test More Information Expanders"); + expect(moreInfoExpanders).not.toBeInTheDocument(); + expect(MoreInformationExpanders).not.toHaveBeenCalled(); + }); + + it("should display find out more link with nhsWebpageLink from vaccine settings", async () => { + const showHowToGetSection = false; + const vaccineType = VaccineType.RSV; + render( + , + ); + + const findOurMoreLink: HTMLElement = screen.getByText("Test Find Out More Link"); + + expect(findOurMoreLink).toBeInTheDocument(); + expect(FindOutMoreLink).toHaveBeenCalledWith( + { + findOutMoreUrl: VaccineInfo[vaccineType].nhsWebpageLink, + vaccineType: vaccineType, + }, + undefined, + ); + }); + }); +}); diff --git a/src/app/_components/content/MoreInformationSection.tsx b/src/app/_components/content/MoreInformationSection.tsx new file mode 100644 index 00000000..2224f858 --- /dev/null +++ b/src/app/_components/content/MoreInformationSection.tsx @@ -0,0 +1,31 @@ +import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; +import { MoreInformationExpanders } from "@src/app/_components/content/MoreInformationExpanders"; +import { VaccineDetails, VaccineInfo, VaccineType } from "@src/models/vaccine"; +import { StyledVaccineContent } from "@src/services/content-api/types"; +import React, { JSX } from "react"; + +const MoreInformationSection = (props: { + styledVaccineContent: StyledVaccineContent | undefined; + vaccineType: VaccineType; + showHowToGetSection: boolean; +}): JSX.Element => { + const vaccineInfo: VaccineDetails = VaccineInfo[props.vaccineType]; + + return ( + <> +

{`More information about the ${vaccineInfo.displayName.midSentenceCase} ${vaccineInfo.displayName.suffix}`}

+ + {props.styledVaccineContent != undefined ? ( + + ) : ( + + )} + + ); +}; + +export { MoreInformationSection }; diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index d957f019..9061fd5e 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -1,7 +1,8 @@ import { auth } from "@project/auth"; import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; +import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; -import Vaccine, { shouldShowHowToGetSection } from "@src/app/_components/vaccine/Vaccine"; +import Vaccine, { shouldShowHowToGetExpander } from "@src/app/_components/vaccine/Vaccine"; import { VaccineType } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/content-service"; import { ContentErrorTypes } from "@src/services/content-api/types"; @@ -33,6 +34,13 @@ jest.mock("@src/app/_components/eligibility/EligibilityVaccinePageContent", () =
Test Eligibility Content Component
)), })); +jest.mock("@src/app/_components/content/MoreInformationSection", () => ({ + MoreInformationSection: jest + .fn() + .mockImplementation(() => ( +
Test More Information Section Component
+ )), +})); jest.mock("@src/app/_components/content/NonPersonalisedVaccinePageContent", () => ({ NonPersonalisedVaccinePageContent: jest.fn().mockImplementation((props) => { if (props.isCampaignOpen) { @@ -148,17 +156,20 @@ describe("Any vaccine page", () => { expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); }); - it("should include more information expanders", async () => { - const expectedMoreInformationHeading: string = "More information about the RSV vaccine"; - + it("should include more information section", async () => { await renderRsvVaccinePage(); - const moreInfoHeading: HTMLElement = screen.getByRole("heading", { - level: 2, - name: expectedMoreInformationHeading, - }); + const moreInfoSection: HTMLElement = screen.getByText("Test More Information Section Component"); - expect(moreInfoHeading).toBeInTheDocument(); + expect(moreInfoSection).toBeInTheDocument(); + expect(MoreInformationSection).toHaveBeenCalledWith( + { + styledVaccineContent: contentSuccessResponse.styledVaccineContent, + vaccineType: VaccineType.RSV, + showHowToGetSection: false, //about to refactor out to pass in campaign context instead? + }, + undefined, + ); }); it("should display custom RSV Pregnancy vaccine component", async () => { @@ -245,20 +256,22 @@ describe("Any vaccine page", () => { expect(nonPersonalisedVaccinePageContent).not.toBeInTheDocument(); }); - it("should not display more information expanders", async () => { - await renderRsvVaccinePage(); - - const moreInfo = screen.queryByRole("heading", { name: "what-heading" }); - - expect(moreInfo).not.toBeInTheDocument(); - }); + it("should still render More Information section", async () => { + const vaccineType = VaccineType.RSV; + await renderNamedVaccinePage(vaccineType); - it("should display find out more link", async () => { - await renderRsvVaccinePage(); + const moreInfoSection: HTMLElement = screen.getByText("Test More Information Section Component"); - const findOutMore: HTMLElement = screen.getByRole("link", { name: "Find out more about the RSV vaccine" }); + expect(moreInfoSection).toBeInTheDocument(); - expect(findOutMore).toBeInTheDocument(); + expect(MoreInformationSection).toHaveBeenCalledWith( + { + styledVaccineContent: contentErrorResponse.styledVaccineContent, + vaccineType: vaccineType, + showHowToGetSection: false, //about to refactor out to pass in campaign context instead? + }, + undefined, + ); }); it("should still render eligibility section of vaccine page", async () => { @@ -439,7 +452,7 @@ describe("shouldShowHowToGetSection", () => { ])( `should decide if to show hotToGet Section for: %s, campaigns: %s, open: %s, PreOpen %s, expected: %s`, async (vaccineType, isSupported, isOpen, isPreOpen, expected) => { - const actual = await shouldShowHowToGetSection(vaccineType, isSupported, isOpen, isPreOpen); + const actual = await shouldShowHowToGetExpander(vaccineType, isSupported, isOpen, isPreOpen); expect(actual).toBe(expected); }, diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 27129711..52a61b81 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -1,9 +1,8 @@ "use server"; import { auth } from "@project/auth"; -import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; -import { MoreInformation } from "@src/app/_components/content/MoreInformation"; +import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; import { NonPersonalisedVaccinePageContent } from "@src/app/_components/content/NonPersonalisedVaccinePageContent"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; import { RSVPregnancyInfo } from "@src/app/_components/vaccine-custom/RSVPregnancyInfo"; @@ -31,7 +30,7 @@ const Vaccine = async ({ vaccineType }: VaccineProps) => { return await requestScopedStorageWrapper(VaccineComponent, { vaccineType }); }; -const shouldShowHowToGetSection = async ( +const shouldShowHowToGetExpander = async ( vaccineType: VaccineType, isCampaignSupported: boolean, isCampaignOpen: boolean, @@ -59,7 +58,7 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise )} - {/* Sections heading - H2 */}
-

{`More information about the ${vaccineInfo.displayName.midSentenceCase} ${vaccineInfo.displayName.suffix}`}

- {/* Expandable sections */} - {contentError != ContentErrorTypes.CONTENT_LOADING_ERROR && styledVaccineContent != undefined ? ( - - ) : ( - - )} + ); }; export default Vaccine; -export { shouldShowHowToGetSection }; +export { shouldShowHowToGetExpander }; From b922f29d056229d876fdca5b83458d752caf910a Mon Sep 17 00:00:00 2001 From: Terence Sheppard <260696118+terence-sheppard-nhs@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:05:21 +0000 Subject: [PATCH 03/24] VIA-832 TS/DB Moved FindOutMoreLink component to MoreInformationSection (cherry picked from commit 1b6accfa00bceb8e3d092c43c5129005f95cd9b1) --- .../content/MoreInformationExpanders.test.tsx | 19 --------------- .../content/MoreInformationExpanders.tsx | 3 --- .../content/MoreInformationSection.test.tsx | 23 +++++++++++++++++++ .../content/MoreInformationSection.tsx | 8 ++++--- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app/_components/content/MoreInformationExpanders.test.tsx b/src/app/_components/content/MoreInformationExpanders.test.tsx index d2e64e2a..d5877989 100644 --- a/src/app/_components/content/MoreInformationExpanders.test.tsx +++ b/src/app/_components/content/MoreInformationExpanders.test.tsx @@ -241,25 +241,6 @@ describe("MoreInformation component ", () => { expect(heading).not.toBeInTheDocument(); }); - it("should display webpage link to more information about vaccine", async () => { - const vaccineType = VaccineType.RSV; - render( - , - ); - - const webpageLink: HTMLElement = screen.getByRole("link", { - name: "Find out more about the RSV vaccine", - }); - - expect(webpageLink).toBeInTheDocument(); - expect(webpageLink).toHaveAttribute("href", "https://test.example.com/"); - expect(webpageLink).toHaveAttribute("target", "_blank"); - }); - it("should not display whatItIsFor section if undefined in content", async () => { const vaccineType = VaccineType.RSV; render( diff --git a/src/app/_components/content/MoreInformationExpanders.tsx b/src/app/_components/content/MoreInformationExpanders.tsx index cda790b8..c181845d 100644 --- a/src/app/_components/content/MoreInformationExpanders.tsx +++ b/src/app/_components/content/MoreInformationExpanders.tsx @@ -1,4 +1,3 @@ -import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; import { HEADINGS } from "@src/app/constants"; import { VaccineInfo, VaccineType } from "@src/models/vaccine"; import styles from "@src/services/content-api/parsers/styles.module.css"; @@ -81,8 +80,6 @@ const MoreInformationExpanders = (props: { - - ); }; diff --git a/src/app/_components/content/MoreInformationSection.test.tsx b/src/app/_components/content/MoreInformationSection.test.tsx index 3fabe0c5..6b1a4a78 100644 --- a/src/app/_components/content/MoreInformationSection.test.tsx +++ b/src/app/_components/content/MoreInformationSection.test.tsx @@ -61,6 +61,29 @@ describe("MoreInformationSection", () => { undefined, ); }); + + it("should include find out more link with url from styled vaccine content", () => { + const showHowToGetSection = false; + const vaccineType = VaccineType.RSV; + render( + , + ); + + const findOurMoreLink: HTMLElement = screen.getByText("Test Find Out More Link"); + + expect(findOurMoreLink).toBeInTheDocument(); + expect(FindOutMoreLink).toHaveBeenCalledWith( + { + findOutMoreUrl: styledVaccineContent.webpageLink, + vaccineType: vaccineType, + }, + undefined, + ); + }); }); describe("when styled vaccine content is unavailable", () => { diff --git a/src/app/_components/content/MoreInformationSection.tsx b/src/app/_components/content/MoreInformationSection.tsx index 2224f858..3ce8022a 100644 --- a/src/app/_components/content/MoreInformationSection.tsx +++ b/src/app/_components/content/MoreInformationSection.tsx @@ -10,20 +10,22 @@ const MoreInformationSection = (props: { showHowToGetSection: boolean; }): JSX.Element => { const vaccineInfo: VaccineDetails = VaccineInfo[props.vaccineType]; + const findOutMoreUrl = props.styledVaccineContent + ? props.styledVaccineContent.webpageLink + : vaccineInfo.nhsWebpageLink; return ( <>

{`More information about the ${vaccineInfo.displayName.midSentenceCase} ${vaccineInfo.displayName.suffix}`}

- {props.styledVaccineContent != undefined ? ( + {props.styledVaccineContent != undefined && ( - ) : ( - )} + ); }; From 6a4b6a99866fb8ed28e7b7e2219d8315777c73fa Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:10:07 +0000 Subject: [PATCH 04/24] VIA-832 TS/DB Refactor Campaign states, extract campaign logic from Vaccine.ts (cherry picked from commit 2fb9fbe575e05005f20059751f0fbf7b8709bc19) --- ...NonPersonalisedVaccinePageContent.test.tsx | 138 +++++------------- .../NonPersonalisedVaccinePageContent.tsx | 10 +- src/app/_components/vaccine/Vaccine.test.tsx | 78 +++------- src/app/_components/vaccine/Vaccine.tsx | 31 ++-- .../campaign-state-evaluator.test.ts | 68 +++++++++ .../campaigns/campaign-state-evaluator.ts | 26 ++++ src/utils/campaigns/campaignState.ts | 6 + 7 files changed, 176 insertions(+), 181 deletions(-) create mode 100644 src/utils/campaigns/campaign-state-evaluator.test.ts create mode 100644 src/utils/campaigns/campaign-state-evaluator.ts create mode 100644 src/utils/campaigns/campaignState.ts diff --git a/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx b/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx index 20451b87..cbd20e92 100644 --- a/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx +++ b/src/app/_components/content/NonPersonalisedVaccinePageContent.test.tsx @@ -1,6 +1,7 @@ import { VaccineType } from "@project/src/models/vaccine"; import { StyledVaccineContent } from "@project/src/services/content-api/types"; import { mockStyledContent } from "@project/test-data/content-api/data"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; import { render, screen } from "@testing-library/react"; import { NonPersonalisedVaccinePageContent } from "./NonPersonalisedVaccinePageContent"; @@ -24,45 +25,23 @@ jest.mock("cheerio", () => ({ describe("NonPersonalisedVaccinePageContent", () => { describe("shows content section, when content available", () => { - const testCases = [ - { - campaignPreOpen: false, - campaignOpen: false, - }, - { - campaignPreOpen: true, - campaignOpen: false, - }, - { - campaignPreOpen: false, - campaignOpen: true, - }, - { - campaignPreOpen: true, - campaignOpen: true, - }, + const campaignStates = [ + CampaignState.OPEN, + CampaignState.PRE_OPEN, + CampaignState.CLOSED, + CampaignState.UNSUPPORTED, ]; - it.each(testCases)("should include overview text when %s", async ({ campaignPreOpen, campaignOpen }) => { - await renderNonPersonalisedVaccinePage( - mockStyledContent, - VaccineType.TD_IPV_3_IN_1, - campaignOpen, - campaignPreOpen, - ); + it.each(campaignStates)("should include overview text when campaign is %s", async (campaignState) => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.TD_IPV_3_IN_1, campaignState); const overviewText: HTMLElement = screen.getByText("Overview text"); expect(overviewText).toBeInTheDocument(); }); - it.each(testCases)("should include recommendation text when %s", async ({ campaignPreOpen, campaignOpen }) => { - await renderNonPersonalisedVaccinePage( - mockStyledContent, - VaccineType.FLU_IN_PREGNANCY, - campaignOpen, - campaignPreOpen, - ); + it.each(campaignStates)("should include recommendation text when campaign is %s", async (campaignState) => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.FLU_IN_PREGNANCY, campaignState); const recommendationText: HTMLElement = screen.getByRole("heading", { name: "Non-urgent advice: Recommendation Heading", @@ -72,40 +51,30 @@ describe("NonPersonalisedVaccinePageContent", () => { expect(recommendationText).toBeInTheDocument(); }); - it.each(testCases)( - "should include additionalInformation text when %s", - async ({ campaignPreOpen, campaignOpen }) => { - await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.MMRV, campaignOpen, campaignPreOpen); + it.each(campaignStates)("should include additionalInformation text when %s", async (campaignState) => { + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.MMRV, campaignState); - const additionalInformation: HTMLElement = screen.getByText("Additional Information component"); + const additionalInformation: HTMLElement = screen.getByText("Additional Information component"); - expect(additionalInformation).toBeInTheDocument(); - }, - ); + expect(additionalInformation).toBeInTheDocument(); + }); - it.each(testCases)( - "should not include additionalInformation text when %s", - async ({ campaignPreOpen, campaignOpen }) => { - await renderNonPersonalisedVaccinePage( - { ...mockStyledContent, additionalInformation: undefined }, - VaccineType.MMRV, - campaignOpen, - campaignPreOpen, - ); + it.each(campaignStates)("should not include additionalInformation text when %s", async (campaignState) => { + await renderNonPersonalisedVaccinePage( + { ...mockStyledContent, additionalInformation: undefined }, + VaccineType.MMRV, + campaignState, + ); - const additionalInformation: HTMLElement | null = screen.queryByText("Additional Information component"); + const additionalInformation: HTMLElement | null = screen.queryByText("Additional Information component"); - expect(additionalInformation).not.toBeInTheDocument(); - }, - ); + expect(additionalInformation).not.toBeInTheDocument(); + }); }); - describe("shows content section, when content available for Vaccines that do not have campagins", () => { - const campaignPreOpen = false; - const campaignOpen = false; - + describe("shows content section, when content available for Vaccines that do not have campaigns", () => { it("should not include actions", async () => { - await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, CampaignState.UNSUPPORTED); const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); @@ -113,7 +82,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include PreOpen actions", async () => { - await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, VaccineType.COVID_19, CampaignState.UNSUPPORTED); const preOpenActions: HTMLElement | null = screen.queryByRole("button", { name: "Book, cancel or change an appointment", @@ -127,10 +96,7 @@ describe("NonPersonalisedVaccinePageContent", () => { const covid19VaccineType = VaccineType.COVID_19; it("should include callout heading when campaign is closed", async () => { - const campaignOpen = false; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.CLOSED); const calloutHeading: HTMLElement = screen.getByRole("heading", { name: "Important: Callout Heading" }); @@ -138,10 +104,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include callout heading when campaign is open", async () => { - const campaignOpen = true; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.OPEN); const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); @@ -149,10 +112,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include callout heading when campaign is pre-open", async () => { - const campaignOpen = false; - const campaignPreOpen = true; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.PRE_OPEN); const calloutHeading: HTMLElement | null = screen.queryByRole("heading", { name: "Important: Callout Heading" }); @@ -160,10 +120,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should include actions when campaign is open", async () => { - const campaignOpen = true; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.OPEN); const actions: HTMLElement = screen.getByRole("button", { name: "Continue to booking" }); @@ -171,10 +128,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include open campaign actions when campaign is pre-open", async () => { - const campaignOpen = false; - const campaignPreOpen = true; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.PRE_OPEN); const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); @@ -182,10 +136,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include actions when campaign is closed", async () => { - const campaignOpen = false; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.CLOSED); const actions: HTMLElement | null = screen.queryByRole("button", { name: "Continue to booking" }); @@ -193,10 +144,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should include pre-open actions when campaign is pre-open", async () => { - const campaignOpen = false; - const campaignPreOpen = true; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.PRE_OPEN); const preOpenActions: HTMLElement = screen.getByRole("button", { name: "Book, cancel or change an appointment" }); @@ -204,10 +152,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include pre-open actions when campaign is open", async () => { - const campaignOpen = true; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.OPEN); const preOpenActions: HTMLElement | null = screen.queryByRole("button", { name: "Book, cancel or change an appointment", @@ -217,10 +162,7 @@ describe("NonPersonalisedVaccinePageContent", () => { }); it("should not include pre-open actions when campaign is closed", async () => { - const campaignOpen = false; - const campaignPreOpen = false; - - await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, campaignOpen, campaignPreOpen); + await renderNonPersonalisedVaccinePage(mockStyledContent, covid19VaccineType, CampaignState.CLOSED); const preOpenActions: HTMLElement | null = screen.queryByRole("button", { name: "Book, cancel or change an appointment", @@ -234,15 +176,13 @@ describe("NonPersonalisedVaccinePageContent", () => { const renderNonPersonalisedVaccinePage = async ( styledVaccineContent: StyledVaccineContent, vaccineType: VaccineType, - isCampaignOpen: boolean, - isCampaignPreOpen: boolean, + campaignState: CampaignState, ) => { render( - await NonPersonalisedVaccinePageContent({ + NonPersonalisedVaccinePageContent({ styledVaccineContent, vaccineType, - isCampaignOpen, - isCampaignPreOpen, + campaignState, }), ); }; diff --git a/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx b/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx index 15fa9746..5ed69b27 100644 --- a/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx +++ b/src/app/_components/content/NonPersonalisedVaccinePageContent.tsx @@ -4,30 +4,30 @@ import { EligibilityActions } from "@src/app/_components/eligibility/Eligibility import WarningCallout from "@src/app/_components/nhs-frontend/WarningCallout"; import { VaccineType } from "@src/models/vaccine"; import { StyledVaccineContent } from "@src/services/content-api/types"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; const NonPersonalisedVaccinePageContent = (props: { styledVaccineContent: StyledVaccineContent; vaccineType: VaccineType; - isCampaignOpen: boolean; - isCampaignPreOpen: boolean; + campaignState: CampaignState; }) => { return ( <> - {!props.isCampaignOpen && !props.isCampaignPreOpen && ( + {props.campaignState === CampaignState.CLOSED && ( )} {props.styledVaccineContent.additionalInformation?.component && (
{props.styledVaccineContent.additionalInformation.component}
)} - {!props.isCampaignOpen && props.isCampaignPreOpen && ( + {props.campaignState === CampaignState.PRE_OPEN && ( )} - {props.isCampaignOpen && ( + {props.campaignState === CampaignState.OPEN && ( )} diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index 9061fd5e..b61f98f3 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -12,9 +12,8 @@ import { EligibilityForPersonType, EligibilityStatus, } from "@src/services/eligibility-api/types"; -import { Campaigns } from "@src/utils/campaigns/types"; -import config from "@src/utils/config"; -import { ConfigMock, configBuilder } from "@test-data/config/builders"; +import { getCampaignState } from "@src/utils/campaigns/campaign-state-evaluator"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; import { mockStyledContent } from "@test-data/content-api/data"; import { eligibilityContentBuilder } from "@test-data/eligibility-api/builders"; import { render, screen } from "@testing-library/react"; @@ -26,6 +25,9 @@ jest.mock("@src/services/content-api/content-service", () => ({ jest.mock("@src/services/eligibility-api/domain/eligibility-filter-service", () => ({ getEligibilityForPerson: jest.fn(), })); +jest.mock("@src/utils/campaigns/campaign-state-evaluator", () => ({ + getCampaignState: jest.fn(), +})); // it would be good to avoid these mocks and rather than do getByTestId(id) use getByRole(role, {name: id}) jest.mock("@src/app/_components/eligibility/EligibilityVaccinePageContent", () => ({ EligibilityVaccinePageContent: jest @@ -43,11 +45,11 @@ jest.mock("@src/app/_components/content/MoreInformationSection", () => ({ })); jest.mock("@src/app/_components/content/NonPersonalisedVaccinePageContent", () => ({ NonPersonalisedVaccinePageContent: jest.fn().mockImplementation((props) => { - if (props.isCampaignOpen) { + if (props.campaignState == CampaignState.OPEN) { return (
Test Non-personalised Vaccine Page Content Component
); - } else if (props.isCampaignPreOpen) { + } else if (props.campaignState == CampaignState.PRE_OPEN) { return (
Test Non-personalised Vaccine Page Content Component @@ -68,7 +70,6 @@ jest.mock("next/headers", () => ({ cookies: jest.fn(), })); jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); -jest.mock("@src/utils/config"); const nhsNumber = "5123456789"; @@ -95,33 +96,6 @@ const contentErrorResponse = { }; describe("Any vaccine page", () => { - const mockedConfig = config as ConfigMock; - - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date("2026-01-30T09:00:00Z")); - const defaultConfig = configBuilder() - .withCampaigns( - Campaigns.fromJson( - JSON.stringify({ - COVID_19: [ - { preStart: "2025-10-15T09:00:00Z", start: "2025-11-01T09:00:00Z", end: "2026-01-31T09:00:00Z" }, - ], - FLU_FOR_ADULTS: [ - { preStart: "2025-11-30T09:00:00Z", start: "2025-11-30T09:00:00Z", end: "2026-03-31T09:00:00Z" }, - ], - FLU_FOR_CHILDREN_AGED_2_TO_3: [ - { preStart: "2025-11-30T09:00:00Z", start: "2025-11-30T09:00:00Z", end: "2026-03-31T09:00:00Z" }, - ], - FLU_IN_PREGNANCY: [ - { preStart: "2025-11-30T09:00:00Z", start: "2025-11-30T09:00:00Z", end: "2026-03-31T09:00:00Z" }, - ], - }), - )!, - ) - .build(); - Object.assign(mockedConfig, defaultConfig); - }); - const renderRsvVaccinePage = async () => { await renderNamedVaccinePage(VaccineType.RSV); }; @@ -198,19 +172,13 @@ describe("Any vaccine page", () => { }); describe("shows correct content for Vaccines that handle campaigns (COVID_19)", () => { - const mockedConfig = config as ConfigMock; - const campaigns = new Campaigns({}); - beforeEach(() => { (getContentForVaccine as jest.Mock).mockResolvedValue(contentSuccessResponse); (getEligibilityForPerson as jest.Mock).mockResolvedValue(eligibilitySuccessResponse); - const defaultConfig = configBuilder().withCampaigns(campaigns).build(); - Object.assign(mockedConfig, defaultConfig); }); it("should display non-personalised vaccine page content for PreOpen Campaign", async () => { - jest.spyOn(campaigns, "isPreOpen").mockReturnValue(true); - jest.spyOn(campaigns, "isOpen").mockReturnValue(false); + (getCampaignState as jest.Mock).mockResolvedValue(CampaignState.PRE_OPEN); await renderNamedVaccinePage(VaccineType.COVID_19); @@ -220,8 +188,7 @@ describe("Any vaccine page", () => { }); it("should display non-personalised vaccine page content for Open Campaign", async () => { - jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - jest.spyOn(campaigns, "isOpen").mockReturnValue(true); + (getCampaignState as jest.Mock).mockResolvedValue(CampaignState.OPEN); await renderNamedVaccinePage(VaccineType.COVID_19); @@ -231,8 +198,7 @@ describe("Any vaccine page", () => { }); it("should display non-personalised vaccine page content for Closed Campaign", async () => { - jest.spyOn(campaigns, "isPreOpen").mockReturnValue(false); - jest.spyOn(campaigns, "isOpen").mockReturnValue(false); + (getCampaignState as jest.Mock).mockResolvedValue(CampaignState.CLOSED); await renderNamedVaccinePage(VaccineType.COVID_19); @@ -439,20 +405,22 @@ describe("Any vaccine page", () => { }); describe("shouldShowHowToGetSection", () => { + // TODO: VIA-832 a lot of these cases are impossible due to how campaigns work (cannot be open and preopen at same time); do we need all of these or only four? + // TODO: VIA-832 also consider how show/hide vaccine settings affect this; is this the correct assertion? it.each([ - [VaccineType.TD_IPV_3_IN_1, false, false, false, true], - [VaccineType.VACCINE_6_IN_1, true, false, false, true], - [VaccineType.ROTAVIRUS, true, true, false, false], - [VaccineType.HPV, true, false, true, false], - [VaccineType.MENB_CHILDREN, false, true, false, true], - [VaccineType.MMR, false, false, true, true], - [VaccineType.RSV, false, false, false, false], - [VaccineType.RSV_PREGNANCY, false, true, false, false], - [VaccineType.FLU_FOR_SCHOOL_AGED_CHILDREN, true, false, false, false], + [VaccineType.TD_IPV_3_IN_1, CampaignState.UNSUPPORTED, true], + [VaccineType.VACCINE_6_IN_1, CampaignState.CLOSED, true], + [VaccineType.ROTAVIRUS, CampaignState.OPEN, false], + [VaccineType.HPV, CampaignState.PRE_OPEN, false], + [VaccineType.MENB_CHILDREN, CampaignState.UNSUPPORTED, true], + [VaccineType.MMR, CampaignState.UNSUPPORTED, true], + [VaccineType.RSV, CampaignState.UNSUPPORTED, false], + [VaccineType.RSV_PREGNANCY, CampaignState.UNSUPPORTED, false], + [VaccineType.FLU_FOR_SCHOOL_AGED_CHILDREN, CampaignState.CLOSED, false], ])( `should decide if to show hotToGet Section for: %s, campaigns: %s, open: %s, PreOpen %s, expected: %s`, - async (vaccineType, isSupported, isOpen, isPreOpen, expected) => { - const actual = await shouldShowHowToGetExpander(vaccineType, isSupported, isOpen, isPreOpen); + async (vaccineType, campaignState, expected) => { + const actual = await shouldShowHowToGetExpander(vaccineType, campaignState); expect(actual).toBe(expected); }, diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 52a61b81..cc86e174 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -11,8 +11,8 @@ import { getContentForVaccine } from "@src/services/content-api/content-service" import { ContentErrorTypes, StyledVaccineContent } from "@src/services/content-api/types"; import { getEligibilityForPerson } from "@src/services/eligibility-api/domain/eligibility-filter-service"; import { EligibilityErrorTypes, EligibilityForPersonType } from "@src/services/eligibility-api/types"; -import config from "@src/utils/config"; -import { getNow } from "@src/utils/date"; +import { getCampaignState } from "@src/utils/campaigns/campaign-state-evaluator"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; import { profilePerformanceEnd, profilePerformanceStart } from "@src/utils/performance"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; import { Session } from "next-auth"; @@ -30,16 +30,12 @@ const Vaccine = async ({ vaccineType }: VaccineProps) => { return await requestScopedStorageWrapper(VaccineComponent, { vaccineType }); }; -const shouldShowHowToGetExpander = async ( - vaccineType: VaccineType, - isCampaignSupported: boolean, - isCampaignOpen: boolean, - isCampaignPreOpen: boolean, -) => { +const shouldShowHowToGetExpander = async (vaccineType: VaccineType, campaignState: CampaignState) => { const vaccineInfo: VaccineDetails = VaccineInfo[vaccineType]; - const isCampaignClosedAndNotSupported = !isCampaignSupported || (!isCampaignOpen && !isCampaignPreOpen); + const isCampaignClosedOrNotSupported = + campaignState === CampaignState.UNSUPPORTED || campaignState === CampaignState.CLOSED; - return vaccineInfo.removeHowToGetExpanderFromMoreInformationSection ? false : isCampaignClosedAndNotSupported; + return vaccineInfo.removeHowToGetExpanderFromMoreInformationSection ? false : isCampaignClosedOrNotSupported; }; const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise => { @@ -49,21 +45,13 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise )} diff --git a/src/utils/campaigns/campaign-state-evaluator.test.ts b/src/utils/campaigns/campaign-state-evaluator.test.ts new file mode 100644 index 00000000..a9c5f5da --- /dev/null +++ b/src/utils/campaigns/campaign-state-evaluator.test.ts @@ -0,0 +1,68 @@ +import { VaccineType } from "@src/models/vaccine"; +import { getCampaignState } from "@src/utils/campaigns/campaign-state-evaluator"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; +import { Campaigns } from "@src/utils/campaigns/types"; +import config from "@src/utils/config"; +import { ConfigMock, configBuilder } from "@test-data/config/builders"; + +jest.mock("@project/auth", () => ({ + auth: jest.fn(), +})); + +jest.mock("next/headers", () => ({ + headers: jest.fn(), + cookies: jest.fn(), +})); +jest.mock("sanitize-data", () => ({ sanitize: jest.fn() })); +jest.mock("@src/utils/config"); + +describe("Campaign helper", () => { + const mockedConfig = config as ConfigMock; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date("2026-01-30T09:00:00Z")); + const defaultConfig = configBuilder() + .withCampaigns( + Campaigns.fromJson( + JSON.stringify({ + COVID_19: [ + // preopen + { preStart: "2026-01-15T09:00:00Z", start: "2026-02-01T09:00:00Z", end: "2026-03-31T09:00:00Z" }, + ], + FLU_FOR_ADULTS: [ + // open + { preStart: "2026-01-15T09:00:00Z", start: "2026-01-15T09:00:00Z", end: "2026-03-31T09:00:00Z" }, + ], + FLU_IN_PREGNANCY: [ + // closed + { preStart: "2025-11-30T09:00:00Z", start: "2025-11-30T09:00:00Z", end: "2025-12-31T09:00:00Z" }, + ], + }), + )!, + ) + .build(); + Object.assign(mockedConfig, defaultConfig); + }); + + describe("getCampaignState for current date", () => { + it("should return preopen if campaign preopen in config", async () => { + const campaignState = await getCampaignState(VaccineType.COVID_19); + expect(campaignState).toBe(CampaignState.PRE_OPEN); + }); + + it("should return open if campaign open in config", async () => { + const campaignState = await getCampaignState(VaccineType.FLU_FOR_ADULTS); + expect(campaignState).toBe(CampaignState.OPEN); + }); + + it("should return closed if campaign closed in config", async () => { + const campaignState = await getCampaignState(VaccineType.FLU_IN_PREGNANCY); + expect(campaignState).toBe(CampaignState.CLOSED); + }); + + it("should return unsupported if vaccine is not in the campaign config", async () => { + const campaignState = await getCampaignState(VaccineType.ROTAVIRUS); + expect(campaignState).toBe(CampaignState.UNSUPPORTED); + }); + }); +}); diff --git a/src/utils/campaigns/campaign-state-evaluator.ts b/src/utils/campaigns/campaign-state-evaluator.ts new file mode 100644 index 00000000..1130aff6 --- /dev/null +++ b/src/utils/campaigns/campaign-state-evaluator.ts @@ -0,0 +1,26 @@ +import { VaccineType } from "@src/models/vaccine"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; +import config from "@src/utils/config"; +import { getNow } from "@src/utils/date"; + +const getCampaignState = async (vaccineType: VaccineType) => { + const campaigns = await config.CAMPAIGNS; + let campaignState: CampaignState; + + if (!campaigns.isSupported(vaccineType)) { + campaignState = CampaignState.UNSUPPORTED; + } else { + const currentDatetime = await getNow(await config.DEPLOY_ENVIRONMENT); + + if (campaigns.isOpen(vaccineType, currentDatetime)) { + campaignState = CampaignState.OPEN; + } else if (campaigns.isPreOpen(vaccineType, currentDatetime)) { + campaignState = CampaignState.PRE_OPEN; + } else { + campaignState = CampaignState.CLOSED; + } + } + return campaignState; +}; + +export { getCampaignState }; diff --git a/src/utils/campaigns/campaignState.ts b/src/utils/campaigns/campaignState.ts new file mode 100644 index 00000000..a68cfe23 --- /dev/null +++ b/src/utils/campaigns/campaignState.ts @@ -0,0 +1,6 @@ +export enum CampaignState { + PRE_OPEN = "PRE_OPEN", + OPEN = "OPEN", + CLOSED = "CLOSED", + UNSUPPORTED = "UNSUPPORTED", +} From 76e05bbf1775f042a749f447ae6ea1c5f84ae472 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:36:05 +0000 Subject: [PATCH 05/24] VIA-832 Refactor: move ShowHowToGetExpander logic into MoreInformation compoenent (cherry picked from commit 466855ff3db971000eb11050f35d1dd5bb7ee8a0) --- .../content/MoreInformationSection.test.tsx | 54 +++++++--- .../content/MoreInformationSection.tsx | 17 +++- src/app/_components/vaccine/Vaccine.test.tsx | 98 ++++++++++--------- src/app/_components/vaccine/Vaccine.tsx | 14 +-- 4 files changed, 109 insertions(+), 74 deletions(-) diff --git a/src/app/_components/content/MoreInformationSection.test.tsx b/src/app/_components/content/MoreInformationSection.test.tsx index 6b1a4a78..1d0230c3 100644 --- a/src/app/_components/content/MoreInformationSection.test.tsx +++ b/src/app/_components/content/MoreInformationSection.test.tsx @@ -1,7 +1,11 @@ import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; import { MoreInformationExpanders } from "@src/app/_components/content/MoreInformationExpanders"; -import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; +import { + MoreInformationSection, + shouldShowHowToGetExpander, +} from "@src/app/_components/content/MoreInformationSection"; import { VaccineInfo, VaccineType } from "@src/models/vaccine"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; import { mockStyledContent } from "@test-data/content-api/data"; import { render, screen } from "@testing-library/react"; @@ -27,7 +31,7 @@ describe("MoreInformationSection", () => { , ); @@ -40,12 +44,13 @@ describe("MoreInformationSection", () => { }); it("should include more information expanders", () => { - const showHowToGetSection = false; + const expectedShouldShowHowToGetSection = false; + render( , ); @@ -56,20 +61,19 @@ describe("MoreInformationSection", () => { { styledVaccineContent: styledVaccineContent, vaccineType: VaccineType.RSV, - showHowToGetSection: showHowToGetSection, + showHowToGetSection: expectedShouldShowHowToGetSection, }, undefined, ); }); it("should include find out more link with url from styled vaccine content", () => { - const showHowToGetSection = false; const vaccineType = VaccineType.RSV; render( , ); @@ -96,7 +100,7 @@ describe("MoreInformationSection", () => { , ); @@ -109,12 +113,11 @@ describe("MoreInformationSection", () => { }); it("should not display more information expanders", () => { - const showHowToGetSection = false; render( , ); @@ -124,13 +127,12 @@ describe("MoreInformationSection", () => { }); it("should display find out more link with nhsWebpageLink from vaccine settings", async () => { - const showHowToGetSection = false; const vaccineType = VaccineType.RSV; render( , ); @@ -147,3 +149,31 @@ describe("MoreInformationSection", () => { }); }); }); + +describe("shouldShowHowToGetSection", () => { + describe("when removeHowToGetExpanderFromMoreInformationSection is set in Vaccine settings", () => { + it.each([ + [VaccineType.RSV, CampaignState.UNSUPPORTED], + [VaccineType.RSV_PREGNANCY, CampaignState.UNSUPPORTED], + [VaccineType.FLU_FOR_SCHOOL_AGED_CHILDREN, CampaignState.CLOSED], + ])(`should return false for %s, regardless of campaign state`, async (vaccineType, campaignState) => { + const actual = shouldShowHowToGetExpander(vaccineType, campaignState); + expect(actual).toBe(false); + }); + }); + + describe("using campaign state when removeHowToGetExpanderFromMoreInformationSection is not set", () => { + it.each([ + [VaccineType.TD_IPV_3_IN_1, CampaignState.UNSUPPORTED, true], + [VaccineType.VACCINE_6_IN_1, CampaignState.CLOSED, true], + [VaccineType.ROTAVIRUS, CampaignState.OPEN, false], + [VaccineType.HPV, CampaignState.PRE_OPEN, false], + ])( + `should return showHowToGet Section as: %s, campaigns: %s, expected: %s`, + async (vaccineType, campaignState, expected) => { + const actual = shouldShowHowToGetExpander(vaccineType, campaignState); + expect(actual).toBe(expected); + }, + ); + }); +}); diff --git a/src/app/_components/content/MoreInformationSection.tsx b/src/app/_components/content/MoreInformationSection.tsx index 3ce8022a..f84aa71b 100644 --- a/src/app/_components/content/MoreInformationSection.tsx +++ b/src/app/_components/content/MoreInformationSection.tsx @@ -2,14 +2,25 @@ import { FindOutMoreLink } from "@src/app/_components/content/FindOutMore"; import { MoreInformationExpanders } from "@src/app/_components/content/MoreInformationExpanders"; import { VaccineDetails, VaccineInfo, VaccineType } from "@src/models/vaccine"; import { StyledVaccineContent } from "@src/services/content-api/types"; +import { CampaignState } from "@src/utils/campaigns/campaignState"; import React, { JSX } from "react"; +const shouldShowHowToGetExpander = (vaccineType: VaccineType, campaignState: CampaignState) => { + const vaccineConfiguredToHideHowToGetExpander: boolean | undefined = + VaccineInfo[vaccineType].removeHowToGetExpanderFromMoreInformationSection; + const isCampaignClosedOrNotSupported = + campaignState === CampaignState.UNSUPPORTED || campaignState === CampaignState.CLOSED; + + return vaccineConfiguredToHideHowToGetExpander ? false : isCampaignClosedOrNotSupported; +}; + const MoreInformationSection = (props: { styledVaccineContent: StyledVaccineContent | undefined; vaccineType: VaccineType; - showHowToGetSection: boolean; + campaignState: CampaignState; }): JSX.Element => { const vaccineInfo: VaccineDetails = VaccineInfo[props.vaccineType]; + const showHowToGetSection: boolean = shouldShowHowToGetExpander(props.vaccineType, props.campaignState); const findOutMoreUrl = props.styledVaccineContent ? props.styledVaccineContent.webpageLink : vaccineInfo.nhsWebpageLink; @@ -22,7 +33,7 @@ const MoreInformationSection = (props: { )} @@ -30,4 +41,4 @@ const MoreInformationSection = (props: { ); }; -export { MoreInformationSection }; +export { MoreInformationSection, shouldShowHowToGetExpander }; diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index b61f98f3..973e3298 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -1,8 +1,9 @@ import { auth } from "@project/auth"; import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; +import { NonPersonalisedVaccinePageContent } from "@src/app/_components/content/NonPersonalisedVaccinePageContent"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; -import Vaccine, { shouldShowHowToGetExpander } from "@src/app/_components/vaccine/Vaccine"; +import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { VaccineType } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/content-service"; import { ContentErrorTypes } from "@src/services/content-api/types"; @@ -44,23 +45,11 @@ jest.mock("@src/app/_components/content/MoreInformationSection", () => ({ )), })); jest.mock("@src/app/_components/content/NonPersonalisedVaccinePageContent", () => ({ - NonPersonalisedVaccinePageContent: jest.fn().mockImplementation((props) => { - if (props.campaignState == CampaignState.OPEN) { - return ( -
Test Non-personalised Vaccine Page Content Component
- ); - } else if (props.campaignState == CampaignState.PRE_OPEN) { - return ( -
- Test Non-personalised Vaccine Page Content Component -
- ); - } else { - return ( -
Test Non-personalised Vaccine Page Content Component
- ); - } - }), + NonPersonalisedVaccinePageContent: jest + .fn() + .mockImplementation(() => ( +
Test Non-personalised Vaccine Page Content Component
+ )), })); jest.mock("@project/auth", () => ({ auth: jest.fn(), @@ -114,12 +103,17 @@ describe("Any vaccine page", () => { nhs_number: nhsNumber, }, }); + + (getCampaignState as jest.Mock).mockResolvedValue(CampaignState.UNSUPPORTED); }); describe("shows content section, when content available", () => { + const mockCampaignState = CampaignState.UNSUPPORTED; + beforeEach(() => { (getContentForVaccine as jest.Mock).mockResolvedValue(contentSuccessResponse); (getEligibilityForPerson as jest.Mock).mockResolvedValue(eligibilitySuccessResponse); + (getCampaignState as jest.Mock).mockResolvedValue(mockCampaignState); }); it("should display non-personalised vaccine page content", async () => { @@ -140,7 +134,7 @@ describe("Any vaccine page", () => { { styledVaccineContent: contentSuccessResponse.styledVaccineContent, vaccineType: VaccineType.RSV, - showHowToGetSection: false, //about to refactor out to pass in campaign context instead? + campaignState: mockCampaignState, }, undefined, ); @@ -182,9 +176,20 @@ describe("Any vaccine page", () => { await renderNamedVaccinePage(VaccineType.COVID_19); - const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock-preopen"); + const nonPersonalisedVaccinePageContent = screen.getByText( + "Test Non-personalised Vaccine Page Content Component", + ); expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); + + expect(NonPersonalisedVaccinePageContent).toHaveBeenCalledWith( + { + styledVaccineContent: contentSuccessResponse.styledVaccineContent, + vaccineType: VaccineType.COVID_19, + campaignState: CampaignState.PRE_OPEN, + }, + undefined, + ); }); it("should display non-personalised vaccine page content for Open Campaign", async () => { @@ -192,9 +197,20 @@ describe("Any vaccine page", () => { await renderNamedVaccinePage(VaccineType.COVID_19); - const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock-open"); + const nonPersonalisedVaccinePageContent = screen.getByText( + "Test Non-personalised Vaccine Page Content Component", + ); expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); + + expect(NonPersonalisedVaccinePageContent).toHaveBeenCalledWith( + { + styledVaccineContent: contentSuccessResponse.styledVaccineContent, + vaccineType: VaccineType.COVID_19, + campaignState: CampaignState.OPEN, + }, + undefined, + ); }); it("should display non-personalised vaccine page content for Closed Campaign", async () => { @@ -202,16 +218,29 @@ describe("Any vaccine page", () => { await renderNamedVaccinePage(VaccineType.COVID_19); - const nonPersonalisedVaccinePageContent = screen.getByTestId("non-personalised-content-mock"); + const nonPersonalisedVaccinePageContent = screen.getByText( + "Test Non-personalised Vaccine Page Content Component", + ); expect(nonPersonalisedVaccinePageContent).toBeInTheDocument(); + + expect(NonPersonalisedVaccinePageContent).toHaveBeenCalledWith( + { + styledVaccineContent: contentSuccessResponse.styledVaccineContent, + vaccineType: VaccineType.COVID_19, + campaignState: CampaignState.CLOSED, + }, + undefined, + ); }); }); describe("shows content section, when content load fails", () => { + const mockCampaignState = CampaignState.UNSUPPORTED; beforeEach(() => { (getContentForVaccine as jest.Mock).mockResolvedValue(contentErrorResponse); (getEligibilityForPerson as jest.Mock).mockResolvedValue(eligibilitySuccessResponse); + (getCampaignState as jest.Mock).mockResolvedValue(mockCampaignState); }); it("should not display non-personalised vaccine page content", async () => { @@ -234,7 +263,7 @@ describe("Any vaccine page", () => { { styledVaccineContent: contentErrorResponse.styledVaccineContent, vaccineType: vaccineType, - showHowToGetSection: false, //about to refactor out to pass in campaign context instead? + campaignState: mockCampaignState, }, undefined, ); @@ -403,26 +432,3 @@ describe("Any vaccine page", () => { ); }; }); - -describe("shouldShowHowToGetSection", () => { - // TODO: VIA-832 a lot of these cases are impossible due to how campaigns work (cannot be open and preopen at same time); do we need all of these or only four? - // TODO: VIA-832 also consider how show/hide vaccine settings affect this; is this the correct assertion? - it.each([ - [VaccineType.TD_IPV_3_IN_1, CampaignState.UNSUPPORTED, true], - [VaccineType.VACCINE_6_IN_1, CampaignState.CLOSED, true], - [VaccineType.ROTAVIRUS, CampaignState.OPEN, false], - [VaccineType.HPV, CampaignState.PRE_OPEN, false], - [VaccineType.MENB_CHILDREN, CampaignState.UNSUPPORTED, true], - [VaccineType.MMR, CampaignState.UNSUPPORTED, true], - [VaccineType.RSV, CampaignState.UNSUPPORTED, false], - [VaccineType.RSV_PREGNANCY, CampaignState.UNSUPPORTED, false], - [VaccineType.FLU_FOR_SCHOOL_AGED_CHILDREN, CampaignState.CLOSED, false], - ])( - `should decide if to show hotToGet Section for: %s, campaigns: %s, open: %s, PreOpen %s, expected: %s`, - async (vaccineType, campaignState, expected) => { - const actual = await shouldShowHowToGetExpander(vaccineType, campaignState); - - expect(actual).toBe(expected); - }, - ); -}); diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index cc86e174..10a80445 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -12,7 +12,6 @@ import { ContentErrorTypes, StyledVaccineContent } from "@src/services/content-a import { getEligibilityForPerson } from "@src/services/eligibility-api/domain/eligibility-filter-service"; import { EligibilityErrorTypes, EligibilityForPersonType } from "@src/services/eligibility-api/types"; import { getCampaignState } from "@src/utils/campaigns/campaign-state-evaluator"; -import { CampaignState } from "@src/utils/campaigns/campaignState"; import { profilePerformanceEnd, profilePerformanceStart } from "@src/utils/performance"; import { requestScopedStorageWrapper } from "@src/utils/requestScopedStorageWrapper"; import { Session } from "next-auth"; @@ -30,14 +29,6 @@ const Vaccine = async ({ vaccineType }: VaccineProps) => { return await requestScopedStorageWrapper(VaccineComponent, { vaccineType }); }; -const shouldShowHowToGetExpander = async (vaccineType: VaccineType, campaignState: CampaignState) => { - const vaccineInfo: VaccineDetails = VaccineInfo[vaccineType]; - const isCampaignClosedOrNotSupported = - campaignState === CampaignState.UNSUPPORTED || campaignState === CampaignState.CLOSED; - - return vaccineInfo.removeHowToGetExpanderFromMoreInformationSection ? false : isCampaignClosedOrNotSupported; -}; - const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise => { profilePerformanceStart(VaccinePagePerformanceMarker); @@ -51,8 +42,6 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise
); }; export default Vaccine; -export { shouldShowHowToGetExpander }; From 89239596d4a12208e22987cfed7d6bed25a62ae8 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:36:24 +0000 Subject: [PATCH 06/24] VIA-832 Refactor: Move 'howToGet' fallback into components that use it If the content API is unavailable some of our RSV pages use a hardcoded piece of text to replace the part of the page where 'how to get the vaccine' would appear. This was previously being controlled in the top level Vaccine component. However, this made it difficult to determine which pages and components rely on content and show this section/fallback and which do not. Moving makes it clearer where the hardcoded fallback 'how to get' section is used and will allow for further refactoring without accidentally losing it along the way. (cherry picked from commit 8b3b354ed0f620a48c719ead0eb8c7952ad7133c) --- .../EligibilityVaccinePageContent.test.tsx | 19 +++-- .../EligibilityVaccinePageContent.tsx | 8 +-- .../RSVEligibilityFallback.test.tsx | 69 +++++++++++++++++-- .../eligibility/RSVEligibilityFallback.tsx | 15 +++- .../vaccine-custom/RSVPregnancyInfo.test.tsx | 48 +++++++++---- .../vaccine-custom/RSVPregnancyInfo.tsx | 15 +++- src/app/_components/vaccine/Vaccine.test.tsx | 21 +++--- src/app/_components/vaccine/Vaccine.tsx | 13 +--- 8 files changed, 147 insertions(+), 61 deletions(-) diff --git a/src/app/_components/eligibility/EligibilityVaccinePageContent.test.tsx b/src/app/_components/eligibility/EligibilityVaccinePageContent.test.tsx index 92dacd07..5776cd30 100644 --- a/src/app/_components/eligibility/EligibilityVaccinePageContent.test.tsx +++ b/src/app/_components/eligibility/EligibilityVaccinePageContent.test.tsx @@ -1,4 +1,3 @@ -import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; import { RSVEligibilityFallback } from "@src/app/_components/eligibility/RSVEligibilityFallback"; import { VaccineType } from "@src/models/vaccine"; @@ -27,8 +26,6 @@ const eligibilityUnavailable = { eligibility: undefined, eligibilityError: EligibilityErrorTypes.ELIGIBILITY_LOADING_ERROR, }; -const howToGetContent =
How Section styled component
; -const howToGetContentFallback = ; describe("EligibilityVaccinePageContent", () => { describe("when eligibility data available", () => { @@ -37,7 +34,7 @@ describe("EligibilityVaccinePageContent", () => { , ); @@ -47,14 +44,14 @@ describe("EligibilityVaccinePageContent", () => { }); describe("when eligibility data not available", () => { - it("should display fallback RSV eligibility component using howToGet text from content-api when eligibility API has failed", async () => { + it("should display fallback RSV eligibility component using content-api when eligibility API has failed", async () => { const vaccineType = VaccineType.RSV; render( , ); @@ -63,7 +60,7 @@ describe("EligibilityVaccinePageContent", () => { expect(RSVEligibilityFallback).toHaveBeenCalledWith( { - howToGetVaccineFallback: mockStyledContent.howToGetVaccine.component, + styledVaccineContent: mockStyledContent, vaccineType, }, undefined, @@ -77,7 +74,7 @@ describe("EligibilityVaccinePageContent", () => { , ); @@ -87,14 +84,14 @@ describe("EligibilityVaccinePageContent", () => { }); describe("when eligibility AND content not available", () => { - it("should use fallback how-to-get text when rendering eligibility fallback component", async () => { + it("should render eligibility fallback component with undefined styled content", async () => { const vaccineType = VaccineType.RSV; render( , ); @@ -103,8 +100,8 @@ describe("EligibilityVaccinePageContent", () => { expect(RSVEligibilityFallback).toHaveBeenCalledWith( { - howToGetVaccineFallback: , vaccineType, + styledVaccineContent: undefined, }, undefined, ); diff --git a/src/app/_components/eligibility/EligibilityVaccinePageContent.tsx b/src/app/_components/eligibility/EligibilityVaccinePageContent.tsx index 39c47396..19bde610 100644 --- a/src/app/_components/eligibility/EligibilityVaccinePageContent.tsx +++ b/src/app/_components/eligibility/EligibilityVaccinePageContent.tsx @@ -1,13 +1,14 @@ import { Eligibility as EligibilityComponent } from "@src/app/_components/eligibility/Eligibility"; import { RSVEligibilityFallback } from "@src/app/_components/eligibility/RSVEligibilityFallback"; import { VaccineType } from "@src/models/vaccine"; +import { StyledVaccineContent } from "@src/services/content-api/types"; import { EligibilityForPersonType } from "@src/services/eligibility-api/types"; import React, { JSX } from "react"; const EligibilityVaccinePageContent = (props: { vaccineType: VaccineType; eligibilityForPerson: EligibilityForPersonType; - howToGetVaccineOrFallback: JSX.Element; + styledVaccineContent: StyledVaccineContent | undefined; }): JSX.Element => { return ( <> @@ -22,10 +23,7 @@ const EligibilityVaccinePageContent = (props: { )} {/* Fallback eligibility section for RSV */} {props.vaccineType === VaccineType.RSV && props.eligibilityForPerson.eligibilityError && ( - + )} ); diff --git a/src/app/_components/eligibility/RSVEligibilityFallback.test.tsx b/src/app/_components/eligibility/RSVEligibilityFallback.test.tsx index efe58655..65655abe 100644 --- a/src/app/_components/eligibility/RSVEligibilityFallback.test.tsx +++ b/src/app/_components/eligibility/RSVEligibilityFallback.test.tsx @@ -1,13 +1,26 @@ +import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { RSVEligibilityFallback } from "@src/app/_components/eligibility/RSVEligibilityFallback"; import { VaccineType } from "@src/models/vaccine"; +import { mockStyledContent } from "@test-data/content-api/data"; import { render, screen, within } from "@testing-library/react"; +jest.mock("@src/app/_components/nbs/PharmacyBookingInfo", () => ({ + PharmacyBookingInfo: jest + .fn() + .mockImplementation(() =>

Pharmacy booking test

), +})); + +jest.mock("@src/app/_components/content/HowToGetVaccineFallback", () => ({ + HowToGetVaccineFallback: jest + .fn() + .mockImplementation(() =>
How to get fallback
), +})); + describe("RSVEligibilityFallback", () => { const vaccineType = VaccineType.RSV; - const howToGetVaccineFallback =
How Section styled component
; it("should display fallback care card", async () => { - render(); + render(); const fallbackHeading: HTMLElement = screen.getByText("The RSV vaccine is recommended if you:"); const fallbackBulletPoint1: HTMLElement = screen.getByText("are aged 75 or over"); @@ -18,13 +31,57 @@ describe("RSVEligibilityFallback", () => { expect(fallbackBulletPoint2).toBeVisible(); }); - it("should display provided how-to-get content", async () => { - render(); + it("should include Pharmacy booking info for specified vaccine", () => { + render(); + + const pharmacyBookingInfo: HTMLElement = screen.getByTestId("pharmacy-booking-info"); + expect(pharmacyBookingInfo).toBeVisible(); + + expect(PharmacyBookingInfo).toHaveBeenCalledWith( + { + vaccineType: vaccineType, + }, + undefined, + ); + }); + + it("should display 'If this applies' paragraph with styled how-to-get from content API", async () => { + render(); + + const fallback = screen.getByTestId("elid-fallback"); + + const fallbackHeading: HTMLElement = within(fallback).getByRole("heading", { + name: "If this applies to you", + level: 3, + }); + const howToGetFromContentAPI: HTMLElement = within(fallback).getByText("How Section styled component"); + + expect(fallbackHeading).toBeVisible(); + expect(howToGetFromContentAPI).toBeVisible(); + + const howToGetFallback: HTMLElement | null = within(fallback).queryByText("How to get fallback"); + expect(howToGetFallback).not.toBeInTheDocument(); + }); + + it("should display fallback how-to-get if content API styled response not available", async () => { + render(); const fallback = screen.getByTestId("elid-fallback"); - const howToGetContent: HTMLElement = within(fallback).getByText("How Section styled component"); + const fallbackHeading: HTMLElement = within(fallback).getByRole("heading", { + name: "If this applies to you", + level: 3, + }); + const howToGetFallback: HTMLElement = within(fallback).getByText("How to get fallback"); + + expect(fallbackHeading).toBeVisible(); + expect(howToGetFallback).toBeVisible(); - expect(howToGetContent).toBeVisible(); + expect(HowToGetVaccineFallback).toHaveBeenCalledWith( + { + vaccineType: vaccineType, + }, + undefined, + ); }); }); diff --git a/src/app/_components/eligibility/RSVEligibilityFallback.tsx b/src/app/_components/eligibility/RSVEligibilityFallback.tsx index 0b8893cc..b707c0fc 100644 --- a/src/app/_components/eligibility/RSVEligibilityFallback.tsx +++ b/src/app/_components/eligibility/RSVEligibilityFallback.tsx @@ -1,11 +1,20 @@ +import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; +import { PharmacyBookingInfo } from "@src/app/_components/nbs/PharmacyBookingInfo"; import NonUrgentCareCard from "@src/app/_components/nhs-frontend/NonUrgentCareCard"; import { VaccineType } from "@src/models/vaccine"; +import { StyledVaccineContent } from "@src/services/content-api/types"; import React, { JSX } from "react"; const RSVEligibilityFallback = (props: { - howToGetVaccineFallback: JSX.Element; + styledVaccineContent: StyledVaccineContent | undefined; vaccineType: VaccineType.RSV; }): JSX.Element => { + const howToGetVaccineOrFallback = props.styledVaccineContent ? ( + props.styledVaccineContent.howToGetVaccine.component + ) : ( + + ); + return (
} /> - {props.howToGetVaccineFallback} +

{HEADINGS.IF_THIS_APPLIES}

+ {howToGetVaccineOrFallback} +
); }; diff --git a/src/app/_components/vaccine-custom/RSVPregnancyInfo.test.tsx b/src/app/_components/vaccine-custom/RSVPregnancyInfo.test.tsx index 4d48c5a8..26e97206 100644 --- a/src/app/_components/vaccine-custom/RSVPregnancyInfo.test.tsx +++ b/src/app/_components/vaccine-custom/RSVPregnancyInfo.test.tsx @@ -1,43 +1,63 @@ +import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { RSVPregnancyInfo } from "@src/app/_components/vaccine-custom/RSVPregnancyInfo"; import { VaccineType } from "@src/models/vaccine"; +import { mockStyledContent } from "@test-data/content-api/data"; import { render, screen } from "@testing-library/react"; import React from "react"; -const howToGetContentMock =
How to get content mock
; - jest.mock("@src/app/_components/nbs/PharmacyBookingInfo", () => ({ PharmacyBookingInfo: jest .fn() .mockImplementation(() =>
Pharmacy Booking
), })); +jest.mock("@src/app/_components/content/HowToGetVaccineFallback", () => ({ + HowToGetVaccineFallback: jest + .fn() + .mockImplementation(() =>
How to get fallback
), +})); + describe("RSV Pregnancy Information", () => { it("should display inset text for rsv in pregnancy", () => { - render( - , - ); + render(); const recommendedBlock: HTMLElement | undefined = screen.getAllByRole("heading", { level: 2 }).at(0); expect(recommendedBlock).toHaveClass("nhsuk-card__heading"); expect(recommendedBlock?.innerHTML).toContain("The RSV vaccine is recommended if you:"); }); - it("should display how to get text outside of expander in rsv pregnancy page", () => { - render( - , - ); + it("should display howToGet text outside expander in rsv pregnancy page when content API available", () => { + render(); const heading: HTMLElement = screen.getByText("How to get the vaccine"); - const content: HTMLElement = screen.getByText("How to get content mock"); + const howToGetFromContentAPI: HTMLElement = screen.getByText("How Section styled component"); expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); + expect(howToGetFromContentAPI).toBeInTheDocument(); + + const howToGetFallback: HTMLElement | null = screen.queryByText("How to get fallback"); + expect(howToGetFallback).not.toBeInTheDocument(); }); - it("should contain pharmacy booking link in how to get section", () => { - render( - , + it("should display fallback how to get text when styled content API unavailable", () => { + render(); + + const heading: HTMLElement = screen.getByText("How to get the vaccine"); + const howToGetFallback: HTMLElement = screen.getByText("How to get fallback"); + + expect(heading).toBeInTheDocument(); + expect(howToGetFallback).toBeInTheDocument(); + + expect(HowToGetVaccineFallback).toHaveBeenCalledWith( + { + vaccineType: VaccineType.RSV_PREGNANCY, + }, + undefined, ); + }); + + it("should contain pharmacy booking link in how to get section", () => { + render(); const pharmacyBookingLink = screen.getByTestId("pharmacy-booking-link-mock"); diff --git a/src/app/_components/vaccine-custom/RSVPregnancyInfo.tsx b/src/app/_components/vaccine-custom/RSVPregnancyInfo.tsx index bed4d647..69ab624b 100644 --- a/src/app/_components/vaccine-custom/RSVPregnancyInfo.tsx +++ b/src/app/_components/vaccine-custom/RSVPregnancyInfo.tsx @@ -1,10 +1,21 @@ +import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { PharmacyBookingInfo } from "@src/app/_components/nbs/PharmacyBookingInfo"; import NonUrgentCareCard from "@src/app/_components/nhs-frontend/NonUrgentCareCard"; import { HEADINGS } from "@src/app/constants"; import { VaccineType } from "@src/models/vaccine"; +import { StyledVaccineContent } from "@src/services/content-api/types"; import React, { JSX } from "react"; -const RSVPregnancyInfo = (props: { vaccineType: VaccineType; howToGetVaccineOrFallback: JSX.Element }): JSX.Element => { +const RSVPregnancyInfo = (props: { + vaccineType: VaccineType; + styledVaccineContent: StyledVaccineContent | undefined; +}): JSX.Element => { + const howToGetVaccineOrFallback = props.styledVaccineContent ? ( + props.styledVaccineContent.howToGetVaccine.component + ) : ( + + ); + return ( <> {HEADINGS.HOW_TO_GET_VACCINE} - {props.howToGetVaccineOrFallback} + {howToGetVaccineOrFallback} ); diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index 973e3298..80abe7c3 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -1,12 +1,11 @@ import { auth } from "@project/auth"; -import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; import { NonPersonalisedVaccinePageContent } from "@src/app/_components/content/NonPersonalisedVaccinePageContent"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { VaccineType } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/content-service"; -import { ContentErrorTypes } from "@src/services/content-api/types"; +import { ContentErrorTypes, StyledVaccineContent } from "@src/services/content-api/types"; import { getEligibilityForPerson } from "@src/services/eligibility-api/domain/eligibility-filter-service"; import { EligibilityErrorTypes, @@ -18,7 +17,7 @@ import { CampaignState } from "@src/utils/campaigns/campaignState"; import { mockStyledContent } from "@test-data/content-api/data"; import { eligibilityContentBuilder } from "@test-data/eligibility-api/builders"; import { render, screen } from "@testing-library/react"; -import React, { JSX } from "react"; +import React from "react"; jest.mock("@src/services/content-api/content-service", () => ({ getContentForVaccine: jest.fn(), @@ -275,7 +274,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( VaccineType.RSV, eligibilitySuccessResponse, - , + contentErrorResponse.styledVaccineContent, ); }); @@ -307,7 +306,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( VaccineType.RSV, eligibilitySuccessResponse, - mockStyledContent.howToGetVaccine.component, + contentSuccessResponse.styledVaccineContent, ); }); @@ -349,7 +348,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( VaccineType.RSV, eligibilityResponseWithNoContentSection, - mockStyledContent.howToGetVaccine.component, + contentSuccessResponse.styledVaccineContent, ); }); @@ -361,7 +360,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( VaccineType.RSV, eligibilityErrorResponse, - mockStyledContent.howToGetVaccine.component, + contentSuccessResponse.styledVaccineContent, ); }); @@ -387,7 +386,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( VaccineType.RSV, eligibilityErrorResponse, - mockStyledContent.howToGetVaccine.component, + contentSuccessResponse.styledVaccineContent, ); }); }); @@ -406,7 +405,7 @@ describe("Any vaccine page", () => { expectRenderEligibilitySectionWith( vaccineType, eligibilityErrorResponse, - , + contentErrorResponse.styledVaccineContent, ); }); }); @@ -418,7 +417,7 @@ describe("Any vaccine page", () => { const expectRenderEligibilitySectionWith = ( vaccineType: VaccineType, eligibilityForPerson: EligibilityForPersonType, - howToGetVaccineOrFallback: JSX.Element, + styledVaccineContent: StyledVaccineContent | undefined, ) => { const eligibilitySection: HTMLElement = screen.getByTestId("eligibility-page-content-mock"); expect(eligibilitySection).toBeInTheDocument(); @@ -426,7 +425,7 @@ describe("Any vaccine page", () => { { vaccineType: vaccineType, eligibilityForPerson: eligibilityForPerson, - howToGetVaccineOrFallback: howToGetVaccineOrFallback, + styledVaccineContent: styledVaccineContent, }, undefined, ); diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 10a80445..0ec8a268 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -1,7 +1,6 @@ "use server"; import { auth } from "@project/auth"; -import { HowToGetVaccineFallback } from "@src/app/_components/content/HowToGetVaccineFallback"; import { MoreInformationSection } from "@src/app/_components/content/MoreInformationSection"; import { NonPersonalisedVaccinePageContent } from "@src/app/_components/content/NonPersonalisedVaccinePageContent"; import { EligibilityVaccinePageContent } from "@src/app/_components/eligibility/EligibilityVaccinePageContent"; @@ -56,12 +55,6 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise - ); - profilePerformanceEnd(VaccinePagePerformanceMarker); return ( @@ -74,18 +67,18 @@ const VaccineComponent = async ({ vaccineType }: VaccineProps): Promise )} - {/* Eligibility section for RSV */} + {/* Personalised Eligibility section (RSV) */} {vaccineInfo.personalisedEligibilityStatusRequired && eligibilityForPerson !== undefined && ( )} {/* Static eligibility section for RSV in pregnancy */} {vaccineType === VaccineType.RSV_PREGNANCY && ( - + )}
From 0d8f23d4b000340c9fbba4a86760cc2bccaa825b Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:14:29 +0000 Subject: [PATCH 07/24] VIA-836 Set node version in snapshot test job (cherry picked from commit 4ff066e66753af09d364accf447a74e1ab6ff416) --- .../workflows/cicd-11-run-snapshot-tests.yaml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/cicd-11-run-snapshot-tests.yaml b/.github/workflows/cicd-11-run-snapshot-tests.yaml index 892da279..9a29bf73 100644 --- a/.github/workflows/cicd-11-run-snapshot-tests.yaml +++ b/.github/workflows/cicd-11-run-snapshot-tests.yaml @@ -7,9 +7,29 @@ env: AWS_REGION: eu-west-2 jobs: + + ################################################# + # Set up metadata for the jobs + ################################################# + + metadata: + name: "Set CI/CD metadata" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + nodejs_version: ${{ steps.variables.outputs.nodejs_version }} + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + - name: "Set CI/CD variables" + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + run-snapshot-tests: name: "Snapshot tests" runs-on: "ubuntu-latest" + needs: [ metadata ] timeout-minutes: 30 concurrency: group: "preprod-env" @@ -27,6 +47,11 @@ jobs: fetch-depth: 0 ref: "main" + - name: "Setup nodejs ${{ needs.metadata.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ needs.metadata.outputs.nodejs_version }} + - name: "Configure AWS credentials" uses: aws-actions/configure-aws-credentials@v5 with: From 3ff9c521e5927dcf93981c835fb8cfe1ca1f1b35 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:13:10 +0000 Subject: [PATCH 08/24] VIA-836 Set node version in main build and test pipelines (cherry picked from commit 96f3b49244f3ee142a5e198e53da901d41df81a5) --- .github/workflows/cicd-1-pull-request.yaml | 1 + .github/workflows/cicd-3-deploy.yaml | 17 ++++++++++++++++- .github/workflows/stage-1-commit.yaml | 4 ++++ .github/workflows/stage-2-test.yaml | 8 ++++++++ .github/workflows/stage-3-build.yaml | 4 ++++ .github/workflows/stage-5-acceptance.yaml | 14 ++++++++++++++ 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 356a8eb0..8597b470 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -173,4 +173,5 @@ jobs: with: environment: "dev" checkout_ref: "${{ github.sha }}" + nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" secrets: inherit diff --git a/.github/workflows/cicd-3-deploy.yaml b/.github/workflows/cicd-3-deploy.yaml index adb155e6..e6a4855e 100644 --- a/.github/workflows/cicd-3-deploy.yaml +++ b/.github/workflows/cicd-3-deploy.yaml @@ -17,6 +17,20 @@ jobs: # Deploy action - download artefacts and deploy to AWS ############################################################# + metadata: + name: "Set CI/CD metadata" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + nodejs_version: ${{ steps.variables.outputs.nodejs_version }} + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + - name: "Set CI/CD variables" + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + deploy-action: name: "Deploy ${{ github.ref_name }} to (${{ github.event.inputs.environment }})" runs-on: ubuntu-latest @@ -57,9 +71,10 @@ jobs: acceptance-stage: name: "Acceptance stage (dev/preprod only)" if: ${{ contains(fromJSON('["dev","preprod"]'), github.event.inputs.environment) }} - needs: [ deploy-action ] + needs: [ metadata, deploy-action ] uses: ./.github/workflows/stage-5-acceptance.yaml with: environment: ${{ github.event.inputs.environment}} checkout_ref: ${{ github.ref_name }} + nodejs_version: ${{ needs.metadata.outputs.nodejs_version }} secrets: inherit diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index 48c0919d..8fc44d36 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -131,6 +131,10 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} - name: Cache node modules id: cache-npm uses: actions/cache@v5 diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 51b46092..548389a9 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -40,6 +40,10 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} - name: Cache node modules id: cache-npm uses: actions/cache@v5 @@ -61,6 +65,10 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} - name: Cache node modules id: cache-npm uses: actions/cache@v5 diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 523dea31..9e4bc6f9 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -49,6 +49,10 @@ jobs: steps: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} - name: Cache node modules id: cache-npm uses: actions/cache@v5 diff --git a/.github/workflows/stage-5-acceptance.yaml b/.github/workflows/stage-5-acceptance.yaml index a2dd0b15..b6e24651 100644 --- a/.github/workflows/stage-5-acceptance.yaml +++ b/.github/workflows/stage-5-acceptance.yaml @@ -16,6 +16,10 @@ on: required: false type: boolean default: false + nodejs_version: + description: "Node.js version, set by the CI/CD pipeline workflow" + required: true + type: string env: AWS_REGION: eu-west-2 @@ -59,6 +63,11 @@ jobs: with: ref: ${{ inputs.checkout_ref }} + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} + - name: "Configure AWS credentials" uses: aws-actions/configure-aws-credentials@v5 with: @@ -105,6 +114,11 @@ jobs: with: ref: ${{ inputs.checkout_ref }} + - name: "Setup nodejs ${{ inputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.nodejs_version }} + - name: "Run contract tests for version ${{ inputs.checkout_ref }} (EliD:sandpit, EliD:mocked)" timeout-minutes: 3 uses: ./.github/actions/run-contract-tests From c03d86e1794b02286be4bf6ab02cad812db701f7 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:28:11 +0000 Subject: [PATCH 09/24] VIA-836 Set nodejs version in Scheduled Assurance pipelines --- .../cicd-10-scheduled-nbs-assurance.yaml | 21 ++- .../workflows/cicd-9-scheduled-assurance.yaml | 142 +++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd-10-scheduled-nbs-assurance.yaml b/.github/workflows/cicd-10-scheduled-nbs-assurance.yaml index a5ca8323..8d202319 100644 --- a/.github/workflows/cicd-10-scheduled-nbs-assurance.yaml +++ b/.github/workflows/cicd-10-scheduled-nbs-assurance.yaml @@ -6,6 +6,20 @@ on: workflow_dispatch: {} jobs: + metadata: + name: "Set CI/CD metadata" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + nodejs_version: ${{ steps.variables.outputs.nodejs_version }} + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + - name: "Set CI/CD variables" + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + run-elid-api-tests: # Only call the tests for preprod environment # - ensures EliD's database is loaded with expected test scenarios @@ -35,7 +49,7 @@ jobs: environment: name: "preprod" - needs: [ run-elid-api-tests ] + needs: [ metadata, run-elid-api-tests ] if: ${{ !cancelled() }} steps: - name: "Checkout main branch" @@ -53,6 +67,11 @@ jobs: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Setup nodejs ${{ needs.metadata.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ needs.metadata.outputs.nodejs_version }} + - name: "Deploy version ${{ steps.get-latest-tag-name.outputs.value }} to (preprod)" timeout-minutes: 10 uses: ./.github/actions/deploy diff --git a/.github/workflows/cicd-9-scheduled-assurance.yaml b/.github/workflows/cicd-9-scheduled-assurance.yaml index 586c43d3..3e5ae805 100644 --- a/.github/workflows/cicd-9-scheduled-assurance.yaml +++ b/.github/workflows/cicd-9-scheduled-assurance.yaml @@ -17,6 +17,21 @@ env: R2_RELEASE_BRANCH: "release/v2.0" jobs: + + metadata: + name: "Set CI/CD metadata" + runs-on: ubuntu-latest + timeout-minutes: 1 + outputs: + nodejs_version: ${{ steps.variables.outputs.nodejs_version }} + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + - name: "Set CI/CD variables" + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + ########################################################## # EliD API tests - to load their DB with test scenarios ########################################################## @@ -27,6 +42,119 @@ jobs: secrets: ELID_PREPROD_AWS_ACCOUNT_ID: ${{ secrets.ELID_PREPROD_AWS_ACCOUNT_ID }} + ########################################################## + # R1.0 deployment and assurance (e2e+snapshots+contract) + ########################################################## + + deploy-and-test-r1: + name: "R1.0 Assurance (E2E, Contract, Snapshot)" + runs-on: "ubuntu-latest" + timeout-minutes: 30 + concurrency: + group: "preprod-env" + cancel-in-progress: false + permissions: + id-token: write + contents: read + environment: + name: "preprod" + + needs: [ metadata, run-elid-api-tests ] + if: ${{ !cancelled() && (github.event_name=='schedule' || (github.event_name=='workflow_dispatch' && (inputs.release=='All' || inputs.release=='Latest R1 tag'))) }} + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: "Setup nodejs ${{ needs.metadata.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ needs.metadata.outputs.nodejs_version }} + + - name: "Get latest tag name on ${{ env.RELEASE_BRANCH }} branch" + id: get-latest-tag-name + run: | + git fetch origin ${{ env.RELEASE_BRANCH }} + echo "value=$(git describe --tags --abbrev=0 --first-parent origin/${{ env.RELEASE_BRANCH }})" | tee -a $GITHUB_OUTPUT + + - name: "Deploy version ${{ steps.get-latest-tag-name.outputs.value }} to (preprod)" + timeout-minutes: 10 + uses: ./.github/actions/deploy + with: + environment: "preprod" + tag_or_sha_to_deploy: ${{ steps.get-latest-tag-name.outputs.value }} + secret_aws_account_id: ${{ secrets.AWS_ACCOUNT_ID }} + secret_aws_iam_role: ${{ secrets.IAM_ROLE }} + secret_aws_slack_channel_id: ${{ secrets.ALARMS_SLACK_CHANNEL_ID }} + + - name: "Run contract tests on ${{ steps.get-latest-tag-name.outputs.value }} (EliD:sandpit, EliD:mocked)" + timeout-minutes: 3 + uses: ./.github/actions/run-contract-tests + with: + target_ref: ${{ steps.get-latest-tag-name.outputs.value }} + env: + CONTENT_API_ENDPOINT: ${{ secrets.CONTENT_API_ENDPOINT }} + CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }} + ELIGIBILITY_API_ENDPOINT: ${{ secrets.ELIGIBILITY_API_ENDPOINT }} + ELIGIBILITY_API_KEY: ${{ secrets.ELIGIBILITY_API_KEY }} + SSM_PREFIX: ${{ secrets.SSM_PREFIX }} + IS_APIM_AUTH_ENABLED: ${{ vars.IS_APIM_AUTH_ENABLED }} + CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED: "false" + NHS_APP_REDIRECT_LOGIN_URL: "dummy" + CONTENT_CACHE_PATH: "dummy" + NHS_LOGIN_URL: "dummy" + NHS_LOGIN_CLIENT_ID: "dummy" + NHS_LOGIN_SCOPE: "dummy" + NHS_LOGIN_PRIVATE_KEY: "dummy" + NBS_URL: "dummy" + NBS_BOOKING_PATH: "dummy" + MAX_SESSION_AGE_MINUTES: 0 + AUTH_SECRET: "dummy" + + - name: "Run E2E tests on ${{ steps.get-latest-tag-name.outputs.value }} (preprod)" + timeout-minutes: 10 + uses: ./.github/actions/run-e2e-tests + with: + checkout_ref: ${{ steps.get-latest-tag-name.outputs.value }} + cross_browser: true + env: + TEST_NHS_APP_URL: ${{ secrets.TEST_NHS_APP_URL }} + TEST_NHS_LOGIN_PASSWORD: ${{ secrets.TEST_NHS_LOGIN_PASSWORD }} + TEST_NHS_LOGIN_OTP: ${{ secrets.TEST_NHS_LOGIN_OTP }} + TEST_NBS_APP_USERNAME: ${{ secrets.TEST_NBS_APP_USERNAME }} + TEST_NBS_APP_PASSWORD: ${{ secrets.TEST_NBS_APP_PASSWORD }} + TEST_APP_URL: ${{ vars.TEST_APP_URL_R1 }} + NHS_APP_REDIRECT_LOGIN_URL: ${{ secrets.NHS_APP_REDIRECT_LOGIN_URL }} + VITA_TEST_USER_PATTERN: ${{ secrets.VITA_TEST_USER_PATTERN }} + DEPLOY_ENVIRONMENT: "preprod" + + - name: "Run snapshot tests on ${{ steps.get-latest-tag-name.outputs.value }} (preprod)" + uses: ./.github/actions/run-snapshot-tests + with: + checkout_ref: ${{ steps.get-latest-tag-name.outputs.value }} + release_name: "release1" + env: + SECRET_IAM_ROLE: ${{ secrets.IAM_ROLE }} + TEST_NHS_APP_URL: ${{ secrets.TEST_NHS_APP_URL }} + TEST_NHS_LOGIN_PASSWORD: ${{ secrets.TEST_NHS_LOGIN_PASSWORD }} + TEST_NHS_LOGIN_OTP: ${{ secrets.TEST_NHS_LOGIN_OTP }} + TEST_APP_URL: ${{ vars.TEST_APP_URL_R1 }} + VITA_TEST_USER_PATTERN: ${{ secrets.VITA_TEST_USER_PATTERN }} + AWS_S3_ARTEFACTS_BUCKET: vita-${{ secrets.AWS_ACCOUNT_ID }}-artefacts-preprod + + - name: "Checkout ${{ env.RELEASE_BRANCH }} for audit" + if: ${{ !cancelled() }} + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_BRANCH }} + path: "release-audit" + + - name: "Audit npm packages (critical vulnerabilities)" + if: ${{ !cancelled() }} + working-directory: release-audit + run: npm audit --audit-level=critical + ########################################################## # R2.0 deployment and assurance (e2e+snapshots+contract) ########################################################## @@ -44,7 +172,7 @@ jobs: environment: name: "preprod" - needs: [ run-elid-api-tests ] + needs: [ metadata, deploy-and-test-r1 ] if: ${{ !cancelled() && (github.event_name=='schedule' || (github.event_name=='workflow_dispatch' && (inputs.release=='All' || inputs.release=='Latest R2 tag'))) }} steps: - name: "Checkout code" @@ -52,6 +180,11 @@ jobs: with: fetch-depth: 0 + - name: "Setup nodejs ${{ needs.metadata.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ needs.metadata.outputs.nodejs_version }} + - name: "Get latest tag name on ${{ env.R2_RELEASE_BRANCH }} branch" id: get-latest-tag-name run: | @@ -152,7 +285,7 @@ jobs: environment: name: "preprod" - needs: [ deploy-and-test-r2 ] + needs: [ metadata, deploy-and-test-r2 ] if: ${{ !cancelled() && (github.event_name=='schedule' || (github.event_name=='workflow_dispatch' && (inputs.release=='All' || inputs.release=='Latest main tag'))) }} steps: - name: "Checkout main branch" @@ -161,6 +294,11 @@ jobs: fetch-depth: 0 ref: "main" + - name: "Setup nodejs ${{ needs.metadata.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ needs.metadata.outputs.nodejs_version }} + - name: "Get latest tag name on main branch" id: get-latest-tag-name run: | From b98a3f9b4ef4f17133e14ea6183a037032b08152 Mon Sep 17 00:00:00 2001 From: Donna Belsey <196086361+donna-belsey-nhs@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:50:23 +0000 Subject: [PATCH 10/24] VIA-836 Detect and set nodejs version after git checkout Ensures that the nodejs version used is correct for the time that the tests were written, rather than the latest main branch. (cherry picked from commit 940f41557db6b58b5dfe07d10220259cbcbae716) --- .github/actions/run-contract-tests/action.yml | 11 +++++++++++ .github/actions/run-e2e-tests/action.yaml | 11 +++++++++++ .github/actions/run-nbs-e2e-tests/action.yaml | 11 +++++++++++ .github/actions/run-snapshot-tests/action.yaml | 11 +++++++++++ 4 files changed, 44 insertions(+) diff --git a/.github/actions/run-contract-tests/action.yml b/.github/actions/run-contract-tests/action.yml index caf6a6cf..a310ee89 100644 --- a/.github/actions/run-contract-tests/action.yml +++ b/.github/actions/run-contract-tests/action.yml @@ -19,6 +19,17 @@ runs: ref: ${{ inputs.target_ref }} path: "code-for-contract-tests" + - name: "Read nodejs version from .tool-versions" + shell: bash + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Setup nodejs ${{ steps.variables.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ steps.variables.outputs.nodejs_version }} + - name: "Cache node modules" uses: actions/cache@v4 env: diff --git a/.github/actions/run-e2e-tests/action.yaml b/.github/actions/run-e2e-tests/action.yaml index d1824d5c..200e4697 100644 --- a/.github/actions/run-e2e-tests/action.yaml +++ b/.github/actions/run-e2e-tests/action.yaml @@ -19,6 +19,17 @@ runs: ref: ${{ inputs.checkout_ref }} path: "code-for-e2e-tests" + - name: "Read nodejs version from .tool-versions" + shell: bash + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Setup nodejs ${{ steps.variables.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ steps.variables.outputs.nodejs_version }} + - name: Cache node modules id: cache-npm uses: actions/cache@v4 diff --git a/.github/actions/run-nbs-e2e-tests/action.yaml b/.github/actions/run-nbs-e2e-tests/action.yaml index 779a08e1..beeebc60 100644 --- a/.github/actions/run-nbs-e2e-tests/action.yaml +++ b/.github/actions/run-nbs-e2e-tests/action.yaml @@ -16,6 +16,17 @@ runs: ref: ${{ inputs.checkout_ref }} path: "code-for-e2e-tests" + - name: "Read nodejs version from .tool-versions" + shell: bash + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Setup nodejs ${{ steps.variables.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ steps.variables.outputs.nodejs_version }} + - name: Cache node modules id: cache-npm uses: actions/cache@v4 diff --git a/.github/actions/run-snapshot-tests/action.yaml b/.github/actions/run-snapshot-tests/action.yaml index e4955196..3c055841 100644 --- a/.github/actions/run-snapshot-tests/action.yaml +++ b/.github/actions/run-snapshot-tests/action.yaml @@ -19,6 +19,17 @@ runs: ref: ${{ inputs.checkout_ref }} path: "code-for-snapshot-tests" + - name: "Read nodejs version from .tool-versions" + shell: bash + id: variables + run: | + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + + - name: "Setup nodejs ${{ steps.variables.outputs.nodejs_version }}" + uses: actions/setup-node@v6 + with: + node-version: ${{ steps.variables.outputs.nodejs_version }} + - name: "Configure AWS credentials" uses: aws-actions/configure-aws-credentials@v5 with: From 82b81dab6b7c5ace767c15343890195a6d4a5341 Mon Sep 17 00:00:00 2001 From: kieran-broomhall-nhs <265510135+kieran-broomhall-nhs@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:40:07 +0000 Subject: [PATCH 11/24] VIA-912: Fixed double click logout (cherry picked from commit 2f9a64910a4e381ed36e988c7780f2dbf9b30aef) --- eslint.config.mjs | 14 ++++++++++++++ .../_components/inactivity/InactivityDialog.tsx | 8 ++++---- .../_components/nhs-frontend/AppHeader.test.tsx | 9 +++++++++ src/app/_components/nhs-frontend/AppHeader.tsx | 15 +++++++++------ src/app/vaccines/[vaccine]/page.test.tsx | 4 ++-- .../auth/apim/fetch-apim-access-token.test.ts | 4 ++-- src/utils/auth/pem-to-crypto-key.test.ts | 8 ++++---- src/utils/auth/user-logout.ts | 2 +- src/utils/get-secret.test.ts | 4 ++-- 9 files changed, 47 insertions(+), 21 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ec39bedc..852f80fa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,20 @@ const eslintConfig = [ }, }, + // Type-aware rules + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, + // Override for test files: turn off compat { files: ["**/*.test.{js,jsx,ts,tsx}", "**/*.spec.{js,jsx,ts,tsx}", "test-data/**"], diff --git a/src/app/_components/inactivity/InactivityDialog.tsx b/src/app/_components/inactivity/InactivityDialog.tsx index 27a79644..1d30a7a0 100644 --- a/src/app/_components/inactivity/InactivityDialog.tsx +++ b/src/app/_components/inactivity/InactivityDialog.tsx @@ -26,7 +26,7 @@ const InactivityDialog = (): JSX.Element => { if (status === "authenticated") { if (isTimedOut) { dialogRef.current?.close(); - userLogout(true); + void userLogout(true); } else if (isIdle) { dialogRef.current?.showModal(); } @@ -35,7 +35,7 @@ const InactivityDialog = (): JSX.Element => { // logout unauthorised users on protected pages if (status === "unauthenticated") { dialogRef.current?.close(); - userLogout(true); + void userLogout(true); } }, [dialogRef, isIdle, isTimedOut, pathname, status]); @@ -58,9 +58,9 @@ const InactivityDialog = (): JSX.Element => {