diff --git a/.env.local b/.env.local deleted file mode 100644 index 887b3ba8..00000000 --- a/.env.local +++ /dev/null @@ -1,19 +0,0 @@ -SSM_PREFIX=/local/ - -# logging -PINO_LOG_LEVEL=info - -# content api -CONTENT_API_ENDPOINT=http://localhost:8081/ -CONTENT_API_KEY=should-not-be-checked-in - -# content api cache -CONTENT_CACHE_PATH=wiremock/__files/ - -# e2e ui tests -VACCINATION_APP_URL=http://localhost:3000 - -# NHS Login -NHS_LOGIN_URL=https://auth.sandpit.signin.nhs.uk -NHS_LOGIN_CLIENT_ID=should-not-be-checked-in -NHS_LOGIN_SCOPE='openid profile gp_registration_details' diff --git a/README.md b/README.md index cd52e6d1..78de6911 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,11 @@ npm run test ``` ### Run UI driven tests -- in headless mode +- make sure to build and run the Next.js app + ``` + npm run app + ``` +- run tests in headless mode ``` npm run e2e ``` diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..ccba566b --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,22 @@ +# Runbook for solving production issues + +## Manual vaccine content cache refresh + +### Triggers +- When the periodic cache refresh has failed for some reason +- When nhs.uk tells us that the content needs to be refreshed asap + +### Action +1. Login to the AWS environment and ensure you are in London **eu-west-2** region. +2. Navigate to **Lambda** service. +3. Click on the lambda with prefix `gh-main-vita` and suffix `nextjs-warmer-function` +4. Select **Test** tab to create a new test event +5. Give the new event a meaningful name +6. Keep the event sharing settings to **Private** +7. Use the following template as the event JSON (feel free to add other non-PII fields as necessary for audit) +```json +{ + "who": "", + "why": "" +} +``` diff --git a/docs/adr/ADR-004_Content_caching_architecture.md b/docs/adr/ADR-004_Content_caching_architecture.md new file mode 100644 index 00000000..35a534b0 --- /dev/null +++ b/docs/adr/ADR-004_Content_caching_architecture.md @@ -0,0 +1,97 @@ +# ADR-004: Content caching architecture + +>| | | +>| ------------ |--------------------------------------------------------| +>| Date | `22/04/2025` | +>| Status | `Accepted` | +>| Deciders | `Engineering, Architecture, ` | +>| Significance | `Structure, Nonfunctional characteristics, Interfaces` | +>| Owners | Ankur Jain, Elena Oanea | + +--- + +- [ADR-004: Content caching architecture](#adr-004-content-caching-architecture) + - [Context](#context) + - [Decision](#decision) + - [Assumptions](#assumptions) + - [Drivers](#drivers) + - [Options](#options) + - [Outcome](#outcome) + - [Rationale](#rationale) + - [Consequences](#consequences) + - [Compliance](#compliance) + - [Notes](#notes) + - [Actions](#actions) + - [Tags](#tags) + +## Context +According to the NHS.UK website content API guidelines for caching, we require a minimum of 7 days before calling the +endpoint again to fetch updated content. This requires us to keep a cache in place. There are two problems to design for. +Firstly, how do we keep the cache updated - meaning cache inserts, updates and deletes. Secondly, how and when does the +app read/write from/to cache that is the latest content. + +## Decision +We decided to keep the design simple for maintainability purposes by separating the reading and writing to two separate +and independent processes. The writing part ensures to keep the cache updated as per the caching guidelines. The reading +part just reads from the cache assuming it is the correct and most up-to-date. This architecture is optimised for write +few and read many operations, which is our use case. + +To ensure that the cache has the most up-to-date data for all vaccines, we have three mechanisms: - +1. a post deployment trigger that updates the cache each time a change is made to the writer or vaccines list. +2. a periodic cron job that updates the cache at a predefined frequency. +3. on demand trigger that allows updating when requested by nhs.uk. + +```mermaid +--- +title: Content cache architecture +--- +graph TD; + A(Show vaccine content - client side); + B(AWS Lambda - nextjs server action); + C[(AWS S3 Cache)]; + D(Content cache hydrator); + E(Cron); + A -- triggers --> B; + B -- reads --> C; + E -- triggers --> D; + D -- writes --> C; +``` + +### Assumptions +- The data itself is not updated very frequently because if it did, then the reads could suffer latency due to locking for updates. +- When we roll out the VitA app for the first time, there is a flag in NHS app that needs to be turned on for us to go live. + This gives sufficient time for the cache writer to have pulled in all vaccine content. +- When we roll out new vaccines, there is a slight delay (seconds) between deployment finish and cache writer trigger. + During this time, if a user visits the new vaccine page, they will see an error page. This should go away as soon as + the new content is available. + +### Drivers +Mostly simplifying the read process, so that it does not get mixed with writing logic. + +### Options +Alternative design was to fetch the content on a cache miss and update the cache. After which, the subsequent reads +would succeed. This makes the read complex, and we really wanted to make read simple as that is going to be frequent. + +### Outcome +The decision is reversible, if it turns out that people are seeing error pages frequently. This would be monitored. + +### Rationale +Design that optimises for multiple reads and very infrequent writes. +Design that is simple to debug and separating the concerns makes it easier. + +## Consequences +As outlined above in assumptions, there might be intermittent error pages shown when the vaccine content is being pulled. + +## Compliance +The errors would be monitored so that service level objectives are met. + +## Notes +None + +## Actions + +- [x] Ankur Jain, 22/04/2025, created the ADR + +## Tags + +`#performance, #maintainability, #testability, #deployability, #modularity, #simplicity` diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 460d7ec9..1bdb308c 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -10,13 +10,25 @@ const removeDistDirectory = async () => { }); }; +const copyExtraFiles = async () => { + // there is an issue with esbuild putting a warning for xhr-sync-worker.js file being external + // at runtime, lambda complains that the file is not present + // DOMPurify apparently depends on this file + fs.copyFile("./node_modules/jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js", `${OUTPUT_DIR}/xhr-sync-worker.js`, (err) => { + if (err) throw err; + console.log("Copied xhr-sync-worker.js (workaround)"); + }); +}; + const buildLambda = async () => { await esbuild.build({ entryPoints: ["src/_lambda/content-cache-hydrator/handler.ts"], bundle: true, minify: true, platform: "node", + jsx: "automatic", target: "node22", + external: ["./xhr-sync-worker.js"], outfile: `${OUTPUT_DIR}/lambda.js` }); }; @@ -24,6 +36,7 @@ const buildLambda = async () => { try { await removeDistDirectory(); await buildLambda(); + await copyExtraFiles(); console.log(`Built lambda successfully -> ${OUTPUT_DIR}/lambda.js`); } catch (e) { console.error("Building lambda failed: ", e); diff --git a/infrastructure/environments/dev/main.tf b/infrastructure/environments/dev/main.tf index 47efd5aa..5d056a28 100644 --- a/infrastructure/environments/dev/main.tf +++ b/infrastructure/environments/dev/main.tf @@ -11,3 +11,12 @@ module "deploy" { log_retention_in_days = local.log_retention_in_days pino_log_level = local.pino_log_level } + +module "post_deploy" { + source = "../../modules/post_deploy" + + default_tags = local.default_tags + prefix = local.prefix + + depends_on = [module.deploy] +} diff --git a/infrastructure/github-iam-role-policy.json b/infrastructure/github-iam-role-policy.json index befea0ce..066ddde3 100644 --- a/infrastructure/github-iam-role-policy.json +++ b/infrastructure/github-iam-role-policy.json @@ -28,15 +28,28 @@ "dynamodb:DescribeTimeToLive", "dynamodb:ListTagsOfResource", "dynamodb:TagResource", + "events:DeleteRule", + "events:DescribeRule", + "events:ListTagsForResource", + "events:ListTargetsByRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + "events:TagResource", "iam:AttachRolePolicy", "iam:CreatePolicy", "iam:CreateRole", + "iam:DeletePolicy", + "iam:DeleteRole", "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", "iam:GetPolicy", "iam:GetPolicyVersion", "iam:GetRole", "iam:GetRolePolicy", "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicyVersions", "iam:ListRolePolicies", "iam:PassRole", "iam:PutRolePolicy", @@ -47,6 +60,7 @@ "lambda:CreateEventSourceMapping", "lambda:CreateFunction", "lambda:CreateFunctionUrlConfig", + "lambda:DeleteFunction", "lambda:GetAlias", "lambda:GetEventSourceMapping", "lambda:GetFunction", @@ -54,9 +68,11 @@ "lambda:GetFunctionConfiguration", "lambda:GetFunctionUrlConfig", "lambda:GetPolicy", + "lambda:ListFunctions", "lambda:ListTags", "lambda:ListVersionsByFunction", "lambda:PublishVersion", + "lambda:RemovePermission", "lambda:TagResource", "lambda:UpdateAlias", "lambda:UpdateFunctionCode", diff --git a/infrastructure/modules/deploy_app/main.tf b/infrastructure/modules/deploy_app/main.tf index 9d74c070..66916431 100644 --- a/infrastructure/modules/deploy_app/main.tf +++ b/infrastructure/modules/deploy_app/main.tf @@ -29,7 +29,7 @@ module "deploy_app" { additional_iam_policies = [aws_iam_policy.cache_lambda_additional_policy] runtime = var.nodejs_version concurrency = 1 - schedule = "rate(2 days)" + schedule = "rate(7 days)" additional_environment_variables = { SSM_PREFIX = var.ssm_prefix CONTENT_CACHE_PATH = var.content_cache_path diff --git a/infrastructure/modules/post_deploy/event_bridge.tf b/infrastructure/modules/post_deploy/event_bridge.tf new file mode 100644 index 00000000..6d4a6077 --- /dev/null +++ b/infrastructure/modules/post_deploy/event_bridge.tf @@ -0,0 +1,30 @@ +resource "aws_cloudwatch_event_rule" "warmer_lambda_deployment_event_rule" { + name = "${var.prefix}-post-deployment-event-rule" + description = "Triggers Warmer Lambda on deployment" + tags = var.default_tags + event_pattern = jsonencode({ + "source" : ["aws.lambda"], + "detail-type" : ["AWS API Call via CloudTrail"], + "detail" : { + "eventSource" : ["lambda.amazonaws.com"], + "eventName" : ["CreateFunction20150331", "UpdateFunctionCode20150331v2", "UpdateFunctionConfiguration20150331v2"], + "requestParameters" : { + "functionName" : [local.warmer_function_name] + } + } + }) +} + +resource "aws_cloudwatch_event_target" "lambda_target" { + target_id = "lambda" + rule = aws_cloudwatch_event_rule.warmer_lambda_deployment_event_rule.name + arn = local.warmer_function_arn +} + +resource "aws_lambda_permission" "allow_event_bridge_to_invoke_lambda" { + statement_id = "AllowExecutionFromEventBridge" + action = "lambda:InvokeFunction" + function_name = local.warmer_function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.warmer_lambda_deployment_event_rule.arn +} diff --git a/infrastructure/modules/post_deploy/locals.tf b/infrastructure/modules/post_deploy/locals.tf new file mode 100644 index 00000000..91026e49 --- /dev/null +++ b/infrastructure/modules/post_deploy/locals.tf @@ -0,0 +1,13 @@ +data "aws_lambda_functions" "all_lambda_functions" {} + +locals { + warmer_function_name = one([ + for name in data.aws_lambda_functions.all_lambda_functions.function_names : + name if length(regexall("^${var.prefix}.*warmer-function$", name)) == 1 + ]) + + warmer_function_arn = one([ + for arn in data.aws_lambda_functions.all_lambda_functions.function_arns : + arn if length(regexall("^${var.prefix}.*warmer-function$", split(":", arn)[6])) == 1 + ]) +} diff --git a/infrastructure/modules/post_deploy/variables.tf b/infrastructure/modules/post_deploy/variables.tf new file mode 100644 index 00000000..20181829 --- /dev/null +++ b/infrastructure/modules/post_deploy/variables.tf @@ -0,0 +1,9 @@ +variable "prefix" { + type = string + description = "Prefix to be applied to resources created" +} + +variable "default_tags" { + type = map(string) + description = "Map of default key-value pair of tags to add to resources" +} diff --git a/jest.config.ts b/jest.config.ts index e5ee0175..46e2cef8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -19,7 +19,7 @@ const config: Config = { "^@src/(.*)$": "/src/$1", "^@test-data/(.*)$": "/mocks/$1" }, - testPathIgnorePatterns: ["/e2e/"], + testPathIgnorePatterns: ["/e2e/", "/.open-next/"], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/package-lock.json b/package-lock.json index 1ae52235..19ecfad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,15 @@ "@aws-sdk/client-ssm": "^3.777.0", "axios": "^1.8.4", "dotenv": "^16.4.7", + "iron-session": "^8.0.4", "isomorphic-dompurify": "^2.22.0", "next": "15.2.4", "nhsuk-react-components": "^5.0.0", "openid-client": "^6.4.2", "pino": "^9.6.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-error-boundary": "^5.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -1696,7 +1698,6 @@ "version": "7.26.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -5835,6 +5836,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -7735,6 +7745,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -10521,6 +10555,18 @@ "react": "^19.0.0" } }, + "node_modules/react-error-boundary": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", + "integrity": "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -10578,7 +10624,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regexp.prototype.flags": { @@ -11812,6 +11857,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 47d00a3f..d4c6df9e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev --turbopack", + "app": "npm run build && npm run start", "build": "next build --experimental-build-mode compile", "build:opennext": "npx --yes @opennextjs/aws@latest build", "build:lambda": "node esbuild.config.mjs", @@ -58,13 +59,15 @@ "@aws-sdk/client-ssm": "^3.777.0", "axios": "^1.8.4", "dotenv": "^16.4.7", + "iron-session": "^8.0.4", "isomorphic-dompurify": "^2.22.0", "next": "15.2.4", "nhsuk-react-components": "^5.0.0", "openid-client": "^6.4.2", "pino": "^9.6.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-error-boundary": "^5.0.0" }, "overrides": { "jsdom": "^26.0.0" diff --git a/src/_lambda/content-cache-hydrator/handler.test.ts b/src/_lambda/content-cache-hydrator/handler.test.ts index 65d2294a..14d7788f 100644 --- a/src/_lambda/content-cache-hydrator/handler.test.ts +++ b/src/_lambda/content-cache-hydrator/handler.test.ts @@ -21,7 +21,7 @@ describe("Lambda Handler", () => { it("returns 500 when cache hydration has failed due to fetching errors", async () => { (fetchContentForVaccine as jest.Mock).mockRejectedValue(new Error("test")); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to filtering invalid content errors", async () => { @@ -29,7 +29,7 @@ describe("Lambda Handler", () => { (getFilteredContentForVaccine as jest.Mock).mockRejectedValue( new Error("test"), ); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to styling errors", async () => { @@ -38,7 +38,7 @@ describe("Lambda Handler", () => { (getStyledContentForVaccine as jest.Mock).mockRejectedValue( new Error("test"), ); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); it("returns 500 when cache hydration has failed due to writing errors", async () => { @@ -46,6 +46,6 @@ describe("Lambda Handler", () => { (getFilteredContentForVaccine as jest.Mock).mockResolvedValue(undefined); (getStyledContentForVaccine as jest.Mock).mockResolvedValue(undefined); (writeContentForVaccine as jest.Mock).mockRejectedValue(new Error("test")); - await expect(handler({})).rejects.toThrow("2 failures"); + await expect(handler({})).rejects.toThrow("3 failures"); }); }); diff --git a/src/app/_components/vaccine/Vaccine.test.tsx b/src/app/_components/vaccine/Vaccine.test.tsx index 35f40cc5..960c6a3a 100644 --- a/src/app/_components/vaccine/Vaccine.test.tsx +++ b/src/app/_components/vaccine/Vaccine.test.tsx @@ -3,7 +3,10 @@ import Vaccine from "@src/app/_components/vaccine/Vaccine"; import { VaccineTypes } from "@src/models/vaccine"; import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; import { StyledVaccineContent } from "@src/services/content-api/parsers/content-styling-service"; -import { mockStyledContent } from "@test-data/content-api/data"; +import { + mockStyledContent, + mockStyledContentWithoutWhatSection, +} from "@test-data/content-api/data"; import { render, screen } from "@testing-library/react"; import { act } from "react"; @@ -13,79 +16,119 @@ describe("Any vaccine page", () => { const mockVaccineName = "Test-Name"; let contentPromise: Promise; - beforeEach(() => { - (getContentForVaccine as jest.Mock).mockResolvedValue(mockStyledContent); - contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); - }); - const renderVaccinePage = async () => { await act(async () => { render( - + , ); }); }; - it("should contain correct vaccine name in heading", async () => { - await renderVaccinePage(); + describe("with all sections available", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue(mockStyledContent); + contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); + }); + + it("should display correct vaccine name in heading", async () => { + await renderVaccinePage(); - const heading = screen.getByRole("heading", { - level: 1, - name: `${mockVaccineName} vaccine`, + const heading = screen.getByRole("heading", { + level: 1, + name: `${mockVaccineName} vaccine`, + }); + + expect(heading).toBeInTheDocument(); }); - expect(heading).toBeInTheDocument(); - }); + it("should display overview text", async () => { + await renderVaccinePage(); + const overviewBlock: HTMLElement = screen.getByText("Overview text"); + expect(overviewBlock).toBeInTheDocument(); + }); - it("should contain overview text", async () => { - await renderVaccinePage(); - const overviewBlock = screen.getByText("Overview text"); - expect(overviewBlock).toBeInTheDocument(); - }); + it("should display whatItIsFor expander block", async () => { + await renderVaccinePage(); - it("should contain whatItIsFor expander block", async () => { - await renderVaccinePage(); + const heading: HTMLElement = screen.getByText("what-heading"); + const content: HTMLElement = screen.getByText( + "What Section styled component", + ); - const heading = screen.getByText("what-heading"); - const content = screen.getByText("What Section styled component"); + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); - }); + it("should display whoVaccineIsFor expander block", async () => { + await renderVaccinePage(); - it("should contain whoVaccineIsFor expander block", async () => { - await renderVaccinePage(); + const heading: HTMLElement = screen.getByText("who-heading"); + const content: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Who Section styled component", + }); - const heading = screen.getByText("who-heading"); - const content = screen.getByRole("heading", { - level: 2, - name: "Who Section styled component", + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); - }); + it("should display howToGetVaccine expander block", async () => { + await renderVaccinePage(); - it("should contain howToGetVaccine expander block", async () => { - await renderVaccinePage(); + const heading: HTMLElement = screen.getByText("how-heading"); + const content: HTMLElement = screen.getByText( + "How Section styled component", + ); - const heading = screen.getByText("how-heading"); - const content = screen.getByText("How Section styled component"); + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + + it("should display webpage link", async () => { + await renderVaccinePage(); + + const webpageLink: HTMLElement = screen.getByRole("link", { + name: `Learn more about the ${mockVaccineName} vaccination on nhs.uk`, + }); - expect(heading).toBeInTheDocument(); - expect(content).toBeInTheDocument(); + expect(webpageLink).toBeInTheDocument(); + expect(webpageLink).toHaveAttribute("href", "https://www.test.com/"); + }); }); - it("should contain webpage link", async () => { - await renderVaccinePage(); + describe("without whatItIsFor section", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + mockStyledContentWithoutWhatSection, + ); + contentPromise = getContentForVaccine(VaccineTypes.SIX_IN_ONE); + }); + + it("should not display whatItIsFor section", async () => { + await renderVaccinePage(); + + const heading: HTMLElement | null = screen.queryByText("what-heading"); + const content: HTMLElement | null = screen.queryByText( + "What Section styled component", + ); - const webpageLink = screen.getByRole("link", { - name: `Learn more about the ${mockVaccineName} vaccination on nhs.uk`, + expect(heading).not.toBeInTheDocument(); + expect(content).not.toBeInTheDocument(); }); - expect(webpageLink).toBeInTheDocument(); - expect(webpageLink).toHaveAttribute("href", "https://www.test.com/"); + it("should display whoVaccineIsFor section", async () => { + await renderVaccinePage(); + + const heading: HTMLElement = screen.getByText("who-heading"); + const content: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Who Section styled component", + }); + + expect(heading).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); }); }); diff --git a/src/app/_components/vaccine/Vaccine.tsx b/src/app/_components/vaccine/Vaccine.tsx index 66474986..e6f14bd5 100644 --- a/src/app/_components/vaccine/Vaccine.tsx +++ b/src/app/_components/vaccine/Vaccine.tsx @@ -1,6 +1,5 @@ "use client"; -import { VaccineTypes } from "@src/models/vaccine"; import { JSX, use } from "react"; import Details from "@src/app/_components/nhs-frontend/Details"; import { useVaccineContentContextValue } from "@src/app/_components/providers/VaccineContentProvider"; @@ -8,7 +7,6 @@ import { StyledVaccineContent } from "@src/services/content-api/parsers/content- interface VaccineProps { name: string; - vaccine: VaccineTypes; } const Vaccine = (props: VaccineProps): JSX.Element => { @@ -22,10 +20,12 @@ const Vaccine = (props: VaccineProps): JSX.Element => {

