diff --git a/database/firestore.rules b/database/firestore.rules index 19febe9eb..637763a5b 100644 --- a/database/firestore.rules +++ b/database/firestore.rules @@ -70,6 +70,9 @@ service cloud.firestore { match /reports/{reportId} { allow read: if userIsAuthenticated() && userIsJAC() && hasPermission('e1'); } + match /reports/sccSummary { // specific rule for sccSummary report + allow write: if userIsAuthenticated() && userIsJAC() && hasPermission('e3'); + } match /tasks/{taskId} { allow create, update: if userIsAuthenticated() && userIsJAC(); allow read: if userIsAuthenticated() && userIsJAC(); diff --git a/functions/actions/exercises/sccSummaryReport.js b/functions/actions/exercises/sccSummaryReport.js new file mode 100644 index 000000000..f1279b8d5 --- /dev/null +++ b/functions/actions/exercises/sccSummaryReport.js @@ -0,0 +1,237 @@ +import { getDocument, formatDate } from '../../shared/helpers.js'; +import htmlWriter from '../../shared/htmlWriter.js'; +import config from '../../shared/config.js'; +import initDrive from '../../shared/google-drive.js'; +import initExerciseHelper from '../../shared/exerciseHelper.js'; + +const drive = initDrive(); + +export default (firebase, db) => { + const { SELECTION_CATEGORIES, applicationCounts, shortlistingMethods, formatSelectionDays } = initExerciseHelper(config); + const { APPLICATION_STATUS } = config; + + return { + generateSccSummaryReport, + exportSccSummaryReport, + }; + + async function generateSccSummaryReport(exerciseId) { + const exercise = await getDocument(db.collection('exercises').doc(exerciseId)); + const sccSummaryReport = await db.collection('exercises').doc(exerciseId).collection('reports').doc('sccSummary').get(); + const reportData = sccSummaryReport.exists ? sccSummaryReport.data() : {}; + + // set the report-specific fields from the report document + const numberOfCandidatesProposedForRecommendation = reportData.numberOfCandidatesProposedForRecommendation || ''; + const shortlistingDates = reportData.shortlistingDates || ''; + const statutoryConsultees = reportData.statutoryConsultees || ''; + const vr = reportData.vr || ''; + const dateS94ListCreated = reportData.dateS94ListCreated || ''; + const candidatesRemainingOnS94List = reportData.candidatesRemainingOnS94List || ''; + const vacanciesByJurisdictionChamber = reportData.vacanciesByJurisdictionChamber || ''; + const characterChecksUndertaken = reportData.characterChecksUndertaken || ''; + const numberOfACandidates = reportData.numberOfACandidates || ''; + const characterIssues = reportData.characterIssues || ''; + const mattersRequiringADecision = reportData.mattersRequiringADecision || ''; + const previouslyDeclaredWithinGuidance = reportData.previouslyDeclaredWithinGuidance || ''; + const highScoringDCandidates = reportData.highScoringDCandidates || ''; + + // set the report-specific fields from the exercise document + let numberOfVacancies = []; + if (exercise.immediateStart > 0) { + numberOfVacancies.push(`${exercise.immediateStart} Immediate start (S87)`); + } + if (exercise.futureStart > 0) { + numberOfVacancies.push(`${exercise.futureStart} Future start (S94)`); + } + + const locationDetails = exercise.location; + const launch = formatDate(exercise.applicationOpenDate); + const closed = formatDate(exercise.applicationCloseDate); + + const applicationCountValues = applicationCounts(exercise); + const numberOfApplications = applicationCountValues.applied || 0; + const numberOfWithdrawals = applicationCountValues.withdrawn || 0; + + let numberOfRemovedOnEligibilityOrASC = 0; + let numberOfShortlisted = 0; + + if (exercise._applicationRecords.status) { + numberOfRemovedOnEligibilityOrASC = + (exercise._applicationRecords.status[APPLICATION_STATUS.REJECTED_INELIGIBLE_STATUTORY] || 0) + + (exercise._applicationRecords.status[APPLICATION_STATUS.REJECTED_INELIGIBLE_ADDITIONAL] || 0); + numberOfShortlisted = exercise._applicationRecords.status[APPLICATION_STATUS.SHORTLISTING_PASSED] || 0; + } + + const shortlistingMethod = shortlistingMethods(exercise); + const selectionCategories = exercise.selectionCategories || []; + const selectionDayTools = Object.values(SELECTION_CATEGORIES).filter((c) => selectionCategories.includes(c.value)).map((c) => c.description); + const datesOfSelectionDays = exercise.selectionDays || []; + + // construct the report document + const report = { + numberOfVacancies: numberOfVacancies.join(', '), + locationDetails, + launch, + closed, + numberOfApplications, + numberOfWithdrawals, + numberOfRemovedOnEligibilityOrASC, + numberOfShortlisted, + selectionDayTools: selectionDayTools.join(', '), + datesOfSelectionDays, + numberOfACandidates, + characterChecksUndertaken, + characterIssues, + mattersRequiringADecision, + previouslyDeclaredWithinGuidance, + highScoringDCandidates, + numberOfCandidatesProposedForRecommendation, + vacanciesByJurisdictionChamber, + vr, + shortlistingDates, + statutoryConsultees: statutoryConsultees.length > 0 ? statutoryConsultees : 'Insert name(s) or Consultation waived', + dateS94ListCreated, + candidatesRemainingOnS94List, + shortlistingMethod: shortlistingMethod.join(', '), + }; + + // store the report document in the database + await db.collection('exercises').doc(exerciseId).collection('reports').doc('sccSummary').set(report, { merge: true }); + + // return the report in the HTTP response + return report; + } + + /** + * exportSccSummaryReport + * Generates an export of the SCC Summary report for the selected exercise + * @param {*} `exerciseId` (required) ID of exercise to include in the export + */ + async function exportSccSummaryReport(exerciseId) { + + const exercise = await getDocument(db.collection('exercises').doc(exerciseId)); + const sccSummaryReport = await generateSccSummaryReport(exerciseId); + + // get drive service + await drive.login(); + + // get settings and apply them + const settings = await getDocument(db.collection('settings').doc('services')); + drive.setDriveId(settings.google.driveId); + + // generate a filename for the document we are going to create ex. JAC00787_SCC Summary + const now = new Date(); + // const timestamp = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString(); + const filename = `${exercise.referenceNumber}_SCC Summary` ; + + // make sure a destination folder exists to create the file in + const folderName = 'SCC Summary Export'; + const folders = await drive.listFolders(); + let folderId = 0; + folders.forEach((v, i) => { + if (v.name === folderName) { + folderId = v.id; + } + }); + if (folderId === 0) { // folder doesn't exist so create it + folderId = await drive.createFolder(folderName); + } + + // Create SCC Summary document + const fileId = await drive.createFile(filename, { + folderId: folderId, + sourceType: drive.MIME_TYPE.HTML, + sourceContent: getHtmlSccSummaryReport(sccSummaryReport), + destinationType: drive.MIME_TYPE.DOCUMENT, + }); + + if (fileId) { + return await drive.exportFile(fileId, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + } + + return false; + } + + function getHtmlSccSummaryReport(sccSummaryReport) { + const { + numberOfVacancies, + locationDetails, + launch, + closed, + numberOfApplications, + numberOfWithdrawals, + numberOfRemovedOnEligibilityOrASC, + numberOfShortlisted, + selectionDayTools, + datesOfSelectionDays, + numberOfACandidates, + characterChecksUndertaken, + characterIssues, + mattersRequiringADecision, + previouslyDeclaredWithinGuidance, + highScoringDCandidates, + numberOfCandidatesProposedForRecommendation, + vacanciesByJurisdictionChamber, + vr, + shortlistingDates, + statutoryConsultees, + dateS94ListCreated, + candidatesRemainingOnS94List, + shortlistingMethod, + } = sccSummaryReport; + + const rows = [ + { header: 'Number of vacancies', content: numberOfVacancies }, + { header: 'Number of candidates proposed for Recommendation', content: numberOfCandidatesProposedForRecommendation }, + { header: 'Vacancies by jurisdiction/chamber', content: vacanciesByJurisdictionChamber }, + { header: 'Location details', content: locationDetails }, + { header: 'VR (includes details of SPTW)', content: `Annex ${vr}` }, + { header: '', content: '' }, + { header: 'Launch', content: formatDate(launch, 'DD/MM/YYYY') }, + { header: 'Closed', content: formatDate(closed, 'DD/MM/YYYY') }, + { header: 'Number of applications', content: numberOfApplications }, + { header: 'Number of withdrawals', content: numberOfWithdrawals }, + { header: 'Number removed on eligibility/ASC', content: numberOfRemovedOnEligibilityOrASC }, + { header: '', content: '' }, + { header: 'Number shortlisted', content: numberOfShortlisted }, + { header: 'Method of shortlisting', content: shortlistingMethod }, + { header: 'Dates of shortlisting', content: shortlistingDates }, + { header: '', content: '' }, + { header: 'Statutory consultee(s)', content: statutoryConsultees }, + { header: '', content: '' }, + { header: 'Selection day tools', content: selectionDayTools }, + { header: 'Number of A-C candidates', content: numberOfACandidates }, + { header: 'Dates of selection days', content: formatSelectionDays({ selectionDays: datesOfSelectionDays}).join('
') }, + { header: '', content: '' }, + { header: 'Date s.94 list Created', content: dateS94ListCreated }, + { header: 'Candidates Remaining on s.94 list', content: candidatesRemainingOnS94List }, + { header: '', content: '' }, + { header: 'Character Checks undertaken', content: characterChecksUndertaken }, + { header: 'Character issues', content: characterIssues }, + { header: 'Matters requiring a decision', content: `Annex ${mattersRequiringADecision}` }, + { header: 'Previously declared, within guidance', content: `Annex ${previouslyDeclaredWithinGuidance}` }, + { header: 'High scoring D candidates', content: `Annex ${highScoringDCandidates}` }, + ]; + + let writer = new htmlWriter(); + writer.addHeading('SCC Summary', 'center'); + writer.addRaw(` + + ` + ); + for (const row of rows) { + writer.addRaw( + ` + + + ` + ); + } + writer.addRaw( + ` +
${row.header}${row.content}
`); + + return writer.toString(); + } +}; + diff --git a/functions/callableFunctions/exportSccSummaryReport.js b/functions/callableFunctions/exportSccSummaryReport.js new file mode 100644 index 000000000..90c2bf144 --- /dev/null +++ b/functions/callableFunctions/exportSccSummaryReport.js @@ -0,0 +1,50 @@ +import functions from 'firebase-functions'; +import { firebase, db, auth } from '../shared/admin.js'; +import initExportSccSummaryReport from '../actions/exercises/sccSummaryReport.js'; +import { getDocument } from '../shared/helpers.js'; +import initLogEvent from '../actions/logs/logEvent.js'; +import initServiceSettings from '../shared/serviceSettings.js'; +import { PERMISSIONS, hasPermissions } from '../shared/permissions.js'; + +const { exportSccSummaryReport } = initExportSccSummaryReport(firebase, db); +const { logEvent } = initLogEvent(firebase, db, auth); +const { checkFunctionEnabled } = initServiceSettings(db); + +export default functions.region('europe-west2').https.onCall(async (data, context) => { + await checkFunctionEnabled(); + + if (!context.auth) { + throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.'); + } + + hasPermissions(context.auth.token.rp, [ + PERMISSIONS.exercises.permissions.canReadExercises.value, + PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value, + PERMISSIONS.applications.permissions.canReadApplications.value, + ]); + + if (!(typeof data.exerciseId === 'string') || data.exerciseId.length === 0) { + throw new functions.https.HttpsError('invalid-argument', 'Please specify an "exerciseId"'); + } + + if (!(typeof data.format === 'string') || data.format.length === 0) { + throw new functions.https.HttpsError('invalid-argument', 'Please specify a data format (excel or googledoc)'); + } + + const exercise = await getDocument(db.collection('exercises').doc(data.exerciseId)); + + if (!exercise) { + throw new functions.https.HttpsError('not-found', 'Excercise not found'); + } + + let details = { + exerciseId: exercise.id, + exerciseRef: exercise.referenceNumber, + }; + let user = { + id: context.auth.token.user_id, + name: context.auth.token.name, + }; + await logEvent('info', 'SCC Summary report exported (to ' + data.format + ')', details, user); + return await exportSccSummaryReport(data.exerciseId, data.format, (data.status || null)); +}); diff --git a/functions/callableFunctions/generateSccSummaryReport.js b/functions/callableFunctions/generateSccSummaryReport.js new file mode 100644 index 000000000..1c7bb51a2 --- /dev/null +++ b/functions/callableFunctions/generateSccSummaryReport.js @@ -0,0 +1,56 @@ +import functions from 'firebase-functions'; +import { firebase, db, auth } from '../shared/admin.js'; +import initGenerateSccSummaryReport from '../actions/exercises/sccSummaryReport.js'; +import { getDocument } from '../shared/helpers.js'; +import initLogEvent from '../actions/logs/logEvent.js'; +import initServiceSettings from '../shared/serviceSettings.js'; +import { PERMISSIONS, hasPermissions } from '../shared/permissions.js'; + +const { generateSccSummaryReport } = initGenerateSccSummaryReport(firebase, db); +const { logEvent } = initLogEvent(firebase, db, auth); +const { checkFunctionEnabled } = initServiceSettings(db); + +const runtimeOptions = { + timeoutSeconds: 120, + memory: '512MB', +}; + +export default functions.runWith(runtimeOptions).region('europe-west2').https.onCall(async (data, context) => { + await checkFunctionEnabled(); + + // authenticate the request + if (!context.auth) { + throw new functions.https.HttpsError('failed-precondition', 'The function must be called while authenticated.'); + } + + hasPermissions(context.auth.token.rp, [ + PERMISSIONS.exercises.permissions.canReadExercises.value, + PERMISSIONS.applications.permissions.canReadApplications.value, + PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value, + ]); + + // validate input parameters + if (!(typeof data.exerciseId === 'string') || data.exerciseId.length === 0) { + throw new functions.https.HttpsError('invalid-argument', 'Please specify an "exerciseId"'); + } + + // generate the report + const result = await generateSccSummaryReport(data.exerciseId); + + // log an event + const exercise = await getDocument(db.collection('exercises').doc(data.exerciseId)); + let details = { + exerciseId: exercise.id, + exerciseRef: exercise.referenceNumber, + }; + let user = { + id: context.auth.token.user_id, + name: context.auth.token.name, + }; + await logEvent('info', 'SCC Summary report generated', details, user); + + // return the report to the caller + return { + result: result, + }; +}); diff --git a/functions/index.js b/functions/index.js index 09c9dfa2d..6ee3f1a5c 100644 --- a/functions/index.js +++ b/functions/index.js @@ -97,6 +97,8 @@ import getLatestReleases from './callableFunctions/getLatestReleases.js'; import verifyFileChecksum from './callableFunctions/verifyFileChecksum.js'; import sendSmsVerificationCode from './callableFunctions/sendSmsVerificationCode.js'; import verifySmsVerificationCode from './callableFunctions/verifySmsVerificationCode.js'; +import generateSccSummaryReport from './callableFunctions/generateSccSummaryReport.js'; +import exportSccSummaryReport from './callableFunctions/exportSccSummaryReport.js'; import getMultipleApplicationData from './callableFunctions/getMultipleApplicationData.js'; // Callable - QTs v2 @@ -208,6 +210,8 @@ export { sendSmsVerificationCode, verifySmsVerificationCode, getMultipleApplicationData, + generateSccSummaryReport, + exportSccSummaryReport, // Callable - QTs v2 listQualifyingTests, diff --git a/functions/shared/exerciseHelper.js b/functions/shared/exerciseHelper.js index c9d92876e..daf3115ae 100644 --- a/functions/shared/exerciseHelper.js +++ b/functions/shared/exerciseHelper.js @@ -1,9 +1,45 @@ +import { formatDate } from './helpers.js'; +import lookup from './converters/lookup.js'; +import _ from 'lodash'; export default (config) => { const EXERCISE_STAGE = config.EXERCISE_STAGE; + + const SELECTION_CATEGORIES = { + LEADERSHIP: { + value: 'leadership', + label: 'Leadership', + description: 'Strategic Leadership Questions', + }, + ROLEPLAY: { + value: 'roleplay', + label: 'Roleplay', + description: 'Role Play', + }, + SITUATIONAL: { + value: 'situational', + label: 'Situational', + description: 'Situational Questions', + }, + INTERVIEW: { + value: 'interview', + label: 'Interview', + description: 'Interview', + }, + OVERALL: { + value: 'overall', + label: 'Overall', + description: 'Overall', + }, + }; + return { + SELECTION_CATEGORIES, availableStages, isStagedExercise, canApplyFullApplicationSubmitted, + applicationCounts, + shortlistingMethods, + formatSelectionDays, }; function availableStages(exercise) { @@ -43,5 +79,64 @@ export default (config) => { return applyFullApplicationSubmitted; } - + + function applicationCounts(exercise) { + const applicationCounts = exercise && exercise._applications ? { ...exercise._applications } : {}; + // include withdrawn applications in applied count + if (applicationCounts && applicationCounts.applied) { + applicationCounts.applied = applicationCounts.applied + (applicationCounts.withdrawn || 0); + } + return applicationCounts; + } + + function shortlistingMethods(exercise) { + const methods = exercise.shortlistingMethods; + if (!(methods instanceof Array)) { + return []; + } + const list = methods.filter(value => (value !== 'other')); + list.sort(); + + if (methods.includes('other')) { + exercise.otherShortlistingMethod.forEach((method) => { + return list.push(method.name); + }); + } + + const lookupList = list.map((method) => { + return lookup(method); + }); + + return lookupList; + } + + function formatSelectionDays(exercise) { + if (!exercise || !exercise.selectionDays) { + return []; + } + + let dateStrings = []; + + for (const selectionDay of exercise.selectionDays) { + let dateString = ''; + const selectionDayStart = formatDate(selectionDay.selectionDayStart, 'DD/MM/YYYY'); + const selectionDayEnd = formatDate(selectionDay.selectionDayEnd, 'DD/MM/YYYY'); + + if (!selectionDayStart || !selectionDayEnd) { + dateString = ''; + } else if (selectionDayStart !== selectionDayEnd) { + dateString = `${selectionDayStart} to ${selectionDayEnd}`; + } else { + dateString = `${selectionDayStart}`; + } + if (!_.isEmpty(dateString)) { + if (selectionDay.selectionDayLocation) { + dateString = `${selectionDay.selectionDayLocation} - ${dateString}`; + } + dateStrings.push(dateString); + } + } + + return dateStrings; + } }; diff --git a/test/functions/exportSccSummaryReport.spec.js b/test/functions/exportSccSummaryReport.spec.js new file mode 100644 index 000000000..92718bfc7 --- /dev/null +++ b/test/functions/exportSccSummaryReport.spec.js @@ -0,0 +1,34 @@ +import assert from 'assert'; +import { firebaseFunctionsTest, generateMockContext } from './helpers.js'; +import { PERMISSIONS } from '../../functions/shared/permissions.js'; +import exportSccSummaryReport from '../../functions/callableFunctions/exportSccSummaryReport.js'; + +const { wrap } = firebaseFunctionsTest; + +describe('exportSccSummaryReport', () => { + context('Permission', () => { + it ('has no permission', async () => { + const wrapped = wrap(exportSccSummaryReport); + try { + await wrapped({}, generateMockContext()); + } catch (e) { + assert.equal(e.code, 'permission-denied'); + } + }); + it ('has permission', async () => { + const wrapped = wrap(exportSccSummaryReport); + const context = generateMockContext({ + permissions: [ + PERMISSIONS.exercises.permissions.canReadExercises.value, + PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value, + PERMISSIONS.applications.permissions.canReadApplications.value, + ], + }); + try { + await wrapped({}, context); + } catch (e) { + assert.equal(e.code, 'invalid-argument'); + } + }); + }); +}); diff --git a/test/functions/generateSccSummaryReport.spec.js b/test/functions/generateSccSummaryReport.spec.js new file mode 100644 index 000000000..996f4fbad --- /dev/null +++ b/test/functions/generateSccSummaryReport.spec.js @@ -0,0 +1,34 @@ +import assert from 'assert'; +import { firebaseFunctionsTest, generateMockContext } from './helpers.js'; +import { PERMISSIONS } from '../../functions/shared/permissions.js'; +import generateSccSummaryReport from '../../functions/callableFunctions/generateSccSummaryReport.js'; + +const { wrap } = firebaseFunctionsTest; + +describe('generateSccSummaryReport', () => { + context('Permission', () => { + it ('has no permission', async () => { + const wrapped = wrap(generateSccSummaryReport); + try { + await wrapped({}, generateMockContext()); + } catch (e) { + assert.equal(e.code, 'permission-denied'); + } + }); + it ('has permission', async () => { + const wrapped = wrap(generateSccSummaryReport); + const context = generateMockContext({ + permissions: [ + PERMISSIONS.exercises.permissions.canReadExercises.value, + PERMISSIONS.applications.permissions.canReadApplications.value, + PERMISSIONS.applicationRecords.permissions.canReadApplicationRecords.value, + ], + }); + try { + await wrapped({}, context); + } catch (e) { + assert.equal(e.code, 'invalid-argument'); + } + }); + }); +}); diff --git a/test/rules/3.exercises.spec.js b/test/rules/3.exercises.spec.js index 0f5410bad..b64d30376 100644 --- a/test/rules/3.exercises.spec.js +++ b/test/rules/3.exercises.spec.js @@ -221,5 +221,69 @@ describe('Exercises', () => { }); }); + context('Write SCC summary reports', () => { + + it('prevent un-authenticated user from writing SCC summary report', async () => { + const db = await setup({}, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('prevent authenticated user from writing SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@email.com', email_verified: false }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('prevent authenticated user with verified email from writing SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@email.com', email_verified: true }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + it('prevent authenticated user with un-verified JAC email from writing SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.digital', email_verified: false }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('prevent authenticated user with verified @judicialappointments.digital email to write SCC summary report with no data', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.digital', email_verified: true }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('prevent authenticated user with verified @judicialappointments.digital email but without permission from writing SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.digital', email_verified: true }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('prevent authenticated user with verified @judicialappointments.gov.uk email but without permission from writing SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.gov.uk', email_verified: true }, {'exercises/ex1': {}}); + await assertFails( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('allow authenticated user with verified @judicialappointments.digital email and permission to write SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.digital', email_verified: true, rp: [PERMISSIONS.exercises.permissions.canUpdateExercises.value] }, {'exercises/ex1': {}}); + await assertSucceeds( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + + it('allow authenticated user with verified @judicialappointments.gov.uk email and permission to write SCC summary report', async () => { + const db = await setup({ uid: 'user1', email: 'user@judicialappointments.gov.uk', email_verified: true, rp: [PERMISSIONS.exercises.permissions.canUpdateExercises.value] }, {'exercises/ex1': {}}); + await assertSucceeds( + db.collection('exercises').doc('ex1').collection('reports').doc('sccSummary').set({}) + ); + }); + }); }); diff --git a/test/shared/exerciseHelper.spec.js b/test/shared/exerciseHelper.spec.js new file mode 100644 index 000000000..0c1e8d23c --- /dev/null +++ b/test/shared/exerciseHelper.spec.js @@ -0,0 +1,139 @@ +import initExerciseHelper from '../../functions/shared/exerciseHelper.js'; + +const config = { + EXERCISE_STAGE: { + REVIEW: 'review', + SHORTLISTED: 'shortlisted', + SELECTED: 'selected', + RECOMMENDED: 'recommended', + HANDOVER: 'handover', + }, +}; + +const exerciseHelper = initExerciseHelper(config); + +describe('shortlistingMethods()', () => { + it('returns empty array when shortlistingMethods is not an array', () => { + const exercise = { + shortlistingMethods: 'not-an-array', + }; + const result = exerciseHelper.shortlistingMethods(exercise); + expect(result).toEqual([]); + }); + + it('returns empty array when shortlistingMethods is undefined', () => { + const exercise = {}; + const result = exerciseHelper.shortlistingMethods(exercise); + expect(result).toEqual([]); + }); + + it('returns sorted list of methods excluding "other"', () => { + const exercise = { + shortlistingMethods: ['other'], + otherShortlistingMethod: [{ name: 'Custom method' }], + }; + const result = exerciseHelper.shortlistingMethods(exercise); + expect(result).toEqual(['Custom method']); + }); + + + + it('handles multiple methods', () => { + const exercise = { + shortlistingMethods: ['situational-judgement-qualifying-test', 'scenario-test-qualifying-test'], + otherShortlistingMethod: [], + }; + const result = exerciseHelper.shortlistingMethods(exercise); + expect(result).toEqual(['Scenario test qualifying test (QT)', 'Situational judgement qualifying test (QT)']); + }); +}); + +describe('formatSelectionDays()', () => { + it('returns empty array when exercise is undefined', () => { + const result = exerciseHelper.formatSelectionDays(undefined); + expect(result).toEqual([]); + }); + + it('returns empty array when selectionDays is undefined', () => { + const exercise = {}; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual([]); + }); + + it('formats single day selection with location', () => { + const exercise = { + selectionDays: [{ + selectionDayStart: new Date('2024-01-15'), + selectionDayEnd: new Date('2024-01-15'), + selectionDayLocation: 'London', + }], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual(['London - 15/1/2024']); + }); + + it('formats date range with location', () => { + const exercise = { + selectionDays: [{ + selectionDayStart: new Date('2024-01-15'), + selectionDayEnd: new Date('2024-01-17'), + selectionDayLocation: 'Manchester', + }], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual(['Manchester - 15/1/2024 to 17/1/2024']); + }); + + it('formats multiple selection days', () => { + const exercise = { + selectionDays: [ + { + selectionDayStart: new Date('2024-01-15'), + selectionDayEnd: new Date('2024-01-15'), + selectionDayLocation: 'London', + }, + { + selectionDayStart: new Date('2024-02-20'), + selectionDayEnd: new Date('2024-02-22'), + selectionDayLocation: 'Manchester', + }, + ], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual([ + 'London - 15/1/2024', + 'Manchester - 20/2/2024 to 22/2/2024', + ]); + }); + + it('handles selection day without location', () => { + const exercise = { + selectionDays: [{ + selectionDayStart: new Date('2024-01-15'), + selectionDayEnd: new Date('2024-01-15'), + }], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual(['15/1/2024']); + }); + + it('handles invalid dates', () => { + const exercise = { + selectionDays: [{ + selectionDayStart: null, + selectionDayEnd: new Date('2024-01-15'), + selectionDayLocation: 'London', + }], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual([]); + }); + + it('handles empty selectionDays array', () => { + const exercise = { + selectionDays: [], + }; + const result = exerciseHelper.formatSelectionDays(exercise); + expect(result).toEqual([]); + }); +});