Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

APS-1677 AP occupancy view #2264

Merged
merged 1 commit into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions integration_tests/pages/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NewDateChangePage from './booking/dateChanges/new'
import BookingShowPage from './booking/show'
import DepartureDateChangeConfirmationPage from './booking/dateChanges/confirmation'
import DepartureDateChangePage from './booking/dateChanges/create'
import OccupancyViewPage from './occupancyView'

export {
CancellationCreatePage,
Expand All @@ -22,4 +23,5 @@ export {
WithdrawConfirmPage,
UnableToMatchPage,
PremisesListPage,
OccupancyViewPage,
}
50 changes: 50 additions & 0 deletions integration_tests/pages/manage/occupancyView.ts
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)
})
}
}
5 changes: 5 additions & 0 deletions integration_tests/pages/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export default abstract class Page {
cy.get('[role="menuitem"]').contains(actionLabel).click()
}

actionShouldNotExist(actionLabel: string): void {
cy.get('.moj-button-menu > button').contains('Actions').click()
cy.get('[role="menuitem"]').contains(actionLabel).should('not.exist')
}

actionMenuShouldNotExist(): void {
cy.get('.moj-button-menu > button').should('not.exist')
}
Expand Down
161 changes: 161 additions & 0 deletions integration_tests/tests/manage/occupancyView.cy.ts
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', () => {
bobmeredith marked this conversation as resolved.
Show resolved Hide resolved
// 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')
})
bobmeredith marked this conversation as resolved.
Show resolved Hide resolved
})
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)
})
})
})
})
2 changes: 1 addition & 1 deletion integration_tests/tests/manage/premises.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ context('Premises', () => {
describe('without placement list view permission', () => {
beforeEach(() => {
// Given I am logged in as a user without placement list view permission
signIn(['future_manager'])
signIn(['future_manager'], ['cas1_premises_view'])
})

it('should not show the placements section', () => {
Expand Down
4 changes: 4 additions & 0 deletions server/controllers/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateChangesController from './dateChangesController'

import type { Services } from '../../services'
import PremisesController from './premises/premisesController'
import ApOccupancyViewController from './premises/apOccupancyViewController'
import PlacementController from './placementController'
import BedsController from './premises/bedsController'
import OutOfServiceBedsController from './outOfServiceBedsController'
Expand Down Expand Up @@ -45,6 +46,7 @@ export const controllers = (services: Services) => {
const nonArrivalsController = new NonArrivalsController(services.premisesService, services.placementService)
const keyworkerController = new KeyworkerController(services.premisesService, services.placementService)
const departuresController = new DeparturesController(services.premisesService, services.placementService)
const apOccupancyViewController = new ApOccupancyViewController(services.premisesService)

return {
premisesController,
Expand All @@ -60,6 +62,7 @@ export const controllers = (services: Services) => {
cancellationsController,
placementController,
keyworkerController,
apOccupancyViewController,
}
}

Expand All @@ -75,4 +78,5 @@ export {
UpdateOutOfServiceBedsController,
BookingsController,
BookingExtensionsController,
ApOccupancyViewController,
}
114 changes: 114 additions & 0 deletions server/controllers/manage/premises/apOccupancyViewController.test.ts
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()
})
})
})
Loading
Loading