More information

-
+ {styledContent.whatVaccineIsFor ? ( +
+ ) : undefined}
{ + return { + buildAuthorizationUrl: jest.fn(), + randomState: jest.fn(() => "randomState"), + }; +}); + +jest.mock("next/server", () => { + const actualNextServer = jest.requireActual("next/server"); + return { + ...actualNextServer, + NextResponse: { + json: actualNextServer.NextResponse.json, + redirect: jest.fn(), + }, + }; +}); + +const mockNhsLoginUrl = "https://nhs-login-url"; +const mockNhsLoginClientId = "vita-client-id"; +const mockNhsLoginScope = "openid profile"; +const mockRedirectUrl = "https://redirect/url"; + +const mockAuthConfig = { + url: mockNhsLoginUrl, + client_id: mockNhsLoginClientId, + scope: mockNhsLoginScope, + redirect_uri: mockRedirectUrl, +}; + +const mockClientConfig = {} as jest.Mock; + +(getAuthConfig as jest.Mock).mockImplementation(() => mockAuthConfig); +(getClientConfig as jest.Mock).mockImplementation(() => mockClientConfig); + +describe("SSO route", () => { + let mockBuildAuthorizationUrl: jest.Mock; + let nextResponseRedirect: jest.Mock; + + beforeEach(() => { + mockBuildAuthorizationUrl = client.buildAuthorizationUrl as jest.Mock; + nextResponseRedirect = NextResponse.redirect as jest.Mock; + }); + + describe("GET endpoint", () => { + it.skip("passes assertedLoginIdentity JWT on to redirect url", async () => { + const mockAssertedLoginJWT = "asserted-login-jwt-value"; + const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); + inboundUrlWithAssertedParam.searchParams.append( + "assertedLoginIdentity", + mockAssertedLoginJWT, + ); + const mockClientBuiltAuthUrl = new URL("https://nhs-login-url"); + mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + const request = new NextRequest(inboundUrlWithAssertedParam); + + await GET(request); + + expect(nextResponseRedirect).toHaveBeenCalledTimes(1); + const redirectedUrl = nextResponseRedirect.mock.lastCall[0]; + const searchParams = redirectedUrl.searchParams; + expect(searchParams.get("asserted_login_identity")).toEqual( + mockAssertedLoginJWT, + ); + }); + + it.skip("redirects the user to NHS Login with expected query params", async () => { + const mockAssertedLoginJWT = "asserted-login-jwt-value"; + const inboundUrlWithAssertedParam = new URL("https://test-inbound-url"); + inboundUrlWithAssertedParam.searchParams.append( + "assertedLoginIdentity", + mockAssertedLoginJWT, + ); + const mockClientBuiltAuthUrl = new URL("https://nhs-login-url/authorize"); + mockBuildAuthorizationUrl.mockReturnValue(mockClientBuiltAuthUrl); + const request = new NextRequest(inboundUrlWithAssertedParam); + + await GET(request); + + expect(nextResponseRedirect).toHaveBeenCalledTimes(1); + const redirectedUrl: URL = nextResponseRedirect.mock.lastCall[0]; + console.log(redirectedUrl); + expect(redirectedUrl.origin).toBe(mockAuthConfig.url); + expect(redirectedUrl.pathname).toBe("/authorize"); + const searchParams = redirectedUrl.searchParams; + expect(searchParams.get("asserted_login_identity")).toEqual( + mockAssertedLoginJWT, + ); + expect(searchParams.get("scope")).toEqual("openid%20profile"); + expect(searchParams.get("client_id")).toEqual(mockAuthConfig.client_id); + expect(searchParams.get("redirect_uri")).toEqual( + `${mockAuthConfig.url}/auth/callback`, + ); + expect(searchParams.get("state")).toBeDefined(); + expect(searchParams.get("nonce")).toBeDefined(); + expect(searchParams.get("prompt")).toEqual("none"); + }); + + it("should fail if assertedLoginIdentity query parameter not provided", async () => { + const urlWithoutAssertedParam = new URL("https://testurl"); + + const request = new NextRequest(urlWithoutAssertedParam); + + const response = await GET(request); + expect(response.status).toBe(400); + }); + + it("should fail if assertedLoginIdentity query parameter is empty", async () => { + const urlWithEmptyAssertedParam = new URL("https://testurl"); + urlWithEmptyAssertedParam.searchParams.append( + "assertedLoginIdentity", + "", + ); + + const request = new NextRequest(urlWithEmptyAssertedParam); + + const response = await GET(request); + expect(response.status).toBe(400); + }); + }); +}); diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100644 index 00000000..2416556d --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { logger } from "@src/utils/logger"; +import { getClientConfig } from "@src/utils/auth/get-client-config"; +import { getAuthConfig } from "@src/utils/auth/get-auth-config"; +import * as client from "openid-client"; + +const log = logger.child({ module: "sso-route" }); + +export async function GET(request: NextRequest) { + const assertedLoginIdentity = request.nextUrl.searchParams.get( + "assertedLoginIdentity", + ); + if (!assertedLoginIdentity) { + log.error("SSO route called without assertedLoginIdentity parameter"); + return NextResponse.json({ message: "Bad request" }, { status: 400 }); + } + try { + const state = client.randomState(); + const authConfig = await getAuthConfig(); + const clientConfig = await getClientConfig(); + const parameters: Record = { + redirect_uri: authConfig.redirect_uri, + scope: authConfig.scope, + state: state, + prompt: "none", + asserted_login_identity: assertedLoginIdentity, + }; + const redirectTo = client.buildAuthorizationUrl(clientConfig, parameters); + + return NextResponse.redirect(redirectTo); + } catch (e) { + log.error(e, "SSO route: error handling not-yet-implemented"); + throw new Error("not-yet-implemented"); + } +} diff --git a/src/app/auth/sso/route.test.ts b/src/app/auth/sso/route.test.ts deleted file mode 100644 index 32a3b736..00000000 --- a/src/app/auth/sso/route.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @jest-environment node - */ - -import { NextRequest } from "next/server"; -import { GET } from "@src/app/auth/sso/route"; -import { configProvider } from "@src/utils/config"; - -jest.mock("@src/utils/config"); - -const mockNhsLoginUrl = "nhs-login/url"; -const mockNhsLoginScope = "openid profile"; -const mockNhsLoginClientId = "vita-client-id"; -const mockVaccinationAppUrl = "vita-base-url"; - -(configProvider as jest.Mock).mockImplementation(() => ({ - NHS_LOGIN_URL: mockNhsLoginUrl, - NHS_LOGIN_CLIENT_ID: mockNhsLoginClientId, - NHS_LOGIN_SCOPE: mockNhsLoginScope, - VACCINATION_APP_URL: mockVaccinationAppUrl, -})); - -describe("SSO route", () => { - describe("GET endpoint", () => { - it("should fail if assertedLoginIdentity query parameter not provided", async () => { - const urlWithoutAssertedParam = new URL("https://testurl"); - - const request = new NextRequest(urlWithoutAssertedParam); - - const response = await GET(request); - expect(response.status).toBe(400); - }); - - it("should fail if assertedLoginIdentity query parameter is empty", async () => { - const urlWithEmptyAssertedParam = new URL("https://testurl"); - urlWithEmptyAssertedParam.searchParams.append( - "assertedLoginIdentity", - "", - ); - - const request = new NextRequest(urlWithEmptyAssertedParam); - - const response = await GET(request); - expect(response.status).toBe(400); - }); - }); -}); diff --git a/src/app/auth/sso/route.ts b/src/app/auth/sso/route.ts deleted file mode 100644 index ec9562e4..00000000 --- a/src/app/auth/sso/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { logger } from "@src/utils/logger"; - -const log = logger.child({ module: "sso-route" }); - -export async function GET(request: NextRequest) { - if (request.nextUrl.searchParams.get("assertedLoginIdentity")) { - return NextResponse.json({ placeholder: "to-be-implemented" }); - } else { - log.error("SSO route called without assertedLoginIdentity parameter"); - - return NextResponse.json({ message: "Bad request" }, { status: 400 }); - } -} diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index 934d61fe..19231d60 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -50,4 +50,11 @@ describe("Vaccination Hub Page", () => { expect(link).toBeInTheDocument(); expect(link.getAttribute("href")).toEqual("/vaccines/rsv"); }); + + it("renders Flu vaccine link", async () => { + const link: HTMLElement = screen.getByRole("link", { name: "Flu vaccine" }); + + expect(link).toBeInTheDocument(); + expect(link.getAttribute("href")).toEqual("/vaccines/flu"); + }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index c2c33875..6d702355 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,6 +28,7 @@ const VaccinationsHub = () => {

+
diff --git a/src/app/schedule/page.test.tsx b/src/app/schedule/page.test.tsx index 8a96c2cd..b36b7b50 100644 --- a/src/app/schedule/page.test.tsx +++ b/src/app/schedule/page.test.tsx @@ -19,9 +19,19 @@ describe("Schedule Page", () => { expect(heading).toBeInTheDocument(); }); + it("renders static description text", async () => { + const description: HTMLElement = screen.getByText( + "Find out about vaccinations for babies, children and adults, including why they're important and how to get them.", + ); + + expect(description).toBeInTheDocument(); + }); + it("renders the section headings", async () => { const expectedSectionText: string[] = [ "Vaccines for babies under 1 year old", + "Vaccines for Adults", + "Vaccines for Seasonal diseases", ]; expectedSectionText.forEach((headingText) => { @@ -34,20 +44,20 @@ describe("Schedule Page", () => { }); }); - it("renders static description text", async () => { - const description: HTMLElement = screen.getByText( - "Find out about vaccinations for babies, children and adults, including why they're important and how to get them.", - ); - - expect(description).toBeInTheDocument(); - }); - it("renders card component with props", async () => { const expectedVaccines = [ { name: "6-in-1 vaccine", href: "/vaccines/6-in-1", }, + { + name: "RSV vaccine", + href: "/vaccines/rsv", + }, + { + name: "Flu vaccine", + href: "/vaccines/flu", + }, ]; expectedVaccines.forEach((vaccine) => { diff --git a/src/app/schedule/page.tsx b/src/app/schedule/page.tsx index 9d01242c..ad84a606 100644 --- a/src/app/schedule/page.tsx +++ b/src/app/schedule/page.tsx @@ -13,6 +13,8 @@ const Schedule = () => { Find out about vaccinations for babies, children and adults, including why they're important and how to get them.

+

Vaccines for Seasonal diseases

+

Vaccines for Adults

Vaccines for babies under 1 year old

diff --git a/src/app/sso-error/page.tsx b/src/app/sso-error/page.tsx new file mode 100644 index 00000000..bfe2a7c7 --- /dev/null +++ b/src/app/sso-error/page.tsx @@ -0,0 +1,5 @@ +const SsoErrorPage = () => { + return
Error occured during SSO. Please try again later.
; +}; + +export default SsoErrorPage; diff --git a/src/app/vaccines/6-in-1/page.test.tsx b/src/app/vaccines/6-in-1/page.test.tsx index 3b267f6f..e0da7f4e 100644 --- a/src/app/vaccines/6-in-1/page.test.tsx +++ b/src/app/vaccines/6-in-1/page.test.tsx @@ -1,36 +1,39 @@ -import { VaccineTypes } from "@src/models/vaccine"; -import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import Vaccine6in1 from "@src/app/vaccines/6-in-1/page"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; -jest.mock("@src/utils/config"); +jest.mock("@src/services/content-api/gateway/content-reader-service"); jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); describe("6-in-1 vaccine page", () => { - (configProvider as jest.Mock).mockImplementation(() => ({ - CONTENT_CACHE_PATH: "wiremock/__files/", - PINO_LOG_LEVEL: "info", - })); + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); - it("should contain back link to vaccination schedule page", () => { - const pathToSchedulePage = "/schedule"; + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; - render(Vaccine6in1()); + render(Vaccine6in1()); - const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); - it("should contain vaccine component", () => { - render(Vaccine6in1()); + it("should contain vaccine component", () => { + render(Vaccine6in1()); - expect(Vaccine).toHaveBeenCalledWith( - { - name: "6-in-1", - vaccine: VaccineTypes.SIX_IN_ONE, - }, - undefined, - ); + expect(Vaccine).toHaveBeenCalledWith( + { + name: "6-in-1", + }, + undefined, + ); + }); }); }); diff --git a/src/app/vaccines/6-in-1/page.tsx b/src/app/vaccines/6-in-1/page.tsx index ffdd0173..7e6794b7 100644 --- a/src/app/vaccines/6-in-1/page.tsx +++ b/src/app/vaccines/6-in-1/page.tsx @@ -19,10 +19,7 @@ const Vaccine6in1 = (): JSX.Element => { 6-in-1 Vaccine - NHS App - +
); diff --git a/src/app/vaccines/flu/page.integration.test.tsx b/src/app/vaccines/flu/page.integration.test.tsx new file mode 100644 index 00000000..9b5ec519 --- /dev/null +++ b/src/app/vaccines/flu/page.integration.test.tsx @@ -0,0 +1,31 @@ +import { configProvider } from "@src/utils/config"; +import { render, screen } from "@testing-library/react"; +import VaccineFlu from "@src/app/vaccines/flu/page"; +import { act } from "react"; +import mockFluVaccineContent from "@project/wiremock/__files/flu-vaccine.json"; + +jest.mock("@src/utils/config"); + +describe("Flu vaccine page - integration test", () => { + (configProvider as jest.Mock).mockImplementation(() => ({ + CONTENT_CACHE_PATH: "wiremock/__files/", + PINO_LOG_LEVEL: "info", + })); + + it("should display Flu content from embedded vaccine component", async () => { + await act(async () => { + render(VaccineFlu()); + }); + + const fluHeading: HTMLElement = await screen.findByRole("heading", { + level: 1, + name: "Flu vaccine", + }); + expect(fluHeading).toBeInTheDocument(); + + const overview: HTMLElement = await screen.findByTestId("overview-text"); + expect(overview).toHaveTextContent( + mockFluVaccineContent.mainEntityOfPage[0].text as string, + ); + }); +}); diff --git a/src/app/vaccines/flu/page.test.tsx b/src/app/vaccines/flu/page.test.tsx new file mode 100644 index 00000000..584cdb39 --- /dev/null +++ b/src/app/vaccines/flu/page.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import VaccineFlu from "@src/app/vaccines/flu/page"; +import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; + +jest.mock("@src/services/content-api/gateway/content-reader-service"); +jest.mock("@src/app/_components/vaccine/Vaccine"); + +describe("Flu vaccine page", () => { + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); + + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; + + render(VaccineFlu()); + + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it("should contain vaccine component", () => { + render(VaccineFlu()); + + expect(Vaccine).toHaveBeenCalledWith( + { + name: "Flu", + }, + undefined, + ); + }); + }); + + describe("when content fails to load with errors", () => { + beforeEach(() => { + (Vaccine as jest.Mock).mockImplementation(() => { + throw new Error("error"); + }); + }); + + it("should display error page", () => { + render(VaccineFlu()); + + const errorHeading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Vaccine content is unavailable", + }); + + expect(errorHeading).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/vaccines/flu/page.tsx b/src/app/vaccines/flu/page.tsx new file mode 100644 index 00000000..871d9e8d --- /dev/null +++ b/src/app/vaccines/flu/page.tsx @@ -0,0 +1,32 @@ +import BackLink from "@src/app/_components/nhs-frontend/BackLink"; +import { JSX } from "react"; + +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; +import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { VaccineContentProvider } from "@src/app/_components/providers/VaccineContentProvider"; +import { StyledVaccineContent } from "@src/services/content-api/parsers/content-styling-service"; +import { ErrorBoundary } from "react-error-boundary"; +import VaccineError from "@src/app/vaccines/vaccine-error/page"; + +export const dynamic = "force-dynamic"; + +const VaccineFlu = (): JSX.Element => { + const contentPromise: Promise = getContentForVaccine( + VaccineTypes.FLU, + ); + + return ( +
+ Flu Vaccine - NHS App + + }> + + + + +
+ ); +}; + +export default VaccineFlu; diff --git a/src/app/vaccines/rsv/page.test.tsx b/src/app/vaccines/rsv/page.test.tsx index 9f516bc3..884da25a 100644 --- a/src/app/vaccines/rsv/page.test.tsx +++ b/src/app/vaccines/rsv/page.test.tsx @@ -1,37 +1,40 @@ -import { VaccineTypes } from "@src/models/vaccine"; -import { configProvider } from "@src/utils/config"; import { render, screen } from "@testing-library/react"; import VaccineRsv from "@src/app/vaccines/rsv/page"; import Vaccine from "@src/app/_components/vaccine/Vaccine"; +import { getContentForVaccine } from "@src/services/content-api/gateway/content-reader-service"; +import { mockStyledContent } from "@test-data/content-api/data"; -jest.mock("@src/utils/config"); +jest.mock("@src/services/content-api/gateway/content-reader-service"); jest.mock("@src/app/_components/vaccine/Vaccine", () => jest.fn(() =>
)); describe("RSV vaccine page", () => { - (configProvider as jest.Mock).mockImplementation(() => ({ - CONTENT_CACHE_PATH: "wiremock/__files/", - PINO_LOG_LEVEL: "info", - })); - - it("should contain back link to vaccination schedule page", () => { - const pathToSchedulePage = "/schedule"; - - render(VaccineRsv()); - - const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); - - expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); - }); - - it("should contain vaccine component", () => { - render(VaccineRsv()); - - expect(Vaccine).toHaveBeenCalledWith( - { - name: "RSV", - vaccine: VaccineTypes.RSV, - }, - undefined, - ); + describe("when content loaded successfully", () => { + beforeEach(() => { + (getContentForVaccine as jest.Mock).mockResolvedValue( + () => mockStyledContent, + ); + (Vaccine as jest.Mock).mockImplementation(() =>
); + }); + + it("should contain back link to vaccination schedule page", () => { + const pathToSchedulePage = "/schedule"; + + render(VaccineRsv()); + + const linkToSchedulePage = screen.getByRole("link", { name: "Go back" }); + + expect(linkToSchedulePage.getAttribute("href")).toBe(pathToSchedulePage); + }); + + it("should contain vaccine component", () => { + render(VaccineRsv()); + + expect(Vaccine).toHaveBeenCalledWith( + { + name: "RSV", + }, + undefined, + ); + }); }); }); diff --git a/src/app/vaccines/rsv/page.tsx b/src/app/vaccines/rsv/page.tsx index fbc947f2..3ef92907 100644 --- a/src/app/vaccines/rsv/page.tsx +++ b/src/app/vaccines/rsv/page.tsx @@ -16,7 +16,7 @@ const VaccineRsv = (): JSX.Element => { RSV Vaccine - NHS App - +
); diff --git a/src/app/vaccines/vaccine-error/page.test.tsx b/src/app/vaccines/vaccine-error/page.test.tsx new file mode 100644 index 00000000..b25d791a --- /dev/null +++ b/src/app/vaccines/vaccine-error/page.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import VaccineError from "@src/app/vaccines/vaccine-error/page"; +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; + +describe("VaccineError", () => { + it.each([VaccineTypes.FLU, VaccineTypes.RSV, VaccineTypes.SIX_IN_ONE])( + `should display vaccine name in error heading`, + (vaccineType: VaccineTypes) => { + render(VaccineError({ vaccineType: vaccineType })); + + const heading: HTMLElement = screen.getByRole("heading", { + level: 1, + name: `${VaccineDisplayNames[vaccineType]} vaccine`, + }); + + expect(heading).toBeInTheDocument(); + }, + ); + + it("should display subheading", () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const subheading: HTMLElement = screen.getByRole("heading", { + level: 2, + name: "Vaccine content is unavailable", + }); + + expect(subheading).toBeInTheDocument(); + }); + + it("should display error text", () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const text: HTMLElement = screen.getByText( + /Sorry, there is a problem showing vaccine/, + ); + + expect(text).toBeInTheDocument(); + }); + + it("renders link in error text", async () => { + render(VaccineError({ vaccineType: VaccineTypes.FLU })); + + const link: HTMLElement = screen.getByRole("link", { + name: "vaccinations on NHS.uk", + }); + + expect(link).toBeInTheDocument(); + expect(link.getAttribute("href")).toEqual( + "https://www.nhs.uk/vaccinations/", + ); + }); +}); diff --git a/src/app/vaccines/vaccine-error/page.tsx b/src/app/vaccines/vaccine-error/page.tsx new file mode 100644 index 00000000..d3a2e072 --- /dev/null +++ b/src/app/vaccines/vaccine-error/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { JSX } from "react"; +import { VaccineDisplayNames, VaccineTypes } from "@src/models/vaccine"; +import { ErrorSummary } from "nhsuk-react-components"; + +interface VaccineProps { + vaccineType: VaccineTypes; +} + +const VaccineError = ({ vaccineType }: VaccineProps): JSX.Element => { + return ( +
+

{`${VaccineDisplayNames[vaccineType]} vaccine`}

+ + Vaccine content is unavailable + +

+ Sorry, there is a problem showing vaccine information. Come back + later, or read about{" "} + + vaccinations on NHS.uk + + . +

+
+
+
+ ); +}; + +export default VaccineError; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..9eb33147 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@src/utils/auth/session"; +import { logger } from "@src/utils/logger"; + +const log = logger.child({ name: "middleware" }); + +export async function middleware(request: NextRequest) { + const sessionCookie = await getSession(); + logger.info(sessionCookie, "session"); + + if (!sessionCookie || !sessionCookie.access_token) { + log.error("Session cookie not found"); + return NextResponse.redirect(`${request.nextUrl.origin}/sso-error`); + } + + try { + return NextResponse.next(); + } catch (error) { + log.error(error, "Error occurred during redirection"); + return NextResponse.redirect(`${request.nextUrl.origin}/sso-error`); + } +} + +export const config = { + matcher: [ + /* + * Apply middleware to all pages except: + * 1. /api/* (exclude all API routes) + * 2. /sso/* (exclude sso route used for initiating auth flow) + * 2. /login (exclude the login page) + * 3. /_next/* (exclude Next.js assets, e.g., /_next/static/*) + */ + "/((?!api|sso|_next/static|_next/image).*)", + ], +}; diff --git a/src/models/vaccine.ts b/src/models/vaccine.ts index 34722b1f..b9398603 100644 --- a/src/models/vaccine.ts +++ b/src/models/vaccine.ts @@ -1,11 +1,13 @@ enum VaccineTypes { SIX_IN_ONE = "SIX_IN_ONE", RSV = "RSV", + FLU = "FLU", } const VaccineDisplayNames: Record = { [VaccineTypes.SIX_IN_ONE]: "6-in-1", [VaccineTypes.RSV]: "RSV", + [VaccineTypes.FLU]: "Flu", }; export { VaccineTypes, VaccineDisplayNames }; diff --git a/src/services/content-api/constants.ts b/src/services/content-api/constants.ts index 16279ce4..ee3c61b3 100644 --- a/src/services/content-api/constants.ts +++ b/src/services/content-api/constants.ts @@ -3,11 +3,13 @@ import { VaccineTypes } from "@src/models/vaccine"; enum VaccineContentPaths { SIX_IN_ONE = "6-in-1-vaccine", RSV = "rsv-vaccine", + FLU = "flu-vaccine", } const vaccineTypeToPath: Record = { [VaccineTypes.SIX_IN_ONE]: VaccineContentPaths.SIX_IN_ONE, [VaccineTypes.RSV]: VaccineContentPaths.RSV, + [VaccineTypes.FLU]: VaccineContentPaths.FLU, }; const CONTENT_API_VACCINATIONS_PATH = "/nhs-website-content/vaccinations"; diff --git a/src/services/content-api/gateway/content-reader-service.ts b/src/services/content-api/gateway/content-reader-service.ts index aeacd325..37e7a0a3 100644 --- a/src/services/content-api/gateway/content-reader-service.ts +++ b/src/services/content-api/gateway/content-reader-service.ts @@ -20,8 +20,9 @@ import { logger } from "@src/utils/logger"; import { isS3Path, S3_PREFIX } from "@src/utils/path"; import { readFile } from "node:fs/promises"; import { Readable } from "stream"; +import { Logger } from "pino"; -const log = logger.child({ module: "content-reader-service" }); +const log: Logger = logger.child({ module: "content-reader-service" }); const _readFileS3 = async (bucket: string, key: string): Promise => { try { diff --git a/src/services/content-api/parsers/content-filter-service.test.ts b/src/services/content-api/parsers/content-filter-service.test.ts index aeacd95f..0a753428 100644 --- a/src/services/content-api/parsers/content-filter-service.test.ts +++ b/src/services/content-api/parsers/content-filter-service.test.ts @@ -1,9 +1,186 @@ -import { getFilteredContentForVaccine } from "@src/services/content-api/parsers/content-filter-service"; +import { + getFilteredContentForVaccine, + _extractDescriptionForVaccine, + _extractHeadlineForAspect, + _extractPartsForAspect, + VaccinePageSubsection, + ContentApiVaccineResponse, + _findAspect, + MainEntityOfPage, + VaccinePageContent, + _hasHealthAspect, +} from "@src/services/content-api/parsers/content-filter-service"; import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; import { VaccineTypes } from "@src/models/vaccine"; +import { contentWithoutBenefitsHealthAspect } from "@test-data/content-api/helpers"; describe("Content Filter", () => { - describe("getPageCopyForVaccine", () => { + describe("_extractDescriptionForVaccine", () => { + it("should return text from mainEntityOfPage object", async () => { + const expectedOverview: string = + "Generic Vaccine Lead Paragraph (overview)"; + + const overview = _extractDescriptionForVaccine( + genericVaccineContentAPIResponse, + "lead paragraph", + ); + + expect(overview).toEqual(expectedOverview); + }); + + it("should throw error when mainEntity text is undefined", async () => { + const responseWithoutEntityOfPage = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + text: undefined, + }, + ], + }; + + const errorMessage = () => + _extractDescriptionForVaccine( + responseWithoutEntityOfPage, + "lead paragraph", + ); + + expect(errorMessage).toThrow( + "Missing text for description: lead paragraph", + ); + }); + }); + + describe("_extractHeadlineForAspect", () => { + it("should extract headline from aspect", async () => { + const expectedHeadline: string = "Getting Access Health Aspect headline"; + + const headline: string = _extractHeadlineForAspect( + genericVaccineContentAPIResponse, + "GettingAccessHealthAspect", + ); + + expect(headline).toEqual(expectedHeadline); + }); + + it("should throw error when headline is undefined", async () => { + const responseWithoutHeadline = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + headline: undefined, + }, + ], + }; + + const errorMessage = () => + _extractHeadlineForAspect( + responseWithoutHeadline, + "BenefitsHealthAspect", + ); + + expect(errorMessage).toThrow( + "Missing headline for Aspect: BenefitsHealthAspect", + ); + }); + }); + + describe("_extractPartsForAspect", () => { + it("should extract parts from aspect", async () => { + const expectedParts: VaccinePageSubsection[] = [ + { + headline: "", + name: "markdown", + text: "

Benefits Health Aspect paragraph 1

", + }, + ]; + + const parts: VaccinePageSubsection[] = _extractPartsForAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + + expect(parts).toEqual(expectedParts); + }); + + it("should throw an error when subsections are undefined", async () => { + const aspect = "BenefitsHealthAspect"; + const responseWithoutParts: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + }, + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + hasPart: undefined, + }, + ], + }; + + const errorMessage = () => { + _extractPartsForAspect(responseWithoutParts, aspect); + }; + + expect(errorMessage).toThrow(`Missing subsections for Aspect: ${aspect}`); + }); + }); + + describe("_findAspect", () => { + it("should find an aspect", () => { + const expectedAspect: MainEntityOfPage = { + ...genericVaccineContentAPIResponse.mainEntityOfPage[1], + }; + const aspect: MainEntityOfPage = _findAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + + expect(aspect).toEqual(expectedAspect); + }); + + it("should throw an error when subsections are undefined", async () => { + const aspect = "BenefitsHealthAspect"; + const responseWithoutAboveAspect: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: [ + { + ...genericVaccineContentAPIResponse.mainEntityOfPage[0], + }, + ], + }; + + const errorMessage = () => { + _findAspect(responseWithoutAboveAspect, aspect); + }; + + expect(errorMessage).toThrow(`Aspect ${aspect} is not present`); + }); + }); + + describe("_hasHealthAspect", () => { + it("should return true if healthAspect exists in content", () => { + const hasHealthAspect = _hasHealthAspect( + genericVaccineContentAPIResponse, + "BenefitsHealthAspect", + ); + expect(hasHealthAspect).toBeTruthy(); + }); + + it("should return false if healthAspect does not exist in content", () => { + const responseWithoutBenefitsHealthAspect = + contentWithoutBenefitsHealthAspect(); + + const hasHealthAspect = _hasHealthAspect( + responseWithoutBenefitsHealthAspect, + "BenefitsHealthAspect", + ); + expect(hasHealthAspect).toBeFalsy(); + }); + }); + + describe("getFilteredContentForVaccine", () => { it("should return overview text from lead paragraph mainEntityOfPage object", async () => { const expectedOverview = { overview: "Generic Vaccine Lead Paragraph (overview)", @@ -58,6 +235,11 @@ describe("Content Filter", () => { name: "markdown", text: "

Suitability Health Aspect paragraph 3

Suitability Health Aspect paragraph 4

", }, + { + headline: "Contraindications Health Aspect headline", + name: "", + text: "", + }, { headline: "", name: "markdown", @@ -125,5 +307,18 @@ describe("Content Filter", () => { expect.objectContaining(expectedWebpageLink), ); }); + + it("should not return whatVaccineIsFor section when BenefitsHealthAspect is missing", async () => { + const responseWithoutBenefitsHealthAspect = + contentWithoutBenefitsHealthAspect(); + + const pageCopyForFlu: VaccinePageContent = + await getFilteredContentForVaccine( + VaccineTypes.FLU, + JSON.stringify(responseWithoutBenefitsHealthAspect), + ); + + expect(pageCopyForFlu.whatVaccineIsFor).toBeUndefined(); + }); }); }); diff --git a/src/services/content-api/parsers/content-filter-service.ts b/src/services/content-api/parsers/content-filter-service.ts index f4531ae4..716ddbf8 100644 --- a/src/services/content-api/parsers/content-filter-service.ts +++ b/src/services/content-api/parsers/content-filter-service.ts @@ -16,7 +16,7 @@ type HasPartSubsection = { identifier: string; }; -type MainEntityOfPage = { +export type MainEntityOfPage = { "@type": string; hasHealthAspect?: string; position: number; @@ -46,7 +46,7 @@ export type ContentApiVaccineResponse = { dateModified: string; lastReviewed: string[]; breadcrumb: object; - hasPart: object; + hasPart: object[]; relatedLink: object; contentSubTypes: object; }; @@ -64,38 +64,51 @@ export type VaccinePageSection = { export type VaccinePageContent = { overview: string; - whatVaccineIsFor: VaccinePageSection; + whatVaccineIsFor?: VaccinePageSection; whoVaccineIsFor: VaccinePageSection; howToGetVaccine: VaccinePageSection; webpageLink: string; }; -const findAspect = ( +const _findAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, -) => { - const aspect = response.mainEntityOfPage.find((page: MainEntityOfPage) => - page.hasHealthAspect?.endsWith(aspectName), +): MainEntityOfPage => { + const aspect: MainEntityOfPage | undefined = response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.hasHealthAspect?.endsWith(aspectName), ); - return aspect!; + if (!aspect) { + throw new Error(`Aspect ${aspectName} is not present`); + } + return aspect; +}; + +const _hasHealthAspect = ( + response: ContentApiVaccineResponse, + aspectName: Aspect, +): boolean => { + const aspect: MainEntityOfPage | undefined = response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.hasHealthAspect?.endsWith(aspectName), + ); + return !!aspect; }; -const extractHeadlineForAspect = ( +const _extractHeadlineForAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, ): string => { - const aspect: MainEntityOfPage = findAspect(response, aspectName); - if (!aspect.headline) { + const aspect: MainEntityOfPage = _findAspect(response, aspectName); + if (!aspect?.headline) { throw new Error(`Missing headline for Aspect: ${aspectName}`); } return aspect.headline; }; -const extractPartsForAspect = ( +const _extractPartsForAspect = ( response: ContentApiVaccineResponse, aspectName: Aspect, ): VaccinePageSubsection[] => { - const aspect: MainEntityOfPage = findAspect(response, aspectName); + const aspect: MainEntityOfPage = _findAspect(response, aspectName); const subsections: VaccinePageSubsection[] | undefined = aspect.hasPart?.map( (part: HasPartSubsection) => { // TODO: fix the schema so that we handle part.headline being undefined @@ -115,49 +128,67 @@ const extractPartsForAspect = ( return subsections; }; -const extractDescriptionForVaccine = ( +const _extractDescriptionForVaccine = ( response: ContentApiVaccineResponse, name: string, ): string => { - const mainEntity = response.mainEntityOfPage.find( - (page: MainEntityOfPage) => page.name === name, - ); - if (!mainEntity || !mainEntity.text) { + const mainEntity: MainEntityOfPage | undefined = + response.mainEntityOfPage.find( + (page: MainEntityOfPage) => page.name === name, + ); + if (!mainEntity?.text) { throw new Error(`Missing text for description: ${name}`); } return mainEntity.text; }; -const generateWhoVaccineIsForHeading = (vaccineType: VaccineTypes): string => { +const _generateWhoVaccineIsForHeading = (vaccineType: VaccineTypes): string => { return `Who should have the ${VaccineDisplayNames[vaccineType]} vaccine`; }; +function _extractHeadlineForContraindicationsAspect( + content: ContentApiVaccineResponse, +) { + return [ + { + headline: _extractHeadlineForAspect( + content, + "ContraindicationsHealthAspect", + ), + text: "", + name: "", + }, + ]; +} + const getFilteredContentForVaccine = async ( vaccineName: VaccineTypes, apiContent: string, ): Promise => { const content: ContentApiVaccineResponse = JSON.parse(apiContent); - const overview: string = extractDescriptionForVaccine( + const overview: string = _extractDescriptionForVaccine( content, "lead paragraph", ); - const whatVaccineIsFor: VaccinePageSection = { - headline: extractHeadlineForAspect(content, "BenefitsHealthAspect"), - subsections: extractPartsForAspect(content, "BenefitsHealthAspect"), - }; + let whatVaccineIsFor; + if (_hasHealthAspect(content, "BenefitsHealthAspect")) { + whatVaccineIsFor = { + headline: _extractHeadlineForAspect(content, "BenefitsHealthAspect"), + subsections: _extractPartsForAspect(content, "BenefitsHealthAspect"), + }; + } const whoVaccineIsFor: VaccinePageSection = { - headline: generateWhoVaccineIsForHeading(vaccineName), - subsections: extractPartsForAspect( - content, - "SuitabilityHealthAspect", - ).concat(extractPartsForAspect(content, "ContraindicationsHealthAspect")), + headline: _generateWhoVaccineIsForHeading(vaccineName), + subsections: _extractPartsForAspect(content, "SuitabilityHealthAspect") + .concat(_extractHeadlineForContraindicationsAspect(content)) + .concat(_extractPartsForAspect(content, "ContraindicationsHealthAspect")), }; const howToGetVaccine: VaccinePageSection = { - headline: extractHeadlineForAspect(content, "GettingAccessHealthAspect"), - subsections: extractPartsForAspect(content, "GettingAccessHealthAspect"), + headline: _extractHeadlineForAspect(content, "GettingAccessHealthAspect"), + subsections: _extractPartsForAspect(content, "GettingAccessHealthAspect"), }; const webpageLink: string = content.webpage; @@ -173,8 +204,10 @@ const getFilteredContentForVaccine = async ( export { getFilteredContentForVaccine, - extractPartsForAspect, - extractHeadlineForAspect, - extractDescriptionForVaccine, - generateWhoVaccineIsForHeading, + _findAspect, + _hasHealthAspect, + _extractPartsForAspect, + _extractHeadlineForAspect, + _extractDescriptionForVaccine, + _generateWhoVaccineIsForHeading, }; diff --git a/src/services/content-api/parsers/content-styling-service.test.tsx b/src/services/content-api/parsers/content-styling-service.test.tsx index 2f7aaaf5..c60dcf0d 100644 --- a/src/services/content-api/parsers/content-styling-service.test.tsx +++ b/src/services/content-api/parsers/content-styling-service.test.tsx @@ -165,7 +165,7 @@ describe("ContentStylingService", () => { expect(styledVaccineContent).not.toBeNull(); expect(styledVaccineContent.overview).toEqual("This is an overview"); - expect(styledVaccineContent.whatVaccineIsFor.heading).toEqual( + expect(styledVaccineContent.whatVaccineIsFor?.heading).toEqual( "What Vaccine Is For", ); expect(styledVaccineContent.whoVaccineIsFor.heading).toEqual( @@ -175,7 +175,7 @@ describe("ContentStylingService", () => { "How to get this Vaccine", ); expect( - isValidElement(styledVaccineContent.whatVaccineIsFor.component), + isValidElement(styledVaccineContent.whatVaccineIsFor?.component), ).toBe(true); expect( isValidElement(styledVaccineContent.whoVaccineIsFor.component), @@ -185,6 +185,37 @@ describe("ContentStylingService", () => { ).toBe(true); expect(styledVaccineContent.webpageLink).toEqual("This is a link"); }); + + it("should return styled content without what-section when what-section is missing", async () => { + const mockWhoSection: VaccinePageSection = { + headline: "Who is this Vaccine For", + subsections: [mockMarkdownSubsection, mockNonUrgentSubsection], + }; + const mockHowSection: VaccinePageSection = { + headline: "How to get this Vaccine", + subsections: [mockMarkdownSubsection, mockNonUrgentSubsection], + }; + const mockContent: VaccinePageContent = { + overview: "This is an overview", + whoVaccineIsFor: mockWhoSection, + howToGetVaccine: mockHowSection, + webpageLink: "This is a link", + }; + + const styledVaccineContent: StyledVaccineContent = + await getStyledContentForVaccine(VaccineTypes.RSV, mockContent); + + expect(styledVaccineContent).not.toBeNull(); + expect(styledVaccineContent.overview).toEqual("This is an overview"); + expect(styledVaccineContent.whatVaccineIsFor).toBeUndefined(); + expect( + isValidElement(styledVaccineContent.whoVaccineIsFor.component), + ).toBe(true); + expect( + isValidElement(styledVaccineContent.howToGetVaccine.component), + ).toBe(true); + expect(styledVaccineContent.webpageLink).toEqual("This is a link"); + }); }); describe("extractHeadingAndContent", () => { diff --git a/src/services/content-api/parsers/content-styling-service.tsx b/src/services/content-api/parsers/content-styling-service.tsx index d193436e..7185aa71 100644 --- a/src/services/content-api/parsers/content-styling-service.tsx +++ b/src/services/content-api/parsers/content-styling-service.tsx @@ -1,12 +1,13 @@ import sanitiseHtml from "@src/utils/sanitise-html"; import InsetText from "@src/app/_components/nhs-frontend/InsetText"; import NonUrgentCareCard from "@src/app/_components/nhs-frontend/NonUrgentCareCard"; +import React from "react"; import { JSX } from "react"; import { VaccineTypes } from "@src/models/vaccine"; import { VaccinePageContent, VaccinePageSection, - VaccinePageSubsection + VaccinePageSubsection, } from "@src/services/content-api/parsers/content-filter-service"; enum SubsectionTypes { @@ -28,7 +29,7 @@ export type NonUrgentContent = { heading: string; content: string }; export type StyledVaccineContent = { overview: string; - whatVaccineIsFor: StyledPageSection; + whatVaccineIsFor?: StyledPageSection; whoVaccineIsFor: StyledPageSection; howToGetVaccine: StyledPageSection; webpageLink: string; @@ -87,12 +88,13 @@ const extractHeadingAndContent = (text: string): NonUrgentContent => { const getStyledContentForVaccine = async ( vaccine: VaccineTypes, - filteredContent: VaccinePageContent + filteredContent: VaccinePageContent, ): Promise => { const overview: string = filteredContent.overview; - const whatVaccineIsFor: StyledPageSection = styleSection( - filteredContent.whatVaccineIsFor, - ); + let whatVaccineIsFor; + if (filteredContent.whatVaccineIsFor) { + whatVaccineIsFor = styleSection(filteredContent.whatVaccineIsFor); + } const whoVaccineIsFor: StyledPageSection = styleSection( filteredContent.whoVaccineIsFor, ); diff --git a/src/utils/auth/get-auth-config.test.ts b/src/utils/auth/get-auth-config.test.ts index 573eb13f..96ebb837 100644 --- a/src/utils/auth/get-auth-config.test.ts +++ b/src/utils/auth/get-auth-config.test.ts @@ -12,17 +12,23 @@ const mockVaccinationAppUrl = "vita-base-url"; NHS_LOGIN_URL: mockNhsLoginUrl, NHS_LOGIN_CLIENT_ID: mockNhsLoginClientId, NHS_LOGIN_SCOPE: mockNhsLoginScope, - VACCINATION_APP_URL: mockVaccinationAppUrl, })); describe("getAuthConfig", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it("is constructed with values from configProvider", async () => { + jest.replaceProperty(process, "env", { + ...process.env, + VACCINATION_APP_URL: mockVaccinationAppUrl, + }); const expectedAuthConfig = { - url: mockVaccinationAppUrl, + url: mockNhsLoginUrl, audience: mockVaccinationAppUrl, client_id: mockNhsLoginClientId, scope: mockNhsLoginScope, - redirect_uri: `${mockVaccinationAppUrl}/auth/callback`, + redirect_uri: `${mockVaccinationAppUrl}/api/auth/callback`, response_type: "code", grant_type: "authorization_code", post_login_route: `${mockVaccinationAppUrl}`, diff --git a/src/utils/auth/get-auth-config.ts b/src/utils/auth/get-auth-config.ts index 3c5b801d..c493ab0f 100644 --- a/src/utils/auth/get-auth-config.ts +++ b/src/utils/auth/get-auth-config.ts @@ -17,15 +17,18 @@ const getAuthConfig = async (): Promise => { config = await configProvider(); } + // TODO: VIA-87 2025-04-17 Check how to get URL of deployed lambda and replace + const vitaUrl = process.env.VACCINATION_APP_URL || "not-yet-implemented"; + return { - url: config.VACCINATION_APP_URL, - audience: config.VACCINATION_APP_URL, + url: config.NHS_LOGIN_URL, + audience: vitaUrl, client_id: config.NHS_LOGIN_CLIENT_ID, scope: config.NHS_LOGIN_SCOPE, - redirect_uri: `${config.VACCINATION_APP_URL}/auth/callback`, + redirect_uri: `${vitaUrl}/api/auth/callback`, response_type: "code", grant_type: "authorization_code", - post_login_route: `${config.VACCINATION_APP_URL}`, + post_login_route: `${vitaUrl}`, }; }; diff --git a/src/utils/auth/get-client-config.test.ts b/src/utils/auth/get-client-config.test.ts index b29a96ad..a4209645 100644 --- a/src/utils/auth/get-client-config.test.ts +++ b/src/utils/auth/get-client-config.test.ts @@ -52,11 +52,6 @@ describe("getClientConfig", () => { }); it("throws if error thrown by discovery method", async () => { - // TODO: VIA 87 2025-04-16 how to get the mock of client.discovery to throw an error when we cannot reference the module by name? (starred import) - // - // const errorThrownByDiscovery = new Error("error-thrown-by-discovery"); - // set discovery mock to throw that - // expect(await getClientConfig()).rejects.toThrow(errorThrownByDiscovery); const errorThrownByDiscovery = new Error("error-thrown-by-discovery"); mockDiscovery.mockRejectedValue(errorThrownByDiscovery); diff --git a/src/utils/auth/get-client-config.ts b/src/utils/auth/get-client-config.ts index 17a6c802..1d5f6b59 100644 --- a/src/utils/auth/get-client-config.ts +++ b/src/utils/auth/get-client-config.ts @@ -10,8 +10,8 @@ const getClientConfig = async () => { const authConfig = await getAuthConfig(); try { return await client.discovery( - new URL(authConfig.url!), - authConfig.client_id!, + new URL(authConfig.url), + authConfig.client_id, "", client.PrivateKeyJwt(key), ); diff --git a/src/utils/auth/session.ts b/src/utils/auth/session.ts new file mode 100644 index 00000000..f7269845 --- /dev/null +++ b/src/utils/auth/session.ts @@ -0,0 +1,46 @@ +import { getIronSession, IronSession, SessionOptions } from "iron-session"; +import { cookies } from "next/headers"; + +export interface SessionData { + isLoggedIn: boolean; + access_token?: string; + state?: string; + userInfo?: { + sub: string; + }; +} + +export const defaultSession: SessionData = { + isLoggedIn: false, + access_token: undefined, + state: undefined, + userInfo: undefined, +}; + +export const sessionOptions: SessionOptions = { + password: + process.env.SESSION_SECRET || + "complex_password_at_least_32_characters_long", + cookieName: "nhs_vaccinations_app", + cookieOptions: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + path: "/", + sameSite: "lax", + maxAge: 60 * 4, + }, + ttl: 60 * 5, +}; + +export async function getSession(): Promise> { + const cookiesList = await cookies(); + const session = await getIronSession( + cookiesList, + sessionOptions, + ); + if (!session.isLoggedIn) { + session.access_token = defaultSession.access_token; + session.userInfo = defaultSession.userInfo; + } + return session; +} diff --git a/src/utils/get-ssm-param.ts b/src/utils/get-ssm-param.ts index 976ad1a9..c0397554 100644 --- a/src/utils/get-ssm-param.ts +++ b/src/utils/get-ssm-param.ts @@ -2,16 +2,17 @@ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; import type { GetParameterCommandOutput } from "@aws-sdk/client-ssm"; import { AWS_PRIMARY_REGION } from "@src/utils/constants"; import { logger } from "@src/utils/logger"; +import { Logger } from "pino"; -const log = logger.child({ module: "get-ssm-param" }); +const log: Logger = logger.child({ module: "get-ssm-param" }); const getSSMParam = async (name: string): Promise => { try { - const client = new SSMClient({ + const client: SSMClient = new SSMClient({ region: AWS_PRIMARY_REGION, }); - const command = new GetParameterCommand({ + const command: GetParameterCommand = new GetParameterCommand({ Name: name, WithDecryption: true, }); diff --git a/test-data/content-api/data.tsx b/test-data/content-api/data.tsx index 6c818342..44d7b674 100644 --- a/test-data/content-api/data.tsx +++ b/test-data/content-api/data.tsx @@ -574,3 +574,16 @@ export const mockStyledContent: StyledVaccineContent = { }, webpageLink: "https://www.test.com/" }; + +export const mockStyledContentWithoutWhatSection: StyledVaccineContent = { + overview: "Overview text", + whoVaccineIsFor: { + heading: "who-heading", + component:

Who Section styled component

+ }, + howToGetVaccine: { + heading: "how-heading", + component:
How Section styled component
+ }, + webpageLink: "https://www.test.com/" +}; diff --git a/test-data/content-api/helpers.ts b/test-data/content-api/helpers.ts new file mode 100644 index 00000000..590f61f3 --- /dev/null +++ b/test-data/content-api/helpers.ts @@ -0,0 +1,16 @@ +import { ContentApiVaccineResponse } from "@src/services/content-api/parsers/content-filter-service"; +import { genericVaccineContentAPIResponse } from "@test-data/content-api/data"; + +const contentWithoutBenefitsHealthAspect = () => { + const responseWithoutBenefitsHealthAspect: ContentApiVaccineResponse = { + ...genericVaccineContentAPIResponse, + mainEntityOfPage: + genericVaccineContentAPIResponse.mainEntityOfPage.filter( + (page) => + page.hasHealthAspect !== "http://schema.org/BenefitsHealthAspect" + ) + }; + return responseWithoutBenefitsHealthAspect; +} + +export {contentWithoutBenefitsHealthAspect} diff --git a/wiremock/__files/flu-vaccine.json b/wiremock/__files/flu-vaccine.json new file mode 100644 index 00000000..8a3b6407 --- /dev/null +++ b/wiremock/__files/flu-vaccine.json @@ -0,0 +1,583 @@ +{ + "@context": "http://schema.org", + "@type": "MedicalWebPage", + "name": "Flu vaccine", + "copyrightHolder": { + "name": "Crown Copyright", + "@type": "Organization" + }, + "license": "https://developer.api.nhs.uk/terms", + "author": { + "url": "https://www.nhs.uk", + "logo": "https://assets.nhs.uk/nhsuk-cms/images/nhs-attribution.width-510.png", + "email": "nhswebsite.servicedesk@nhs.net", + "@type": "Organization", + "name": "NHS website" + }, + "about": { + "@type": "WebPage", + "name": "Flu vaccine", + "alternateName": "" + }, + "description": "Find out about the flu vaccine for adults, including who should have it, how to get it and side effects.", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/", + "genre": [ + "Vaccine" + ], + "keywords": "", + "dateModified": "2025-04-15T14:14:49+00:00", + "lastReviewed": [ + "2023-11-23T08:28:00+00:00", + "2026-11-23T08:28:00+00:00" + ], + "breadcrumb": { + "@context": "http://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + { + "@type": "ListItem", + "position": 0, + "item": { + "@id": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/", + "name": "Vaccinations", + "genre": [] + } + }, + { + "@type": "ListItem", + "position": 1, + "item": { + "@id": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/", + "name": "Flu vaccine", + "genre": [ + "Vaccine" + ] + } + } + ] + }, + "hasPart": [ + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/OverviewHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#overview", + "description": "The flu vaccine helps protect against flu. It's given every year in autumn or early winter to adults at higher risk from flu.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Who it's for" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Contraindications" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "How to get it" + }, + { + "@type": "WebPageElement", + "headline": "Flu vaccine", + "text": "Side effects" + }, + { + "text": "

The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.

", + "@type": "WebPageElement" + }, + { + "text": "

Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.

", + "@type": "WebPageElement" + }, + { + "text": "

Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.

", + "@type": "WebPageElement" + }, + { + "text": "

Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.

", + "@type": "WebPageElement" + } + ], + "headline": "" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/SuitabilityHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#who-its-for", + "description": "The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

" + }, + { + "@type": "WebPageElement", + "headline": "Health conditions that mean you're eligible for the flu vaccine", + "text": "
\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "headline": "Who should have the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/GettingAccessHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#how-to-get-it", + "description": "Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

" + }, + { + "@type": "WebPageElement", + "headline": "Frontline health or social care workers", + "text": "
\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "@type": "WebPageElement", + "headline": "Information", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "headline": "How to get the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/ContraindicationsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#contraindications", + "description": "Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

" + }, + { + "@type": "WebPageElement", + "headline": "Information", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "headline": "Who cannot have the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/IngredientsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#ingredients", + "description": "You can check the ingredients in the flu vaccine by asking to see the patient leaflet or searching for it online.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

" + }, + { + "@type": "WebPageElement", + "headline": "", + "text": ">]>)]), StructValue([('title', 'Vaccines for people aged 60 to 64'), ('body', >]>)]), StructValue([('title', 'Vaccines for people aged 18 to 59'), ('body', >]>)])]>" + } + ], + "headline": "Flu vaccine ingredients" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/SideEffectsHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#side-effects", + "description": "Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

