From 48ee6742f377c773b8f25b8f0b55e502e0d092a4 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:01:46 +0000 Subject: [PATCH 01/25] chore: diagnostic logging --- .../serviceSearchClient/src/live-serviceSearch-client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 7ab8cf826..de9231946 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -161,11 +161,11 @@ export class LiveServiceSearchClient implements ServiceSearchClient { stripApiKeyFromHeaders(error: AxiosError) { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { - if (error.response?.headers) { - delete error.response.headers[key] + if (error.response?.headers && error.response.headers[key]) { + error.response.headers[key] = error.response.headers[key].substring(0, 5) } - if (error.request?.headers) { - delete error.request.headers[key] + if (error.request?.headers && error.request.headers[key]) { + error.request.headers[key] = error.request.headers[key].substring(0, 5) } }) } From 5da82d492f8e44cb0128e7dc5ab282f6b91c4b4f Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:03:40 +0000 Subject: [PATCH 02/25] chore: trigger build From 848810b015361689412bc1c0bce6e7d4a6639298 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:37:45 +0000 Subject: [PATCH 03/25] chore: trigger build From e14cc726c75f919aada8b9a439a18a4de98c8b7f Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:58:20 +0000 Subject: [PATCH 04/25] chore: update test for revised headers --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 4 ++-- .../tests/live-serviceSearch-client.test.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index de9231946..46c7b9e38 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -162,10 +162,10 @@ export class LiveServiceSearchClient implements ServiceSearchClient { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { if (error.response?.headers && error.response.headers[key]) { - error.response.headers[key] = error.response.headers[key].substring(0, 5) + error.response.headers[key] = `${error.response.headers[key].substring(0, 5)}*****` } if (error.request?.headers && error.request.headers[key]) { - error.request.headers[key] = error.request.headers[key].substring(0, 5) + error.request.headers[key] = `${error.request.headers[key].substring(0, 5)}*****` } }) } diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 38f08b620..fe70f9a70 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -64,7 +64,8 @@ describe("live serviceSearch client", () => { // The config doesn't get touched by the stripping function expect(axiosErr.config!.headers).toHaveProperty("subscription-key") expect(axiosErr.config!.headers).toHaveProperty("keep", "yes") - expect(axiosErr.response!.headers).not.toHaveProperty("subscription-key") + expect(axiosErr.response!.headers).toHaveProperty("subscription-key") + expect(axiosErr.response!.headers["subscription-key"]).toEqual("secre*****") expect(axiosErr.response!.headers).toHaveProperty("foo", "bar") }) From bb90c499abcaf73a20800bcafbd87dd30d4fda12 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:01:38 +0000 Subject: [PATCH 05/25] chore: fix consequential test failure --- .../serviceSearchClient/src/live-serviceSearch-client.ts | 4 ++-- .../tests/live-serviceSearch-client.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 46c7b9e38..93c8005bb 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -161,10 +161,10 @@ export class LiveServiceSearchClient implements ServiceSearchClient { stripApiKeyFromHeaders(error: AxiosError) { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { - if (error.response?.headers && error.response.headers[key]) { + if (error.response?.headers[key]) { error.response.headers[key] = `${error.response.headers[key].substring(0, 5)}*****` } - if (error.request?.headers && error.request.headers[key]) { + if (error.request?.headers[key]) { error.request.headers[key] = `${error.request.headers[key].substring(0, 5)}*****` } }) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index fe70f9a70..9db02767b 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -65,7 +65,7 @@ describe("live serviceSearch client", () => { expect(axiosErr.config!.headers).toHaveProperty("subscription-key") expect(axiosErr.config!.headers).toHaveProperty("keep", "yes") expect(axiosErr.response!.headers).toHaveProperty("subscription-key") - expect(axiosErr.response!.headers["subscription-key"]).toEqual("secre*****") + expect(axiosErr.response!.headers?.["subscription-key"]).toEqual("secre*****") expect(axiosErr.response!.headers).toHaveProperty("foo", "bar") }) @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {}}, - request: {detail: "reqError"}, + config: {headers: {"request-startTime": 1234}}, + request: {detail: "reqError", headers: {}}, response: undefined } as unknown as AxiosError From d35f6b7e1a23e4f80a8e67386049a73be1c79626 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:43:45 +0000 Subject: [PATCH 06/25] chore: another test fix --- .../tests/live-serviceSearch-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 9db02767b..7a7ae23dd 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {"request-startTime": 1234}}, - request: {detail: "reqError", headers: {}}, + config: {headers: {}}, + request: {detail: "reqError"}, response: undefined } as unknown as AxiosError From f1b134a516662d47666d08457f5a181ad6ba2884 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:48:58 +0000 Subject: [PATCH 07/25] chore: fix test --- .../tests/live-serviceSearch-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 7a7ae23dd..9db02767b 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {}}, - request: {detail: "reqError"}, + config: {headers: {"request-startTime": 1234}}, + request: {detail: "reqError", headers: {}}, response: undefined } as unknown as AxiosError From b8d4632299f45696dbce004feaa4a51fd5892702 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:50:55 +0000 Subject: [PATCH 08/25] Revert "chore: fix test" This reverts commit f1b134a516662d47666d08457f5a181ad6ba2884. --- .../tests/live-serviceSearch-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 9db02767b..7a7ae23dd 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {"request-startTime": 1234}}, - request: {detail: "reqError", headers: {}}, + config: {headers: {}}, + request: {detail: "reqError"}, response: undefined } as unknown as AxiosError From d3a5ffdc60e96c20d9dac70a6acb39882bf852d8 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:51:20 +0000 Subject: [PATCH 09/25] Revert "chore: another test fix" This reverts commit d35f6b7e1a23e4f80a8e67386049a73be1c79626. --- .../tests/live-serviceSearch-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 7a7ae23dd..9db02767b 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {}}, - request: {detail: "reqError"}, + config: {headers: {"request-startTime": 1234}}, + request: {detail: "reqError", headers: {}}, response: undefined } as unknown as AxiosError From 936d2793ebcbc9107829884f119d5e223be124a7 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:51:49 +0000 Subject: [PATCH 10/25] Revert "chore: fix consequential test failure" This reverts commit bb90c499abcaf73a20800bcafbd87dd30d4fda12. --- .../serviceSearchClient/src/live-serviceSearch-client.ts | 4 ++-- .../tests/live-serviceSearch-client.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 93c8005bb..46c7b9e38 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -161,10 +161,10 @@ export class LiveServiceSearchClient implements ServiceSearchClient { stripApiKeyFromHeaders(error: AxiosError) { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { - if (error.response?.headers[key]) { + if (error.response?.headers && error.response.headers[key]) { error.response.headers[key] = `${error.response.headers[key].substring(0, 5)}*****` } - if (error.request?.headers[key]) { + if (error.request?.headers && error.request.headers[key]) { error.request.headers[key] = `${error.request.headers[key].substring(0, 5)}*****` } }) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 9db02767b..fe70f9a70 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -65,7 +65,7 @@ describe("live serviceSearch client", () => { expect(axiosErr.config!.headers).toHaveProperty("subscription-key") expect(axiosErr.config!.headers).toHaveProperty("keep", "yes") expect(axiosErr.response!.headers).toHaveProperty("subscription-key") - expect(axiosErr.response!.headers?.["subscription-key"]).toEqual("secre*****") + expect(axiosErr.response!.headers["subscription-key"]).toEqual("secre*****") expect(axiosErr.response!.headers).toHaveProperty("foo", "bar") }) @@ -117,8 +117,8 @@ describe("live serviceSearch client", () => { const axiosErr = { isAxiosError: true, message: "reqfail", - config: {headers: {"request-startTime": 1234}}, - request: {detail: "reqError", headers: {}}, + config: {headers: {}}, + request: {detail: "reqError"}, response: undefined } as unknown as AxiosError From 9d5bfbf8b587e8859684fbacb2eecffa1477abbd Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:30:43 +0000 Subject: [PATCH 11/25] fix: make sam-sync --- Makefile | 1 + README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 257728965..83e32add7 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,7 @@ sam-sync-sandbox: guard-stack_name compile download-get-secrets-layer sam-deploy: guard-AWS_DEFAULT_PROFILE guard-stack_name sam deploy \ + --template-file SAMtemplates/main_template.yaml \ --stack-name $$stack_name \ --parameter-overrides \ EnableSplunk=false \ diff --git a/README.md b/README.md index 032566dda..a73dd7449 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ Note - the command will keep running and should not be stopped. You can now call this api - note getMyPrescriptions requires an nhsd-nhslogin-user header ```bash -curl --header "nhsd-nhslogin-user: P9:9446041481" https://${stack_name}.dev.prescriptionsforpatients.national.nhs.uk/Bundle +curl --header "nhsd-nhslogin-user: P9:9446041481" --header "x-request-id: $(uuid)" \ + https://${stack_name}.dev.eps.national.nhs.uk/Bundle ``` You can also use the AWS vscode extension to invoke the API or lambda directly From 2435343470c7d90cb9fdfa428e02c26566194c28 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:36:12 +0000 Subject: [PATCH 12/25] fix: layer failure to load SSv3 key --- .../src/live-serviceSearch-client.ts | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 46c7b9e38..a7a508e2a 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -1,4 +1,5 @@ import {Logger} from "@aws-lambda-powertools/logger" +import {getSecret} from "@aws-lambda-powertools/parameters/secrets" import axios, {AxiosError, AxiosInstance} from "axios" import axiosRetry from "axios-retry" import {handleUrl} from "./handleUrl" @@ -26,26 +27,39 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = { "$top": 1 } -export function getServiceSearchEndpoint(): string { +export function getServiceSearchVersion(logger: Logger | null = null): number { const endpoint = process.env.TargetServiceSearchServer || "service-search" - const baseUrl = `https://${endpoint}` if (endpoint.toLowerCase().includes("api.service.nhs.uk")) { - // service search v3 + logger?.info("Using service search v3 endpoint") SERVICE_SEARCH_BASE_QUERY_PARAMS["api-version"] = 3 - return `${baseUrl}/service-search-api/` + return 3 + } + logger?.warn("Using service search v2 endpoint") + return 2 +} + +export function getServiceSearchEndpoint(logger: Logger | null = null): string { + switch (getServiceSearchVersion(logger)) { + case 3: + return `https://${process.env.TargetServiceSearchServer}/service-search-api/` + case 2: + default: + return `https://${process.env.TargetServiceSearchServer}/service-search` } - // service search v2 - return `${baseUrl}/service-search` } export class LiveServiceSearchClient implements ServiceSearchClient { private readonly axiosInstance: AxiosInstance private readonly logger: Logger - private readonly outboundHeaders: {"apikey": string | undefined, "Subscription-Key": string | undefined} + private readonly outboundHeaders: {"apikey"?: string, "Subscription-Key"?: string} constructor(logger: Logger) { this.logger = logger - + this.logger.warn("ServiceSearchClient configured", + { + v2: process.env.ServiceSearchApiKey !== undefined, + v3: process.env.ServiceSearch3ApiKey !== undefined + }) this.axiosInstance = axios.create() axiosRetry(this.axiosInstance, {retries: 3}) @@ -95,15 +109,49 @@ export class LiveServiceSearchClient implements ServiceSearchClient { return Promise.reject(err) }) - this.outboundHeaders = { - "Subscription-Key": process.env.ServiceSearchApiKey, - "apikey": process.env.ServiceSearch3ApiKey + const version = getServiceSearchVersion(this.logger) + if (version === 3) { + this.outboundHeaders = { + "apikey": process.env.ServiceSearch3ApiKey + } + } else { + this.outboundHeaders = { + "Subscription-Key": process.env.ServiceSearchApiKey + } + } + } + + private async loadApiKeyFromSecretsManager(): Promise { + try { + const secretArn = process.env.ServiceSearch3ApiKeyARN + if (!secretArn) { + this.logger.error("ServiceSearch3ApiKeyARN environment variable is not set") + return undefined + } + this.logger.info("Loading ServiceSearch API key from Secrets Manager", {secretArn}) + + const secret = await getSecret(secretArn, { + maxAge: 300 // Cache for 5 minutes + }) + + this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager") + return secret as string + } catch (error) { + this.logger.error("Failed to load ServiceSearch API key from Secrets Manager", {error}) + return undefined } } async searchService(odsCode: string): Promise { try { - const address = getServiceSearchEndpoint() + // Load API key if not set in environment (secrets layer is failing to load v3 key) + if (getServiceSearchVersion(this.logger) === 3 && !this.outboundHeaders.apikey) { + this.logger.info("API key not in environment, attempting to load from Secrets Manager") + this.logger.info("Current environment variables", {env: process.env}) + this.outboundHeaders.apikey = await this.loadApiKeyFromSecretsManager() + } + + const address = getServiceSearchEndpoint(this.logger) const queryParams = {...SERVICE_SEARCH_BASE_QUERY_PARAMS, search: odsCode} this.logger.info(`making request to ${address} with ods code ${odsCode}`, {odsCode: odsCode}) From ce2ba47b2f9f85baaf1d2a6a4db763e4da93b418 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:37:46 +0000 Subject: [PATCH 13/25] chore: simplify condition --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index a7a508e2a..8aaad6004 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -209,10 +209,10 @@ export class LiveServiceSearchClient implements ServiceSearchClient { stripApiKeyFromHeaders(error: AxiosError) { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { - if (error.response?.headers && error.response.headers[key]) { + if (error.response?.headers?.[key]) { error.response.headers[key] = `${error.response.headers[key].substring(0, 5)}*****` } - if (error.request?.headers && error.request.headers[key]) { + if (error.request?.headers?.[key]) { error.request.headers[key] = `${error.request.headers[key].substring(0, 5)}*****` } }) From 613d0b5f84a9da48585ba4755c84d1a19af44d76 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:47:25 +0000 Subject: [PATCH 14/25] chore: tweak logging --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 8aaad6004..9a5b0e3b2 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -134,7 +134,8 @@ export class LiveServiceSearchClient implements ServiceSearchClient { maxAge: 300 // Cache for 5 minutes }) - this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager") + this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager", + {secret: `${secret?.toString().substring(0, 5)}*****`}) return secret as string } catch (error) { this.logger.error("Failed to load ServiceSearch API key from Secrets Manager", {error}) @@ -147,7 +148,6 @@ export class LiveServiceSearchClient implements ServiceSearchClient { // Load API key if not set in environment (secrets layer is failing to load v3 key) if (getServiceSearchVersion(this.logger) === 3 && !this.outboundHeaders.apikey) { this.logger.info("API key not in environment, attempting to load from Secrets Manager") - this.logger.info("Current environment variables", {env: process.env}) this.outboundHeaders.apikey = await this.loadApiKeyFromSecretsManager() } From cfa9c4ddb3dccb2a9926c4e45756cde1a745cee1 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:08:41 +0000 Subject: [PATCH 15/25] chore: clean up logging --- .../serviceSearchClient/src/live-serviceSearch-client.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 9a5b0e3b2..67893e398 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -55,7 +55,7 @@ export class LiveServiceSearchClient implements ServiceSearchClient { constructor(logger: Logger) { this.logger = logger - this.logger.warn("ServiceSearchClient configured", + this.logger.info("ServiceSearchClient configured", { v2: process.env.ServiceSearchApiKey !== undefined, v3: process.env.ServiceSearch3ApiKey !== undefined @@ -134,8 +134,7 @@ export class LiveServiceSearchClient implements ServiceSearchClient { maxAge: 300 // Cache for 5 minutes }) - this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager", - {secret: `${secret?.toString().substring(0, 5)}*****`}) + this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager") return secret as string } catch (error) { this.logger.error("Failed to load ServiceSearch API key from Secrets Manager", {error}) @@ -210,10 +209,10 @@ export class LiveServiceSearchClient implements ServiceSearchClient { const headerKeys = ["subscription-key", "apikey"] headerKeys.forEach((key) => { if (error.response?.headers?.[key]) { - error.response.headers[key] = `${error.response.headers[key].substring(0, 5)}*****` + delete error.response.headers[key] } if (error.request?.headers?.[key]) { - error.request.headers[key] = `${error.request.headers[key].substring(0, 5)}*****` + delete error.request.headers[key] } }) } From e6fc73a8c438197463a36ccc79ab2650b08d0960 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:14:35 +0000 Subject: [PATCH 16/25] test: increase coverage, eg new func getServiceSearchVersion --- .../tests/live-serviceSearch-client.test.ts | 167 +++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index fe70f9a70..c3b85d9fc 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -29,13 +29,39 @@ describe("live serviceSearch client", () => { }) // Helper function tests - test("getServiceSearchEndpoint returns correct URL", async () => { + test("getServiceSearchEndpoint returns correct URL for v2", async () => { const {getServiceSearchEndpoint} = await import("../src/live-serviceSearch-client.js") const endpoint = getServiceSearchEndpoint() expect(endpoint).toBe(serviceSearchUrl) }) - test("stripApiKeyFromHeaders removes only subscription-key header", () => { + test("getServiceSearchEndpoint returns correct URL for v3", async () => { + process.env.TargetServiceSearchServer = "api.service.nhs.uk" + const {getServiceSearchEndpoint} = await import("../src/live-serviceSearch-client.js") + const endpoint = getServiceSearchEndpoint(logger) + expect(endpoint).toBe("https://api.service.nhs.uk/service-search-api/") + process.env.TargetServiceSearchServer = "live" + }) + + test("getServiceSearchVersion returns 3 and logs info for v3 endpoint", async () => { + process.env.TargetServiceSearchServer = "api.service.nhs.uk" + const infoSpy = jest.spyOn(Logger.prototype, "info") + const {getServiceSearchVersion} = await import("../src/live-serviceSearch-client.js") + const version = getServiceSearchVersion(logger) + expect(version).toBe(3) + expect(infoSpy).toHaveBeenCalledWith("Using service search v3 endpoint") + process.env.TargetServiceSearchServer = "live" + }) + + test("getServiceSearchVersion returns 2 and logs warn for v2 endpoint", async () => { + const warnSpy = jest.spyOn(Logger.prototype, "warn") + const {getServiceSearchVersion} = await import("../src/live-serviceSearch-client.js") + const version = getServiceSearchVersion(logger) + expect(version).toBe(2) + expect(warnSpy).toHaveBeenCalledWith("Using service search v2 endpoint") + }) + + test("stripKeyFromHeaders removes only subscription-key header", () => { const axiosErr: AxiosError = { isAxiosError: true, config: { @@ -64,8 +90,46 @@ describe("live serviceSearch client", () => { // The config doesn't get touched by the stripping function expect(axiosErr.config!.headers).toHaveProperty("subscription-key") expect(axiosErr.config!.headers).toHaveProperty("keep", "yes") - expect(axiosErr.response!.headers).toHaveProperty("subscription-key") - expect(axiosErr.response!.headers["subscription-key"]).toEqual("secre*****") + expect(axiosErr.response!.headers).not.toHaveProperty("subscription-key") + expect(axiosErr.response!.headers).toHaveProperty("foo", "bar") + }) + + test("stripApiKeyFromHeaders removes only apikey header", () => { + const axiosErr: AxiosError = { + isAxiosError: true, + config: { + headers: new axios.AxiosHeaders({"apikey": "secret", keep: "yes"}) + } satisfies AxiosRequestConfig, + response: { + headers: {"apikey": "secret", foo: "bar"}, + data: null, + status: 200, + statusText: "", + config: {headers: new axios.AxiosHeaders()} satisfies AxiosRequestConfig, + request: {} + } satisfies AxiosResponse, + request: { + headers: {"apikey": "secret", keep: "yes"} + }, + toJSON: function (): object { + throw new Error("Function not implemented.") + }, + name: "", + message: "" + } + + expect(axiosErr.config!.headers).toHaveProperty("apikey") + expect(axiosErr.request!.headers).toHaveProperty("apikey") + expect(axiosErr.response!.headers).toHaveProperty("apikey") + + client.stripApiKeyFromHeaders(axiosErr) + + // The config doesn't get touched by the stripping function + expect(axiosErr.config!.headers).toHaveProperty("apikey") + expect(axiosErr.config!.headers).toHaveProperty("keep", "yes") + expect(axiosErr.request!.headers).not.toHaveProperty("apikey") + expect(axiosErr.request!.headers).toHaveProperty("keep", "yes") + expect(axiosErr.response!.headers).not.toHaveProperty("apikey") expect(axiosErr.response!.headers).toHaveProperty("foo", "bar") }) @@ -131,6 +195,25 @@ describe("live serviceSearch client", () => { ) }) + // Test AxiosError without response or request (general axios error) + test("searchService logs general axios error when no response or request", async () => { + const axiosErr = { + isAxiosError: true, + message: "generalfail", + config: {headers: {}}, + request: undefined, + response: undefined + } as unknown as AxiosError + + jest.spyOn(client["axiosInstance"], "get").mockRejectedValue(axiosErr) + const errSpy = jest.spyOn(Logger.prototype, "error") + + await expect(client.searchService("y")).rejects.toBe(axiosErr) + expect(errSpy).toHaveBeenCalledWith( + "general error calling serviceSearch", {error: axiosErr} + ) + }) + describe("integration scenarios", () => { const validUrlData: ServiceSearchData = { value: [ @@ -257,4 +340,80 @@ describe("live serviceSearch client", () => { }) }) + + describe("v3 service search integration", () => { + const v3ServiceSearchUrl = "https://api.service.nhs.uk/service-search-api/" + const validUrlData: ServiceSearchData = { + value: [ + {URL: "https://example.com", OrganisationSubType: "DistanceSelling"} + ] + } + + beforeEach(() => { + process.env.TargetServiceSearchServer = "api.service.nhs.uk" + process.env.ServiceSearch3ApiKey = "v3-test-key" + }) + + afterEach(() => { + process.env.TargetServiceSearchServer = "live" + delete process.env.ServiceSearch3ApiKey + jest.restoreAllMocks() + }) + + test("uses v3 endpoint and apikey header", async () => { + const infoSpy = jest.spyOn(Logger.prototype, "info") + + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + + const result = await v3Client.searchService("test-ods") + + expect(infoSpy).toHaveBeenCalledWith("Using service search v3 endpoint") + expect(infoSpy).toHaveBeenCalledWith( + "ServiceSearchClient configured", + {v2: true, v3: true} + ) + expect(result).toEqual(new URL(validUrlData.value[0].URL)) + }) + + test("logs error when API key ARN is not set and API key is missing", async () => { + delete process.env.ServiceSearch3ApiKey + delete process.env.ServiceSearch3ApiKeyARN + + const errorSpy = jest.spyOn(Logger.prototype, "error") + + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + + const result = await v3Client.searchService("test-ods") + + expect(errorSpy).toHaveBeenCalledWith( + "ServiceSearch3ApiKeyARN environment variable is not set" + ) + expect(result).toEqual(new URL(validUrlData.value[0].URL)) + }) + + test("attempts to load from secrets manager when API key is not in environment", async () => { + delete process.env.ServiceSearch3ApiKey + process.env.ServiceSearch3ApiKeyARN = "arn:aws:secretsmanager:region:account:secret:test" + + const infoSpy = jest.spyOn(Logger.prototype, "info") + const errorSpy = jest.spyOn(Logger.prototype, "error") + + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + + const result = await v3Client.searchService("test-ods") + + expect(infoSpy).toHaveBeenCalledWith( + "API key not in environment, attempting to load from Secrets Manager" + ) + // Since getSecret will actually fail in the test environment, we should see an error + expect(errorSpy).toHaveBeenCalledWith( + "Failed to load ServiceSearch API key from Secrets Manager", + expect.objectContaining({error: expect.anything()}) + ) + expect(result).toEqual(new URL(validUrlData.value[0].URL)) + }) + }) }) From ad0dcc486214df999931c4ef37378075b8154694 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:58:54 +0000 Subject: [PATCH 17/25] chore: tmp remove URL from --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 67893e398..2d539ce0e 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -23,7 +23,7 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = { "api-version": 2, "searchFields": "ODSCode", "$filter": "OrganisationTypeId eq 'PHA' and OrganisationSubType eq 'DistanceSelling'", - "$select": "URL,OrganisationSubType", + "$select": "OrganisationSubType", "$top": 1 } From fc6f8a6de06fa348f2410ac631eb17347dd85a7d Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:38:11 +0000 Subject: [PATCH 18/25] Revert "chore: tmp remove URL from serviceSearch" This reverts commit ad0dcc486214df999931c4ef37378075b8154694. --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 2d539ce0e..67893e398 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -23,7 +23,7 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = { "api-version": 2, "searchFields": "ODSCode", "$filter": "OrganisationTypeId eq 'PHA' and OrganisationSubType eq 'DistanceSelling'", - "$select": "OrganisationSubType", + "$select": "URL,OrganisationSubType", "$top": 1 } From 10acd44b5ee7e1fe69a17cc74f0758ce285121b9 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:39:25 +0000 Subject: [PATCH 19/25] fix: handle diff v3 response --- .../src/live-serviceSearch-client.ts | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 67893e398..1a7e40b78 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -1,6 +1,6 @@ import {Logger} from "@aws-lambda-powertools/logger" import {getSecret} from "@aws-lambda-powertools/parameters/secrets" -import axios, {AxiosError, AxiosInstance} from "axios" +import axios, {AxiosError, AxiosInstance, AxiosResponse} from "axios" import axiosRetry from "axios-retry" import {handleUrl} from "./handleUrl" @@ -15,15 +15,29 @@ type Service = { "OrganisationSubType": string } +type Contact = { + "ContactMethodType": string + "ContactValue": string +} + export type ServiceSearchData = { "value": Array } +export type ServiceSearch3Data = { + "@odata.context": string + "value": Array<{ + "@search.score": number + "OrganisationSubType": string + "Contacts": Array + }> +} + export const SERVICE_SEARCH_BASE_QUERY_PARAMS = { "api-version": 2, "searchFields": "ODSCode", "$filter": "OrganisationTypeId eq 'PHA' and OrganisationSubType eq 'DistanceSelling'", - "$select": "URL,OrganisationSubType", + "$select": "Contacts,OrganisationSubType", "$top": 1 } @@ -145,7 +159,8 @@ export class LiveServiceSearchClient implements ServiceSearchClient { async searchService(odsCode: string): Promise { try { // Load API key if not set in environment (secrets layer is failing to load v3 key) - if (getServiceSearchVersion(this.logger) === 3 && !this.outboundHeaders.apikey) { + const apiVsn = getServiceSearchVersion(this.logger) + if (apiVsn === 3 && !this.outboundHeaders.apikey) { this.logger.info("API key not in environment, attempting to load from Secrets Manager") this.outboundHeaders.apikey = await this.loadApiKeyFromSecretsManager() } @@ -160,22 +175,14 @@ export class LiveServiceSearchClient implements ServiceSearchClient { timeout: SERVICE_SEARCH_TIMEOUT }) - const serviceSearchResponse: ServiceSearchData = response.data - const services = serviceSearchResponse.value - if (services.length === 0) { - return undefined + this.logger.info(`received response from serviceSearch for ods code ${odsCode}`, + {odsCode: odsCode, status: response.status, data: response.data}) + if (apiVsn === 2) { + return this.handleV2Response(response, odsCode) + } else { + return this.handleV3Response(response, odsCode) } - this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode}) - const service = services[0] - const urlString = service["URL"] - - if (urlString === null) { - this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode}) - return undefined - } - const serviceUrl = handleUrl(urlString, odsCode, this.logger) - return serviceUrl } catch (error) { if (axios.isAxiosError(error)) { this.stripApiKeyFromHeaders(error) @@ -204,6 +211,36 @@ export class LiveServiceSearchClient implements ServiceSearchClient { throw error } } + handleV3Response(response: AxiosResponse, odsCode: string): URL | undefined { + const contacts = response.data.value[0]?.Contacts + const websiteContact = contacts?.find((contact: Contact) => contact.ContactMethodType === "Website") + const websiteUrl = websiteContact?.ContactValue + if (!websiteUrl) { + this.logger.warn(`pharmacy with ods code ${odsCode} has no website`, {odsCode: odsCode}) + return undefined + } + const serviceUrl = handleUrl(websiteUrl, odsCode, this.logger) + return serviceUrl + } + + handleV2Response(response: AxiosResponse, odsCode: string): URL | undefined { + const serviceSearchResponse: ServiceSearchData = response.data + const services = serviceSearchResponse.value + if (services.length === 0) { + return undefined + } + + this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode}) + const service = services[0] + const urlString = service["URL"] + + if (urlString === null) { + this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode}) + return undefined + } + const serviceUrl = handleUrl(urlString, odsCode, this.logger) + return serviceUrl + } stripApiKeyFromHeaders(error: AxiosError) { const headerKeys = ["subscription-key", "apikey"] From 694e71a225eee1ba520ed400fbc012b521bda74a Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:45:47 +0000 Subject: [PATCH 20/25] test: v3 response data --- .../tests/live-serviceSearch-client.test.ts | 113 ++++++++++++++++-- 1 file changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index c3b85d9fc..15b77921d 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -1,4 +1,4 @@ -import {LiveServiceSearchClient, ServiceSearchData} from "../src/live-serviceSearch-client" +import {LiveServiceSearchClient, ServiceSearchData, ServiceSearch3Data} from "../src/live-serviceSearch-client" import {jest} from "@jest/globals" import MockAdapter from "axios-mock-adapter" import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios" @@ -343,9 +343,16 @@ describe("live serviceSearch client", () => { describe("v3 service search integration", () => { const v3ServiceSearchUrl = "https://api.service.nhs.uk/service-search-api/" - const validUrlData: ServiceSearchData = { + const validV3Data: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", value: [ - {URL: "https://example.com", OrganisationSubType: "DistanceSelling"} + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [ + {ContactMethodType: "Website", ContactValue: "https://example.com"} + ] + } ] } @@ -364,7 +371,7 @@ describe("live serviceSearch client", () => { const infoSpy = jest.spyOn(Logger.prototype, "info") const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) const result = await v3Client.searchService("test-ods") @@ -373,7 +380,7 @@ describe("live serviceSearch client", () => { "ServiceSearchClient configured", {v2: true, v3: true} ) - expect(result).toEqual(new URL(validUrlData.value[0].URL)) + expect(result).toEqual(new URL("https://example.com")) }) test("logs error when API key ARN is not set and API key is missing", async () => { @@ -383,14 +390,14 @@ describe("live serviceSearch client", () => { const errorSpy = jest.spyOn(Logger.prototype, "error") const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) const result = await v3Client.searchService("test-ods") expect(errorSpy).toHaveBeenCalledWith( "ServiceSearch3ApiKeyARN environment variable is not set" ) - expect(result).toEqual(new URL(validUrlData.value[0].URL)) + expect(result).toEqual(new URL("https://example.com")) }) test("attempts to load from secrets manager when API key is not in environment", async () => { @@ -401,7 +408,7 @@ describe("live serviceSearch client", () => { const errorSpy = jest.spyOn(Logger.prototype, "error") const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validUrlData) + mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) const result = await v3Client.searchService("test-ods") @@ -413,7 +420,95 @@ describe("live serviceSearch client", () => { "Failed to load ServiceSearch API key from Secrets Manager", expect.objectContaining({error: expect.anything()}) ) - expect(result).toEqual(new URL(validUrlData.value[0].URL)) + expect(result).toEqual(new URL("https://example.com")) + }) + + test("returns undefined when v3 response has no Contacts", async () => { + const noContactsData: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [ + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [] + } + ] + } + + const warnSpy = jest.spyOn(Logger.prototype, "warn") + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, noContactsData) + + const result = await v3Client.searchService("test-ods") + + expect(warnSpy).toHaveBeenCalledWith( + "pharmacy with ods code test-ods has no website", + {odsCode: "test-ods"} + ) + expect(result).toBeUndefined() + }) + + test("returns undefined when v3 response has no Website contact", async () => { + const noWebsiteData: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [ + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [ + {ContactMethodType: "Phone", ContactValue: "01234567890"} + ] + } + ] + } + + const warnSpy = jest.spyOn(Logger.prototype, "warn") + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, noWebsiteData) + + const result = await v3Client.searchService("test-ods") + + expect(warnSpy).toHaveBeenCalledWith( + "pharmacy with ods code test-ods has no website", + {odsCode: "test-ods"} + ) + expect(result).toBeUndefined() + }) + + test("handles v3 URL without protocol", async () => { + const noProtocolData: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [ + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [ + {ContactMethodType: "Website", ContactValue: "example.com"} + ] + } + ] + } + + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, noProtocolData) + + const result = await v3Client.searchService("test-ods") + + expect(result).toEqual(new URL("https://example.com")) + }) + + test("returns undefined when v3 response has empty value array", async () => { + const emptyData: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [] + } + + const v3Client = new LiveServiceSearchClient(logger) + mock.onGet(v3ServiceSearchUrl).reply(200, emptyData) + + const result = await v3Client.searchService("test-ods") + + expect(result).toBeUndefined() }) }) }) From a7a2f39e1cceae5e2d9fe404304e8088a87dbc49 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:56:19 +0000 Subject: [PATCH 21/25] fix: revised test approach for less mocks --- .../src/live-serviceSearch-client.ts | 15 +- .../tests/live-serviceSearch-client.test.ts | 160 ++++++------------ 2 files changed, 59 insertions(+), 116 deletions(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index 1a7e40b78..f60beae56 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -1,6 +1,6 @@ import {Logger} from "@aws-lambda-powertools/logger" import {getSecret} from "@aws-lambda-powertools/parameters/secrets" -import axios, {AxiosError, AxiosInstance, AxiosResponse} from "axios" +import axios, {AxiosError, AxiosInstance} from "axios" import axiosRetry from "axios-retry" import {handleUrl} from "./handleUrl" @@ -178,9 +178,9 @@ export class LiveServiceSearchClient implements ServiceSearchClient { this.logger.info(`received response from serviceSearch for ods code ${odsCode}`, {odsCode: odsCode, status: response.status, data: response.data}) if (apiVsn === 2) { - return this.handleV2Response(response, odsCode) + return this.handleV2Response(odsCode, response.data) } else { - return this.handleV3Response(response, odsCode) + return this.handleV3Response(odsCode, response.data) } } catch (error) { @@ -211,8 +211,8 @@ export class LiveServiceSearchClient implements ServiceSearchClient { throw error } } - handleV3Response(response: AxiosResponse, odsCode: string): URL | undefined { - const contacts = response.data.value[0]?.Contacts + handleV3Response(odsCode: string, data: ServiceSearch3Data): URL | undefined { + const contacts = data.value[0]?.Contacts const websiteContact = contacts?.find((contact: Contact) => contact.ContactMethodType === "Website") const websiteUrl = websiteContact?.ContactValue if (!websiteUrl) { @@ -223,9 +223,8 @@ export class LiveServiceSearchClient implements ServiceSearchClient { return serviceUrl } - handleV2Response(response: AxiosResponse, odsCode: string): URL | undefined { - const serviceSearchResponse: ServiceSearchData = response.data - const services = serviceSearchResponse.value + handleV2Response(odsCode: string, data: ServiceSearchData): URL | undefined { + const services = data.value if (services.length === 0) { return undefined } diff --git a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts index 15b77921d..cc17efdc2 100644 --- a/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts +++ b/packages/serviceSearchClient/tests/live-serviceSearch-client.test.ts @@ -341,90 +341,27 @@ describe("live serviceSearch client", () => { }) - describe("v3 service search integration", () => { - const v3ServiceSearchUrl = "https://api.service.nhs.uk/service-search-api/" - const validV3Data: ServiceSearch3Data = { - "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", - value: [ - { - "@search.score": 1.0, - OrganisationSubType: "DistanceSelling", - Contacts: [ - {ContactMethodType: "Website", ContactValue: "https://example.com"} - ] - } - ] - } - - beforeEach(() => { - process.env.TargetServiceSearchServer = "api.service.nhs.uk" - process.env.ServiceSearch3ApiKey = "v3-test-key" - }) - - afterEach(() => { - process.env.TargetServiceSearchServer = "live" - delete process.env.ServiceSearch3ApiKey - jest.restoreAllMocks() - }) - - test("uses v3 endpoint and apikey header", async () => { - const infoSpy = jest.spyOn(Logger.prototype, "info") - - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) - - const result = await v3Client.searchService("test-ods") - - expect(infoSpy).toHaveBeenCalledWith("Using service search v3 endpoint") - expect(infoSpy).toHaveBeenCalledWith( - "ServiceSearchClient configured", - {v2: true, v3: true} - ) - expect(result).toEqual(new URL("https://example.com")) - }) - - test("logs error when API key ARN is not set and API key is missing", async () => { - delete process.env.ServiceSearch3ApiKey - delete process.env.ServiceSearch3ApiKeyARN - - const errorSpy = jest.spyOn(Logger.prototype, "error") - - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) - - const result = await v3Client.searchService("test-ods") - - expect(errorSpy).toHaveBeenCalledWith( - "ServiceSearch3ApiKeyARN environment variable is not set" - ) - expect(result).toEqual(new URL("https://example.com")) - }) - - test("attempts to load from secrets manager when API key is not in environment", async () => { - delete process.env.ServiceSearch3ApiKey - process.env.ServiceSearch3ApiKeyARN = "arn:aws:secretsmanager:region:account:secret:test" - - const infoSpy = jest.spyOn(Logger.prototype, "info") - const errorSpy = jest.spyOn(Logger.prototype, "error") - - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, validV3Data) - - const result = await v3Client.searchService("test-ods") + describe("handleV3Response", () => { + test("returns URL from Website contact", () => { + const data: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [ + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [ + {ContactMethodType: "Website", ContactValue: "https://example.com"} + ] + } + ] + } - expect(infoSpy).toHaveBeenCalledWith( - "API key not in environment, attempting to load from Secrets Manager" - ) - // Since getSecret will actually fail in the test environment, we should see an error - expect(errorSpy).toHaveBeenCalledWith( - "Failed to load ServiceSearch API key from Secrets Manager", - expect.objectContaining({error: expect.anything()}) - ) + const result = client.handleV3Response("TEST123", data) expect(result).toEqual(new URL("https://example.com")) }) - test("returns undefined when v3 response has no Contacts", async () => { - const noContactsData: ServiceSearch3Data = { + test("returns undefined when response has no Contacts", () => { + const data: ServiceSearch3Data = { "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", value: [ { @@ -436,47 +373,42 @@ describe("live serviceSearch client", () => { } const warnSpy = jest.spyOn(Logger.prototype, "warn") - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, noContactsData) - - const result = await v3Client.searchService("test-ods") + const result = client.handleV3Response("TEST123", data) expect(warnSpy).toHaveBeenCalledWith( - "pharmacy with ods code test-ods has no website", - {odsCode: "test-ods"} + "pharmacy with ods code TEST123 has no website", + {odsCode: "TEST123"} ) expect(result).toBeUndefined() }) - test("returns undefined when v3 response has no Website contact", async () => { - const noWebsiteData: ServiceSearch3Data = { + test("returns undefined when response has no Website contact", () => { + const data: ServiceSearch3Data = { "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", value: [ { "@search.score": 1.0, OrganisationSubType: "DistanceSelling", Contacts: [ - {ContactMethodType: "Phone", ContactValue: "01234567890"} + {ContactMethodType: "Phone", ContactValue: "01234567890"}, + {ContactMethodType: "Email", ContactValue: "test@example.com"} ] } ] } const warnSpy = jest.spyOn(Logger.prototype, "warn") - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, noWebsiteData) - - const result = await v3Client.searchService("test-ods") + const result = client.handleV3Response("TEST123", data) expect(warnSpy).toHaveBeenCalledWith( - "pharmacy with ods code test-ods has no website", - {odsCode: "test-ods"} + "pharmacy with ods code TEST123 has no website", + {odsCode: "TEST123"} ) expect(result).toBeUndefined() }) - test("handles v3 URL without protocol", async () => { - const noProtocolData: ServiceSearch3Data = { + test("handles URL without protocol", () => { + const data: ServiceSearch3Data = { "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", value: [ { @@ -489,26 +421,38 @@ describe("live serviceSearch client", () => { ] } - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, noProtocolData) - - const result = await v3Client.searchService("test-ods") - + const result = client.handleV3Response("TEST123", data) expect(result).toEqual(new URL("https://example.com")) }) - test("returns undefined when v3 response has empty value array", async () => { - const emptyData: ServiceSearch3Data = { + test("returns undefined when value array is empty", () => { + const data: ServiceSearch3Data = { "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", value: [] } - const v3Client = new LiveServiceSearchClient(logger) - mock.onGet(v3ServiceSearchUrl).reply(200, emptyData) + const result = client.handleV3Response("TEST123", data) + expect(result).toBeUndefined() + }) - const result = await v3Client.searchService("test-ods") + test("finds Website contact among multiple contacts", () => { + const data: ServiceSearch3Data = { + "@odata.context": "https://api.service.nhs.uk/service-search-api/$metadata#Services", + value: [ + { + "@search.score": 1.0, + OrganisationSubType: "DistanceSelling", + Contacts: [ + {ContactMethodType: "Phone", ContactValue: "01234567890"}, + {ContactMethodType: "Website", ContactValue: "https://pharmacy.example.com"}, + {ContactMethodType: "Email", ContactValue: "test@example.com"} + ] + } + ] + } - expect(result).toBeUndefined() + const result = client.handleV3Response("TEST123", data) + expect(result).toEqual(new URL("https://pharmacy.example.com")) }) }) }) From 76c38942affbdda58295d586f4e9898e2069c2c5 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:03:35 +0000 Subject: [PATCH 22/25] fix: v2 compatibility --- packages/serviceSearchClient/src/live-serviceSearch-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/serviceSearchClient/src/live-serviceSearch-client.ts b/packages/serviceSearchClient/src/live-serviceSearch-client.ts index f60beae56..439403548 100644 --- a/packages/serviceSearchClient/src/live-serviceSearch-client.ts +++ b/packages/serviceSearchClient/src/live-serviceSearch-client.ts @@ -37,7 +37,7 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = { "api-version": 2, "searchFields": "ODSCode", "$filter": "OrganisationTypeId eq 'PHA' and OrganisationSubType eq 'DistanceSelling'", - "$select": "Contacts,OrganisationSubType", + "$select": "URL,OrganisationSubType", "$top": 1 } @@ -46,6 +46,7 @@ export function getServiceSearchVersion(logger: Logger | null = null): number { if (endpoint.toLowerCase().includes("api.service.nhs.uk")) { logger?.info("Using service search v3 endpoint") SERVICE_SEARCH_BASE_QUERY_PARAMS["api-version"] = 3 + SERVICE_SEARCH_BASE_QUERY_PARAMS["$select"] = "Contacts,OrganisationSubType" return 3 } logger?.warn("Using service search v2 endpoint") From 8650585cc9490e1759673286ec74d5bf2709b445 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:05:51 +0000 Subject: [PATCH 23/25] test: new distance selling regression test --- .github/workflows/run_regression_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_regression_tests.yml b/.github/workflows/run_regression_tests.yml index e107abbf2..126c00685 100644 --- a/.github/workflows/run_regression_tests.yml +++ b/.github/workflows/run_regression_tests.yml @@ -79,7 +79,7 @@ jobs: GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then - REGRESSION_TEST_REPO_TAG="v3.8.18" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name + REGRESSION_TEST_REPO_TAG="aea-5976-distance-selling" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then @@ -121,7 +121,7 @@ jobs: GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then - REGRESSION_TEST_REPO_TAG="v3.8.18" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name + REGRESSION_TEST_REPO_TAG="aea-5976-distance-selling" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then From 13e9f1372cf70d033b1ff6c27468004503acc6c7 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:49:43 +0000 Subject: [PATCH 24/25] ops: pin proxygen regression tests --- .github/workflows/run_regression_tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_regression_tests.yml b/.github/workflows/run_regression_tests.yml index 126c00685..c32a50d57 100644 --- a/.github/workflows/run_regression_tests.yml +++ b/.github/workflows/run_regression_tests.yml @@ -121,7 +121,8 @@ jobs: GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then - REGRESSION_TEST_REPO_TAG="aea-5976-distance-selling" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name + # pin tests back before Proxygen for now + REGRESSION_TEST_REPO_TAG="v3.8.10" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then From eae8d2eff991c207a02556411aa57c50ee7dca3f Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:00:37 +0000 Subject: [PATCH 25/25] chore: bump regression tests to 3.8.19 --- .github/workflows/run_regression_tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run_regression_tests.yml b/.github/workflows/run_regression_tests.yml index a5941c4b8..c92462e0f 100644 --- a/.github/workflows/run_regression_tests.yml +++ b/.github/workflows/run_regression_tests.yml @@ -79,8 +79,8 @@ jobs: GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }} run: | if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then - REGRESSION_TEST_REPO_TAG="aea-5976-distance-selling" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name - REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG + REGRESSION_TEST_REPO_TAG="v3.8.19" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name + REGRESSION_TEST_WORKFLOW_TAG="v3.8.19" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then echo "Error: One or both tag variables are not set" >&2 @@ -121,8 +121,8 @@ jobs: # GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }} # run: | # if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then - # REGRESSION_TEST_REPO_TAG="v3.8.18" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name - # REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG + # REGRESSION_TEST_REPO_TAG="v3.8.19" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name + # REGRESSION_TEST_WORKFLOW_TAG="v3.8.19" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG # if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then # echo "Error: One or both tag variables are not set" >&2