Skip to content

Commit 89170b6

Browse files
authored
feat/df-875: Handles no state on save-and-exit (#1124)
* Handles no state on save-and-exit * Extra coverage * Slight rework * Extra coverage * Fixed path * Reworked after review * Content change atfer review * Rework atfer review * Missing space in JSDoc
1 parent cb83df5 commit 89170b6

6 files changed

Lines changed: 156 additions & 6 deletions

File tree

src/server/models/save-and-exit.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const emailFieldName = 'email'
2222
const emailConfirmationFieldName = 'emailConfirmation'
2323
const securityQuestionFieldName = 'securityQuestion'
2424
const securityAnswerFieldName = 'securityAnswer'
25+
const general = 'general'
2526

2627
const GOVUK_LABEL__M = 'govuk-label--m'
2728
const saveAndExitExpiryDays = config.get('saveAndExitExpiryDays')
@@ -55,6 +56,7 @@ function buildErrors(err) {
5556
return {}
5657
}
5758

59+
const generalError = err.details.find((item) => item.path[0] === general)
5860
const emailError = err.details.find((item) => item.path[0] === emailFieldName)
5961
const emailConfirmationError = err.details.find(
6062
(item) => item.path[0] === emailConfirmationFieldName
@@ -65,8 +67,13 @@ function buildErrors(err) {
6567
const securityAnswerError = err.details.find(
6668
(item) => item.path[0] === securityAnswerFieldName
6769
)
70+
6871
const errors = []
6972

73+
if (generalError) {
74+
errors.push({ text: generalError.message, href: '#' })
75+
}
76+
7077
if (emailError) {
7178
errors.push({ text: emailError.message, href: `#${emailFieldName}` })
7279
}

src/server/routes/save-and-exit-helper.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ export function getPayloadFromFlash(request) {
88
return request.yar.flash(SAVE_AND_EXIT_PAYLOAD)
99
}
1010

11+
/**
12+
* Check that the form has state
13+
* @param {FormSubmissionState} formState
14+
*/
15+
export function hasState(formState) {
16+
return Object.keys(formState).length > 0
17+
}
18+
1119
/**
1220
* @import { Request } from '@hapi/hapi'
1321
* @import { SaveAndExitParams } from '~/src/server/models/save-and-exit.js'
22+
* @import { FormSubmissionState } from '@defra/forms-engine-plugin/engine/types.js'
1423
*/
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
getPayloadFromFlash,
3+
hasState
4+
} from '~/src/server/routes/save-and-exit-helper.js'
5+
6+
describe('save-and-exit-helper tests', () => {
7+
describe('getPayloadFromFlash', () => {
8+
test('calls flash', () => {
9+
const mockRequest = {
10+
yar: { flash: jest.fn().mockReturnValueOnce('flash-content') }
11+
}
12+
// @ts-expect-error - partial mock of request
13+
const res = getPayloadFromFlash(mockRequest)
14+
expect(res).toBe('flash-content')
15+
})
16+
})
17+
18+
describe('hasState', () => {
19+
test('returns true if some state', () => {
20+
expect(hasState({ field1: 'val1' })).toBe(true)
21+
})
22+
23+
test('returns false if no state', () => {
24+
expect(hasState({})).toBe(false)
25+
})
26+
})
27+
})

src/server/routes/save-and-exit.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { StatusCodes } from 'http-status-codes'
1111
import Joi from 'joi'
1212

1313
import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
14+
import { createJoiError } from '~/src/server/helpers/error-helper.js'
1415
import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js'
1516
import {
1617
confirmationViewModel,
@@ -26,7 +27,10 @@ import {
2627
resumeSuccessViewModel,
2728
validatePayloadSchema
2829
} from '~/src/server/models/save-and-exit.js'
29-
import { getPayloadFromFlash } from '~/src/server/routes/save-and-exit-helper.js'
30+
import {
31+
getPayloadFromFlash,
32+
hasState
33+
} from '~/src/server/routes/save-and-exit-helper.js'
3034
import {
3135
getFormMetadata,
3236
getFormMetadataById,
@@ -40,6 +44,7 @@ const maxInvalidPasswordAttempts = 5
4044
const ERROR_BASE_URL = '/resume-form-error'
4145

4246
// View paths
47+
const SAVE_AND_EXIT_DETAILS = 'save-and-exit/details'
4348
const RESUME_ERROR = 'save-and-exit/resume-error'
4449
const RESUME_ERROR_LOCKED = 'save-and-exit/resume-error-locked'
4550
const RESUME_PASSWORD_PATH = 'save-and-exit/resume-password'
@@ -52,6 +57,19 @@ export function getPasswordAttemptsLeft(attemptsSoFar) {
5257
return maxInvalidPasswordAttempts - attemptsSoFar
5358
}
5459

60+
/**
61+
* @param {Partial<{ errors?: { text: string, href: string }[]}>} model
62+
* @param {{ href: string, text: string }} error
63+
*/
64+
export function addError(model, error) {
65+
if (model.errors) {
66+
model.errors.push(error)
67+
} else {
68+
model.errors = [error]
69+
}
70+
return model
71+
}
72+
5573
export default [
5674
/**
5775
* @satisfies {ServerRoute<{ Params: SaveAndExitParams }>}
@@ -70,6 +88,13 @@ export default [
7088
// The current page state may be invalid so we don't want to push into the cache as normal properties.
7189
const cacheService = getCacheService(request.server)
7290
const formState = await cacheService.getState(request)
91+
92+
// Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared
93+
// so just show the form from the start
94+
if (!hasState(formState)) {
95+
return h.redirect(model.serviceUrl)
96+
}
97+
7398
const pagePayload = getPayloadFromFlash(request)
7499
const currentPagePayload = Array.isArray(pagePayload)
75100
? {}
@@ -98,7 +123,9 @@ export default [
98123
// Clear any previous save and exit session state
99124
request.yar.clear(getKey(slug, status))
100125

101-
return h.view('save-and-exit/details', model)
126+
return h
127+
.view(SAVE_AND_EXIT_DETAILS, model)
128+
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
102129
},
103130
options: {
104131
validate: {
@@ -126,6 +153,23 @@ export default [
126153
}
127154
const state = await cacheService.getState(request)
128155

156+
const statusPath = status ? `/${status}` : ''
157+
158+
// Handle the user navigating back from previously submitting a save-and-exit. The state has been cleared
159+
// so we need to warn the user
160+
if (!hasState(state)) {
161+
const model = detailsViewModel(
162+
metadata,
163+
status,
164+
/** @type {SaveAndExitPayload} */ (payload),
165+
createJoiError(
166+
'general',
167+
'Your information is no longer available. Return to the start of the form.'
168+
)
169+
)
170+
return h.view(SAVE_AND_EXIT_DETAILS, model).takeover()
171+
}
172+
129173
await publishSaveAndExitEvent(
130174
metadata.id,
131175
metadata.title,
@@ -142,8 +186,6 @@ export default [
142186
request.yar.set(getKey(slug, status), email)
143187

144188
// Redirect to the save and exit confirmation page
145-
const statusPath = status ? `/${status}` : ''
146-
147189
return h.redirect(`/save-and-exit/${slug}/confirmation${statusPath}`)
148190
},
149191
options: {
@@ -159,7 +201,7 @@ export default [
159201
err
160202
)
161203

162-
return h.view('save-and-exit/details', model).takeover()
204+
return h.view(SAVE_AND_EXIT_DETAILS, model).takeover()
163205
},
164206
params: paramsSchema,
165207
payload: payloadSchema

src/server/routes/save-and-exit.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes'
22

33
import { createJoiError } from '~/src/server/helpers/error-helper.js'
44
import { createServer } from '~/src/server/index.js'
5+
import { addError } from '~/src/server/routes/save-and-exit.js'
56
import {
67
getFormMetadata,
78
getFormMetadataById,
@@ -480,6 +481,22 @@ describe('Save-and-exit check routes', () => {
480481
expect(response.headers.location).toBe('/resume-form-error')
481482
})
482483
})
484+
485+
describe('addError', () => {
486+
test('adds error to existing array', () => {
487+
const model = { errors: [{ href: '#', text: 'Some error text1' }] }
488+
const error = { href: '#', text: 'Some error text2' }
489+
const newModel = addError(model, error)
490+
expect(newModel.errors).toHaveLength(2)
491+
})
492+
493+
test('adds error to new error array', () => {
494+
const model = {}
495+
const error = { href: '#', text: 'Some error text' }
496+
const newModel = addError(model, error)
497+
expect(newModel.errors).toHaveLength(1)
498+
})
499+
})
483500
})
484501

485502
/**

test/form/save-and-exit.test.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { within } from '@testing-library/dom'
44
import { StatusCodes } from 'http-status-codes'
55

66
import { createServer } from '~/src/server/index.js'
7-
import { getPayloadFromFlash } from '~/src/server/routes/save-and-exit-helper.js'
7+
import {
8+
getPayloadFromFlash,
9+
hasState
10+
} from '~/src/server/routes/save-and-exit-helper.js'
811
import { getFormMetadata } from '~/src/server/services/formsService.js'
912
import * as fixtures from '~/test/fixtures/index.js'
1013
import { renderResponse } from '~/test/helpers/component-helpers.js'
@@ -31,6 +34,7 @@ describe('Save and exit', () => {
3134

3235
beforeEach(() => {
3336
jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata)
37+
jest.mocked(hasState).mockReturnValue(true)
3438
})
3539

3640
afterAll(async () => {
@@ -104,6 +108,20 @@ describe('Save and exit', () => {
104108
expect($securityAnswerLabel).toBeInTheDocument()
105109
})
106110

111+
it('redirects to first page of form when missing state', async () => {
112+
const options = {
113+
method: 'GET',
114+
url: '/save-and-exit/basic'
115+
}
116+
117+
jest.mocked(hasState).mockReturnValue(false)
118+
119+
const { response } = await renderResponse(server, options)
120+
121+
expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY)
122+
expect(response.headers.location).toBe('/form/test-form')
123+
})
124+
107125
it('shows the details page with errors', async () => {
108126
const options = {
109127
method: 'POST',
@@ -161,6 +179,36 @@ describe('Save and exit', () => {
161179
expect(response2.statusCode).toBe(StatusCodes.OK)
162180
})
163181

182+
it('posts details page catches error if no state', async () => {
183+
const options = {
184+
method: 'POST',
185+
url: '/save-and-exit/basic',
186+
payload: {
187+
email: 'enrique.chase@defra.gov.uk',
188+
emailConfirmation: 'enrique.chase@defra.gov.uk',
189+
securityQuestion: 'audio-recommendation',
190+
securityAnswer: 'Chase & Status'
191+
}
192+
}
193+
194+
jest.mocked(hasState).mockReturnValue(false)
195+
196+
const { container } = await renderResponse(server, options)
197+
198+
const $errorSummary = container.getByRole('alert')
199+
const $heading = within($errorSummary).getByRole('heading', {
200+
name: 'There is a problem',
201+
level: 2
202+
})
203+
const $errorItems = within($errorSummary).getAllByRole('listitem')
204+
205+
expect($errorSummary).toBeInTheDocument()
206+
expect($heading).toBeInTheDocument()
207+
expect($errorItems[0]).toHaveTextContent(
208+
'Your information is no longer available. Return to the start of the form.'
209+
)
210+
})
211+
164212
it('confirmation page errors if no details are flashed', async () => {
165213
const options = {
166214
method: 'GET',

0 commit comments

Comments
 (0)