" + } + ], + "headline": "Side effects of the flu vaccine" + }, + { + "@type": "HealthTopicContent", + "hasHealthAspect": "http://schema.org/EffectivenessHealthAspect", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/flu-vaccine/#effectiveness", + "description": "The flu vaccine helps protect you against common types of flu viruses. There's still a chance you might get flu, but it's likely to be milder.", + "hasPart": [ + { + "@type": "WebPageElement", + "headline": "", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

" + } + ], + "headline": "How well the flu vaccine works and how long it lasts" + } + ], + "relatedLink": [ + { + "@type": "LinkRole", + "url": "https://int.api.service.nhs.uk/nhs-website-content/vaccinations/", + "name": "Vaccinations", + "linkRelationship": "Navigation", + "position": 0 + } + ], + "contentSubTypes": [], + "mainEntityOfPage": [ + { + "position": 0, + "identifier": 0, + "text": "The flu vaccine helps protect against flu, which can be a serious or life-threatening illness. It's offered on the NHS every year in autumn or early winter to people at higher risk of getting seriously ill from flu.", + "name": "lead paragraph", + "@type": "WebPageElement" + }, + { + "identifier": "0", + "name": "section heading", + "position": 1, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

This page is about the flu vaccine for adults. There are also pages about the children's flu vaccine and flu jab in pregnancy.

