diff --git a/api.planx.uk/client/index.test.ts b/api.planx.uk/client/index.test.ts new file mode 100644 index 0000000000..7d8e1978f6 --- /dev/null +++ b/api.planx.uk/client/index.test.ts @@ -0,0 +1,23 @@ +import { CoreDomainClient } from "@opensystemslab/planx-core"; +import { getClient } from "."; +import { userContext } from "../modules/auth/middleware"; +import { getJWT } from "../tests/mockJWT"; + +test("getClient() throws an error if a store is not set", () => { + expect(() => getClient()).toThrow(); +}); + +test("getClient() returns a client if store is set", () => { + const getStoreMock = jest.spyOn(userContext, "getStore"); + getStoreMock.mockReturnValue({ + user: { + sub: "123", + jwt: getJWT({ role: "teamEditor" }), + }, + }); + + const client = getClient(); + + expect(client).toBeDefined(); + expect(client).toBeInstanceOf(CoreDomainClient); +}); diff --git a/api.planx.uk/helpers.test.ts b/api.planx.uk/helpers.test.ts index d862f88a94..953bfdb4f1 100644 --- a/api.planx.uk/helpers.test.ts +++ b/api.planx.uk/helpers.test.ts @@ -1,5 +1,10 @@ import { ComponentType } from "@opensystemslab/planx-core/types"; -import { dataMerged, getFormattedEnvironment, isLiveEnv } from "./helpers"; +import { + dataMerged, + getFlowData, + getFormattedEnvironment, + isLiveEnv, +} from "./helpers"; import { queryMock } from "./tests/graphqlQueryMock"; import { userContext } from "./modules/auth/middleware"; import { getJWT } from "./tests/mockJWT"; @@ -167,6 +172,7 @@ describe("dataMerged() function", () => { }, }); }); + it("handles multiple external portal nodes", async () => { const result = await dataMerged("parent-id"); const nodeTypes = Object.values(result).map((node) => @@ -180,3 +186,19 @@ describe("dataMerged() function", () => { expect(areAllPortalsFlattened).toBe(true); }); }); + +describe("getFlowData() function", () => { + it("throws an error if a flow is not found", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + variables: { + id: "child-id", + }, + data: { + flow: null, + }, + }); + + await expect(getFlowData("child-id")).rejects.toThrow(); + }); +}); diff --git a/api.planx.uk/lib/hasura/metadata/index.test.ts b/api.planx.uk/lib/hasura/metadata/index.test.ts index 9ec9fee8c7..c9526c31f6 100644 --- a/api.planx.uk/lib/hasura/metadata/index.test.ts +++ b/api.planx.uk/lib/hasura/metadata/index.test.ts @@ -1,7 +1,10 @@ import { createScheduledEvent, RequiredScheduledEventArgs } from "."; -import Axios from "axios"; +import Axios, { AxiosError } from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + post: jest.fn(), +})); const mockAxios = Axios as jest.Mocked; const mockScheduledEvent: RequiredScheduledEventArgs = { @@ -16,6 +19,11 @@ test("createScheduledEvent returns an error if request fails", async () => { await expect(createScheduledEvent(mockScheduledEvent)).rejects.toThrow(); }); +test("createScheduledEvent returns an error if Axios errors", async () => { + mockAxios.post.mockRejectedValue(new AxiosError()); + await expect(createScheduledEvent(mockScheduledEvent)).rejects.toThrow(); +}); + test("createScheduledEvent returns response data on success", async () => { mockAxios.post.mockResolvedValue({ data: "test data" }); await expect(createScheduledEvent(mockScheduledEvent)).resolves.toBe( diff --git a/api.planx.uk/lib/hasura/schema/index.test.ts b/api.planx.uk/lib/hasura/schema/index.test.ts index 72b4412c84..5229f6ee8a 100644 --- a/api.planx.uk/lib/hasura/schema/index.test.ts +++ b/api.planx.uk/lib/hasura/schema/index.test.ts @@ -1,7 +1,10 @@ import { runSQL } from "."; -import Axios from "axios"; +import Axios, { AxiosError } from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + post: jest.fn(), +})); const mockAxios = Axios as jest.Mocked; const sql = "SELECT * FROM TEST"; @@ -11,6 +14,11 @@ test("runSQL returns an error if request fails", async () => { await expect(runSQL(sql)).rejects.toThrow(); }); +test("runSQL returns an error if Axios errors", async () => { + mockAxios.post.mockRejectedValue(new AxiosError()); + await expect(runSQL(sql)).rejects.toThrow(); +}); + test("runSQL returns response data on success", async () => { mockAxios.post.mockResolvedValue({ data: "test data" }); await expect(runSQL(sql)).resolves.toBe("test data"); diff --git a/api.planx.uk/lib/notify/index.test.ts b/api.planx.uk/lib/notify/index.test.ts new file mode 100644 index 0000000000..997fc451e9 --- /dev/null +++ b/api.planx.uk/lib/notify/index.test.ts @@ -0,0 +1,39 @@ +import { sendEmail } from "."; +import { NotifyClient } from "notifications-node-client"; +import { NotifyConfig } from "../../types"; + +jest.mock("notifications-node-client"); + +const TEST_EMAIL = "simulate-delivered@notifications.service.gov.uk"; +const mockConfig: NotifyConfig = { + personalisation: { + teamName: "test", + emailReplyToId: "test", + helpEmail: "test", + helpOpeningHours: "test", + helpPhone: "test", + }, +}; + +describe("sendEmail", () => { + it("throws an error if an invalid template is used", async () => { + await expect( + // @ts-expect-error Argument of type "invalidTemplate" is not assignable to parameter + sendEmail("invalidTemplate", "test@example.com", {}), + ).rejects.toThrow(); + }); + + it("throw an error if an error is thrown within sendEmail()", async () => { + const mockNotifyClient = NotifyClient.mock.instances[0]; + mockNotifyClient.sendEmail.mockRejectedValue(new Error()); + await expect(sendEmail("save", TEST_EMAIL, mockConfig)).rejects.toThrow(); + }); + + it("throw an error if the NotifyClient errors", async () => { + const mockNotifyClient = NotifyClient.mock.instances[0]; + mockNotifyClient.sendEmail.mockRejectedValue({ + response: { data: { errors: ["Invalid email"] } }, + }); + await expect(sendEmail("save", TEST_EMAIL, mockConfig)).rejects.toThrow(); + }); +}); diff --git a/api.planx.uk/lib/notify/index.ts b/api.planx.uk/lib/notify/index.ts index a3963d4d9d..7970f70cc3 100644 --- a/api.planx.uk/lib/notify/index.ts +++ b/api.planx.uk/lib/notify/index.ts @@ -66,7 +66,7 @@ const sendEmail = async ( expiryDate?: string; } = { message: "Success" }; if (template === "expiry") - softDeleteSession(config.personalisation.sessionId!); + await softDeleteSession(config.personalisation.sessionId!); if (template === "save") returnValue.expiryDate = config.personalisation.expiryDate; return returnValue; diff --git a/api.planx.uk/modules/admin/session/bops.test.ts b/api.planx.uk/modules/admin/session/bops.test.ts index f4441e20dc..245ae59063 100644 --- a/api.planx.uk/modules/admin/session/bops.test.ts +++ b/api.planx.uk/modules/admin/session/bops.test.ts @@ -37,6 +37,18 @@ describe("BOPS payload admin endpoint", () => { .expect(403); }); + it("returns an error if the BOPS payload generation fails", async () => { + mockGenerateBOPSPayload.mockRejectedValueOnce("Error!"); + + await supertest(app) + .get(endpoint`123`) + .set(authHeader({ role: "platformAdmin" })) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to get BOPS payload/); + }); + }); + it("returns a JSON payload", async () => { await supertest(app) .get(endpoint`123`) diff --git a/api.planx.uk/modules/admin/session/digitalPlanningData.test.ts b/api.planx.uk/modules/admin/session/digitalPlanningData.test.ts index 09d3f114c9..c8fb078cb3 100644 --- a/api.planx.uk/modules/admin/session/digitalPlanningData.test.ts +++ b/api.planx.uk/modules/admin/session/digitalPlanningData.test.ts @@ -40,6 +40,22 @@ describe("Digital Planning Application payload admin endpoint", () => { .expect(403); }); + it("returns an error if the Digital Planning payload generation fails", async () => { + mockGenerateDigitalPlanningApplicationPayload.mockRejectedValueOnce( + "Error!", + ); + + await supertest(app) + .get(endpoint`123`) + .set(authHeader({ role: "platformAdmin" })) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch( + /Failed to make Digital Planning Application payload/, + ); + }); + }); + it("returns a valid JSON payload", async () => { await supertest(app) .get(endpoint`123`) diff --git a/api.planx.uk/modules/admin/session/oneAppXML.test.ts b/api.planx.uk/modules/admin/session/oneAppXML.test.ts index 5988ba1778..af18f0d186 100644 --- a/api.planx.uk/modules/admin/session/oneAppXML.test.ts +++ b/api.planx.uk/modules/admin/session/oneAppXML.test.ts @@ -38,6 +38,18 @@ describe("OneApp XML endpoint", () => { .expect(403); }); + it("returns an error if the XML generation fails", async () => { + mockGenerateOneAppXML.mockRejectedValueOnce("Error!"); + + await supertest(app) + .get(endpoint`123`) + .set(authHeader({ role: "platformAdmin" })) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to get OneApp XML/); + }); + }); + it("returns XML", async () => { await supertest(app) .get(endpoint`123`) diff --git a/api.planx.uk/modules/admin/session/summary.test.ts b/api.planx.uk/modules/admin/session/summary.test.ts index a74cbd9844..75551c8cac 100644 --- a/api.planx.uk/modules/admin/session/summary.test.ts +++ b/api.planx.uk/modules/admin/session/summary.test.ts @@ -39,6 +39,10 @@ describe("Session summary admin endpoint", () => { .expect(403); }); + it.todo("returns an error if the service fails"); + + it.todo("returns an error if the session can't be found"); + it("returns JSON", async () => { await supertest(app) .get(endpoint`abc123`) diff --git a/api.planx.uk/modules/admin/session/zip.test.ts b/api.planx.uk/modules/admin/session/zip.test.ts index dccf00e126..0444e5734a 100644 --- a/api.planx.uk/modules/admin/session/zip.test.ts +++ b/api.planx.uk/modules/admin/session/zip.test.ts @@ -40,4 +40,6 @@ describe("zip data admin endpoint", () => { .expect(200) .expect("content-type", "application/zip"); }); + + it.todo("returns an error if the service fails"); }); diff --git a/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts b/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts index 8e376038c2..4d10555c97 100644 --- a/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts +++ b/api.planx.uk/modules/flows/copyFlow/copyFlow.test.ts @@ -4,6 +4,7 @@ import { queryMock } from "../../../tests/graphqlQueryMock"; import { authHeader } from "../../../tests/mockJWT"; import app from "../../../server"; import { Flow } from "../../../types"; +import { userContext } from "../../auth/middleware"; beforeEach(() => { queryMock.mockQuery({ @@ -174,6 +175,56 @@ it("inserts copied unique flow data", async () => { }); }); +it("throws an error if the a GraphQL operation fails", async () => { + const body = { + insert: true, + replaceValue: "T3ST1", + }; + + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: null, + }, + }, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + await supertest(app) + .post("/flows/1/copy") + .send(body) + .set(auth) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to copy flow/); + }); +}); + +it("throws an error if user details are missing", async () => { + const getStoreMock = jest.spyOn(userContext, "getStore"); + getStoreMock.mockReturnValue(undefined); + + const body = { + insert: true, + replaceValue: "T3ST1", + }; + + await supertest(app) + .post("/flows/1/copy") + .send(body) + .set(auth) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to copy flow/); + }); +}); + // the original flow const mockFlowData: Flow["data"] = { _root: { diff --git a/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts b/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts index 118c72bd63..1139f484e8 100644 --- a/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts +++ b/api.planx.uk/modules/flows/copyFlowAsPortal/service.ts @@ -7,7 +7,6 @@ import { Flow } from "../../../types"; const copyPortalAsFlow = async (flowId: string, portalNodeId: string) => { // fetch the parent flow data const flow = await getFlowData(flowId); - if (!flow) throw Error("Unknown flowId"); // confirm that the node id provided is a valid portal if ( diff --git a/api.planx.uk/modules/flows/findReplace/findReplace.test.ts b/api.planx.uk/modules/flows/findReplace/findReplace.test.ts index 918aa1db1a..743aa06920 100644 --- a/api.planx.uk/modules/flows/findReplace/findReplace.test.ts +++ b/api.planx.uk/modules/flows/findReplace/findReplace.test.ts @@ -239,6 +239,31 @@ describe("string replacement", () => { }); }); }); + + it("returns an error thrown by the service", async () => { + queryMock.mockQuery({ + name: "GetFlowData", + matchOnVariables: false, + data: { + flow: { + data: null, + }, + }, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + await supertest(app) + .post("/flows/1/search?find=designated.monument&replace=monument") + .set(auth) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to find and replace/); + }); + }); }); describe("HTML replacement", () => { diff --git a/api.planx.uk/modules/flows/findReplace/service.ts b/api.planx.uk/modules/flows/findReplace/service.ts index d79354b4c6..719b869c16 100644 --- a/api.planx.uk/modules/flows/findReplace/service.ts +++ b/api.planx.uk/modules/flows/findReplace/service.ts @@ -64,7 +64,6 @@ const findAndReplaceInFlow = async ( replace?: string, ) => { const flow = await getFlowData(flowId); - if (!flow) throw Error("Unknown flowId"); // Find if (!replace) { diff --git a/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts b/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts index f7a95196d0..aedb1762b3 100644 --- a/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts +++ b/api.planx.uk/modules/flows/moveFlow/moveFlow.test.ts @@ -62,3 +62,50 @@ it("moves a flow to a new team", async () => { }); }); }); + +it("returns an error when the service errors", async () => { + queryMock.reset(); + queryMock.mockQuery({ + name: "GetTeamBySlug", + variables: { + slug: "new-team", + }, + data: { + teams: null, + }, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + await supertest(app) + .post("/flows/1/move/new-team") + .set(authHeader({ role: "teamEditor" })) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Failed to move flow/); + }); +}); + +it("returns an error for an invalid team", async () => { + queryMock.reset(); + queryMock.mockQuery({ + name: "GetTeamBySlug", + variables: { + slug: "new-team", + }, + data: { + teams: [], + }, + }); + + await supertest(app) + .post("/flows/1/move/new-team") + .set(authHeader({ role: "teamEditor" })) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Unable to find a team matching slug/); + }); +}); diff --git a/api.planx.uk/modules/flows/publish/publish.test.ts b/api.planx.uk/modules/flows/publish/publish.test.ts index 3f1f5173c5..691b4991ec 100644 --- a/api.planx.uk/modules/flows/publish/publish.test.ts +++ b/api.planx.uk/modules/flows/publish/publish.test.ts @@ -153,4 +153,17 @@ describe("publish", () => { }); }); }); + + it("throws an error if user details are missing", async () => { + const getStoreMock = jest.spyOn(userContext, "getStore"); + getStoreMock.mockReturnValue(undefined); + + await supertest(app) + .post("/flows/1/publish") + .set(auth) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/User details missing from request/); + }); + }); }); diff --git a/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts index cc2c593d67..d836ff4c21 100644 --- a/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.test.ts @@ -133,9 +133,14 @@ describe("Send email endpoint for invite to pay templates", () => { }); describe("'Payment Expiry' templates", () => { - const _templates = ["payment-expiry", "payment-expiry-agent"]; - it.todo( - "soft deletes the payment_request when a payment expiry email is sent", - ); + const templates = ["payment-expiry", "payment-expiry-agent"]; + + for (const template of templates) { + describe(`${template} Template`, () => { + it.todo( + "soft deletes the payment_request when a payment expiry email is sent", + ); + }); + } }); }); diff --git a/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts index c23a806896..7f0899a204 100644 --- a/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts +++ b/api.planx.uk/modules/pay/service/inviteToPay/sendPaymentEmail.ts @@ -38,8 +38,6 @@ const sendSinglePaymentEmail = async ({ paymentRequestId, template, ); - if (!session || !paymentRequest) - throw Error(`Invalid payment request: ${paymentRequestId}`); const config = await getInviteToPayNotifyConfig(session, paymentRequest); const recipient = template.includes("-agent") ? session.email diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.test.ts b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts index 2d12b1e69a..d272205f34 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.test.ts @@ -1,5 +1,11 @@ -import { Team } from "../../../types"; -import { convertSlugToName, getResumeLink } from "./utils"; +import { queryMock } from "../../../tests/graphqlQueryMock"; +import { LowCalSession, LowCalSessionData, Team } from "../../../types"; +import { + convertSlugToName, + getResumeLink, + getSessionDetails, + setupEmailEventTriggers, +} from "./utils"; describe("convertSlugToName util function", () => { it("should return the correct value", () => { @@ -41,3 +47,66 @@ describe("getResumeLink util function", () => { expect(testCase).toEqual(expectedResult); }); }); + +describe("getSessionDetails() util function", () => { + it("sets defaults for values not in the session", async () => { + const result = await getSessionDetails({ + data: { id: "flowId", passport: { data: {} }, breadcrumbs: {} }, + id: "abc123", + created_at: "2023-01-01", + submitted_at: "2023-02-02", + has_user_saved: true, + } as LowCalSession); + + expect(result.address).toEqual("Address not submitted"); + expect(result.projectType).toEqual("Project type not submitted"); + }); + + it("defaults to address title if no single line address is present", async () => { + const sessionData: LowCalSessionData = { + id: "flowId", + passport: { + data: { + _address: { + title: "Address title", + }, + }, + }, + breadcrumbs: {}, + }; + + const result = await getSessionDetails({ + data: sessionData, + id: "abc123", + created_at: "2023-01-01", + submitted_at: "2023-02-02", + has_user_saved: true, + } as LowCalSession); + + expect(result.address).toEqual("Address title"); + }); +}); + +describe("setupEmailEventTriggers util function", () => { + it("handles GraphQL errors", async () => { + queryMock.mockQuery({ + name: "SetupEmailNotifications", + data: { + session: { + id: "123", + hasUserSaved: true, + }, + }, + variables: { + sessionId: "123", + }, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + await expect(setupEmailEventTriggers("123")).rejects.toThrow(); + }); +}); diff --git a/api.planx.uk/modules/saveAndReturn/service/utils.ts b/api.planx.uk/modules/saveAndReturn/service/utils.ts index a46968a7b0..b1b26b5f38 100644 --- a/api.planx.uk/modules/saveAndReturn/service/utils.ts +++ b/api.planx.uk/modules/saveAndReturn/service/utils.ts @@ -147,7 +147,7 @@ interface SessionDetails { /** * Parse session details into an object which will be read by email template */ -const getSessionDetails = async ( +export const getSessionDetails = async ( session: LowCalSession, ): Promise => { const passportProtectTypes = @@ -248,7 +248,7 @@ interface SetupEmailNotifications { // Update lowcal_sessions.has_user_saved column to kick-off the setup_lowcal_expiry_events & // setup_lowcal_reminder_events event triggers in Hasura // Should only run once on initial save of a session -const setupEmailEventTriggers = async (sessionId: string) => { +export const setupEmailEventTriggers = async (sessionId: string) => { try { const mutation = gql` mutation SetupEmailNotifications($sessionId: uuid!) { diff --git a/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts b/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts index 578408be07..dd31f4b5a7 100644 --- a/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts +++ b/api.planx.uk/modules/saveAndReturn/service/validateSession.test.ts @@ -11,6 +11,8 @@ import { mockGetMostRecentPublishedFlow, stubInsertReconciliationRequests, stubUpdateLowcalSessionData, + mockLockedSession, + mockErrorSession, } from "../../../tests/mocks/saveAndReturnMocks"; import type { Node, Flow, Breadcrumb } from "../../../types"; import { userContext } from "../../auth/middleware"; @@ -76,6 +78,42 @@ describe("Validate Session endpoint", () => { }); }); + it("returns a 403 if the session is locked", async () => { + queryMock.mockQuery(mockLockedSession); + + const data = { + payload: { + sessionId: "locked-id", + email: mockLowcalSession.email, + }, + }; + await supertest(app) + .post(validateSessionPath) + .send(data) + .expect(403) + .then((response) => { + expect(response.body.message).toMatch(/Session locked/); + }); + }); + + it("returns an error message if the service fails", async () => { + queryMock.mockQuery(mockErrorSession); + + const data = { + payload: { + sessionId: "locked-id", + email: mockLowcalSession.email, + }, + }; + await supertest(app) + .post(validateSessionPath) + .send(data) + .expect(500) + .then((response) => { + expect(response.body.error).toMatch(/Failed to validate session/); + }); + }); + it("returns a 200 OK for a valid session where there have been no updates", async () => { mockQueryWithFlowDiff({ flow: mockFlow.data, diff: null }); diff --git a/api.planx.uk/modules/send/email/index.test.ts b/api.planx.uk/modules/send/email/index.test.ts index d4d59114ca..891230c689 100644 --- a/api.planx.uk/modules/send/email/index.test.ts +++ b/api.planx.uk/modules/send/email/index.test.ts @@ -59,19 +59,19 @@ describe(`sending an application by email to a planning office`, () => { queryMock.mockQuery({ name: "GetSessionData", - matchOnVariables: false, data: { session: { data: {} }, }, variables: { id: "123" }, + matchOnVariables: true, }); queryMock.mockQuery({ name: "GetSessionEmailDetails", - matchOnVariables: false, + matchOnVariables: true, data: { session: { - email: "applicant@test.com", + email: "simulate-delivered@notifications.service.gov.uk", flow: { slug: "test-flow" }, }, }, @@ -170,6 +170,26 @@ describe(`sending an application by email to a planning office`, () => { }); }); }); + + it("errors if session detail can't be found", async () => { + queryMock.mockQuery({ + name: "GetSessionEmailDetails", + matchOnVariables: false, + data: { + session: null, + }, + variables: { id: "123" }, + }); + + await supertest(app) + .post("/email-submission/other-council") + .set({ Authorization: process.env.HASURA_PLANX_API_KEY }) + .send({ payload: { sessionId: "123" } }) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Cannot find session/); + }); + }); }); describe(`downloading application data received by email`, () => { @@ -185,7 +205,7 @@ describe(`downloading application data received by email`, () => { queryMock.mockQuery({ name: "GetSessionData", - matchOnVariables: false, + matchOnVariables: true, data: { session: { data: { passport: { test: "dummy data" } } }, }, @@ -218,6 +238,27 @@ describe(`downloading application data received by email`, () => { }); }); + it("errors if session data is not found", async () => { + queryMock.mockQuery({ + name: "GetSessionData", + data: { + session: { data: null }, + }, + variables: { id: "456" }, + }); + + await supertest(app) + .get( + "/download-application-files/456?email=planners@southwark.gov.uk&localAuthority=southwark", + ) + .expect(400) + .then((res) => { + expect(res.body.error).toMatch( + /Failed to find session data for this sessionId/, + ); + }); + }); + it("calls addTemplateFilesToZip()", async () => { await supertest(app) .get( diff --git a/api.planx.uk/modules/send/email/index.ts b/api.planx.uk/modules/send/email/index.ts index cb997bf545..93df80543f 100644 --- a/api.planx.uk/modules/send/email/index.ts +++ b/api.planx.uk/modules/send/email/index.ts @@ -18,6 +18,8 @@ export async function sendToEmail( // `/email-submission/:localAuthority` is only called via Hasura's scheduled event webhook, so body is wrapped in a "payload" key const { payload } = req.body; + const localAuthority = req.params.localAuthority; + if (!payload?.sessionId) { return next({ status: 400, @@ -26,7 +28,6 @@ export async function sendToEmail( } try { - const localAuthority = req.params.localAuthority; // Confirm this local authority (aka team) has an email configured in teams.submission_email const { sendToEmail, notifyPersonalisation } = await getTeamEmailSettings(localAuthority); @@ -44,7 +45,7 @@ export async function sendToEmail( // Prepare email template const config: EmailSubmissionNotifyConfig = { personalisation: { - serviceName: flowName || "PlanX", + serviceName: flowName, sessionId: payload.sessionId, applicantEmail: email, downloadLink: `${process.env.API_URL_EXT}/download-application-files/${payload.sessionId}?email=${sendToEmail}&localAuthority=${localAuthority}`, @@ -54,12 +55,7 @@ export async function sendToEmail( // Send the email const response = await sendEmail("submit", sendToEmail, config); - if (response?.message !== "Success") { - return next({ - status: 500, - message: `Failed to send "Submit" email (${localAuthority}): ${response?.message}`, - }); - } + // Mark session as submitted so that reminder and expiry emails are not triggered markSessionAsSubmitted(payload.sessionId); @@ -78,7 +74,9 @@ export async function sendToEmail( } catch (error) { return next({ error, - message: `Failed to send "Submit" email. ${(error as Error).message}`, + message: `Failed to send "Submit" email (${localAuthority}): ${ + (error as Error).message + }`, }); } } diff --git a/api.planx.uk/modules/sendEmail/index.test.ts b/api.planx.uk/modules/sendEmail/index.test.ts index 96debfbfcc..8ae931321a 100644 --- a/api.planx.uk/modules/sendEmail/index.test.ts +++ b/api.planx.uk/modules/sendEmail/index.test.ts @@ -7,6 +7,7 @@ import { mockSetupEmailNotifications, mockSoftDeleteLowcalSession, mockValidateSingleSessionRequest, + mockValidateSingleSessionRequestMissingSession, } from "../../tests/mocks/saveAndReturnMocks"; import { CoreDomainClient } from "@opensystemslab/planx-core"; @@ -200,6 +201,71 @@ describe("Send Email endpoint", () => { }); describe("'Expiry' template", () => { + it("returns an error if unable to delete the session", async () => { + queryMock.mockQuery({ + name: "ValidateSingleSessionRequest", + data: { + flows_by_pk: mockFlow, + lowcalSessions: [ + { + ...mockLowcalSession, + id: "456", + }, + ], + }, + variables: { + sessionId: "456", + }, + }); + + queryMock.mockQuery({ + name: "SetupEmailNotifications", + data: { + session: { + id: "456", + hasUserSaved: true, + }, + }, + variables: { + sessionId: "456", + }, + }); + + queryMock.mockQuery({ + name: "SoftDeleteLowcalSession", + data: { + update_lowcal_sessions_by_pk: { + id: "456", + }, + }, + variables: { + sessionId: "456", + }, + matchOnVariables: true, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], + }); + + const data = { + payload: { + sessionId: "456", + email: TEST_EMAIL, + }, + }; + + await supertest(app) + .post(`/send-email/expiry`) + .set("Authorization", "testtesttest") + .send(data) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Error deleting session/); + }); + }); + it("soft deletes the session when an expiry email is sent", async () => { const data = { payload: { @@ -239,6 +305,18 @@ describe("Setting up send email events", () => { queryMock.mockQuery(mockSetupEmailNotifications); }); + test("Missing sessions are handled", async () => { + queryMock.mockQuery(mockValidateSingleSessionRequestMissingSession); + + await supertest(app) + .post(SAVE_ENDPOINT) + .send(data) + .expect(500) + .then((res) => { + expect(res.body.error).toMatch(/Unable to find session/); + }); + }); + test("Initial save sets ups email notifications", async () => { queryMock.mockQuery(mockValidateSingleSessionRequest); diff --git a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts index 50a863c391..bf406d3a3a 100644 --- a/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts +++ b/api.planx.uk/modules/webhooks/service/sanitiseApplicationData/operations.test.ts @@ -162,6 +162,15 @@ describe("Data sanitation operations", () => { expect(mockRunSQL).toHaveBeenCalled(); expect(result).toEqual(mockIds); }); + + it("handles empty responses", async () => { + mockRunSQL.mockResolvedValue({ + result: undefined, + }); + const result = await deleteHasuraEventLogs(); + expect(mockRunSQL).toHaveBeenCalled(); + expect(result).toHaveLength(0); + }); }); describe("deleteApplicationFiles", () => { @@ -187,6 +196,12 @@ describe("Data sanitation operations", () => { const fileCount = mockIds.length * filesPerMockSessionCount; expect(deletedFiles).toHaveLength(fileCount); }); + + it("handles missing sessions", async () => { + queryMock.mockQuery(mockGetExpiredSessionIdsQuery); + mockFindSession.mockResolvedValue(null); + await expect(deleteApplicationFiles()).rejects.toThrow(); + }); }); describe("deleteHasuraScheduledEventsForSubmittedSessions", () => { @@ -198,5 +213,14 @@ describe("Data sanitation operations", () => { expect(mockRunSQL).toHaveBeenCalled(); expect(result).toEqual(mockIds); }); + + it("handles empty responses", async () => { + mockRunSQL.mockResolvedValue({ + result: undefined, + }); + const result = await deleteHasuraScheduledEventsForSubmittedSessions(); + expect(mockRunSQL).toHaveBeenCalled(); + expect(result).toHaveLength(0); + }); }); }); diff --git a/api.planx.uk/tests/mocks/saveAndReturnMocks.ts b/api.planx.uk/tests/mocks/saveAndReturnMocks.ts index 4e3828ca70..199acc438a 100644 --- a/api.planx.uk/tests/mocks/saveAndReturnMocks.ts +++ b/api.planx.uk/tests/mocks/saveAndReturnMocks.ts @@ -100,6 +100,31 @@ export const mockNotFoundSession = { }, }; +export const mockLockedSession = { + name: "FindSession", + data: { + sessions: [ + { + lockedAt: "2023-01-02-11.22.33.444444", + }, + ], + }, + variables: { + sessionId: "locked-id", + email: mockLowcalSession.email, + }, +}; + +export const mockErrorSession = { + name: "FindSession", + data: {}, + graphqlErrors: [ + { + message: "Something went wrong", + }, + ], +}; + export const mockGetMostRecentPublishedFlow = (data: Flow["data"]) => ({ name: "GetMostRecentPublishedFlow", data: { @@ -149,6 +174,18 @@ export const mockValidateSingleSessionRequest = { variables: { sessionId: mockLowcalSession.id, }, + matchOnVariables: true, +}; + +export const mockValidateSingleSessionRequestMissingSession = { + name: "ValidateSingleSessionRequest", + data: { + flows_by_pk: mockFlow, + lowcalSessions: [], + }, + variables: { + sessionId: mockLowcalSession.id, + }, }; export const mockSoftDeleteLowcalSession = { @@ -161,6 +198,7 @@ export const mockSoftDeleteLowcalSession = { variables: { sessionId: "123", }, + matchOnVariables: true, }; export const mockSetupEmailNotifications = {