generated from ministryofjustice/hmpps-template-typescript
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2264 from ministryofjustice/feature/APS-1677_ap_o…
…ccupancy_view APS-1677 AP occupancy view
- Loading branch information
Showing
20 changed files
with
699 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import type { Cas1PremiseCapacity, Cas1PremisesSummary } from '@approved-premises/api' | ||
import Page from '../page' | ||
import { DateFormats, daysToWeeksAndDays } from '../../../server/utils/dateUtils' | ||
import paths from '../../../server/paths/manage' | ||
|
||
export default class OccupancyViewPage extends Page { | ||
constructor(private pageTitle: string) { | ||
super(pageTitle) | ||
} | ||
|
||
static visit(premises: Cas1PremisesSummary): OccupancyViewPage { | ||
cy.visit(paths.premises.occupancy.view({ premisesId: premises.id })) | ||
return new OccupancyViewPage(`View spaces in ${premises.name}`) | ||
} | ||
|
||
static visitUnauthorised(premises: Cas1PremisesSummary): OccupancyViewPage { | ||
cy.visit(paths.premises.occupancy.view({ premisesId: premises.id }), { | ||
failOnStatusCode: false, | ||
}) | ||
return new OccupancyViewPage(`Authorisation Error`) | ||
} | ||
|
||
shouldShowCalendarHeading(startDate: string, durationDays: number): void { | ||
const calendarTitle = `Showing ${DateFormats.formatDuration(daysToWeeksAndDays(String(durationDays)))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}` | ||
cy.contains(calendarTitle) | ||
} | ||
|
||
shouldShowCalendar(premisesCapacity: Cas1PremiseCapacity): void { | ||
cy.get('#calendar-key').within(() => { | ||
cy.contains('Available') | ||
cy.contains('Full') | ||
cy.contains('Overbooked') | ||
}) | ||
cy.get('#calendar').find('li').should('have.length', premisesCapacity.capacity.length) | ||
cy.get('#calendar') | ||
.find('li') | ||
.each((day, index) => { | ||
const dayCapacity = premisesCapacity.capacity[index] | ||
const expectedClass = { '-1': 'govuk-tag--red', '0': 'govuk-tag--yellow', '1': '' }[ | ||
String(Math.sign(dayCapacity.availableBedCount - dayCapacity.bookingCount)) | ||
] | ||
cy.wrap(day).within(() => { | ||
cy.contains(`${dayCapacity.bookingCount} booked`) | ||
cy.contains(`${dayCapacity.availableBedCount - dayCapacity.bookingCount} available`) | ||
cy.contains(DateFormats.isoDateToUIDate(dayCapacity.date, { format: 'longNoYear' })) | ||
}) | ||
if (expectedClass) cy.wrap(day).should('have.class', expectedClass) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
import { addDays } from 'date-fns' | ||
import { | ||
cas1PremiseCapacityFactory, | ||
cas1PremisesSummaryFactory, | ||
cas1SpaceBookingSummaryFactory, | ||
staffMemberFactory, | ||
} from '../../../server/testutils/factories' | ||
|
||
import { OccupancyViewPage, PremisesShowPage } from '../../pages/manage' | ||
|
||
import { signIn } from '../signIn' | ||
import { DateFormats } from '../../../server/utils/dateUtils' | ||
import Page from '../../pages/page' | ||
|
||
context('Premises occupancy', () => { | ||
describe('show', () => { | ||
const startDateObj = new Date() | ||
const endDateObj = new Date(startDateObj) | ||
endDateObj.setDate(endDateObj.getDate() + 84) | ||
|
||
const startDate = DateFormats.dateObjToIsoDate(startDateObj) | ||
const endDate = DateFormats.dateObjToIsoDate(endDateObj) | ||
const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate, endDate }) | ||
|
||
const premises = premisesCapacity.premise | ||
const placements = cas1SpaceBookingSummaryFactory.buildList(30) | ||
const keyworkers = staffMemberFactory.keyworker().buildList(5) | ||
|
||
beforeEach(() => { | ||
cy.task('reset') | ||
|
||
// Given there is a premises in the database | ||
cy.task('stubSinglePremises', premises) | ||
cy.task('stubPremisesStaffMembers', { premisesId: premises.id, staffMembers: keyworkers }) | ||
|
||
// And it has a list of upcoming placements | ||
cy.task('stubSpaceBookingSummaryList', { premisesId: premises.id, placements, residency: 'upcoming' }) | ||
cy.task('stubPremiseCapacity', { premisesId: premises.id, startDate, endDate, premiseCapacity: premisesCapacity }) | ||
}) | ||
|
||
describe('with premises view permission', () => { | ||
beforeEach(() => { | ||
// Given I am logged in as a future manager with premises_view permission | ||
signIn(['future_manager'], ['cas1_premises_view']) | ||
}) | ||
|
||
it('should show the next 12 weeks if navigated from premises page', () => { | ||
// When I visit premises details page | ||
const page = PremisesShowPage.visit(premises) | ||
|
||
// And I click the action link view spaces | ||
page.clickAction('View spaces') | ||
// Then I should navigate to the occupancy view | ||
const occPage = Page.verifyOnPage(OccupancyViewPage, `View spaces in ${premises.name}`) | ||
occPage.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) | ||
occPage.shouldShowCalendar(premisesCapacity) | ||
}) | ||
|
||
it('should allow the user to change the calendar duration', () => { | ||
const endDate26 = DateFormats.dateObjToIsoDate(addDays(startDateObj, 7 * 26)) | ||
cy.task('stubPremiseCapacity', { | ||
premisesId: premises.id, | ||
startDate, | ||
endDate: endDate26, | ||
premiseCapacity: premisesCapacity, | ||
}) | ||
// When I visit the occupancy view page | ||
const page = OccupancyViewPage.visit(premises) | ||
// Then I should be shown the default period | ||
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) | ||
// When I select a different duration | ||
page.getSelectInputByIdAndSelectAnEntry('durationDays', '26 weeks') | ||
// and click submit | ||
page.clickSubmit() | ||
// Then the duration should change | ||
page.shouldShowCalendarHeading(startDate, 26 * 7) | ||
// and the new duration should be selected | ||
page.shouldHaveSelectText('durationDays', '26 weeks') | ||
}) | ||
|
||
it('should allow the user to change the start date', () => { | ||
const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5)) | ||
const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7)) | ||
|
||
cy.task('stubPremiseCapacity', { | ||
premisesId: premises.id, | ||
startDate: newStartDate, | ||
endDate: newEndDate, | ||
premiseCapacity: premisesCapacity, | ||
}) | ||
// When I visit the occupancy view page | ||
const page = OccupancyViewPage.visit(premises) | ||
// Then I should be shown the default period | ||
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) | ||
// When I select a different start date | ||
page.clearAndCompleteDateInputs('startDate', newStartDate) | ||
// and click submit | ||
page.clickSubmit() | ||
// Then the start date should change | ||
page.shouldShowCalendarHeading(newStartDate, 12 * 7) | ||
// and the new duration should be selected | ||
page.shouldHaveSelectText('durationDays', '12 weeks') | ||
// and the start date should be populated | ||
page.dateInputsShouldContainDate('startDate', newStartDate) | ||
}) | ||
|
||
it('should validate the start date', () => { | ||
const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5)) | ||
const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7)) | ||
const badStartDate = '2023-02-29' | ||
|
||
cy.task('stubPremiseCapacity', { | ||
premisesId: premises.id, | ||
startDate: newStartDate, | ||
endDate: newEndDate, | ||
premiseCapacity: premisesCapacity, | ||
}) | ||
// When I visit the occupancy view page | ||
const page = OccupancyViewPage.visit(premises) | ||
// Then I should be shown the default period | ||
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) | ||
// When I select a bad start date and submit | ||
page.clearAndCompleteDateInputs('startDate', badStartDate) | ||
page.clickSubmit() | ||
// Then an error should be shown | ||
page.shouldShowErrorMessagesForFields(['startDate'], { | ||
startDate: 'Enter a valid date', | ||
}) | ||
// And the bad date I entered should be populated | ||
page.dateInputsShouldContainDate('startDate', badStartDate) | ||
// And the calendar should be blank | ||
cy.get('.calendar').should('not.exist') | ||
// When I select a different start date and click submit | ||
page.clearAndCompleteDateInputs('startDate', newStartDate) | ||
page.clickSubmit() | ||
// Then the new start date should be shown | ||
page.shouldHaveSelectText('durationDays', '12 weeks') | ||
page.dateInputsShouldContainDate('startDate', newStartDate) | ||
}) | ||
|
||
it('should not be available in the premises actions menu if the premises does not support space bookings', () => { | ||
// Given that I am looking at a premises that does not support space bookings | ||
const nonSpaceBookingPremises = cas1PremisesSummaryFactory.build({ supportsSpaceBookings: false }) | ||
cy.task('stubSinglePremises', nonSpaceBookingPremises) | ||
// When I visit premises details page | ||
const page = PremisesShowPage.visit(nonSpaceBookingPremises) | ||
// Then the view spaces action should not be shown | ||
page.actionShouldNotExist('View spaces') | ||
}) | ||
}) | ||
describe('Without premises view permission', () => { | ||
it('should not be availble if the user lacks premises_view permission', () => { | ||
// Given I am logged in as a future manager without premises_view permission | ||
signIn(['future_manager']) | ||
// When I navigate to the view premises occupancy page | ||
// Then I should see an error | ||
OccupancyViewPage.visitUnauthorised(premises) | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
server/controllers/manage/premises/apOccupancyViewController.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import type { Cas1PremisesSummary } from '@approved-premises/api' | ||
import type { NextFunction, Request, Response } from 'express' | ||
import { DeepMocked, createMock } from '@golevelup/ts-jest' | ||
|
||
import { PremisesService } from 'server/services' | ||
import ApOccupancyViewController from './apOccupancyViewController' | ||
|
||
import { cas1PremiseCapacityFactory, cas1PremisesSummaryFactory } from '../../../testutils/factories' | ||
|
||
import paths from '../../../paths/manage' | ||
import { occupancyCalendar } from '../../../utils/premises/occupancy' | ||
import { DateFormats } from '../../../utils/dateUtils' | ||
|
||
describe('AP occupancyViewController', () => { | ||
const token = 'TEST_TOKEN' | ||
const premisesId = 'some-uuid' | ||
|
||
let request: DeepMocked<Request> | ||
let response: DeepMocked<Response> = createMock<Response>({}) | ||
const next: DeepMocked<NextFunction> = createMock<NextFunction>({}) | ||
|
||
const premisesService = createMock<PremisesService>({}) | ||
const occupancyViewController = new ApOccupancyViewController(premisesService) | ||
|
||
beforeEach(() => { | ||
jest.resetAllMocks() | ||
request = createMock<Request>({ user: { token }, params: { premisesId }, flash: jest.fn() }) | ||
response = createMock<Response>({ locals: { user: { permissions: ['cas1_space_booking_list'] } } }) | ||
|
||
jest.useFakeTimers() | ||
jest.setSystemTime(new Date('2024-01-01')) | ||
}) | ||
|
||
describe('view', () => { | ||
const mockPremises = async (startDate: string = DateFormats.dateObjToIsoDate(new Date())) => { | ||
const premisesSummary: Cas1PremisesSummary = cas1PremisesSummaryFactory.build({ id: premisesId }) | ||
const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate }) | ||
premisesService.getCapacity.mockResolvedValue(premisesCapacity) | ||
premisesService.find.mockResolvedValue(premisesSummary) | ||
|
||
const requestHandler = occupancyViewController.view() | ||
await requestHandler(request, response, next) | ||
|
||
return { | ||
premisesSummary, | ||
premisesCapacity, | ||
} | ||
} | ||
|
||
it('should render the premises occupancy view with default date and duration', async () => { | ||
const startDate = '2024-01-01' | ||
const endDate = '2024-03-25' | ||
const { premisesSummary, premisesCapacity } = await mockPremises(startDate) | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
pageHeading: `View spaces in ${premisesSummary.name}`, | ||
calendarHeading: 'Showing 12 weeks from 1 Jan 2024', | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, startDate, endDate) | ||
}) | ||
|
||
it('should render the premises occupancy view with specified valid date and duration', async () => { | ||
request = createMock<Request>({ | ||
user: { token }, | ||
params: { premisesId }, | ||
flash: jest.fn(), | ||
query: { 'startDate-year': '2024', 'startDate-month': '06', 'startDate-day': '20', durationDays: '7' }, | ||
}) | ||
const { premisesSummary, premisesCapacity } = await mockPremises('2024-06-20') | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
calendarHeading: 'Showing 1 week from 20 Jun 2024', | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), | ||
errorSummary: [], | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, '2024-06-20', '2024-06-27') | ||
}) | ||
|
||
it('should render error if date is invalid', async () => { | ||
request = createMock<Request>({ | ||
user: { token }, | ||
params: { premisesId }, | ||
flash: jest.fn(), | ||
query: { 'startDate-year': '2023', 'startDate-month': '02', 'startDate-day': '29', durationDays: '7' }, | ||
}) | ||
const { premisesSummary } = await mockPremises('2024-06-20') | ||
|
||
expect(response.render).toHaveBeenCalledWith( | ||
'manage/premises/occupancy/view', | ||
expect.objectContaining({ | ||
premises: premisesSummary, | ||
backLink: paths.premises.show({ premisesId }), | ||
calendar: [], | ||
errorSummary: [{ text: 'Enter a valid date', href: '#startDate' }], | ||
}), | ||
) | ||
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) | ||
expect(premisesService.getCapacity).not.toHaveBeenCalled() | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.