" + } + ], + "description": "", + "hasPart": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

This page is about the flu vaccine for adults. There are also pages about the children's flu vaccine and flu jab in pregnancy.

" + } + ] + }, + { + "identifier": "0", + "name": "section heading", + "position": 2, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Health conditions that mean you're eligible for the flu vaccine", + "identifier": "18", + "mainEntity": "
\n\n\n\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "description": "The flu vaccine is recommended for people at higher risk from flu, including all adults aged 65 and over and people with certain health conditions.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine is recommended for people at higher risk of getting seriously ill from flu.

It's offered on the NHS every year in autumn or early winter.

You can get the free NHS flu vaccine if you:

  • are aged 65 or over
  • have certain long-term health conditions
  • are pregnant
  • live in a care home
  • are the main carer for an older or disabled person, or receive a carer's allowance
  • live with someone who has a weakened immune system

Frontline health and social care workers can also get a flu vaccine through their employer.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Health conditions that mean you're eligible for the flu vaccine", + "identifier": "18", + "mainEntity": "
\n\n\n\n

The flu vaccine is recommended for people with certain long-term health conditions, including:

  • conditions that affect your breathing, such as asthma (needing a steroid inhaler or tablets), chronic obstructive pulmonary disease (COPD) or cystic fibrosis
  • heart conditions, such as coronary heart disease or heart failure
  • chronic kidney disease
  • liver disease, such as cirrhosis or hepatitis
  • some conditions that affect your brain or nerves, such as Parkinson's disease, motor neurone disease, multiple sclerosis or cerebral palsy
  • diabetes or Addison's disease
  • a weakened immune system due to a condition such as HIV or AIDS, or due to a treatment such as chemotherapy or steroid medicine
  • problems with your spleen, such as sickle cell disease, or if you've had your spleen removed
  • a learning disability
  • being very overweight – a body mass index (BMI) of 40 or above

Speak to your GP surgery or specialist if you have a health condition and you're not sure if you're eligible for the flu vaccine.

\n
" + } + ], + "hasHealthAspect": "http://schema.org/SuitabilityHealthAspect", + "headline": "Who should have the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 3, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Frontline health or social care workers", + "identifier": "18", + "mainEntity": "
\n\n\n\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "description": "Most adults can get a flu vaccine from a GP surgery or some pharmacies. Some people get it through their employer, care home or an antenatal clinic.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The NHS will let you know in autumn or early winter when you can get your flu vaccine.

If you're eligible for an NHS flu vaccine, you will be able to get your vaccine from:

  • your GP surgery
  • a pharmacy that offers NHS flu vaccination (if you're aged 18 or over)

Some people may be able to get vaccinated through their maternity service, care home, or their employer if they are a frontline health or social care worker.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Frontline health or social care workers", + "identifier": "18", + "mainEntity": "
\n\n\n\n

Frontline health and social care workers should get the flu vaccine through their employer.

If you cannot get a flu vaccine through your employer, you can get it at a pharmacy or your GP surgery if you're employed:

  • by a registered residential care or nursing home
  • by a registered domiciliary care provider
  • by a voluntary managed hospice provider
  • through direct payments or personal health budgets
\n
" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Having the flu vaccine at the same time as other vaccines

You can have the flu vaccine at the same time as other vaccines such as the COVID-19 and shingles vaccines.

It's not usually given at the same time as the RSV vaccine, but you can have them at the same time if a doctor or nurse thinks it's needed.

" + } + ], + "hasHealthAspect": "http://schema.org/GettingAccessHealthAspect", + "headline": "How to get the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 4, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "description": "Most people who need it can have the flu vaccine. You cannot have it if you've had a serious allergic reaction to it or an ingredient in it.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

Most people who are eligible for the flu vaccine can have it.

You only cannot have the vaccine if you've had a serious allergic reaction (anaphylaxis) to a previous dose of the vaccine or an ingredient in the vaccine.

Some of the flu vaccines used in the UK contain egg protein. Tell the person vaccinating you if you have an egg allergy.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

Getting vaccinated if you're unwell

If you have a high temperature, wait until you're feeling better before having your flu vaccine.

" + } + ], + "hasHealthAspect": "http://schema.org/ContraindicationsHealthAspect", + "headline": "Who cannot have the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 5, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "@type": "WebPageElement", + "position": 1, + "name": "Expander Group", + "identifier": "20", + "mainEntity": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 65 and over", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 60 to 64", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 18 to 59", + "identifier": "18", + "mainEntity": "" + } + ] + } + ], + "description": "You can check the ingredients in the flu vaccine by asking to see the patient leaflet or searching for it online.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

There are several types of flu vaccine given in the UK. If you're eligible for the flu vaccine on the NHS, you'll be offered one of the types that's most appropriate for you.

You can check the ingredients in the patient leaflets.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + }, + { + "@type": "WebPageElement", + "position": 1, + "name": "Expander Group", + "identifier": "20", + "mainEntity": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 65 and over", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 1, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 60 to 64", + "identifier": "18", + "mainEntity": "" + }, + { + "position": 2, + "@type": "WebPageElement", + "name": "Expander", + "subjectOf": "Vaccines for people aged 18 to 59", + "identifier": "18", + "mainEntity": "" + } + ] + } + ], + "hasHealthAspect": "http://schema.org/IngredientsHealthAspect", + "headline": "Flu vaccine ingredients" + }, + { + "identifier": "0", + "name": "section heading", + "position": 6, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "description": "Side effects of the flu vaccine include pain or soreness where the injection was given, a slightly raised temperature and an aching body.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The most common side effects of the flu vaccine are mild and get better within 1 to 2 days.

They can include:

  • pain or soreness where the injection was given
  • a slightly raised temperature
  • an aching body

More serious side effects such as a severe allergic reaction (anaphylaxis) are very rare. The person who vaccinates you will be trained to deal with allergic reactions and treat them immediately.

The injected flu vaccines used in the UK do not contain live flu viruses. They cannot give you flu.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "hasHealthAspect": "http://schema.org/SideEffectsHealthAspect", + "headline": "Side effects of the flu vaccine" + }, + { + "identifier": "0", + "name": "section heading", + "position": 7, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

More about vaccine safety

Find out more about why vaccinations are important and the safest way to protect yourself

" + } + ], + "description": "", + "hasPart": [ + { + "position": 0, + "@type": "WebPageElement", + "name": "Information", + "identifier": "3", + "text": "

More about vaccine safety

Find out more about why vaccinations are important and the safest way to protect yourself

" + } + ] + }, + { + "identifier": "0", + "name": "section heading", + "position": 8, + "@type": "WebPageElement", + "mainEntityOfPage": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "description": "The flu vaccine helps protect you against common types of flu viruses. There's still a chance you might get flu, but it's likely to be milder.", + "hasPart": [ + { + "position": 0, + "identifier": "1", + "text": "

The flu vaccine aims to protect you against the most common types of flu viruses.

There's still a chance you might get flu after getting vaccinated, but it's likely to be milder and not last as long.

The vaccine usually takes up to 14 days to work.

Protection from the flu vaccine goes down with time and the types of flu virus the vaccine protects against are updated each year. This is why it's important to get the flu vaccine every year.

", + "@type": "WebPageElement", + "name": "markdown", + "headline": "" + } + ], + "hasHealthAspect": "http://schema.org/EffectivenessHealthAspect", + "headline": "How well the flu vaccine works and how long it lasts" + } + ], + "webpage": "https://www.nhs.uk/vaccinations/flu-vaccine/" +} diff --git a/wiremock/mappings/mapping-vaccines.json b/wiremock/mappings/mapping-vaccines.json index c482374d..9e39e5a3 100644 --- a/wiremock/mappings/mapping-vaccines.json +++ b/wiremock/mappings/mapping-vaccines.json @@ -25,6 +25,19 @@ "Content-Type": "application/json" } } + }, + { + "request": { + "method": "GET", + "url": "/nhs-website-content/vaccinations/flu-vaccine" + }, + "response": { + "status": 200, + "bodyFileName": "flu-vaccine.json", + "headers": { + "Content-Type": "application/json" + } + } } ] }