diff --git a/backend/migrations/20241008122413-createTableForQualificationsCertificates.js b/backend/migrations/20241008122413-createTableForQualificationsCertificates.js new file mode 100644 index 0000000000..30f1438e81 --- /dev/null +++ b/backend/migrations/20241008122413-createTableForQualificationsCertificates.js @@ -0,0 +1,71 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.createTable( + 'QualificationCertificates', + { + ID: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + UID: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()'), + allowNull: false, + unique: true, + }, + WorkerQualificationsFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'WorkerQualifications', + schema: 'cqc', + }, + key: 'ID', + }, + }, + WorkerFK: { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'Worker', + schema: 'cqc', + }, + key: 'ID', + }, + }, + FileName: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + UploadDate: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + Key: { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + }, + { schema: 'cqc' }, + ); + }, + + async down(queryInterface) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + return queryInterface.dropTable({ + tableName: 'QualificationCertificates', + schema: 'cqc', + }); + }, +}; diff --git a/backend/server/models/classes/helpers/bulkUploadQualificationHelper.js b/backend/server/models/classes/helpers/bulkUploadQualificationHelper.js new file mode 100644 index 0000000000..06da460726 --- /dev/null +++ b/backend/server/models/classes/helpers/bulkUploadQualificationHelper.js @@ -0,0 +1,84 @@ +const { compact } = require('lodash'); + +const models = require('../../index'); +const WorkerCertificateService = require('../../../routes/establishments/workerCertificate/workerCertificateService'); + +class BulkUploadQualificationHelper { + constructor({ workerId, workerUid, establishmentId, savedBy, externalTransaction }) { + this.workerId = workerId; + this.workerUid = workerUid; + this.establishmentId = establishmentId; + this.savedBy = savedBy; + this.bulkUploaded = true; + this.externalTransaction = externalTransaction; + this.qualificationCertificateService = WorkerCertificateService.initialiseQualifications(); + } + + async processQualificationsEntities(qualificationsEntities) { + const promisesToReturn = []; + + const allQualificationRecords = await models.workerQualifications.findAll({ + where: { + workerFk: this.workerId, + }, + }); + + for (const bulkUploadEntity of qualificationsEntities) { + const currentQualificationId = bulkUploadEntity?._qualification?.id; + const existingQualification = allQualificationRecords.find( + (record) => record.qualificationFk === currentQualificationId, + ); + + if (existingQualification) { + promisesToReturn.push(this.updateQualification(existingQualification, bulkUploadEntity)); + } else { + promisesToReturn.push(this.createNewQualification(bulkUploadEntity)); + } + } + + const bulkUploadQualificationFks = compact(qualificationsEntities.map((entity) => entity?._qualification?.id)); + const qualificationsToDelete = allQualificationRecords.filter( + (qualification) => !bulkUploadQualificationFks.includes(qualification.qualificationFk), + ); + for (const qualification of qualificationsToDelete) { + promisesToReturn.push(this.deleteQualification(qualification)); + } + + return promisesToReturn; + } + + createNewQualification(entityFromBulkUpload) { + entityFromBulkUpload.workerId = this.workerId; + entityFromBulkUpload.workerUid = this.workerUid; + entityFromBulkUpload.establishmentId = this.establishmentId; + + return entityFromBulkUpload.save(this.savedBy, this.bulkUploaded, 0, this.externalTransaction); + } + + updateQualification(existingRecord, entityFromBulkUpload) { + const fieldsToUpdate = { + source: 'Bulk', + updatedBy: this.savedBy.toLowerCase(), + notes: entityFromBulkUpload.notes ?? existingRecord.notes, + year: entityFromBulkUpload.year ?? existingRecord.year, + }; + + existingRecord.set(fieldsToUpdate); + + return existingRecord.save({ transaction: this.externalTransaction }); + } + + async deleteQualification(existingRecord) { + const certificatesFound = await existingRecord.getQualificationCertificates(); + if (certificatesFound?.length) { + await this.qualificationCertificateService.deleteCertificatesWithTransaction( + certificatesFound, + this.externalTransaction, + ); + } + + return existingRecord.destroy({ transaction: this.externalTransaction }); + } +} + +module.exports = BulkUploadQualificationHelper; diff --git a/backend/server/models/classes/qualification.js b/backend/server/models/classes/qualification.js index 2c73d402f2..683d386644 100644 --- a/backend/server/models/classes/qualification.js +++ b/backend/server/models/classes/qualification.js @@ -56,6 +56,7 @@ class Qualification extends EntityValidator { this._qualification = null; this._year = null; this._notes = null; + this._qualificationCertificates = null; // lifecycle properties this._isNew = false; @@ -105,7 +106,7 @@ class Qualification extends EntityValidator { } get workerId() { - return this._workerUid; + return this._workerId; } get workerUid() { return this._workerUid; @@ -139,6 +140,11 @@ class Qualification extends EntityValidator { get created() { return this._created; } + + get qualificationCertificates() { + return this._qualificationCertificates; + } + get updated() { return this._updated; } @@ -156,9 +162,17 @@ class Qualification extends EntityValidator { if (this._notes === null) return null; return unescape(this._notes); } + + get qualificationCertificates() { + return this._qualificationCertificates; + } + set qualification(qualification) { this._qualification = qualification; } + set qualificationCertificates(qualificationCertificates) { + this._qualificationCertificates = qualificationCertificates; + } set year(year) { this._year = year; } @@ -171,6 +185,10 @@ class Qualification extends EntityValidator { } } + set qualificationCertificates(qualificationCertificates) { + this._qualificationCertificates = qualificationCertificates; + } + // used by save to initialise a new Qualification Record; returns true if having initialised this Qualification Record _initialise() { if (this._uid === null) { @@ -542,6 +560,15 @@ class Qualification extends EntityValidator { model: models.workerAvailableQualifications, as: 'qualification', }, + { + model: models.qualificationCertificates, + as: 'qualificationCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + order: [ + [models.qualificationCertificates, 'uploadDate', 'DESC'], + [models.qualificationCertificates, 'filename', 'ASC'], + ], + }, ], }; @@ -560,10 +587,12 @@ class Qualification extends EntityValidator { }; this._year = fetchResults.year; this._notes = fetchResults.notes !== null && fetchResults.notes.length === 0 ? null : fetchResults.notes; + this._qualificationCertificates = fetchResults.qualificationCertificates; this._created = fetchResults.created; this._updated = fetchResults.updated; this._updatedBy = fetchResults.updatedBy; + this._qualificationCertificates = fetchResults.qualificationCertificates; return true; } @@ -573,7 +602,7 @@ class Qualification extends EntityValidator { // typically errors when making changes to model or database schema! this._log(Qualification.LOG_ERROR, err); - throw new Error(`Failed to load Qualification record with uid (${this.uid})`); + throw new Error(`Failed to load Qualification record with uid (${uid})`); } } @@ -618,6 +647,7 @@ class Qualification extends EntityValidator { this._qualification = null; this._year = null; this._notes = null; + this._qualificationCertificates = null; this._created = null; this._updated = null; @@ -655,6 +685,11 @@ class Qualification extends EntityValidator { as: 'qualification', attributes: ['id', 'group', 'title', 'level'], }, + { + model: models.qualificationCertificates, + as: 'qualificationCertificates', + attributes: ['uid', 'filename', 'uploadDate'], + }, ], order: [ //['completed', 'DESC'], @@ -674,6 +709,13 @@ class Qualification extends EntityValidator { }, year: thisRecord.year != null ? thisRecord.year : undefined, notes: thisRecord.notes !== null && thisRecord.notes.length > 0 ? unescape(thisRecord.notes) : undefined, + qualificationCertificates: thisRecord.qualificationCertificates?.map((certificate) => { + return { + uid: certificate.uid, + filename: certificate.filename, + uploadDate: certificate.uploadDate?.toISOString(), + }; + }), created: thisRecord.created.toISOString(), updated: thisRecord.updated.toISOString(), updatedBy: thisRecord.updatedBy, @@ -712,6 +754,7 @@ class Qualification extends EntityValidator { qualification: this.qualification, year: this.year !== null ? this.year : undefined, notes: this._notes !== null ? this.notes : undefined, + qualificationCertificates: this.qualificationCertificates ?? [], }; return myDefaultJSON; diff --git a/backend/server/models/classes/worker.js b/backend/server/models/classes/worker.js index c20cde9d9d..ac2b9f167b 100644 --- a/backend/server/models/classes/worker.js +++ b/backend/server/models/classes/worker.js @@ -28,10 +28,13 @@ const JSON_DOCUMENT_TYPE = require('./worker/workerProperties').JSON_DOCUMENT; const SEQUELIZE_DOCUMENT_TYPE = require('./worker/workerProperties').SEQUELIZE_DOCUMENT; const TrainingCertificateRoute = require('../../routes/establishments/workerCertificate/trainingCertificate'); +const WorkerCertificateService = require('../../routes/establishments/workerCertificate/workerCertificateService'); // WDF Calculator const WdfCalculator = require('./wdfCalculator').WdfCalculator; +const BulkUploadQualificationHelper = require('./helpers/bulkUploadQualificationHelper'); + const STOP_VALIDATING_ON = ['UNCHECKED', 'DELETE', 'DELETED', 'NOCHANGE']; class Worker extends EntityValidator { @@ -473,7 +476,6 @@ class Worker extends EntityValidator { // and qualifications records this._qualificationsEntities = []; if (document.qualifications && Array.isArray(document.qualifications)) { - // console.log("WA DEBUG - document.qualifications: ", document.qualifications) document.qualifications.forEach((thisQualificationRecord) => { const newQualificationRecord = new Qualification(null, null); @@ -528,7 +530,7 @@ class Worker extends EntityValidator { } async saveAssociatedEntities(savedBy, bulkUploaded = false, externalTransaction) { - const newQualificationsPromises = []; + const qualificationChangePromises = []; const newTrainingPromises = []; try { @@ -551,29 +553,22 @@ class Worker extends EntityValidator { }); } - // there is no change audit on qualifications; simply delete all that is there and recreate - if (this._qualificationsEntities && this._qualificationsEntities.length > 0) { - // delete all existing training records for this worker - await models.workerQualifications.destroy({ - where: { - workerFk: this._id, - }, - transaction: externalTransaction, - }); - - // now create new training records - this._qualificationsEntities.forEach((currentQualificationRecord) => { - currentQualificationRecord.workerId = this._id; - currentQualificationRecord.workerUid = this._uid; - currentQualificationRecord.establishmentId = this._establishmentId; - newQualificationsPromises.push( - currentQualificationRecord.save(savedBy, bulkUploaded, 0, externalTransaction), - ); + if (bulkUploaded && ['NEW', 'UPDATE', 'CHGSUB'].includes(this.status)) { + const qualificationHelper = new BulkUploadQualificationHelper({ + workerId: this._id, + workerUid: this._uid, + establishmentId: this._establishmentId, + savedBy, + bulkUploaded, + externalTransaction, }); + const qualificationEntities = this._qualificationsEntities ? this._qualificationsEntities : []; + const promisesToPush = await qualificationHelper.processQualificationsEntities(qualificationEntities); + qualificationChangePromises.push(...promisesToPush); } await Promise.all(newTrainingPromises); - await Promise.all(newQualificationsPromises); + await Promise.all(qualificationChangePromises); } catch (err) { console.error('Worker::saveAssociatedEntities error: ', err); // rethrow error to ensure the transaction is rolled back @@ -1153,6 +1148,7 @@ class Worker extends EntityValidator { } await this.deleteAllTrainingCertificatesAssociatedWithWorker(thisTransaction); + await this.deleteAllQualificationCertificatesAssociatedWithWorker(thisTransaction); // always recalculate WDF - if not bulk upload (this._status) if (this._status === null) { @@ -1900,17 +1896,13 @@ class Worker extends EntityValidator { } async deleteAllTrainingCertificatesAssociatedWithWorker(transaction) { - const trainingCertificates = await models.trainingCertificates.getAllTrainingCertificateRecordsForWorker(this._id); - - if (!trainingCertificates.length) return; - - const trainingCertificateUids = trainingCertificates.map((cert) => cert.uid); - const filesToDeleteFromS3 = trainingCertificates.map((cert) => { - return { Key: cert.key }; - }); + const workerTrainingCertificateService = WorkerCertificateService.initialiseTraining(); + await workerTrainingCertificateService.deleteAllCertificates(this._id, transaction); + } - await models.trainingCertificates.deleteCertificate(trainingCertificateUids, transaction); - await TrainingCertificateRoute.deleteCertificatesFromS3(filesToDeleteFromS3); + async deleteAllQualificationCertificatesAssociatedWithWorker(transaction) { + const workerQualificationCertificateService = WorkerCertificateService.initialiseQualifications(); + await workerQualificationCertificateService.deleteAllCertificates(this._id, transaction); } } diff --git a/backend/server/models/qualificationCertificates.js b/backend/server/models/qualificationCertificates.js new file mode 100644 index 0000000000..60ca895a13 --- /dev/null +++ b/backend/server/models/qualificationCertificates.js @@ -0,0 +1,108 @@ +/* jshint indent: 2 */ +const dayjs = require('dayjs'); + +module.exports = function (sequelize, DataTypes) { + const QualificationCertificates = sequelize.define( + 'qualificationCertificates', + { + id: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + autoIncrement: true, + field: '"ID"', + }, + uid: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: '"UID"', + }, + workerFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerFK"', + }, + workerQualificationsFk: { + type: DataTypes.INTEGER, + allowNull: false, + field: '"WorkerQualificationsFK"', + }, + filename: { + type: DataTypes.TEXT, + allowNull: true, + field: '"FileName"', + }, + uploadDate: { + type: DataTypes.DATE, + allowNull: false, + field: '"UploadDate"', + }, + key: { + type: DataTypes.TEXT, + allowNull: false, + field: '"Key"', + }, + }, + { + tableName: 'QualificationCertificates', + schema: 'cqc', + createdAt: false, + updatedAt: false, + }, + ); + + QualificationCertificates.associate = (models) => { + QualificationCertificates.belongsTo(models.worker, { + foreignKey: 'workerFk', + targetKey: 'id', + as: 'worker', + }); + + QualificationCertificates.belongsTo(models.workerQualifications, { + foreignKey: 'workerQualificationsFk', + targetKey: 'id', + as: 'workerQualifications', + }); + }; + + QualificationCertificates.addCertificate = function ({ recordId, workerFk, filename, fileId, key }) { + const timeNow = dayjs().format(); + + return this.create({ + uid: fileId, + workerFk: workerFk, + workerQualificationsFk: recordId, + filename: filename, + uploadDate: timeNow, + key, + }); + }; + + QualificationCertificates.deleteCertificate = async function (uids, transaction) { + return await this.destroy({ + where: { + uid: uids, + }, + ...(transaction ? { transaction } : {}), + }); + }; + + QualificationCertificates.countCertificatesToBeDeleted = async function (uids) { + return await this.count({ + where: { + uid: uids, + }, + }); + }; + + QualificationCertificates.getAllCertificateRecordsForWorker = async function (workerFk) { + return await this.findAll({ + where: { + workerFk, + }, + }); + }; + + return QualificationCertificates; +}; diff --git a/backend/server/models/trainingCertificates.js b/backend/server/models/trainingCertificates.js index a5bb4457d7..aee9b8f178 100644 --- a/backend/server/models/trainingCertificates.js +++ b/backend/server/models/trainingCertificates.js @@ -66,13 +66,13 @@ module.exports = function (sequelize, DataTypes) { }); }; - TrainingCertificates.addCertificate = function ({ trainingRecordId, workerFk, filename, fileId, key }) { + TrainingCertificates.addCertificate = function ({ recordId, workerFk, filename, fileId, key }) { const timeNow = dayjs().format(); return this.create({ uid: fileId, workerFk: workerFk, - workerTrainingFk: trainingRecordId, + workerTrainingFk: recordId, filename: filename, uploadDate: timeNow, key, @@ -96,7 +96,7 @@ module.exports = function (sequelize, DataTypes) { }); }; - TrainingCertificates.getAllTrainingCertificateRecordsForWorker = async function (workerFk) { + TrainingCertificates.getAllCertificateRecordsForWorker = async function (workerFk) { return await this.findAll({ where: { workerFk, diff --git a/backend/server/models/workerQualifications.js b/backend/server/models/workerQualifications.js index 58ff4cbbe7..2c4367e561 100644 --- a/backend/server/models/workerQualifications.js +++ b/backend/server/models/workerQualifications.js @@ -79,6 +79,12 @@ module.exports = function (sequelize, DataTypes) { targetKey: 'id', as: 'qualification', }); + WorkerQualifications.hasMany(models.qualificationCertificates, { + foreignKey: 'workerQualificationsFk', + sourceKey: 'id', + as: 'qualificationCertificates', + onDelete: 'CASCADE', + }); }; return WorkerQualifications; diff --git a/backend/server/routes/establishments/qualification/index.js b/backend/server/routes/establishments/qualification/index.js index 71a738f692..dc74369cbc 100644 --- a/backend/server/routes/establishments/qualification/index.js +++ b/backend/server/routes/establishments/qualification/index.js @@ -1,13 +1,15 @@ // default route for Workers' qualification endpoint const express = require('express'); const router = express.Router({ mergeParams: true }); +const QualificationCertificateRoute = require('../workerCertificate/qualificationCertificate'); // all user functionality is encapsulated const Qualification = require('../../../models/classes/qualification').Qualification; -const QualificationDuplicateException = require('../../../models/classes/qualification') - .QualificationDuplicateException; +const QualificationDuplicateException = + require('../../../models/classes/qualification').QualificationDuplicateException; const { hasPermission } = require('../../../utils/security/hasPermission'); +const WorkerCertificateService = require('../workerCertificate/workerCertificateService'); // NOTE - the Worker route uses middleware to validate the given worker id against the known establishment // prior to all qualification endpoints, thus ensuring we this necessary rigidity on Establishment/Worker relationship @@ -159,10 +161,11 @@ const updateQualification = async (req, res) => { }; // deletes requested qualification record using the qualification uid -const deleteQualification = async (req, res) => { +const deleteQualificationRecord = async (req, res) => { const establishmentId = req.establishmentId; const qualificationUid = req.params.qualificationUid; const workerUid = req.params.workerId; + const establishmentUid = req.params.id; const thisQualificationRecord = new Qualification(establishmentId, workerUid); @@ -173,13 +176,26 @@ const deleteQualification = async (req, res) => { if (await thisQualificationRecord.restore(qualificationUid)) { // TODO: JSON validation + const qualificationCertificates = thisQualificationRecord?._qualificationCertificates; + + const qualificationCertificateService = WorkerCertificateService.initialiseQualifications(); + + if (qualificationCertificates?.length) { + await qualificationCertificateService.deleteCertificates( + qualificationCertificates, + establishmentUid, + workerUid, + qualificationUid, + ); + } + // by deleting after the restore we can be sure this qualification record belongs to the given worker const deleteSuccess = await thisQualificationRecord.delete(); if (deleteSuccess) { return res.status(204).json(); } else { - return res.status(404).json('Not Found'); + return res.status(404).send('Not Found'); } } else { // not found worker @@ -187,7 +203,7 @@ const deleteQualification = async (req, res) => { } } catch (err) { console.error(err); - return res.status(500).send(); + return res.status(err.statusCode ? err.statusCode : 500).send(); } }; @@ -196,6 +212,8 @@ router.route('/').post(hasPermission('canEditWorker'), createQualification); router.route('/available').get(hasPermission('canViewWorker'), availableQualifications); router.route('/:qualificationUid').get(hasPermission('canViewWorker'), viewQualification); router.route('/:qualificationUid').put(hasPermission('canEditWorker'), updateQualification); -router.route('/:qualificationUid').delete(hasPermission('canEditWorker'), deleteQualification); +router.route('/:qualificationUid').delete(hasPermission('canEditWorker'), deleteQualificationRecord); +router.use('/:qualificationUid/certificate', QualificationCertificateRoute); module.exports = router; +module.exports.deleteQualificationRecord = deleteQualificationRecord; diff --git a/backend/server/routes/establishments/training/index.js b/backend/server/routes/establishments/training/index.js index fc44a698b4..32c5e63c4b 100644 --- a/backend/server/routes/establishments/training/index.js +++ b/backend/server/routes/establishments/training/index.js @@ -8,6 +8,7 @@ const MandatoryTraining = require('../../../models/classes/mandatoryTraining').M const TrainingCertificateRoute = require('../workerCertificate/trainingCertificate'); const { hasPermission } = require('../../../utils/security/hasPermission'); +const WorkerCertificateService = require('../workerCertificate/workerCertificateService'); // NOTE - the Worker route uses middleware to validate the given worker id against the known establishment // prior to all training endpoints, thus ensuring we this necessary rigidity on Establishment/Worker relationship @@ -153,66 +154,44 @@ const updateTrainingRecord = async (req, res) => { } }; -// deletes requested training record using the training uid -const deleteTrainingRecord = async (req, res) => { - const establishmentId = req.establishmentId; - const trainingUid = req.params.trainingUid; - const workerUid = req.params.workerId; - const establishmentUid = req.params.id; +const deleteTrainingRecordEndpoint = async (req, res) => { + const trainingRecord = new Training(req.establishmentId, req.params.workerId); + const trainingCertificateService = WorkerCertificateService.initialiseTraining(); - const thisTrainingRecord = new Training(establishmentId, workerUid); + return await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); +}; +const deleteTrainingRecord = async (req, res, trainingRecord, trainingCertificateService) => { try { - // before updating a Worker, we need to be sure the Worker is - // available to the given establishment. The best way of doing that - // is to restore from given UID - if (await thisTrainingRecord.restore(trainingUid)) { - // TODO: JSON validation - - const trainingCertificates = thisTrainingRecord?._trainingCertificates; - - if (trainingCertificates?.length > 0) { - let trainingCertificatesfilesToDeleteFromS3 = []; - let trainingCertificatesUidsToDeleteFromDb = []; + const trainingUid = req.params.trainingUid; + const workerUid = req.params.workerId; + const establishmentUid = req.params.id; - for (const trainingCertificate of trainingCertificates) { - let fileKey = TrainingCertificateRoute.makeFileKey( - establishmentUid, - workerUid, - trainingUid, - trainingCertificate.uid, - ); - - trainingCertificatesUidsToDeleteFromDb.push(trainingCertificate.uid); - trainingCertificatesfilesToDeleteFromS3.push({ Key: fileKey }); - } + const trainingRecordFound = await trainingRecord.restore(trainingUid); + if (!trainingRecordFound) { + return res.status(404).send('Not Found'); + } - const deletedTrainingCertificatesFromDatabase = await TrainingCertificateRoute.deleteRecordsFromDatabase( - trainingCertificatesUidsToDeleteFromDb, - ); + const trainingCertificates = trainingRecord?._trainingCertificates; - if (deletedTrainingCertificatesFromDatabase) { - await TrainingCertificateRoute.deleteCertificatesFromS3(trainingCertificatesfilesToDeleteFromS3); - } else { - console.log('Failed to delete training certificates'); - return res.status(500).send(); - } - } + if (trainingCertificates?.length) { + await trainingCertificateService.deleteCertificates( + trainingCertificates, + establishmentUid, + workerUid, + trainingUid, + ); + } - // by deleting after the restore we can be sure this training record belongs to the given worker - const deleteSuccess = await thisTrainingRecord.delete(); - if (deleteSuccess) { - return res.status(204).json(); - } else { - return res.status(404).send('Not Found'); - } + const deleteSuccess = await trainingRecord.delete(); + if (deleteSuccess) { + return res.status(204).json(); } else { - // not found worker return res.status(404).send('Not Found'); } } catch (err) { console.error(err); - return res.status(500).send(); + return res.status(err.statusCode ? err.statusCode : 500).send(); } }; @@ -220,7 +199,7 @@ router.route('/').get(hasPermission('canViewWorker'), getTrainingListWithMissing router.route('/').post(hasPermission('canEditWorker'), createTrainingRecord); router.route('/:trainingUid').get(hasPermission('canViewWorker'), viewTrainingRecord); router.route('/:trainingUid').put(hasPermission('canEditWorker'), updateTrainingRecord); -router.route('/:trainingUid').delete(hasPermission('canEditWorker'), deleteTrainingRecord); +router.route('/:trainingUid').delete(hasPermission('canEditWorker'), deleteTrainingRecordEndpoint); router.use('/:trainingUid/certificate', TrainingCertificateRoute); module.exports = router; diff --git a/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js b/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js new file mode 100644 index 0000000000..2a5047dcb8 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/qualificationCertificate.js @@ -0,0 +1,81 @@ +const WorkerCertificateService = require('./workerCertificateService'); + +const express = require('express'); +const models = require('../../../models'); + +const { hasPermission } = require('../../../utils/security/hasPermission'); +const router = express.Router({ mergeParams: true }); + +const initialiseCertificateService = () => { + return WorkerCertificateService.initialiseQualifications(); +}; + +const requestUploadUrlEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); + + try { + const responsePayload = await certificateService.requestUploadUrl( + req.body.files, + req.params.id, + req.params.workerId, + req.params.qualificationUid, + ); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return certificateService.sendErrorResponse(res, err); + } +}; + +const confirmUploadEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); + + try { + await certificateService.confirmUpload(req.body.files, req.params.qualificationUid); + return res.status(200).send(); + } catch (err) { + return certificateService.sendErrorResponse(res, err); + } +}; + +const getPresignedUrlForCertificateDownloadEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); + + try { + const responsePayload = await certificateService.getPresignedUrlForCertificateDownload( + req.body.files, + req.params.id, + req.params.workerId, + req.params.qualificationUid, + ); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return certificateService.sendErrorResponse(res, err); + } +}; + +const deleteCertificatesEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); + + try { + await certificateService.deleteCertificates( + req.body.files, + req.params.id, + req.params.workerId, + req.params.qualificationUid, + ); + return res.status(200).send(); + } catch (err) { + return certificateService.sendErrorResponse(res, err); + } +}; + +router.route('/').post(hasPermission('canEditWorker'), requestUploadUrlEndpoint); +router.route('/').put(hasPermission('canEditWorker'), confirmUploadEndpoint); +router.route('/download').post(hasPermission('canEditWorker'), getPresignedUrlForCertificateDownloadEndpoint); +router.route('/delete').post(hasPermission('canEditWorker'), deleteCertificatesEndpoint); + +module.exports = router; +module.exports.requestUploadUrlEndpoint = requestUploadUrlEndpoint; +module.exports.confirmUploadEndpoint = confirmUploadEndpoint; +module.exports.getPresignedUrlForCertificateDownloadEndpoint = getPresignedUrlForCertificateDownloadEndpoint; +module.exports.deleteCertificatesEndpoint = deleteCertificatesEndpoint; diff --git a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js index d6122995e6..fd42abc73d 100644 --- a/backend/server/routes/establishments/workerCertificate/trainingCertificate.js +++ b/backend/server/routes/establishments/workerCertificate/trainingCertificate.js @@ -1,201 +1,81 @@ -const { v4: uuidv4 } = require('uuid'); -const express = require('express'); +const WorkerCertificateService = require('./workerCertificateService'); -const config = require('../../../config/config'); +const express = require('express'); const models = require('../../../models'); -const s3 = require('./s3'); const { hasPermission } = require('../../../utils/security/hasPermission'); - -const certificateBucket = String(config.get('workerCertificate.bucketname')); -const uploadSignedUrlExpire = config.get('workerCertificate.uploadSignedUrlExpire'); -const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlExpire'); - const router = express.Router({ mergeParams: true }); -const makeFileKey = (establishmentUid, workerId, trainingUid, fileId) => { - return `${establishmentUid}/${workerId}/trainingCertificate/${trainingUid}/${fileId}`; -}; - -const requestUploadUrl = async (req, res) => { - const { files } = req.body; - const { id, workerId, trainingUid } = req.params; - if (!files || !files.length) { - return res.status(400).send('Missing `files` param in request body'); - } - - if (!files.every((file) => file?.filename)) { - return res.status(400).send('Missing file name in request body'); - } - - const responsePayload = []; - - for (const file of files) { - const filename = file.filename; - const fileId = uuidv4(); - const key = makeFileKey(id, workerId, trainingUid, fileId); - const signedUrl = await s3.getSignedUrlForUpload({ - bucket: certificateBucket, - key, - options: { expiresIn: uploadSignedUrlExpire }, - }); - responsePayload.push({ filename, signedUrl, fileId, key }); - } - - return res.status(200).json({ files: responsePayload }); +const initialiseCertificateService = () => { + return WorkerCertificateService.initialiseTraining(); }; -const confirmUpload = async (req, res) => { - const { establishmentId } = req; - const { trainingUid } = req.params; - const { files } = req.body; +const requestUploadUrlEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); - if (!files || !files.length) { - return res.status(400).send('Missing `files` param in request body'); - } - - const trainingRecord = await models.workerTraining.findOne({ - where: { - uid: trainingUid, - }, - attributes: ['id', 'workerFk'], - }); - - if (!trainingRecord) { - return res.status(400).send('Failed to find related training record'); - } - - const { workerFk, id: trainingRecordId } = trainingRecord.dataValues; - - const etagsMatchRecord = await verifyEtagsForAllFiles(establishmentId, files); - if (!etagsMatchRecord) { - return res.status(400).send('Failed to verify files on S3'); - } - - for (const file of files) { - const { filename, fileId, key } = file; - - try { - await models.trainingCertificates.addCertificate({ trainingRecordId, workerFk, filename, fileId, key }); - } catch (err) { - console.error(err); - return res.status(500).send('Failed to add records to database'); - } - } - - return res.status(200).send(); -}; - -const verifyEtagsForAllFiles = async (establishmentId, files) => { try { - for (const file of files) { - const etagMatchS3Record = await s3.verifyEtag(certificateBucket, file.key, file.etag); - if (!etagMatchS3Record) { - console.error('Etags in the request does not match the record at AWS bucket'); - return false; - } - } + const responsePayload = await certificateService.requestUploadUrl( + req.body.files, + req.params.id, + req.params.workerId, + req.params.trainingUid, + ); + return res.status(200).json({ files: responsePayload }); } catch (err) { - console.error(err); - return false; + return certificateService.sendErrorResponse(res, err); } - return true; }; -const getPresignedUrlForCertificateDownload = async (req, res) => { - const { filesToDownload } = req.body; - const { id, workerId, trainingUid } = req.params; - - if (!filesToDownload || !filesToDownload.length) { - return res.status(400).send('No files provided in request body'); - } - - const responsePayload = []; - - for (const file of filesToDownload) { - const signedUrl = await s3.getSignedUrlForDownload({ - bucket: certificateBucket, - key: makeFileKey(id, workerId, trainingUid, file.uid), - options: { expiresIn: downloadSignedUrlExpire }, - }); - responsePayload.push({ signedUrl, filename: file.filename }); - } - - return res.status(200).json({ files: responsePayload }); -}; +const confirmUploadEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); -const deleteRecordsFromDatabase = async (uids) => { try { - await models.trainingCertificates.deleteCertificate(uids); - return true; - } catch (error) { - console.log(error); - return false; + await certificateService.confirmUpload(req.body.files, req.params.trainingUid); + return res.status(200).send(); + } catch (err) { + return certificateService.sendErrorResponse(res, err); } }; -const deleteCertificatesFromS3 = async (filesToDeleteFromS3) => { - const deleteFromS3Response = await s3.deleteCertificatesFromS3({ - bucket: certificateBucket, - objects: filesToDeleteFromS3, - }); +const getPresignedUrlForCertificateDownloadEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); - if (deleteFromS3Response?.Errors?.length > 0) { - console.error(JSON.stringify(deleteFromS3Response.Errors)); + try { + const responsePayload = await certificateService.getPresignedUrlForCertificateDownload( + req.body.files, + req.params.id, + req.params.workerId, + req.params.trainingUid, + ); + return res.status(200).json({ files: responsePayload }); + } catch (err) { + return certificateService.sendErrorResponse(res, err); } }; -const deleteCertificates = async (req, res) => { - const { filesToDelete } = req.body; - const { id, workerId, trainingUid } = req.params; - - if (!filesToDelete || !filesToDelete.length) { - return res.status(400).send('No files provided in request body'); - } - - let filesToDeleteFromS3 = []; - let filesToDeleteFromDatabase = []; - - for (const file of filesToDelete) { - let fileKey = makeFileKey(id, workerId, trainingUid, file.uid); - - filesToDeleteFromDatabase.push(file.uid); - filesToDeleteFromS3.push({ Key: fileKey }); - } +const deleteCertificatesEndpoint = async (req, res) => { + const certificateService = initialiseCertificateService(); try { - const noOfFilesFoundInDatabase = await models.trainingCertificates.countCertificatesToBeDeleted( - filesToDeleteFromDatabase, + await certificateService.deleteCertificates( + req.body.files, + req.params.id, + req.params.workerId, + req.params.trainingUid, ); - - if (noOfFilesFoundInDatabase !== filesToDeleteFromDatabase.length) { - return res.status(400).send('Invalid request'); - } - - const deletionFromDatabase = await deleteRecordsFromDatabase(filesToDeleteFromDatabase); - if (!deletionFromDatabase) { - return res.status(500).send(); - } - } catch (error) { - console.log(error); - return res.status(500).send(); + return res.status(200).send(); + } catch (err) { + return certificateService.sendErrorResponse(res, err); } - - await deleteCertificatesFromS3(filesToDeleteFromS3); - - return res.status(200).send(); }; -router.route('/').post(hasPermission('canEditWorker'), requestUploadUrl); -router.route('/').put(hasPermission('canEditWorker'), confirmUpload); -router.route('/download').post(hasPermission('canEditWorker'), getPresignedUrlForCertificateDownload); -router.route('/delete').post(hasPermission('canEditWorker'), deleteCertificates); +router.route('/').post(hasPermission('canEditWorker'), requestUploadUrlEndpoint); +router.route('/').put(hasPermission('canEditWorker'), confirmUploadEndpoint); +router.route('/download').post(hasPermission('canEditWorker'), getPresignedUrlForCertificateDownloadEndpoint); +router.route('/delete').post(hasPermission('canEditWorker'), deleteCertificatesEndpoint); module.exports = router; -module.exports.requestUploadUrl = requestUploadUrl; -module.exports.confirmUpload = confirmUpload; -module.exports.getPresignedUrlForCertificateDownload = getPresignedUrlForCertificateDownload; -module.exports.deleteCertificates = deleteCertificates; -module.exports.deleteRecordsFromDatabase = deleteRecordsFromDatabase; -module.exports.deleteCertificatesFromS3 = deleteCertificatesFromS3; -module.exports.makeFileKey = makeFileKey; +module.exports.requestUploadUrlEndpoint = requestUploadUrlEndpoint; +module.exports.confirmUploadEndpoint = confirmUploadEndpoint; +module.exports.getPresignedUrlForCertificateDownloadEndpoint = getPresignedUrlForCertificateDownloadEndpoint; +module.exports.deleteCertificatesEndpoint = deleteCertificatesEndpoint; diff --git a/backend/server/routes/establishments/workerCertificate/workerCertificateService.js b/backend/server/routes/establishments/workerCertificate/workerCertificateService.js new file mode 100644 index 0000000000..7b3ccf2419 --- /dev/null +++ b/backend/server/routes/establishments/workerCertificate/workerCertificateService.js @@ -0,0 +1,264 @@ +const { v4: uuidv4 } = require('uuid'); + +const config = require('../../../config/config'); +const models = require('../../../models'); + +const s3 = require('./s3'); + +const certificateBucket = String(config.get('workerCertificate.bucketname')); +const uploadSignedUrlExpire = config.get('workerCertificate.uploadSignedUrlExpire'); +const downloadSignedUrlExpire = config.get('workerCertificate.downloadSignedUrlExpire'); +const HttpError = require('../../../utils/errors/httpError'); + +class WorkerCertificateService { + certificatesModel; + certificateTypeModel; + recordType; + + static initialiseQualifications = () => { + const service = new WorkerCertificateService(); + service.certificatesModel = models.qualificationCertificates; + service.certificateTypeModel = models.workerQualifications; + service.recordType = 'qualification'; + return service; + }; + + static initialiseTraining = () => { + const service = new WorkerCertificateService(); + service.certificatesModel = models.trainingCertificates; + service.certificateTypeModel = models.workerTraining; + service.recordType = 'training'; + return service; + }; + + constructor() {} + + makeFileKey = (establishmentUid, workerUid, recordUid, fileId) => { + return `${establishmentUid}/${workerUid}/${this.recordType}Certificate/${recordUid}/${fileId}`; + }; + + getFileKeys = async (workerUid, recordUid, fileIds) => { + const certificateRecords = await this.certificatesModel.findAll({ + where: { + uid: fileIds, + }, + include: [ + { + model: this.certificateTypeModel, + as: this.certificateTypeModel.name, + where: { uid: recordUid }, + }, + { + model: models.worker, + as: models.worker.name, + where: { uid: workerUid }, + }, + ], + attributes: ['uid', 'key'], + }); + + if (!certificateRecords || certificateRecords.length !== fileIds.length) { + throw new HttpError(`Failed to find related ${this.recordType} certificate records`, 400); + } + + return certificateRecords; + }; + + async requestUploadUrl(files, establishmentUid, workerUid, recordUid) { + if (!files || !files.length) { + throw new HttpError('Missing `files` param in request body', 400); + } + + if (!files.every((file) => file?.filename)) { + throw new HttpError('Missing file name in request body', 400); + } + + const responsePayload = []; + + for (const file of files) { + const filename = file.filename; + const fileId = uuidv4(); + const key = this.makeFileKey(establishmentUid, workerUid, recordUid, fileId); + const signedUrl = await s3.getSignedUrlForUpload({ + bucket: certificateBucket, + key, + options: { expiresIn: uploadSignedUrlExpire }, + }); + responsePayload.push({ filename, signedUrl, fileId, key }); + } + + return responsePayload; + } + + async confirmUpload(files, recordUid) { + if (!files || !files.length) { + throw new HttpError('Missing `files` param in request body', 400); + } + + const record = await this.certificateTypeModel.findOne({ + where: { + uid: recordUid, + }, + attributes: ['id', 'workerFk'], + }); + + if (!record) { + throw new HttpError(`Failed to find related ${this.recordType} record`, 400); + } + + const { workerFk, id: recordId } = record.dataValues; + + const etagsMatchRecord = await this.verifyEtagsForAllFiles(files); + + if (!etagsMatchRecord) { + throw new HttpError('Failed to verify files on S3', 400); + } + + for (const file of files) { + const { filename, fileId, key } = file; + + try { + await this.certificatesModel.addCertificate({ recordId, workerFk, filename, fileId, key }); + } catch (err) { + console.log(err); + throw new HttpError('Failed to add records to database', 500); + } + } + } + + verifyEtagsForAllFiles = async (files) => { + try { + for (const file of files) { + const etagMatchS3Record = await s3.verifyEtag(certificateBucket, file.key, file.etag); + if (!etagMatchS3Record) { + console.error('Etags in the request does not match the record at AWS bucket'); + return false; + } + } + } catch (err) { + console.error(err); + return false; + } + return true; + }; + + async getPresignedUrlForCertificateDownload(files, establishmentUid, workerUid, recordUid) { + if (!files || !files.length) { + throw new HttpError('No files provided in request body', 400); + } + + const allFileIds = files.map((file) => file.uid); + const responsePayload = []; + + const certRecords = await this.getFileKeys(workerUid, recordUid, allFileIds); + + for (const file of files) { + const signedUrl = await s3.getSignedUrlForDownload({ + bucket: certificateBucket, + key: certRecords.find((record) => record.uid === file.uid).key, + options: { expiresIn: downloadSignedUrlExpire }, + }); + responsePayload.push({ signedUrl, filename: file.filename }); + } + + return responsePayload; + } + + deleteRecordsFromDatabase = async (uids, transaction) => { + try { + await this.certificatesModel.deleteCertificate(uids, transaction); + return true; + } catch (error) { + return false; + } + }; + + deleteCertificatesFromS3 = async (filesToDeleteFromS3) => { + const deleteFromS3Response = await s3.deleteCertificatesFromS3({ + bucket: certificateBucket, + objects: filesToDeleteFromS3, + }); + + if (deleteFromS3Response?.Errors?.length > 0) { + console.error(JSON.stringify(deleteFromS3Response.Errors)); + } + }; + + async deleteAllCertificates(workerUid, transaction) { + const certificates = await this.certificatesModel.getAllCertificateRecordsForWorker(workerUid); + + if (!certificates.length) return; + const certificateUids = certificates.map((cert) => cert.uid); + const filesToDeleteFromS3 = certificates.map((cert) => { + return { Key: cert.key }; + }); + + await this.certificatesModel.deleteCertificate(certificateUids, transaction); + await this.deleteCertificatesFromS3(filesToDeleteFromS3); + } + + async deleteCertificates(files, establishmentUid, workerUid, recordUid) { + if (!files || !files.length) { + throw new HttpError('No files provided in request body', 400); + } + + const allFileIds = files.map((file) => file.uid); + const certRecords = await this.getFileKeys(workerUid, recordUid, allFileIds); + + let filesToDeleteFromS3 = []; + let filesToDeleteFromDatabase = []; + + for (const file of files) { + let fileKey = certRecords.find((record) => record.uid === file.uid).key; + + filesToDeleteFromDatabase.push(file.uid); + filesToDeleteFromS3.push({ Key: fileKey }); + } + + try { + const noOfFilesFoundInDatabase = await this.certificatesModel.countCertificatesToBeDeleted( + filesToDeleteFromDatabase, + ); + + if (noOfFilesFoundInDatabase !== filesToDeleteFromDatabase.length) { + throw new HttpError('Invalid request', 400); + } + + const deletionFromDatabase = await this.deleteRecordsFromDatabase(filesToDeleteFromDatabase); + if (!deletionFromDatabase) { + throw new HttpError(undefined, 500); + } + } catch (error) { + if (error.statusCode !== undefined) { + throw error; + } + throw new HttpError(undefined, 500); + } + + await this.deleteCertificatesFromS3(filesToDeleteFromS3); + } + + async deleteCertificatesWithTransaction(certificateRecords, externalTransaction) { + if (!certificateRecords || certificateRecords.length < 1 || !externalTransaction) { + return; + } + + const filesToDeleteFromS3 = certificateRecords.map((record) => ({ Key: record.key })); + const certificateRecordUids = certificateRecords.map((record) => record.uid); + + externalTransaction.afterCommit(() => { + this.deleteCertificatesFromS3(filesToDeleteFromS3); + }); + await this.certificatesModel.destroy({ where: { uid: certificateRecordUids }, transaction: externalTransaction }); + } + + sendErrorResponse(res, err) { + if (err instanceof HttpError) { + return res.status(err.statusCode).send(err.message); + } + console.error('WorkerCertificateService error: ', err); + return res.status(500).send('Internal server error'); + } +} + +module.exports = WorkerCertificateService; diff --git a/backend/server/test/factories/models.js b/backend/server/test/factories/models.js index 539cb2e39c..a29f536798 100644 --- a/backend/server/test/factories/models.js +++ b/backend/server/test/factories/models.js @@ -86,6 +86,19 @@ const trainingBuilder = build('Training', { }, }); +const qualificationBuilder = build('Qualification', { + fields: { + id: sequence(), + uid: fake((f) => f.datatype.uuid()), + title: fake((f) => f.lorem.sentence()), + expires: fake((f) => f.date.future(1).toISOString()), + categoryFk: perBuild(() => { + return categoryBuilder().id; + }), + completed: fake((f) => f.date.past(1).toISOString()), + }, +}); + const mandatoryTrainingBuilder = build('MandatoryTraining', { fields: { id: sequence(), @@ -165,6 +178,7 @@ module.exports.workerBuilder = workerBuilder; module.exports.jobBuilder = jobBuilder; module.exports.categoryBuilder = categoryBuilder; module.exports.trainingBuilder = trainingBuilder; +module.exports.qualificationBuilder = qualificationBuilder; module.exports.mandatoryTrainingBuilder = mandatoryTrainingBuilder; module.exports.workerBuilderWithWdf = workerBuilderWithWdf; module.exports.establishmentWithShareWith = establishmentWithShareWith; diff --git a/backend/server/test/unit/mockdata/qualifications.js b/backend/server/test/unit/mockdata/qualifications.js index 57ae063779..093959013d 100644 --- a/backend/server/test/unit/mockdata/qualifications.js +++ b/backend/server/test/unit/mockdata/qualifications.js @@ -12,6 +12,18 @@ exports.mockQualificationRecords = { }, year: 2014, notes: 'test note', + qualificationCertificates: [ + { + uid: '4c2bdb47-f45a-4d6e-9e0b-ae9e4455559d', + filename: 'certificate 1.pdf', + uploadDate: '2021-10-21T14:38:57.449Z', + }, + { + uid: '4c2bdb47-f45a-4d6e-9e0b-ae9e4455559e', + filename: 'certificate 2.pdf', + uploadDate: '2021-10-21T14:38:57.449Z', + }, + ], created: '2021-10-21T14:38:57.449Z', updated: '2021-10-21T14:38:57.449Z', updatedBy: 'greenj', @@ -21,6 +33,7 @@ exports.mockQualificationRecords = { qualification: { id: 74, group: 'Diploma', title: 'Adult Care', level: '4' }, year: 2020, notes: 'Test', + qualificationCertificates: [], created: '2021-10-21T14:10:31.755Z', updated: '2021-10-21T14:10:31.755Z', updatedBy: 'greenj', @@ -35,6 +48,7 @@ exports.mockQualificationRecords = { }, year: 2019, notes: 'It helped me do my job', + qualificationCertificates: [], created: '2021-10-21T14:09:36.155Z', updated: '2021-10-21T14:09:36.155Z', updatedBy: 'greenj', @@ -49,6 +63,7 @@ exports.mockQualificationRecords = { }, year: 2020, notes: 'It was great ', + qualificationCertificates: [], created: '2021-10-21T14:08:59.685Z', updated: '2021-10-21T14:08:59.685Z', updatedBy: 'greenj', @@ -63,6 +78,7 @@ exports.mockQualificationRecords = { }, year: 2011, notes: '', + qualificationCertificates: [], created: '2021-10-13T13:04:26.628Z', updated: '2021-10-13T13:04:26.628Z', updatedBy: 'greenj', @@ -81,12 +97,25 @@ exports.expectedQualificationsSortedByGroup = { title: 'Health and Social Care - Dementia', year: 2014, notes: 'test note', + qualificationCertificates: [ + { + uid: '4c2bdb47-f45a-4d6e-9e0b-ae9e4455559d', + filename: 'certificate 1.pdf', + uploadDate: '2021-10-21T14:38:57.449Z', + }, + { + uid: '4c2bdb47-f45a-4d6e-9e0b-ae9e4455559e', + filename: 'certificate 2.pdf', + uploadDate: '2021-10-21T14:38:57.449Z', + }, + ], uid: 'fa8f6b14-efd8-4622-b679-14df36202957', }, { title: 'Adult Care', year: 2020, notes: 'Test', + qualificationCertificates: [], uid: 'a5fd6fd1-e21e-4510-97a6-ce4a3a0e8423', }, ], @@ -98,6 +127,7 @@ exports.expectedQualificationsSortedByGroup = { title: 'Adult Care Worker (standard)', year: 2019, notes: 'It helped me do my job', + qualificationCertificates: [], uid: '8832e33d-515d-40ac-8e75-2bfa93341e13', }, ], @@ -109,6 +139,7 @@ exports.expectedQualificationsSortedByGroup = { title: 'Activity Provision in Social Care', year: 2020, notes: 'It was great ', + qualificationCertificates: [], uid: 'b4832913-29db-414c-89d7-30a73907f425', }, ], @@ -120,9 +151,54 @@ exports.expectedQualificationsSortedByGroup = { title: 'Award in Stroke Awareness', year: 2011, notes: '', + qualificationCertificates: [], uid: '8e34ac81-7fae-4a3b-afd7-18aa85d09b10', }, ], }, ], }; + +exports.mockQualificationsRecordWithoutCertificates = { + uid: 'fa8f6b14-efd8-4622-b679-14df36202957', + workerUid: '32fa83f9-dc21-4685-82d4-021024c0d5fe', + qualification: { + id: 82, + group: 'Diploma', + title: 'Health and Social Care - Dementia', + level: '2', + }, + year: 2014, + notes: 'test note', + created: '2021-10-21T14:38:57.449Z', + updated: '2021-10-21T14:38:57.449Z', + updatedBy: 'greenj', +}; + +exports.mockQualificationsRecordWithCertificates = { + uid: 'fa8f6b14-efd8-4622-b679-14df36202957', + workerUid: '32fa83f9-dc21-4685-82d4-021024c0d5fe', + qualification: { + id: 82, + group: 'Diploma', + title: 'Health and Social Care - Dementia', + level: '2', + }, + year: 2014, + notes: 'test note', + created: '2021-10-21T14:38:57.449Z', + updated: '2021-10-21T14:38:57.449Z', + updatedBy: 'greenj', + trainingCertificates: [ + { + uid: 'uid-1', + filename: 'communication_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'communication_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ], +}; diff --git a/backend/server/test/unit/models/classes/helpers/bulkUploadQualificationHelper.spec.js b/backend/server/test/unit/models/classes/helpers/bulkUploadQualificationHelper.spec.js new file mode 100644 index 0000000000..2e5eb3a3c4 --- /dev/null +++ b/backend/server/test/unit/models/classes/helpers/bulkUploadQualificationHelper.spec.js @@ -0,0 +1,261 @@ +const expect = require('chai').expect; +const sinon = require('sinon'); +const { v4: uuidv4 } = require('uuid'); + +const models = require('../../../../../models'); +const Qualification = require('../../../../../models/classes/qualification').Qualification; +const WorkerCertificateService = require('../../../../../routes/establishments/workerCertificate/workerCertificateService'); +const BulkUploadQualificationHelper = require('../../../../../models/classes/helpers/bulkUploadQualificationHelper'); + +const buildMockQualificationEntity = async (override = {}) => { + const propertiesToLoad = { + qualification: { + id: 152, + title: 'Level 2 Adult Social Care Certificate', + level: null, + group: 'Certificate', + }, + year: 2023, + notes: 'test notes', + ...override, + }; + + if (Qualification.prototype.validateQualificationRecord?.restore?.sinon) { + Qualification.prototype.validateQualificationRecord.resolves(propertiesToLoad); + } else { + sinon.stub(Qualification.prototype, 'validateQualificationRecord').resolves(propertiesToLoad); + } + + const mockQualificationEntity = new Qualification(null, null); + await mockQualificationEntity.load(); + + return mockQualificationEntity; +}; + +const buildMockWorkerQualification = (override = {}) => { + return new models.workerQualifications({ + uid: uuidv4(), + workerFk: '100', + qualificationFk: 152, + created: new Date(), + updated: new Date(), + source: 'Online', + updatedBy: 'test', + ...override, + }); +}; + +describe('/server/models/classes/helpers/bulkUploadQualificationHelper.js', () => { + const mockWorkerId = '100'; + const mockWorkerUid = uuidv4(); + const mockEstablishmentId = '1234'; + const mockSavedBy = 'admin3'; + const mockBulkUploaded = true; + const mockExternalTransaction = { sequelize: 'mockSequelizeTransactionObject' }; + + const setupHelper = () => { + return new BulkUploadQualificationHelper({ + workerId: mockWorkerId, + workerUid: mockWorkerUid, + establishmentId: mockEstablishmentId, + savedBy: mockSavedBy, + externalTransaction: mockExternalTransaction, + }); + }; + + it('should instantiates', () => { + const helper = setupHelper(); + expect(helper instanceof BulkUploadQualificationHelper).to.be.true; + }); + + describe('processQualificationsEntities', () => { + let mockExistingQualifications = []; + let mockQualificationsEntities = []; + const helper = setupHelper(); + + beforeEach(async () => { + mockExistingQualifications = [ + buildMockWorkerQualification({ qualificationFk: 31 }), // should be modified + buildMockWorkerQualification({ qualificationFk: 1 }), // should be deleted + buildMockWorkerQualification({ qualificationFk: 2 }), // should be deleted + buildMockWorkerQualification({ qualificationFk: 152 }), // should be modified + ]; + mockQualificationsEntities = await Promise.all( + [ + { qualification: { id: 3 } }, // new + { qualification: { id: 4 } }, // new + { qualification: { id: 152 } }, // existing + { qualification: { id: 31 } }, // existing + ].map(buildMockQualificationEntity), + ); + + sinon.stub(helper, 'createNewQualification').callsFake(() => { + return Promise.resolve(); + }); + sinon.stub(helper, 'updateQualification').callsFake(() => { + return Promise.resolve(); + }); + sinon.stub(helper, 'deleteQualification').callsFake(() => { + return Promise.resolve(); + }); + + sinon.stub(models.workerQualifications, 'findAll').resolves(mockExistingQualifications); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call createNewQualification for each qualification that is not in database but appear in bulk upload entities', async () => { + const returnedPromises = await helper.processQualificationsEntities(mockQualificationsEntities); + + expect(helper.createNewQualification).to.have.been.calledTwice; + expect(helper.createNewQualification).to.have.been.calledWith(mockQualificationsEntities[0]); + expect(helper.createNewQualification).to.have.been.calledWith(mockQualificationsEntities[1]); + + expect(returnedPromises) + .to.be.an('array') + .that.includes(helper.createNewQualification.returnValues[0], helper.createNewQualification.returnValues[1]); + }); + + it('should call updateQualification for each existing qualification that also appear in bulk upload entities', async () => { + const returnedPromises = await helper.processQualificationsEntities(mockQualificationsEntities); + + expect(helper.updateQualification).to.have.been.calledTwice; + expect(helper.updateQualification).to.have.been.calledWith( + mockExistingQualifications[0], + mockQualificationsEntities[3], + ); + expect(helper.updateQualification).to.have.been.calledWith( + mockExistingQualifications[3], + mockQualificationsEntities[2], + ); + + expect(returnedPromises) + .to.be.an('array') + .that.includes(helper.updateQualification.returnValues[0], helper.updateQualification.returnValues[1]); + }); + + it('should call deleteQualification for each existing qualification that is not in the bulk upload entities', async () => { + const returnedPromises = await helper.processQualificationsEntities(mockQualificationsEntities); + + expect(helper.deleteQualification).to.have.been.calledTwice; + expect(helper.deleteQualification).to.have.been.calledWith(mockExistingQualifications[1]); + expect(helper.deleteQualification).to.have.been.calledWith(mockExistingQualifications[2]); + + expect(returnedPromises) + .to.be.an('array') + .that.includes(helper.deleteQualification.returnValues[0], helper.deleteQualification.returnValues[1]); + }); + }); + + describe('createNewQualification', () => { + beforeEach(() => { + sinon.stub(Qualification.prototype, 'save').callsFake(() => { + return Promise.resolve(); + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + const helper = setupHelper(); + + it('should return a promise that saves the new qualification to database', async () => { + const entityFromBulkUpload = await buildMockQualificationEntity(); + const returnedPromise = helper.createNewQualification(entityFromBulkUpload); + + expect(entityFromBulkUpload.workerId).to.equal(mockWorkerId); + expect(entityFromBulkUpload.workerUid).to.equal(mockWorkerUid); + expect(entityFromBulkUpload.establishmentId).to.equal(mockEstablishmentId); + + expect(returnedPromise).to.be.a('promise'); + + await returnedPromise; + expect(entityFromBulkUpload.save).to.have.been.calledWith( + mockSavedBy, + mockBulkUploaded, + 0, + mockExternalTransaction, + ); + }); + }); + + describe('updateQualification', () => { + beforeEach(() => { + sinon.stub(models.workerQualifications.prototype, 'save').callsFake(() => { + return Promise.resolve(); + }); + }); + + const helper = setupHelper(); + + it('should return a promise that updates the existing record according to incoming bulk upload entity', async () => { + const mockExistingRecord = buildMockWorkerQualification(); + const mockEntityFromBulkUpload = await buildMockQualificationEntity(); + + const returnedPromise = helper.updateQualification(mockExistingRecord, mockEntityFromBulkUpload); + + expect(mockExistingRecord.updatedBy).to.equal(mockSavedBy.toLowerCase()); + expect(mockExistingRecord.source).to.equal('Bulk'); + expect(mockExistingRecord.notes).to.equal(mockEntityFromBulkUpload.notes); + expect(mockExistingRecord.year).to.equal(mockEntityFromBulkUpload.year); + + expect(returnedPromise).to.be.a('promise'); + + await returnedPromise; + expect(mockExistingRecord.save).to.have.been.calledWith({ transaction: mockExternalTransaction }); + }); + }); + + describe('deleteQualification', () => { + let qualificationCertificateServiceSpy; + const helper = setupHelper(); + + beforeEach(() => { + sinon.stub(models.workerQualifications.prototype, 'destroy').callsFake(() => { + return Promise.resolve(); + }); + qualificationCertificateServiceSpy = sinon.stub( + helper.qualificationCertificateService, + 'deleteCertificatesWithTransaction', + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should return a promise that deletes the qualification record from database', async () => { + const mockExistingRecord = buildMockWorkerQualification(); + sinon.stub(mockExistingRecord, 'getQualificationCertificates').returns([]); + + const returnedPromise = helper.deleteQualification(mockExistingRecord); + expect(returnedPromise).to.be.a('promise'); + + await returnedPromise; + expect(mockExistingRecord.destroy).to.have.been.calledWith({ transaction: mockExternalTransaction }); + expect(qualificationCertificateServiceSpy).not.to.have.been.called; + }); + + it('should call deleteCertificatesWithTransaction if there are any certificates attached to this qualification', async () => { + const mockExistingRecord = buildMockWorkerQualification(); + const mockCertificateRecords = [ + { uid: '123', key: 'abc' }, + { uid: '456', key: 'def' }, + ]; + sinon.stub(mockExistingRecord, 'getQualificationCertificates').returns(mockCertificateRecords); + + const returnedPromise = helper.deleteQualification(mockExistingRecord); + expect(returnedPromise).to.be.a('promise'); + + await returnedPromise; + expect(qualificationCertificateServiceSpy).to.have.been.calledWith( + mockCertificateRecords, + mockExternalTransaction, + ); + expect(mockExistingRecord.destroy).to.have.been.calledWith({ transaction: mockExternalTransaction }); + }); + }); +}); diff --git a/backend/server/test/unit/models/classes/qualification.spec.js b/backend/server/test/unit/models/classes/qualification.spec.js index d7b3a05978..f4464773e5 100644 --- a/backend/server/test/unit/models/classes/qualification.spec.js +++ b/backend/server/test/unit/models/classes/qualification.spec.js @@ -1,6 +1,7 @@ 'use strict'; const expect = require('chai').expect; const sinon = require('sinon'); +const models = require('../../../../models'); //include Qualification class const Qualification = require('../../../../models/classes/qualification').Qualification; @@ -46,15 +47,20 @@ let workerQualificationRecords = { } describe('/server/models/class/qualification.js', () => { - describe('getQualsCounts', () => { - beforeEach(() => { - sinon.stub(Qualification, 'fetch').callsFake(() => { - return workerQualificationRecords; - }) - }); - afterEach(() => { - sinon.restore(); + let stubFindWorkerQualificationRecord; + + beforeEach(() => { + sinon.stub(Qualification, 'fetch').callsFake(() => { + return workerQualificationRecords; }); + stubFindWorkerQualificationRecord = sinon.stub(models.workerQualifications, 'findOne').returns(workerQualificationRecords); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getQualsCounts', () => { it('should return updated worker records : Qualification.getQualsCounts', async () => { const updateWorkerRecords = await Qualification.getQualsCounts(establishmentId, workerRecords); @@ -72,4 +78,62 @@ describe('/server/models/class/qualification.js', () => { } }); }); + + describe('restore', () => { + describe('errors', () => { + it('should throw an exception when qualification UID is an invalid format', async () => { + const qualification = new Qualification(1, 2); + let error; + + try { + await qualification.restore('Some invalid UID'); + } catch (err) { + error = err; + } + + expect(error.message).to.equal('Failed to restore'); + }); + + it('should throw an exception when establishmentId is null', async () => { + const qualification = new Qualification(null, 2); + let error; + + try { + await qualification.restore("5bc1270a-4343-4d99-89e2-30ee62766c89"); + } catch (err) { + error = err; + } + + expect(error.message).to.equal('Failed to restore'); + }); + + it('should throw an exception when WorkerUid is null', async () => { + const qualification = new Qualification(1, null); + let error; + + try { + await qualification.restore("5bc1270a-4343-4d99-89e2-30ee62766c89"); + } catch (err) { + error = err; + } + + expect(error.message).to.equal('Failed to restore'); + }); + + it('should throw an exception when failed to load record', async () => { + const qualification = new Qualification(1, 2); + const testUid = "5bc1270a-4343-4d99-89e2-30ee62766c89"; + stubFindWorkerQualificationRecord.throws(); + let error; + + try { + await qualification.restore(testUid); + } catch (err) { + error = err; + } + + expect(error.message).to.equal(`Failed to load Qualification record with uid (${testUid})`); + }); + }); + }); }); diff --git a/backend/server/test/unit/models/classes/worker.spec.js b/backend/server/test/unit/models/classes/worker.spec.js index f105cc6c60..a6dc185a1a 100644 --- a/backend/server/test/unit/models/classes/worker.spec.js +++ b/backend/server/test/unit/models/classes/worker.spec.js @@ -6,6 +6,7 @@ const models = require('../../../../models'); const Worker = require('../../../../models/classes/worker').Worker; const TrainingCertificateRoute = require('../../../../routes/establishments/workerCertificate/trainingCertificate'); const { Training } = require('../../../../models/classes/training'); +const WorkerCertificateService = require('../../../../routes/establishments/workerCertificate/workerCertificateService'); const worker = new Worker(); @@ -433,6 +434,7 @@ describe('Worker Class', () => { describe('deleteAllTrainingCertificatesAssociatedWithWorker()', async () => { let mockWorker; + let stubs; const trainingCertificatesReturnedFromDb = () => { return [ { uid: 'abc123', key: 'abc123/trainingCertificate/dasdsa12312' }, @@ -444,75 +446,58 @@ describe('Worker Class', () => { beforeEach(() => { mockWorker = new Worker(); mockWorker._id = 12345; + stubs = { + getWorkerCertificateServiceInstance: sinon.stub(WorkerCertificateService, 'initialiseTraining').returns(new WorkerCertificateService()), + deleteAllCertificates: sinon.stub(WorkerCertificateService.prototype, 'deleteAllCertificates'), + getTrainingCertificates: sinon.stub(models.trainingCertificates, 'getAllCertificateRecordsForWorker').resolves(trainingCertificatesReturnedFromDb), + } }); afterEach(() => { sinon.restore(); }); - it('should call getAllTrainingCertificateRecordsForWorker with worker ID', async () => { - const getAllTrainingCertificateRecordsForWorkerSpy = sinon - .stub(models.trainingCertificates, 'getAllTrainingCertificateRecordsForWorker') - .resolves([]); - - await mockWorker.deleteAllTrainingCertificatesAssociatedWithWorker(); - - expect(getAllTrainingCertificateRecordsForWorkerSpy.args[0][0]).to.equal(12345); - }); - - it('should not make DB or S3 deletion calls if no training certificates found', async () => { - sinon.stub(models.trainingCertificates, 'getAllTrainingCertificateRecordsForWorker').resolves([]); + it('should call deleteAllCertificates on WorkerCertificateService', async () => { + const transaction = models.sequelize.transaction(); + await mockWorker.deleteAllTrainingCertificatesAssociatedWithWorker(transaction); - const dbDeleteCertificateSpy = sinon.stub(models.trainingCertificates, 'deleteCertificate'); - const s3DeleteCertificateSpy = sinon.stub(TrainingCertificateRoute, 'deleteCertificatesFromS3'); + expect(stubs.getWorkerCertificateServiceInstance).to.have.been.called; + expect(stubs.deleteAllCertificates).to.be.calledWith(12345); + }) + }); - await mockWorker.deleteAllTrainingCertificatesAssociatedWithWorker(); + describe('deleteAllQualificationsCertificatesAssociatedWithWorker()', async () => { + let mockWorker; + let stubs; + const qualificationCertificatesReturnedFromDb = () => { + return [ + { uid: 'abc123', key: 'abc123/qualificationCertificate/dasdsa12312' }, + { uid: 'def456', key: 'def456/qualificationCertificate/deass12092' }, + { uid: 'ghi789', key: 'ghi789/qualificationCertificate/da1412342' }, + ]; + }; - expect(dbDeleteCertificateSpy.callCount).to.equal(0); - expect(s3DeleteCertificateSpy.callCount).to.equal(0); + beforeEach(() => { + mockWorker = new Worker(); + mockWorker._id = 12345; + stubs = { + getWorkerCertificateServiceInstance: sinon.stub(WorkerCertificateService, 'initialiseQualifications').returns(new WorkerCertificateService()), + deleteAllCertificates: sinon.stub(WorkerCertificateService.prototype, 'deleteAllCertificates'), + getQualificationCertificates: sinon.stub(models.qualificationCertificates, 'getAllCertificateRecordsForWorker').resolves(qualificationCertificatesReturnedFromDb), + } }); - it('should call deleteCertificate on DB model with uids returned from getAllTrainingCertificateRecordsForWorker and pass in transaction', async () => { - const trainingCertificates = trainingCertificatesReturnedFromDb(); - - sinon - .stub(models.trainingCertificates, 'getAllTrainingCertificateRecordsForWorker') - .resolves(trainingCertificates); - - const dbDeleteCertificateSpy = sinon.stub(models.trainingCertificates, 'deleteCertificate'); - sinon.stub(TrainingCertificateRoute, 'deleteCertificatesFromS3'); - const transaction = await models.sequelize.transaction(); - - await mockWorker.deleteAllTrainingCertificatesAssociatedWithWorker(transaction); - - expect(dbDeleteCertificateSpy.args[0][0]).to.deep.equal([ - trainingCertificates[0].uid, - trainingCertificates[1].uid, - trainingCertificates[2].uid, - ]); - - expect(dbDeleteCertificateSpy.args[0][1]).to.deep.equal(transaction); + afterEach(() => { + sinon.restore(); }); - it('should call deleteCertificatesFromS3 with keys returned from getAllTrainingCertificateRecordsForWorker', async () => { - const trainingCertificates = trainingCertificatesReturnedFromDb(); - - sinon - .stub(models.trainingCertificates, 'getAllTrainingCertificateRecordsForWorker') - .resolves(trainingCertificates); - - sinon.stub(models.trainingCertificates, 'deleteCertificate'); - const s3DeleteCertificateSpy = sinon.stub(TrainingCertificateRoute, 'deleteCertificatesFromS3'); - const transaction = await models.sequelize.transaction(); + it('should call deleteAllCertificates on WorkerCertificateService', async () => { + const transaction = models.sequelize.transaction(); + await mockWorker.deleteAllQualificationCertificatesAssociatedWithWorker(transaction); - await mockWorker.deleteAllTrainingCertificatesAssociatedWithWorker(transaction); - - expect(s3DeleteCertificateSpy.args[0][0]).to.deep.equal([ - { Key: trainingCertificates[0].key }, - { Key: trainingCertificates[1].key }, - { Key: trainingCertificates[2].key }, - ]); - }); + expect(stubs.getWorkerCertificateServiceInstance).to.have.been.called; + expect(stubs.deleteAllCertificates).to.be.calledWith(12345); + }) }); describe('saveAssociatedEntities', async () => { diff --git a/backend/server/test/unit/routes/establishments/qualifications/index.spec.js b/backend/server/test/unit/routes/establishments/qualifications/index.spec.js new file mode 100644 index 0000000000..4023412881 --- /dev/null +++ b/backend/server/test/unit/routes/establishments/qualifications/index.spec.js @@ -0,0 +1,120 @@ +const expect = require('chai').expect; +const sinon = require('sinon'); +const httpMocks = require('node-mocks-http'); +const buildUser = require('../../../../factories/user'); +const models = require('../../../../../models'); +const Qualifications = require('../../../../../models/classes/qualification').Qualification; +const { deleteQualificationRecord } = require('../../../../../routes/establishments/qualification/index'); +const { + mockQualificationsRecordWithCertificates, + mockQualificationsRecordWithoutCertificates, +} = require('../../../mockdata/qualifications'); +const QualificationCertificateRoute = require('../../../../../routes/establishments/workerCertificate/qualificationCertificate'); +const WorkerCertificateService = require('../../../../../routes/establishments/workerCertificate/workerCertificateService'); +const HttpError = require('../../../../../utils/errors/httpError'); + +describe('server/routes/establishments/qualifications/index.js', () => { + afterEach(() => { + sinon.restore(); + }); + + const user = buildUser(); + + describe('deleteQualificationsRecord', () => { + let req; + let res; + let stubFindQualificationsRecord; + let stubRestoredQualificationsRecord; + let stubDestroyQualificationsRecord; + let workerUid = mockQualificationsRecordWithCertificates.workerUid; + let qualificationsUid = mockQualificationsRecordWithCertificates.uid; + let establishmentUid = user.establishment.uid; + let qualificationsRecord; + + let stubs; + + beforeEach(() => { + req = httpMocks.createRequest({ + method: 'DELETE', + url: `/api/establishment/${establishmentUid}/worker/${workerUid}/qualifications/${qualificationsUid}/`, + params: { qualificationsUid: qualificationsUid, workerId: workerUid, id: establishmentUid }, + establishmentId: '10', + }); + res = httpMocks.createResponse(); + + qualificationsRecord = new Qualifications(establishmentUid, workerUid); + + stubs = { + qualificationsRecord: sinon.createStubInstance(Qualifications), + restoreQualificationsRecord: sinon.stub(Qualifications.prototype, 'restore'), + getWorkerCertificateServiceInstance: sinon.stub(WorkerCertificateService, 'initialiseQualifications').returns(new WorkerCertificateService()), + destroyQualificationsRecord: sinon.stub(models.workerQualifications, 'destroy'), + deleteCertificates: sinon.stub(WorkerCertificateService.prototype, 'deleteCertificates'), + } + }); + + it('should return with a status of 204 when the qualifications record is deleted with qualifications certificates', async () => { + stubs.restoreQualificationsRecord.returns(mockQualificationsRecordWithCertificates); + stubs.destroyQualificationsRecord.returns(1); + + await deleteQualificationRecord(req, res); + + expect(res.statusCode).to.equal(204); + }); + + it('should return with a status of 204 when the qualifications record is deleted with no qualifications certificates', async () => { + stubs.restoreQualificationsRecord.returns(mockQualificationsRecordWithoutCertificates); + stubs.destroyQualificationsRecord.returns(1); + + await deleteQualificationRecord(req, res); + + expect(res.statusCode).to.equal(204); + }); + + describe('errors', () => { + describe('restoring qualifications record', () => { + it('should pass through status code if one is provided', async () => { + stubs.restoreQualificationsRecord.throws(new HttpError('Test error message', 123)); + req.params.qualificationUid = 'mockQualificationId'; + + await deleteQualificationRecord(req, res); + + expect(res.statusCode).to.equal(123); + }); + + it('should return a 404 status code if there is an unknown worker uid', async () => { + qualificationsRecord_workerUid = 'mockWorkerUid'; + + await deleteQualificationRecord(req, res); + + const response = res._getData(); + + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); + }); + + it('should return a 500 status code if there is an error loading the qualifications record', async () => { + stubs.restoreQualificationsRecord.throws(); + + await deleteQualificationRecord(req, res); + + expect(res.statusCode).to.equal(500); + }); + }); + + describe('deleting qualifications record', () => { + it('should return with a status of 404 when there is an error deleting the qualifications record from the database', async () => { + stubs.restoreQualificationsRecord.returns(mockQualificationsRecordWithCertificates); + stubs.destroyQualificationsRecord.returns(0); + + await deleteQualificationRecord(req, res); + + const response = res._getData(); + + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); + }); + }); + }); + }); +}); diff --git a/backend/server/test/unit/routes/establishments/training/index.spec.js b/backend/server/test/unit/routes/establishments/training/index.spec.js index 04fcd16674..b57520f231 100644 --- a/backend/server/test/unit/routes/establishments/training/index.spec.js +++ b/backend/server/test/unit/routes/establishments/training/index.spec.js @@ -2,14 +2,13 @@ const expect = require('chai').expect; const sinon = require('sinon'); const httpMocks = require('node-mocks-http'); const buildUser = require('../../../../factories/user'); -const models = require('../../../../../models'); const Training = require('../../../../../models/classes/training').Training; const { deleteTrainingRecord } = require('../../../../../routes/establishments/training/index'); const { mockTrainingRecordWithCertificates, mockTrainingRecordWithoutCertificates, } = require('../../../mockdata/training'); -const TrainingCertificateRoute = require('../../../../../routes/establishments/workerCertificate/trainingCertificate'); +const HttpError = require('../../../../../utils/errors/httpError'); describe('server/routes/establishments/training/index.js', () => { afterEach(() => { @@ -21,14 +20,11 @@ describe('server/routes/establishments/training/index.js', () => { describe('deleteTrainingRecord', () => { let req; let res; - let stubFindTrainingRecord; - let stubRestoredTrainingRecord; - let stubDeleteTrainingCertificatesFromDatabase; - let stubDestroyTrainingRecord; let workerUid = mockTrainingRecordWithCertificates.workerUid; let trainingUid = mockTrainingRecordWithCertificates.uid; let establishmentUid = user.establishment.uid; let trainingRecord; + let trainingCertificateService; beforeEach(() => { req = httpMocks.createRequest({ @@ -39,93 +35,110 @@ describe('server/routes/establishments/training/index.js', () => { }); res = httpMocks.createResponse(); - trainingRecord = new Training(establishmentUid, workerUid); + trainingRecord = sinon.createStubInstance(Training); + trainingRecord.restore.resolves(true); - stubRestoredTrainingRecord = sinon.stub(trainingRecord, 'restore'); - stubFindTrainingRecord = sinon.stub(models.workerTraining, 'findOne'); - stubDeleteTrainingCertificatesFromDatabase = sinon.stub(TrainingCertificateRoute, 'deleteRecordsFromDatabase'); - sinon.stub(TrainingCertificateRoute, 'deleteCertificatesFromS3').returns([]); - stubDestroyTrainingRecord = sinon.stub(models.workerTraining, 'destroy'); + trainingCertificateService = { + deleteCertificates: sinon.stub(), + }; }); - it('should return with a status of 204 when the training record is deleted with training certificates', async () => { - stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubDeleteTrainingCertificatesFromDatabase.returns(true); - stubDestroyTrainingRecord.returns(1); + describe('With training certificates', () => { + beforeEach(() => { + trainingRecord.trainingCertificates = mockTrainingRecordWithCertificates.trainingCertificates; + trainingRecord.delete.resolves(1); + }); - await deleteTrainingRecord(req, res); + it('should return with a status of 204', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - expect(res.statusCode).to.equal(204); - }); + expect(res.statusCode).to.equal(204); + }); - it('should return with a status of 204 when the training record is deleted with no training certificates', async () => { - stubRestoredTrainingRecord.returns(mockTrainingRecordWithoutCertificates); - stubFindTrainingRecord.returns(mockTrainingRecordWithoutCertificates); - stubDestroyTrainingRecord.returns(1); + it('should call delete on training instance', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - await deleteTrainingRecord(req, res); + expect(trainingRecord.delete).to.have.been.calledOnce; + }); - expect(res.statusCode).to.equal(204); + it('should call deleteCertificates', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); + + expect(trainingCertificateService.deleteCertificates.calledOnce).to.be.true; + expect( + trainingCertificateService.deleteCertificates.calledWith( + mockTrainingRecordWithCertificates.trainingCertificates, + establishmentUid, + workerUid, + trainingUid, + ), + ).to.be.true; + }); }); - describe('errors', () => { - describe('restoring training record', () => { - it('should return a 500 status code if there is an invalid training uid', async () => { - req.params.trainingUid = 'mockTrainingUid'; + describe('Without training certificates', () => { + beforeEach(() => { + trainingRecord.trainingCertificates = null; + trainingRecord.delete.resolves(1); + }); - await deleteTrainingRecord(req, res); + it('should return with a status of 204 when successful', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - expect(res.statusCode).to.equal(500); - }); + expect(res.statusCode).to.equal(204); + }); - it('should return a 404 status code if there is an unknown worker uid', async () => { - trainingRecord_workerUid = 'mockWorkerUid'; + it('should call delete on training instance', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - await deleteTrainingRecord(req, res); + expect(trainingRecord.delete).to.have.been.calledOnce; + }); - const response = res._getData(); + it('should not call deleteCertificates when no training certificates', async () => { + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - expect(res.statusCode).to.equal(404); - expect(response).to.equal('Not Found'); - }); + expect(trainingCertificateService.deleteCertificates.called).to.be.false; + }); + }); + + describe('errors', () => { + it('should pass through status code if one is provided', async () => { + trainingRecord.restore.throws(new HttpError('Test error message', 123)); - it('should return a 500 status code if there is an error loading the training record', async () => { - stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubFindTrainingRecord.throws(); + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - await deleteTrainingRecord(req, res); + expect(res.statusCode).to.equal(123); + }); + + it('should default to status code 500 if no error code is provided', async () => { + trainingRecord.restore.throws(new Error()); - expect(res.statusCode).to.equal(500); - }); + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); + + expect(res.statusCode).to.equal(500); }); - describe('deleting certificates', () => { - it('should return with a status of 500 when there is an error deleting certificates from the database', async () => { - stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubDeleteTrainingCertificatesFromDatabase.returns(false); + it('should return a 404 status code when failed to restore training (e.g. unknown worker uid)', async () => { + trainingRecord.restore.resolves(false); + + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - await deleteTrainingRecord(req, res); + const response = res._getData(); - expect(res.statusCode).to.equal(500); - }); + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); }); - describe('deleting training record', () => { - it('should return with a status of 404 when there is an error deleting the training record from the database', async () => { - stubRestoredTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubFindTrainingRecord.returns(mockTrainingRecordWithCertificates); - stubDeleteTrainingCertificatesFromDatabase.returns(true); - stubDestroyTrainingRecord.returns(0); + it('should return with a status of 404 when there is an error deleting the training record from the database', async () => { + trainingRecord.restore.resolves(true); + trainingRecord.delete.resolves(0); - await deleteTrainingRecord(req, res); + await deleteTrainingRecord(req, res, trainingRecord, trainingCertificateService); - const response = res._getData(); + const response = res._getData(); - expect(res.statusCode).to.equal(404); - expect(response).to.equal('Not Found'); - }); + expect(res.statusCode).to.equal(404); + expect(response).to.equal('Not Found'); }); }); }); diff --git a/backend/server/test/unit/routes/establishments/workerCertificate/qualificationsCertificate.spec.js b/backend/server/test/unit/routes/establishments/workerCertificate/qualificationsCertificate.spec.js new file mode 100644 index 0000000000..88830ec799 --- /dev/null +++ b/backend/server/test/unit/routes/establishments/workerCertificate/qualificationsCertificate.spec.js @@ -0,0 +1,256 @@ +const sinon = require('sinon'); +const expect = require('chai').expect; +const httpMocks = require('node-mocks-http'); + +const buildUser = require('../../../../factories/user'); +const { qualificationBuilder } = require('../../../../factories/models'); + +const qualificationCertificateRoute = require('../../../../../routes/establishments/workerCertificate/qualificationCertificate'); +const WorkerCertificateService = require('../../../../../routes/establishments/workerCertificate/workerCertificateService'); +const HttpError = require('../../../../../utils/errors/httpError'); + +describe('backend/server/routes/establishments/workerCertificate/qualificationCertificate.js', () => { + const user = buildUser(); + const qualification = qualificationBuilder(); + + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => { }); + + describe('requestUploadUrl', () => { + let res; + + function createReq(override = {}) { + const mockRequestBody = { files: [{ filename: 'cert1.pdf' }, { filename: 'cert2.pdf' }] }; + + const req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/qualifications/${qualification.uid}/certificate`, + body: mockRequestBody, + establishmentId: user.establishment.uid, + ...override, + }); + + return req; + } + + beforeEach(() => { + responsePayload = [{ data: 'someData' }, { data: 'someData2' }]; + stubGetUrl = sinon.stub(WorkerCertificateService.prototype, "requestUploadUrl").returns(responsePayload); + res = httpMocks.createResponse(); + }); + + it('should reply with a status of 200', async () => { + const req = createReq(); + + await qualificationCertificateRoute.requestUploadUrlEndpoint(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should wrap the response data in a files property', async () => { + const req = createReq(); + + await qualificationCertificateRoute.requestUploadUrlEndpoint(req, res); + + const actual = await res._getJSONData(); + + expect(actual).to.deep.equal({ files: responsePayload }); + }); + + it('should reply with status 400 if an exception is thrown', async () => { + const req = createReq({ body: {} }); + + stubGetUrl.throws(new HttpError('Test message', 400)) + + await qualificationCertificateRoute.requestUploadUrlEndpoint(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test message'); + }); + }); + + describe('confirmUpload', () => { + const mockUploadFiles = [ + { filename: 'cert1.pdf', fileId: 'uuid1', etag: 'etag1', key: 'mockKey' }, + { filename: 'cert2.pdf', fileId: 'uuid2', etag: 'etag2', key: 'mockKey2' }, + ]; + + let res; + + beforeEach(() => { + stubConfirmUpload = sinon.stub(WorkerCertificateService.prototype, "confirmUpload").returns(); + sinon.stub(console, 'error'); // mute error log + res = httpMocks.createResponse(); + }); + + const createPutReq = (override) => { + return createReq({ method: 'PUT', body: { files: mockUploadFiles }, ...override }); + }; + + it('should reply with a status of 200 when no exception is thrown', async () => { + const req = createPutReq(); + + await qualificationCertificateRoute.confirmUploadEndpoint(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should call workerCertificateService.confirmUpload with the correct parameter format', async () => { + const req = createPutReq({ body: { files: ['fileName'] }, params: { id: 1, qualificationUid: 3 } }); + + await qualificationCertificateRoute.confirmUploadEndpoint(req, res); + + expect(stubConfirmUpload).to.have.been.calledWith( + ['fileName'], + 3 + ); + }) + + it('should reply with status code 400 when a HttpError is thrown with status code 400', async () => { + const req = createPutReq(); + + stubConfirmUpload.throws(new HttpError('Test exception 400', 400)); + + await qualificationCertificateRoute.confirmUploadEndpoint(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test exception 400'); + }); + + it('should reply with status code 500 when a HttpError is thrown with status code 500', async () => { + const req = createPutReq(); + + stubConfirmUpload.throws(new HttpError('Test exception 500', 500)); + + await qualificationCertificateRoute.confirmUploadEndpoint(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Test exception 500'); + }); + }); + + describe('getPresignedUrlForCertificateDownload', () => { + let res; + let mockFileUid; + let mockFileName; + + beforeEach(() => { + responsePayload = {}; + stubPresignedCertificate = sinon.stub(WorkerCertificateService.prototype, "getPresignedUrlForCertificateDownload").returns(responsePayload); + mockFileUid = 'mockFileUid'; + mockFileName = 'mockFileName'; + req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/qualifications/${qualification.uid}/certificate/download`, + body: { files: [{ uid: mockFileUid, filename: mockFileName }] }, + params: { id: user.establishment.uid, workerId: user.uid, qualificationUid: qualification.uid }, + }); + res = httpMocks.createResponse(); + }); + + it('should reply with a status of 200 when no exception is thrown', async () => { + await qualificationCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should call workerCertificateService.getPresignedUrlForCertificateDowload with the correct parameter format', async () => { + await qualificationCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); + + expect(stubPresignedCertificate).to.have.been.calledWith( + [{ uid: mockFileUid, filename: mockFileName }], + user.establishment.uid, + user.uid, + qualification.uid, + ); + }); + + it('should return reply with status code 400 when a HttpError is thrown with status code 400', async () => { + stubPresignedCertificate.throws(new HttpError('Test exception', 400)); + + await qualificationCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test exception'); + }); + + it('should return reply with status code 500 when a HttpError is thrown with status code 500', async () => { + stubPresignedCertificate.throws(new HttpError('Test exception', 500)); + + await qualificationCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Test exception'); + }); + }); + + describe('delete certificates', () => { + let res; + let mockFileUid1; + let mockFileUid2; + let mockFileUid3; + + let mockKey1; + let mockKey2; + let mockKey3; + + beforeEach(() => { + mockFileUid1 = 'mockFileUid1'; + mockFileUid2 = 'mockFileUid2'; + mockFileUid3 = 'mockFileUid3'; + + mockKey1 = `${user.establishment.uid}/${user.uid}/qualificationsCertificate/${qualification.uid}/${mockFileUid1}`; + mockKey2 = `${user.establishment.uid}/${user.uid}/qualificationsCertificate/${qualification.uid}/${mockFileUid2}`; + mockKey3 = `${user.establishment.uid}/${user.uid}/qualificationsCertificate/${qualification.uid}/${mockFileUid3}`; + req = httpMocks.createRequest({ + method: 'POST', + url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/qualifications/${qualification.uid}/certificate/delete`, + body: { files: [{ uid: mockFileUid1, filename: 'mockFileName1' }] }, + params: { id: user.establishment.uid, workerId: user.uid, qualificationUid: qualification.uid }, + }); + res = httpMocks.createResponse(); + errorMessage = 'DatabaseError'; + stubDeleteCertificates = sinon.stub(WorkerCertificateService.prototype, 'deleteCertificates'); + }); + + it('should return a 200 status when no exceptions are thrown', async () => { + await qualificationCertificateRoute.deleteCertificatesEndpoint(req, res); + + expect(res.statusCode).to.equal(200); + }); + + it('should call workerCertificateService.deleteCertificates with the correct parameter format', async () => { + await qualificationCertificateRoute.deleteCertificatesEndpoint(req, res); + + expect(stubDeleteCertificates).to.have.been.calledWith( + [{ uid: mockFileUid1, filename: 'mockFileName1' }], + user.establishment.uid, + user.uid, + qualification.uid + ); + }); + + describe('errors', () => { + it('should return status code 400 when a HttpError is thrown with status code 400', async () => { + stubDeleteCertificates.throws(new HttpError('Test exception', 400)); + + await qualificationCertificateRoute.deleteCertificatesEndpoint(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test exception'); + }); + + it('should return status code 500 when a HttpError is thrown with status code 500', async () => { + stubDeleteCertificates.throws(new HttpError('Test exception', 500)); + + await qualificationCertificateRoute.deleteCertificatesEndpoint(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Test exception'); + }); + }); + }); +}); \ No newline at end of file diff --git a/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js b/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js index b35ba94f55..486cd2ae36 100644 --- a/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js +++ b/backend/server/test/unit/routes/establishments/workerCertificate/trainingCertificate.spec.js @@ -1,15 +1,14 @@ const sinon = require('sinon'); const expect = require('chai').expect; const httpMocks = require('node-mocks-http'); -const uuid = require('uuid'); -const models = require('../../../../../models'); const buildUser = require('../../../../factories/user'); const { trainingBuilder } = require('../../../../factories/models'); const s3 = require('../../../../../routes/establishments/workerCertificate/s3'); -const config = require('../../../../../config/config'); const trainingCertificateRoute = require('../../../../../routes/establishments/workerCertificate/trainingCertificate'); +const WorkerCertificateService = require('../../../../../routes/establishments/workerCertificate/workerCertificateService'); +const HttpError = require('../../../../../utils/errors/httpError'); describe('backend/server/routes/establishments/workerCertificate/trainingCertificate.js', () => { const user = buildUser(); @@ -19,10 +18,9 @@ describe('backend/server/routes/establishments/workerCertificate/trainingCertifi sinon.restore(); }); - beforeEach(() => {}); + beforeEach(() => { }); describe('requestUploadUrl', () => { - const mockUploadFiles = ['cert1.pdf', 'cert2.pdf']; const mockSignedUrl = 'http://localhost/mock-upload-url'; let res; @@ -42,229 +40,158 @@ describe('backend/server/routes/establishments/workerCertificate/trainingCertifi beforeEach(() => { sinon.stub(s3, 'getSignedUrlForUpload').returns(mockSignedUrl); + responsePayload = [{ data: 'someData' }, { data: 'someData2' }]; + stubGetUrl = sinon.stub(WorkerCertificateService.prototype, "requestUploadUrl").returns(responsePayload); res = httpMocks.createResponse(); }); it('should reply with a status of 200', async () => { const req = createReq(); - await trainingCertificateRoute.requestUploadUrl(req, res); + await trainingCertificateRoute.requestUploadUrlEndpoint(req, res); expect(res.statusCode).to.equal(200); }); - it('should include a signed url for upload and a uuid for each file', async () => { + it('should wrap the response data in a files property', async () => { const req = createReq(); - await trainingCertificateRoute.requestUploadUrl(req, res); + await trainingCertificateRoute.requestUploadUrlEndpoint(req, res); const actual = await res._getJSONData(); - expect(actual.files).to.have.lengthOf(mockUploadFiles.length); - - actual.files.forEach((file) => { - const { fileId, filename, signedUrl } = file; - expect(uuid.validate(fileId)).to.be.true; - expect(filename).to.be.oneOf(mockUploadFiles); - expect(signedUrl).to.equal(mockSignedUrl); - }); + expect(actual).to.deep.equal({ files: responsePayload }); }); - it('should reply with status 400 if files param was missing in body', async () => { + it('should reply with status 400 if an exception is thrown', async () => { const req = createReq({ body: {} }); - await trainingCertificateRoute.requestUploadUrl(req, res); - - expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('Missing `files` param in request body'); - }); - - it('should reply with status 400 if filename was missing in any of the files', async () => { - const req = createReq({ body: { files: [{ filename: 'file1.pdf' }, { anotherItem: 'no file name' }] } }); + stubGetUrl.throws(new HttpError('Test message', 400)) - await trainingCertificateRoute.requestUploadUrl(req, res); + await trainingCertificateRoute.requestUploadUrlEndpoint(req, res); expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('Missing file name in request body'); + expect(res._getData()).to.equal('Test message'); }); + }); - describe('confirmUpload', () => { - const mockUploadFiles = [ - { filename: 'cert1.pdf', fileId: 'uuid1', etag: 'etag1', key: 'mockKey' }, - { filename: 'cert2.pdf', fileId: 'uuid2', etag: 'etag2', key: 'mockKey2' }, - ]; - const mockWorkerFk = user.id; - const mockTrainingRecord = { dataValues: { workerFk: user.id, id: training.id } }; - - let stubAddCertificate; - - beforeEach(() => { - sinon.stub(models.workerTraining, 'findOne').returns(mockTrainingRecord); - sinon.stub(s3, 'verifyEtag').returns(true); - stubAddCertificate = sinon.stub(models.trainingCertificates, 'addCertificate'); - sinon.stub(console, 'error'); // mute error log - }); - - const createPutReq = (override) => { - return createReq({ method: 'PUT', body: { files: mockUploadFiles }, ...override }); - }; - - it('should reply with a status of 200', async () => { - const req = createPutReq(); - - await trainingCertificateRoute.confirmUpload(req, res); - - expect(res.statusCode).to.equal(200); - }); - - it('should add a new record to database for each file', async () => { - const req = createPutReq(); - - await trainingCertificateRoute.confirmUpload(req, res); - - expect(stubAddCertificate).to.have.been.callCount(mockUploadFiles.length); - - mockUploadFiles.forEach((file) => { - expect(stubAddCertificate).to.have.been.calledWith({ - trainingRecordId: training.id, - workerFk: mockWorkerFk, - filename: file.filename, - fileId: file.fileId, - key: file.key, - }); - }); - }); - - it('should reply with status 400 if file param was missing', async () => { - const req = createPutReq({ body: {} }); - - await trainingCertificateRoute.confirmUpload(req, res); + describe('confirmUpload', () => { + const mockUploadFiles = [ + { filename: 'cert1.pdf', fileId: 'uuid1', etag: 'etag1', key: 'mockKey' }, + { filename: 'cert2.pdf', fileId: 'uuid2', etag: 'etag2', key: 'mockKey2' }, + ]; - expect(res.statusCode).to.equal(400); - expect(stubAddCertificate).not.to.be.called; - }); + let res; - it(`should reply with status 400 if training record does not exist in database`, async () => { - models.workerTraining.findOne.restore(); - sinon.stub(models.workerTraining, 'findOne').returns(null); + beforeEach(() => { + stubConfirmUpload = sinon.stub(WorkerCertificateService.prototype, "confirmUpload").returns(); + sinon.stub(console, 'error'); // mute error log + res = httpMocks.createResponse(); + }); - const req = createPutReq(); + const createPutReq = (override) => { + return createReq({ method: 'PUT', body: { files: mockUploadFiles }, ...override }); + }; - await trainingCertificateRoute.confirmUpload(req, res); + it('should reply with a status of 200 when no exception is thrown', async () => { + const req = createPutReq(); - expect(res.statusCode).to.equal(400); - expect(stubAddCertificate).not.to.be.called; - }); + await trainingCertificateRoute.confirmUploadEndpoint(req, res); - it(`should reply with status 400 if etag from request does not match the etag on s3`, async () => { - s3.verifyEtag.restore(); - sinon.stub(s3, 'verifyEtag').returns(false); + expect(res.statusCode).to.equal(200); + }); - const req = createPutReq(); + it('should call workerCertificateService.confirmUpload with the correct parameter format', async () => { + const req = createPutReq({ body: { files: ['fileName'] }, params: { id: 1, trainingUid: 3 } }); - await trainingCertificateRoute.confirmUpload(req, res); + await trainingCertificateRoute.confirmUploadEndpoint(req, res); - expect(res.statusCode).to.equal(400); - expect(stubAddCertificate).not.to.be.called; - }); + expect(stubConfirmUpload).to.have.been.calledWith( + ['fileName'], + 3 + ); + }) - it('should reply with status 400 if the file does not exist on s3', async () => { - s3.verifyEtag.restore(); - sinon.stub(s3, 'verifyEtag').throws('403: UnknownError'); + it('should reply with status code 400 when a HttpError is thrown with status code 400', async () => { + const req = createPutReq(); - const req = createPutReq(); + stubConfirmUpload.throws(new HttpError('Test exception 400', 400)); - await trainingCertificateRoute.confirmUpload(req, res); + await trainingCertificateRoute.confirmUploadEndpoint(req, res); - expect(res.statusCode).to.equal(400); - expect(stubAddCertificate).not.to.be.called; - }); + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test exception 400'); + }); - it('should reply with status 500 if failed to add new certificate record to database', async () => { - stubAddCertificate.throws('DatabaseError'); + it('should reply with status code 500 when a HttpError is thrown with status code 500', async () => { + const req = createPutReq(); - const req = createPutReq(); + stubConfirmUpload.throws(new HttpError('Test exception 500', 500)); - await trainingCertificateRoute.confirmUpload(req, res); + await trainingCertificateRoute.confirmUploadEndpoint(req, res); - expect(res.statusCode).to.equal(500); - }); + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Test exception 500'); }); }); describe('getPresignedUrlForCertificateDownload', () => { - const mockSignedUrl = 'http://localhost/mock-download-url'; let res; let mockFileUid; let mockFileName; beforeEach(() => { - getSignedUrlForDownloadSpy = sinon.stub(s3, 'getSignedUrlForDownload').returns(mockSignedUrl); + responsePayload = {}; + stubPresignedCertificate = sinon.stub(WorkerCertificateService.prototype, "getPresignedUrlForCertificateDownload").returns(responsePayload); mockFileUid = 'mockFileUid'; mockFileName = 'mockFileName'; req = httpMocks.createRequest({ method: 'POST', url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/training/${training.uid}/certificate/download`, - body: { filesToDownload: [{ uid: mockFileUid, filename: mockFileName }] }, - establishmentId: user.establishment.uid, + body: { files: [{ uid: mockFileUid, filename: mockFileName }] }, params: { id: user.establishment.uid, workerId: user.uid, trainingUid: training.uid }, }); res = httpMocks.createResponse(); }); - it('should reply with a status of 200', async () => { - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + it('should reply with a status of 200 when no exception is thrown', async () => { + await trainingCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); expect(res.statusCode).to.equal(200); }); - it('should return an array with signed url for download and file name in response', async () => { - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); - const actual = await res._getJSONData(); - - expect(actual.files).to.deep.equal([{ signedUrl: mockSignedUrl, filename: mockFileName }]); - }); - - it('should call getSignedUrlForDownload with bucket name from config', async () => { - const bucketName = config.get('workerCertificate.bucketname'); + it('should call workerCertificateService.getPresignedUrlForCertificateDowload with the correct parameter format', async () => { + await trainingCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); - expect(getSignedUrlForDownloadSpy.args[0][0].bucket).to.equal(bucketName); + expect(stubPresignedCertificate).to.have.been.calledWith( + [{ uid: mockFileUid, filename: mockFileName }], + user.establishment.uid, + user.uid, + training.uid, + ); }); - it('should call getSignedUrlForDownload with key of formatted uids passed in params', async () => { - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + it('should return reply with status code 400 when a HttpError is thrown with status code 400', async () => { + stubPresignedCertificate.throws(new HttpError('Test exception', 400)); - const expectedKey = `${req.params.id}/${req.params.workerId}/trainingCertificate/${req.params.trainingUid}/${mockFileUid}`; - expect(getSignedUrlForDownloadSpy.args[0][0].key).to.equal(expectedKey); - }); - - it('should return 400 status and no files message if no file uids in req body', async () => { - req.body = { filesToDownload: [] }; - - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + await trainingCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('No files provided in request body'); - expect(getSignedUrlForDownloadSpy).not.to.be.called; + expect(res._getData()).to.equal('Test exception'); }); - it('should return 400 status and no files message if no req body', async () => { - req.body = {}; + it('should return reply with status code 500 when a HttpError is thrown with status code 500', async () => { + stubPresignedCertificate.throws(new HttpError('Test exception', 500)); - await trainingCertificateRoute.getPresignedUrlForCertificateDownload(req, res); + await trainingCertificateRoute.getPresignedUrlForCertificateDownloadEndpoint(req, res); - expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('No files provided in request body'); - expect(getSignedUrlForDownloadSpy).not.to.be.called; + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Test exception'); }); }); describe('delete certificates', () => { let res; - let stubDeleteCertificatesFromS3; - let stubDeleteCertificate; - let errorMessage; let mockFileUid1; let mockFileUid2; let mockFileUid3; @@ -284,85 +211,49 @@ describe('backend/server/routes/establishments/workerCertificate/trainingCertifi req = httpMocks.createRequest({ method: 'POST', url: `/api/establishment/${user.establishment.uid}/worker/${user.uid}/training/${training.uid}/certificate/delete`, - body: { filesToDelete: [{ uid: mockFileUid1, filename: 'mockFileName1' }] }, - establishmentId: user.establishment.uid, + body: { files: [{ uid: mockFileUid1, filename: 'mockFileName1' }] }, params: { id: user.establishment.uid, workerId: user.uid, trainingUid: training.uid }, }); res = httpMocks.createResponse(); errorMessage = 'DatabaseError'; - stubDeleteCertificatesFromS3 = sinon.stub(s3, 'deleteCertificatesFromS3'); - stubDeleteCertificate = sinon.stub(models.trainingCertificates, 'deleteCertificate'); - stubCountCertificatesToBeDeleted = sinon.stub(models.trainingCertificates, 'countCertificatesToBeDeleted'); + stubDeleteCertificates = sinon.stub(WorkerCertificateService.prototype, 'deleteCertificates'); }); - it('should return a 200 status when files successfully deleted', async () => { - stubDeleteCertificate.returns(1); - stubDeleteCertificatesFromS3.returns({ Deleted: [{ Key: mockKey1 }] }); - stubCountCertificatesToBeDeleted.returns(1); - - await trainingCertificateRoute.deleteCertificates(req, res); + it('should return a 200 status when no exceptions are thrown', async () => { + await trainingCertificateRoute.deleteCertificatesEndpoint(req, res); expect(res.statusCode).to.equal(200); }); - describe('errors', () => { - it('should return 400 status and message if no files in req body', async () => { - req.body = {}; - - await trainingCertificateRoute.deleteCertificates(req, res); + it('should call workerCertificateService.deleteCertificates with the correct parameter format', async () => { + await trainingCertificateRoute.deleteCertificatesEndpoint(req, res); - expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('No files provided in request body'); - }); + expect(stubDeleteCertificates).to.have.been.calledWith( + [{ uid: mockFileUid1, filename: 'mockFileName1' }], + user.establishment.uid, + user.uid, + training.uid, + ); + }); - it('should return 500 if there was a database error when calling countCertificatesToBeDeleted', async () => { - req.body = { - filesToDelete: [ - { uid: mockFileUid1, filename: 'mockFileName1' }, - { uid: mockFileUid2, filename: 'mockFileName2' }, - { uid: mockFileUid3, filename: 'mockFileName3' }, - ], - }; - stubCountCertificatesToBeDeleted.throws(errorMessage); + describe('errors', () => { + it('should return status code 400 when a HttpError is thrown with status code 400', async () => { + stubDeleteCertificates.throws(new HttpError('Test exception', 400)); - await trainingCertificateRoute.deleteCertificates(req, res); + await trainingCertificateRoute.deleteCertificatesEndpoint(req, res); - expect(res.statusCode).to.equal(500); + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Test exception'); }); - it('should return 500 if there was a database error on DB deleteCertificate call', async () => { - req.body = { - filesToDelete: [ - { uid: mockFileUid1, filename: 'mockFileName1' }, - { uid: mockFileUid2, filename: 'mockFileName2' }, - { uid: mockFileUid3, filename: 'mockFileName3' }, - ], - }; - stubCountCertificatesToBeDeleted.returns(3); - stubDeleteCertificate.throws(errorMessage); + it('should return status code 500 when a HttpError is thrown with status code 500', async () => { + stubDeleteCertificates.throws(new HttpError('Test exception', 500)); - await trainingCertificateRoute.deleteCertificates(req, res); + await trainingCertificateRoute.deleteCertificatesEndpoint(req, res); expect(res.statusCode).to.equal(500); - }); - - it('should return 400 status code if the number of records in database does not match request', async () => { - req.body = { - filesToDelete: [ - { uid: mockFileUid1, filename: 'mockFileName1' }, - { uid: mockFileUid2, filename: 'mockFileName2' }, - { uid: mockFileUid3, filename: 'mockFileName3' }, - ], - }; - - stubCountCertificatesToBeDeleted.returns(3); - stubCountCertificatesToBeDeleted.returns(0); - - await trainingCertificateRoute.deleteCertificates(req, res); - - expect(res.statusCode).to.equal(400); - expect(res._getData()).to.equal('Invalid request'); + expect(res._getData()).to.equal('Test exception'); }); }); }); -}); +}); \ No newline at end of file diff --git a/backend/server/test/unit/routes/establishments/workerCertificate/workerCertificateService.spec.js b/backend/server/test/unit/routes/establishments/workerCertificate/workerCertificateService.spec.js new file mode 100644 index 0000000000..bc06a0fd82 --- /dev/null +++ b/backend/server/test/unit/routes/establishments/workerCertificate/workerCertificateService.spec.js @@ -0,0 +1,668 @@ +const sinon = require('sinon'); +const expect = require('chai').expect; +const httpMocks = require('node-mocks-http'); +const uuid = require('uuid'); + +const models = require('../../../../../models'); +const buildUser = require('../../../../factories/user'); +const { qualificationBuilder } = require('../../../../factories/models'); +const s3 = require('../../../../../routes/establishments/workerCertificate/s3'); +const config = require('../../../../../config/config'); + +const WorkerCertificateService = require('../../../../../routes/establishments/workerCertificate/workerCertificateService'); +const HttpError = require('../../../../../utils/errors/httpError'); + +describe('backend/server/routes/establishments/workerCertificate/workerCertificateService.js', () => { + const user = buildUser(); + const qualification = qualificationBuilder(); + let services; + let stubs; + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => { + stubs = {}; + services = { + qualifications: WorkerCertificateService.initialiseQualifications(), + training: WorkerCertificateService.initialiseTraining(), + }; + }); + + describe('requestUploadUrl', () => { + const mockUploadFiles = ['cert1.pdf', 'cert2.pdf']; + const mockSignedUrl = 'http://localhost/mock-upload-url'; + + beforeEach(() => { + mockRequestBody = { + files: [{ filename: 'cert1.pdf' }, { filename: 'cert2.pdf' }], + params: { id: 1, workerId: 2, recordUid: 3 }, + }; + sinon.stub(s3, 'getSignedUrlForUpload').returns(mockSignedUrl); + }); + + it('should include a signed url for upload and a uuid for each file', async () => { + const result = await services.qualifications.requestUploadUrl( + mockRequestBody.files, + mockRequestBody.establishmentUid, + mockRequestBody.workerUid, + mockRequestBody.recordUid, + ); + + expect(result).to.have.lengthOf(mockUploadFiles.length); + + result.forEach((file) => { + const { fileId, filename, signedUrl } = file; + expect(uuid.validate(fileId)).to.be.true; + expect(filename).to.be.oneOf(mockUploadFiles); + expect(signedUrl).to.equal(mockSignedUrl); + }); + }); + + it('should throw a HttpError with status 400 if files param was missing in body', async () => { + let error; + + try { + await services.qualifications.requestUploadUrl({ params: { id: 1, workerId: 2, recordUid: 3 } }); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('Missing `files` param in request body'); + }); + + it('should throw a HttpError with status 400 if filename was missing in any of the files', async () => { + try { + await services.qualifications.requestUploadUrl( + [{ filename: 'file1.pdf' }, { somethingElse: 'no file name' }], + 1, + 2, + 3, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('Missing file name in request body'); + }); + }); + + describe('confirmUpload', () => { + const mockUploadFiles = [ + { filename: 'cert1.pdf', fileId: 'uuid1', etag: 'etag1', key: 'mockKey' }, + { filename: 'cert2.pdf', fileId: 'uuid2', etag: 'etag2', key: 'mockKey2' }, + ]; + const mockWorkerFk = user.id; + const mockQualificationRecord = { dataValues: { workerFk: user.id, id: qualification.id } }; + + let stubAddCertificate; + + beforeEach(() => { + stubWorkerQualifications = sinon.stub(models.workerQualifications, 'findOne').returns(mockQualificationRecord); + sinon.stub(s3, 'verifyEtag').returns(true); + stubAddCertificate = sinon.stub(models.qualificationCertificates, 'addCertificate'); + sinon.stub(console, 'error'); // mute error log + }); + + createReq = (override) => { + return { files: mockUploadFiles, params: { establishmentUid: 1, workerId: 2, recordUid: 3 }, ...override }; + }; + + it('should add a new record to database for each file', async () => { + const req = createReq(); + + await services.qualifications.confirmUpload(req.files, req.params.qualificationUid); + + expect(stubAddCertificate).to.have.been.callCount(mockUploadFiles.length); + + mockUploadFiles.forEach((file) => { + expect(stubAddCertificate).to.have.been.calledWith({ + recordId: qualification.id, + workerFk: mockWorkerFk, + filename: file.filename, + fileId: file.fileId, + key: file.key, + }); + }); + }); + + it('should reply with status 400 if file param was missing', async () => { + const req = createReq({ files: [] }); + let error; + try { + await services.qualifications.confirmUpload(req.files, req.params.qualificationUid); + } catch (err) { + error = err; + } + expect(error.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it(`should reply with status 400 if qualification record does not exist in database`, async () => { + models.workerQualifications.findOne.restore(); + sinon.stub(models.workerQualifications, 'findOne').returns(null); + + const req = createReq(); + let error; + + try { + await services.qualifications.confirmUpload(req.files, req.params.qualificationUid); + } catch (err) { + error = err; + } + expect(error.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it(`should reply with status 400 if etag from request does not match the etag on s3`, async () => { + s3.verifyEtag.restore(); + sinon.stub(s3, 'verifyEtag').returns(false); + + const req = createReq(); + let error; + + try { + await services.qualifications.confirmUpload(req.files, req.params.qualificationUid); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it('should reply with status 400 if the file does not exist on s3', async () => { + s3.verifyEtag.restore(); + sinon.stub(s3, 'verifyEtag').throws('403: UnknownError'); + + const req = createReq(); + let error; + + try { + await services.qualifications.confirmUpload(req.files, req.params.recordUid); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(stubAddCertificate).not.to.be.called; + }); + + it('should reply with status 500 if failed to add new certificate record to database', async () => { + stubAddCertificate.throws('DatabaseError'); + + const req = createReq(); + let error; + + try { + await services.qualifications.confirmUpload(req.files, req.params.recordUid); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(500); + }); + }); + + describe('getPresignedUrlForCertificateDownload', () => { + const mockSignedUrl = 'http://localhost/mock-download-url'; + let res; + let mockFileUid; + let mockFileName; + + beforeEach(() => { + getSignedUrlForDownloadSpy = sinon.stub(s3, 'getSignedUrlForDownload').returns(mockSignedUrl); + sinon.stub(services.qualifications, 'getFileKeys').callsFake((workerUid, recordUid, fileIds) => { + return fileIds.map((fileId) => ({ + uid: fileId, + key: `${user.establishment.uid}/${workerUid}/qualificationCertificate/${recordUid}/${fileId}`, + })); + }); + + mockFileUid = 'mockFileUid'; + mockFileName = 'mockFileName'; + req = { + files: [{ uid: mockFileUid, filename: mockFileName }], + params: { establishmentUid: user.establishment.uid, workerUid: user.uid, recordUid: qualification.uid }, + }; + }); + + it('should return an array with signed url for download and file name in response', async () => { + const actual = await services.qualifications.getPresignedUrlForCertificateDownload( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + + expect(actual).to.deep.equal([{ signedUrl: mockSignedUrl, filename: mockFileName }]); + }); + + it('should call getSignedUrlForDownload with bucket name from config', async () => { + const bucketName = config.get('workerCertificate.bucketname'); + + await services.qualifications.getPresignedUrlForCertificateDownload( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + expect(getSignedUrlForDownloadSpy.args[0][0].bucket).to.equal(bucketName); + }); + + it('should call getSignedUrlForDownload with key of formatted uids passed in params', async () => { + await services.qualifications.getPresignedUrlForCertificateDownload( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + + const expectedKey = `${req.params.establishmentUid}/${req.params.workerUid}/qualificationCertificate/${req.params.recordUid}/${mockFileUid}`; + expect(getSignedUrlForDownloadSpy.args[0][0].key).to.equal(expectedKey); + }); + + it('should return 400 status and no files message if no files provided in file parameter', async () => { + req.files = []; + + let error; + try { + await services.qualifications.getPresignedUrlForCertificateDownload( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('No files provided in request body'); + expect(getSignedUrlForDownloadSpy).not.to.be.called; + }); + }); + + describe('delete certificates', () => { + let errorMessage; + let mockFileUid1; + let mockFileUid2; + let mockFileUid3; + + let mockKey1; + let mockKey2; + let mockKey3; + + beforeEach(() => { + mockFileUid1 = 'mockFileUid1'; + mockFileUid2 = 'mockFileUid2'; + mockFileUid3 = 'mockFileUid3'; + + mockKey1 = `${user.establishment.uid}/${user.uid}/qualificationCertificate/${qualification.uid}/${mockFileUid1}`; + mockKey2 = `${user.establishment.uid}/${user.uid}/qualificationCertificate/${qualification.uid}/${mockFileUid2}`; + mockKey3 = `${user.establishment.uid}/${user.uid}/qualificationCertificate/${qualification.uid}/${mockFileUid3}`; + req = httpMocks.createRequest({ + files: [{ uid: mockFileUid1, filename: 'mockFileName1' }], + params: { establishmentUid: user.establishment.uid, workerUid: user.uid, recordUid: qualification.uid }, + }); + errorMessage = 'DatabaseError'; + stubs.deleteCertificatesFromS3 = sinon.stub(s3, 'deleteCertificatesFromS3'); + stubs.deleteCertificate = sinon.stub(models.qualificationCertificates, 'deleteCertificate'); + stubs.countCertificatesToBeDeleted = sinon.stub(models.qualificationCertificates, 'countCertificatesToBeDeleted'); + + sinon.stub(services.qualifications, 'getFileKeys').callsFake((workerUid, recordUid, fileIds) => { + return fileIds.map((fileId) => ({ + uid: fileId, + key: `${user.establishment.uid}/${workerUid}/qualificationCertificate/${recordUid}/${fileId}`, + })); + }); + }); + + it('should delete certificate from S3', async () => { + const bucketName = config.get('workerCertificate.bucketname'); + stubs.deleteCertificate.returns(1); + stubs.deleteCertificatesFromS3.returns({ Deleted: [{ Key: mockKey1 }] }); + stubs.countCertificatesToBeDeleted.returns(1); + + await services.qualifications.deleteCertificates( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + + expect(stubs.deleteCertificatesFromS3).to.be.calledWith({ + bucket: bucketName, + objects: [ + { + Key: `${req.params.establishmentUid}/${req.params.workerUid}/qualificationCertificate/${req.params.recordUid}/${mockFileUid1}`, + }, + ], + }); + }); + + describe('errors', () => { + it('should throw a HttpError with status code 400 and message if no files in req body', async () => { + req.files = []; + let error; + + try { + await services.qualifications.deleteCertificates( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('No files provided in request body'); + }); + + it('should throw a HttpError with status code 500 if there was a database error when calling countCertificatesToBeDeleted', async () => { + req.files = [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ]; + stubs.countCertificatesToBeDeleted.throws(errorMessage); + let error; + + try { + await services.qualifications.deleteCertificates( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(500); + }); + + it('should throw a HttpError with status code 500 if there was a database error on DB deleteCertificate call', async () => { + req.files = [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ]; + stubs.countCertificatesToBeDeleted.returns(3); + stubs.deleteCertificate.throws(errorMessage); + let error; + + try { + await services.qualifications.deleteCertificates( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(500); + }); + + it('should throw a HttpError with status code 400 if the number of records in database does not match request', async () => { + req.files = [ + { uid: mockFileUid1, filename: 'mockFileName1' }, + { uid: mockFileUid2, filename: 'mockFileName2' }, + { uid: mockFileUid3, filename: 'mockFileName3' }, + ]; + + stubs.countCertificatesToBeDeleted.returns(1); + + try { + await services.qualifications.deleteCertificates( + req.files, + req.params.establishmentUid, + req.params.workerUid, + req.params.recordUid, + ); + } catch (err) { + error = err; + } + + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('Invalid request'); + }); + }); + }); + + describe('deleteAllCertificates', () => { + const trainingCertificatesReturnedFromDb = [ + { uid: 'abc123', key: 'abc123/trainingCertificate/dasdsa12312' }, + { uid: 'def456', key: 'def456/trainingCertificate/deass12092' }, + { uid: 'ghi789', key: 'ghi789/trainingCertificate/da1412342' }, + ]; + + const qualificationCertificatesReturnedFromDb = [ + { uid: 'abc123', key: 'abc123/qualificationCertificate/dasdsa12312' }, + { uid: 'def456', key: 'def456/qualificationCertificate/deass12092' }, + { uid: 'ghi789', key: 'ghi789/qualificationCertificate/da1412342' }, + ]; + + beforeEach(() => { + stubs.getAllTrainingCertificatesForUser = sinon + .stub(models.trainingCertificates, 'getAllCertificateRecordsForWorker') + .resolves(trainingCertificatesReturnedFromDb); + stubs.deleteTrainingCertificatesFromDb = sinon.stub(models.trainingCertificates, 'deleteCertificate'); + stubs.getAllQualificationCertificatesForUser = sinon + .stub(models.qualificationCertificates, 'getAllCertificateRecordsForWorker') + .resolves(qualificationCertificatesReturnedFromDb); + stubs.deleteQualificationCertificatesFromDb = sinon.stub(models.qualificationCertificates, 'deleteCertificate'); + stubs.deleteCertificatesFromS3 = sinon.stub(s3, 'deleteCertificatesFromS3'); + }); + + describe('Training:', () => { + it('should get all certificates for user', async () => { + const transaction = models.sequelize.transaction(); + await services.training.deleteAllCertificates(12345, transaction); + + expect(stubs.getAllTrainingCertificatesForUser).to.be.calledWith(12345); + }); + + it('should not make DB or S3 deletion calls if no training certificates found', async () => { + stubs.getAllTrainingCertificatesForUser.resolves([]); + + const transaction = models.sequelize.transaction(); + await services.training.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteTrainingCertificatesFromDb).to.not.have.been.called; + expect(stubs.deleteCertificatesFromS3).to.not.have.been.called; + }); + + it('should call deleteCertificate on DB model with uids returned from getAllTrainingCertificateRecordsForWorker and pass in transaction', async () => { + const transaction = await models.sequelize.transaction(); + await services.training.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteTrainingCertificatesFromDb.args[0][0]).to.deep.equal([ + trainingCertificatesReturnedFromDb[0].uid, + trainingCertificatesReturnedFromDb[1].uid, + trainingCertificatesReturnedFromDb[2].uid, + ]); + + expect(stubs.deleteTrainingCertificatesFromDb.args[0][1]).to.deep.equal(transaction); + }); + + it('should call deleteCertificatesFromS3 with keys returned from getAllTrainingCertificateRecordsForWorker', async () => { + const bucketName = config.get('workerCertificate.bucketname'); + const transaction = await models.sequelize.transaction(); + + await services.training.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteCertificatesFromS3.args[0][0]).to.deep.equal({ + bucket: bucketName, + objects: [ + { Key: trainingCertificatesReturnedFromDb[0].key }, + { Key: trainingCertificatesReturnedFromDb[1].key }, + { Key: trainingCertificatesReturnedFromDb[2].key }, + ], + }); + }); + }); + + describe('Qualifications:', () => { + it('should get all certificates for user', async () => { + const transaction = models.sequelize.transaction(); + await services.qualifications.deleteAllCertificates(12345, transaction); + + expect(stubs.getAllQualificationCertificatesForUser).to.be.calledWith(12345); + }); + + it('should not make DB or S3 deletion calls if no qualification certificates found', async () => { + stubs.getAllQualificationCertificatesForUser.resolves([]); + + const transaction = models.sequelize.transaction(); + await services.qualifications.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteQualificationCertificatesFromDb).to.not.have.been.called; + expect(stubs.deleteCertificatesFromS3).to.not.have.been.called; + }); + + it('should call deleteCertificate on DB model with uids returned from getAllQualificationCertificateRecordsForWorker and pass in transaction', async () => { + const transaction = await models.sequelize.transaction(); + await services.qualifications.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteQualificationCertificatesFromDb.args[0][0]).to.deep.equal([ + qualificationCertificatesReturnedFromDb[0].uid, + qualificationCertificatesReturnedFromDb[1].uid, + qualificationCertificatesReturnedFromDb[2].uid, + ]); + + expect(stubs.deleteQualificationCertificatesFromDb.args[0][1]).to.deep.equal(transaction); + }); + + it('should call deleteCertificatesFromS3 with keys returned from getAllQualificationCertificateRecordsForWorker', async () => { + const bucketName = config.get('workerCertificate.bucketname'); + const transaction = await models.sequelize.transaction(); + + await services.qualifications.deleteAllCertificates(12345, transaction); + + expect(stubs.deleteCertificatesFromS3.args[0][0]).to.deep.equal({ + bucket: bucketName, + objects: [ + { Key: qualificationCertificatesReturnedFromDb[0].key }, + { Key: qualificationCertificatesReturnedFromDb[1].key }, + { Key: qualificationCertificatesReturnedFromDb[2].key }, + ], + }); + }); + }); + }); + + describe('getFileKeys', () => { + const mockFileIds = ['mock-file-id-1', 'mock-file-id-2', 'mock-file-id-3']; + const mockRecords = [ + { uid: 'mock-file-id-1', key: 'file-key-formock-file-id-1' }, + { uid: 'mock-file-id-2', key: 'file-key-formock-file-id-2' }, + { uid: 'mock-file-id-3', key: 'file-key-formock-file-id-3' }, + ]; + + it('should return an array that contain every key for the given certificate records', async () => { + sinon.stub(models.qualificationCertificates, 'findAll').resolves(mockRecords); + + const actual = await services.qualifications.getFileKeys(user.uid, qualification.uid, mockFileIds); + expect(actual).to.deep.equal(mockRecords); + }); + + it('should throw an error if certificate records are not found', async () => { + sinon.stub(models.qualificationCertificates, 'findAll').resolves([]); + + try { + await services.qualifications.getFileKeys(user.uid, qualification.uid, mockFileIds); + } catch (err) { + error = err; + } + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('Failed to find related qualification certificate records'); + }); + + it('should throw an error if the number of certificate records found does not match the number of given ids', async () => { + sinon.stub(models.qualificationCertificates, 'findAll').resolves(mockRecords.slice(0, 1)); + + try { + await services.qualifications.getFileKeys(user.uid, qualification.uid, mockFileIds); + } catch (err) { + error = err; + } + expect(error.statusCode).to.equal(400); + expect(error.message).to.equal('Failed to find related qualification certificate records'); + }); + }); + + describe('deleteCertificatesWithTransaction', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockCertificateRecords = [ + new models.qualificationCertificates({ key: 'file-key-1', uid: 'file-uid-1' }), + new models.qualificationCertificates({ key: 'file-key-2', uid: 'file-uid-2' }), + new models.qualificationCertificates({ key: 'file-key-3', uid: 'file-uid-3' }), + ]; + + it('should delete every certificate records that are passed in', async () => { + const stubDeleteCertificate = sinon.stub(models.qualificationCertificates, 'destroy'); + const mockExternalTransaction = { mockTransaction: '', afterCommit: sinon.stub() }; + + await services.qualifications.deleteCertificatesWithTransaction(mockCertificateRecords, mockExternalTransaction); + + expect(stubDeleteCertificate).to.have.been.calledWith({ + where: { uid: ['file-uid-1', 'file-uid-2', 'file-uid-3'] }, + transaction: mockExternalTransaction, + }); + }); + + it("should register a deleteCertificatesFromS3 call to the transaction's after commit hook", async () => { + sinon.stub(models.qualificationCertificates, 'destroy'); + const stubDeleteCertificatesFromS3 = sinon.stub(services.qualifications, 'deleteCertificatesFromS3'); + const mockExternalTransaction = { mockTransaction: '', afterCommit: sinon.stub() }; + + await services.qualifications.deleteCertificatesWithTransaction(mockCertificateRecords, mockExternalTransaction); + + expect(mockExternalTransaction.afterCommit).to.have.been.called; + + // emulate the afterCommit call being triggered when database operation complete; + const registeredCall = mockExternalTransaction.afterCommit.args[0][0]; + registeredCall(); + + expect(stubDeleteCertificatesFromS3).to.have.been.calledWith([ + { Key: 'file-key-1' }, + { Key: 'file-key-2' }, + { Key: 'file-key-3' }, + ]); + }); + }); + + describe('sendErrorResponse', () => { + let res; + + beforeEach(() => { + res = httpMocks.createResponse(); + }); + + it('should send status code and message from error when HttpError thrown', async () => { + const errorThrown = new HttpError('Invalid request', 400); + + services.qualifications.sendErrorResponse(res, errorThrown); + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.equal('Invalid request'); + }); + + it('should send 500 status code and Internal server error message when unexpected error', async () => { + const errorThrown = new Error('Unexpected error'); + + services.qualifications.sendErrorResponse(res, errorThrown); + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.equal('Internal server error'); + }); + }); +}); diff --git a/backend/server/utils/qualificationRecordsUtils.js b/backend/server/utils/qualificationRecordsUtils.js index 4cda201535..27b7af0460 100644 --- a/backend/server/utils/qualificationRecordsUtils.js +++ b/backend/server/utils/qualificationRecordsUtils.js @@ -12,6 +12,7 @@ const addRecordGroupToSortedQualifications = (sortedQualifications, record) => { year: record.year, notes: record.notes, uid: record.uid, + qualificationCertificates: record.qualificationCertificates, }; const existingGroup = sortedQualifications.groups.find((obj) => obj.group === record.qualification.group); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74aa2d2c02..81211e5736 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@sentry/node": "^6.13.1", "@sentry/tracing": "^6.13.1", "@types/offscreencanvas": "^2019.7.0", + "@zip.js/zip.js": "^2.7.52", "ajv-errors": "^1.0.1", "angulartics2": "^10.0.0", "canvg": "^3.0.7", @@ -6602,6 +6603,16 @@ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.52", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.52.tgz", + "integrity": "sha512-+5g7FQswvrCHwYKNMd/KFxZSObctLSsQOgqBSi0LzwHo3li9Eh1w5cF5ndjQw9Zbr3ajVnd2+XyiX85gAetx1Q==", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -31801,6 +31812,11 @@ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, + "@zip.js/zip.js": { + "version": "2.7.52", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.52.tgz", + "integrity": "sha512-+5g7FQswvrCHwYKNMd/KFxZSObctLSsQOgqBSi0LzwHo3li9Eh1w5cF5ndjQw9Zbr3ajVnd2+XyiX85gAetx1Q==" + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index efba772183..6abfc253ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@sentry/node": "^6.13.1", "@sentry/tracing": "^6.13.1", "@types/offscreencanvas": "^2019.7.0", + "@zip.js/zip.js": "^2.7.52", "ajv-errors": "^1.0.1", "angulartics2": "^10.0.0", "canvg": "^3.0.7", diff --git a/frontend/src/app/core/model/qualification.model.ts b/frontend/src/app/core/model/qualification.model.ts index dafb91c48f..271f33667c 100644 --- a/frontend/src/app/core/model/qualification.model.ts +++ b/frontend/src/app/core/model/qualification.model.ts @@ -1,3 +1,5 @@ +import { Certificate, CertificateDownload } from './trainingAndQualifications.model'; + export enum QualificationType { NVQ = 'NVQ', Other = 'Other type of qualification', @@ -33,6 +35,7 @@ export interface QualificationRequest { } export interface QualificationsResponse { + workerUid: string; count: number; lastUpdated?: string; qualifications: Qualification[]; @@ -44,8 +47,9 @@ export interface QualificationResponse { updated: string; updatedBy: string; qualification: Qualification; - year: number; - notes: string; + year?: number; + notes?: string; + qualificationCertificates?: QualificationCertificate[]; } export interface Qualification { @@ -63,6 +67,7 @@ export interface Qualification { title?: string; group?: string; }; + qualificationCertificates?: QualificationCertificate[]; } export interface QualificationsByGroup { @@ -81,4 +86,21 @@ export interface BasicQualificationRecord { title: string; uid: string; year: number; + qualificationCertificates: QualificationCertificate[]; +} + +export interface QualificationCertificate extends Certificate {} + +export interface QualificationCertificateDownloadEvent { + recordType: 'qualification'; + recordUid: string; + qualificationType: QualificationType; + filesToDownload: CertificateDownload[]; +} + +export interface QualificationCertificateUploadEvent { + recordType: 'qualification'; + recordUid: string; + qualificationType: QualificationType; + files: File[]; } diff --git a/frontend/src/app/core/model/training.model.ts b/frontend/src/app/core/model/training.model.ts index 855039d5ef..76c53b5ec6 100644 --- a/frontend/src/app/core/model/training.model.ts +++ b/frontend/src/app/core/model/training.model.ts @@ -1,3 +1,5 @@ +import { Certificate, CertificateDownload } from './trainingAndQualifications.model'; + export interface TrainingCategory { id: number; seq: number; @@ -36,21 +38,21 @@ export interface TrainingResponse { training: TrainingRecord[]; } -export interface CertificateDownload { - uid: string; - filename: string; +export interface TrainingCertificateDownloadEvent { + recordType: 'training'; + recordUid: string; + categoryName: string; + filesToDownload: CertificateDownload[]; } -export interface CertificateUpload { +export interface TrainingCertificateUploadEvent { + recordType: 'training'; + recordUid: string; + categoryName: string; files: File[]; - trainingRecord: TrainingRecord; } -export interface TrainingCertificate { - uid: string; - filename: string; - uploadDate: string; -} +export interface TrainingCertificate extends Certificate {} export interface TrainingRecord { accredited?: boolean; @@ -132,29 +134,3 @@ export interface TrainingRecordCategories { training: Training[]; isMandatory: boolean; } - -export interface UploadCertificateSignedUrlRequest { - files: { filename: string }[]; -} - -export interface UploadCertificateSignedUrlResponse { - files: { filename: string; fileId: string; signedUrl: string; key: string }[]; -} - -export interface DownloadCertificateSignedUrlResponse { - files: { filename: string; signedUrl: string }[]; -} - -export interface S3UploadResponse { - headers: { etag: string }; -} -export interface FileInfoWithETag { - filename: string; - fileId: string; - etag: string; - key: string; -} - -export interface ConfirmUploadRequest { - files: { filename: string; fileId: string; etag: string }[]; -} diff --git a/frontend/src/app/core/model/trainingAndQualifications.model.ts b/frontend/src/app/core/model/trainingAndQualifications.model.ts index ac9d5a7b4f..75dd79828c 100644 --- a/frontend/src/app/core/model/trainingAndQualifications.model.ts +++ b/frontend/src/app/core/model/trainingAndQualifications.model.ts @@ -1,5 +1,9 @@ -import { QualificationsByGroup } from './qualification.model'; -import { TrainingRecords } from './training.model'; +import { + QualificationsByGroup, + QualificationCertificateDownloadEvent, + QualificationCertificateUploadEvent, +} from './qualification.model'; +import { TrainingRecords, TrainingCertificateDownloadEvent, TrainingCertificateUploadEvent } from './training.model'; export interface TrainingAndQualificationRecords { qualifications: QualificationsByGroup; @@ -14,3 +18,44 @@ export interface TrainingCounts { missingMandatoryTraining?: number; staffMissingMandatoryTraining?: number; } + +export interface Certificate { + uid: string; + filename: string; + uploadDate: string; +} + +export interface CertificateDownload { + uid: string; + filename: string; +} + +export type CertificateDownloadEvent = TrainingCertificateDownloadEvent | QualificationCertificateDownloadEvent; + +export type CertificateUploadEvent = TrainingCertificateUploadEvent | QualificationCertificateUploadEvent; + +export interface UploadCertificateSignedUrlRequest { + files: { filename: string }[]; +} + +export interface UploadCertificateSignedUrlResponse { + files: { filename: string; fileId: string; signedUrl: string; key: string }[]; +} + +export interface DownloadCertificateSignedUrlResponse { + files: { filename: string; signedUrl: string }[]; +} + +export interface S3UploadResponse { + headers: { etag: string }; +} +export interface FileInfoWithETag { + filename: string; + fileId: string; + etag: string; + key: string; +} + +export interface ConfirmUploadRequest { + files: { filename: string; fileId: string; etag: string }[]; +} diff --git a/frontend/src/app/core/services/certificate.service.spec.ts b/frontend/src/app/core/services/certificate.service.spec.ts new file mode 100644 index 0000000000..ff7521b7a2 --- /dev/null +++ b/frontend/src/app/core/services/certificate.service.spec.ts @@ -0,0 +1,337 @@ +import { toArray } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { + mockQualificationRecordsResponse, + mockTrainingRecordsResponse, + qualificationUidsWithCerts, + qualificationUidsWithoutCerts as qualificationUidsNoCerts, + trainingUidsWithCerts, + trainingUidsWithoutCerts as trainingUidsNoCerts, +} from '@core/test-utils/MockCertificateService'; + +import { QualificationCertificateService, TrainingCertificateService } from './certificate.service'; +import { mockCertificateFileBlob } from '../test-utils/MockCertificateService'; + +describe('CertificateService', () => { + const testConfigs = [ + { + certificateType: 'training', + serviceClass: TrainingCertificateService, + }, + { + certificateType: 'qualification', + serviceClass: QualificationCertificateService, + }, + ]; + testConfigs.forEach(({ certificateType, serviceClass }) => { + describe(`for Certificate type: ${certificateType}`, () => { + let service: TrainingCertificateService | QualificationCertificateService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [serviceClass], + }); + if (certificateType === 'training') { + service = TestBed.inject(TrainingCertificateService); + } else { + service = TestBed.inject(QualificationCertificateService); + } + + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + TestBed.inject(HttpTestingController).verify(); + }); + + describe('addCertificates', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + + const mockUploadFiles = [new File([''], 'certificate.pdf')]; + const mockFileId = 'mockFileId'; + const mockEtagFromS3 = 'mock-etag'; + const mockSignedUrl = 'http://localhost/mock-signed-url-for-upload'; + + const certificateEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate`; + + it('should call to backend to retreive a signed url to upload certificate', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + expect(signedUrlRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains filename', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + + const expectedRequestBody = { files: [{ filename: 'certificate.pdf' }] }; + + expect(signedUrlRequest.request.body).toEqual(expectedRequestBody); + }); + + it('should upload the file with the signed url received from backend', async () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[0]); + }); + + it('should call to backend to confirm upload complete', async () => { + const mockKey = 'abcd/adsadsadvfdv/123dsvf'; + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne(certificateEndpoint); + signedUrlRequest.flush({ + files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId, key: mockKey }], + }); + + const uploadToS3Request = http.expectOne(mockSignedUrl); + uploadToS3Request.flush(null, { headers: { etag: mockEtagFromS3 } }); + + const confirmUploadRequest = http.expectOne(certificateEndpoint); + const expectedconfirmUploadReqBody = { + files: [{ filename: mockUploadFiles[0].name, fileId: mockFileId, etag: mockEtagFromS3, key: mockKey }], + }; + + expect(confirmUploadRequest.request.method).toBe('PUT'); + expect(confirmUploadRequest.request.body).toEqual(expectedconfirmUploadReqBody); + }); + + describe('multiple files upload', () => { + const mockUploadFilenames = ['certificate1.pdf', 'certificate2.pdf', 'certificate3.pdf']; + const mockUploadFiles = mockUploadFilenames.map((filename) => new File([''], filename)); + const mockFileIds = ['fileId1', 'fileId2', 'fileId3']; + const mockEtags = ['etag1', 'etag2', 'etag3']; + const mockSignedUrls = mockFileIds.map((fileId) => `${mockSignedUrl}/${fileId}`); + const mockKeys = mockFileIds.map((fileId) => `${fileId}/mockKey`); + + const mockSignedUrlResponse = { + files: mockUploadFilenames.map((filename, index) => ({ + filename, + signedUrl: mockSignedUrls[index], + fileId: mockFileIds[index], + key: mockKeys[index], + })), + }; + + const expectedSignedUrlReqBody = { + files: [ + { filename: mockUploadFiles[0].name }, + { filename: mockUploadFiles[1].name }, + { filename: mockUploadFiles[2].name }, + ], + }; + const expectedConfirmUploadReqBody = { + files: [ + { filename: mockUploadFiles[0].name, fileId: mockFileIds[0], etag: mockEtags[0], key: mockKeys[0] }, + { filename: mockUploadFiles[1].name, fileId: mockFileIds[1], etag: mockEtags[1], key: mockKeys[1] }, + { filename: mockUploadFiles[2].name, fileId: mockFileIds[2], etag: mockEtags[2], key: mockKeys[2] }, + ], + }; + + it('should be able to upload multiple files at the same time', () => { + service.addCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockUploadFiles).subscribe(); + + const signedUrlRequest = http.expectOne({ method: 'POST', url: certificateEndpoint }); + expect(signedUrlRequest.request.body).toEqual(expectedSignedUrlReqBody); + + signedUrlRequest.flush(mockSignedUrlResponse); + + mockSignedUrls.forEach((signedUrl, index) => { + const uploadToS3Request = http.expectOne(signedUrl); + expect(uploadToS3Request.request.method).toBe('PUT'); + expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[index]); + + uploadToS3Request.flush(null, { headers: { etag: mockEtags[index] } }); + }); + + const confirmUploadRequest = http.expectOne({ method: 'PUT', url: certificateEndpoint }); + expect(confirmUploadRequest.request.body).toEqual(expectedConfirmUploadReqBody); + }); + }); + }); + + describe('downloadCertificates', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + + const mockFiles = [{ uid: 'mockCertificateUid123', filename: 'mockCertificateName' }]; + + const certificateDownloadEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate/download`; + + it('should make call to expected backend endpoint', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + expect(downloadRequest.request.method).toBe('POST'); + }); + + it('should have request body that contains file uid', async () => { + service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFiles).subscribe(); + + const downloadRequest = http.expectOne(certificateDownloadEndpoint); + const expectedRequestBody = { files: mockFiles }; + + expect(downloadRequest.request.body).toEqual(expectedRequestBody); + }); + }); + + describe('triggerCertificateDownloads', () => { + it('should download certificates by creating and triggering anchor tag, then cleaning DOM', () => { + const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; + const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); + const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); + const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); + const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); + spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); + + service.triggerCertificateDownloads(mockCertificates).subscribe(); + + const downloadReq = http.expectOne(mockCertificates[0].signedUrl); + downloadReq.flush(mockBlob); + + // Assert anchor element appended + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalled(); + + // Assert anchor element has correct attributes + const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; + expect(createdAnchor.href).toBe(mockBlobUrl); + expect(createdAnchor.download).toBe(mockCertificates[0].filename); + + // Assert DOM is cleaned up after download + expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); + + describe('downloadAllCertificatesAsBlobs', () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + + const recordsEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}`; + + const mockRecordsResponse = + certificateType === 'training' ? mockTrainingRecordsResponse : mockQualificationRecordsResponse; + const recordsHavingCertificates = + certificateType === 'training' ? trainingUidsWithCerts : qualificationUidsWithCerts; + const recordsWithoutCertificates = + certificateType === 'training' ? trainingUidsNoCerts : qualificationUidsNoCerts; + + it('should query the backend to get all training / qualification records for the worker', async () => { + service.downloadAllCertificatesAsBlobs(mockWorkplaceUid, mockWorkerUid).subscribe(); + + http.expectOne(recordsEndpoint); + }); + + it('should request backend for signedUrls and download every certificates as blobs', async () => { + service.downloadAllCertificatesAsBlobs(mockWorkplaceUid, mockWorkerUid).subscribe(); + + http.expectOne(recordsEndpoint).flush(mockRecordsResponse); + + recordsHavingCertificates.forEach((recordUid) => { + const certificateDownloadEndpoint = `${recordsEndpoint}/${recordUid}/certificate/download`; + const mockresponse = { + files: [ + { signedUrl: `https://mocks3endpoint/${recordUid}-1.pdf`, filename: `${recordUid}-1.pdf` }, + { signedUrl: `https://mocks3endpoint/${recordUid}-2.pdf`, filename: `${recordUid}-2.pdf` }, + ], + }; + http.expectOne({ url: certificateDownloadEndpoint, method: 'POST' }).flush(mockresponse); + + http.expectOne(`https://mocks3endpoint/${recordUid}-1.pdf`).flush(mockCertificateFileBlob); + http.expectOne(`https://mocks3endpoint/${recordUid}-2.pdf`).flush(mockCertificateFileBlob); + }); + + // should not call certificate download endpoint for records that have no certificates + recordsWithoutCertificates.forEach((recordUid) => { + const certificateDownloadEndpoint = `${recordsEndpoint}/${recordUid}/certificate/download`; + http.expectNone(certificateDownloadEndpoint); + }); + }); + + it('should return an observable for every certificates as file blobs', async () => { + const returnedObservable = service.downloadAllCertificatesAsBlobs(mockWorkplaceUid, mockWorkerUid); + const promise = returnedObservable.pipe(toArray()).toPromise(); + + http.expectOne(recordsEndpoint).flush(mockRecordsResponse); + + recordsHavingCertificates.forEach((recordUid) => { + const certificateDownloadEndpoint = `${recordsEndpoint}/${recordUid}/certificate/download`; + const mockResponse = { + files: [ + { signedUrl: `https://mocks3endpoint/${recordUid}-1.pdf`, filename: `${recordUid}-1.pdf` }, + { signedUrl: `https://mocks3endpoint/${recordUid}-2.pdf`, filename: `${recordUid}-2.pdf` }, + ], + }; + http.expectOne({ url: certificateDownloadEndpoint, method: 'POST' }).flush(mockResponse); + + http.expectOne(`https://mocks3endpoint/${recordUid}-1.pdf`).flush(mockCertificateFileBlob); + http.expectOne(`https://mocks3endpoint/${recordUid}-2.pdf`).flush(mockCertificateFileBlob); + }); + + const allFileBlobs = await promise; + expect(allFileBlobs.length).toEqual(4); + + const expectedFolderName = + certificateType === 'training' ? 'Training certificates' : 'Qualification certificates'; + + for (const recordUid of recordsHavingCertificates) { + expect(allFileBlobs).toContain({ + filename: `${expectedFolderName}/${recordUid}-1.pdf`, + fileBlob: mockCertificateFileBlob, + }); + expect(allFileBlobs).toContain({ + filename: `${expectedFolderName}/${recordUid}-2.pdf`, + fileBlob: mockCertificateFileBlob, + }); + } + }); + }); + + describe('deleteCertificates', () => { + it('should call the endpoint for deleting training certificates', async () => { + const mockWorkplaceUid = 'mockWorkplaceUid'; + const mockWorkerUid = 'mockWorkerUid'; + const mockRecordUid = 'mockRecordUid'; + const mockFilesToDelete = [ + { + uid: 'uid-1', + filename: 'first_aid_v1.pdf', + uploadDate: '2024-09-23T11:02:10.000Z', + }, + ]; + + const deleteCertificatesEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/${certificateType}/${mockRecordUid}/certificate/delete`; + + service.deleteCertificates(mockWorkplaceUid, mockWorkerUid, mockRecordUid, mockFilesToDelete).subscribe(); + + const deleteRequest = http.expectOne(deleteCertificatesEndpoint); + const expectedRequestBody = { files: mockFilesToDelete }; + + expect(deleteRequest.request.method).toBe('POST'); + expect(deleteRequest.request.body).toEqual(expectedRequestBody); + }); + }); + }); + }); +}); diff --git a/frontend/src/app/core/services/certificate.service.ts b/frontend/src/app/core/services/certificate.service.ts new file mode 100644 index 0000000000..20956b85bf --- /dev/null +++ b/frontend/src/app/core/services/certificate.service.ts @@ -0,0 +1,242 @@ +import { capitalize } from 'lodash'; +import { forkJoin, from, Observable } from 'rxjs'; +import { concatMap, filter, map, mergeAll, mergeMap, tap } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Qualification, QualificationCertificate, QualificationsResponse } from '@core/model/qualification.model'; +import { TrainingCertificate, TrainingRecord, TrainingResponse } from '@core/model/training.model'; +import { + ConfirmUploadRequest, + DownloadCertificateSignedUrlResponse, + FileInfoWithETag, + S3UploadResponse, + UploadCertificateSignedUrlRequest, + UploadCertificateSignedUrlResponse, +} from '@core/model/trainingAndQualifications.model'; +import { Certificate, CertificateDownload } from '@core/model/trainingAndQualifications.model'; +import { FileUtil, NamedFileBlob } from '@core/utils/file-util'; + +@Injectable({ + providedIn: 'root', +}) +export class BaseCertificateService { + constructor(protected http: HttpClient) { + if (this.constructor == BaseCertificateService) { + throw new Error("Abstract base class can't be instantiated."); + } + } + + recordType: string; + + protected recordsEndpoint(workplaceUid: string, workerUid: string): string { + throw new Error('Not implemented for base class'); + } + + protected certificateEndpoint(workplaceUid: string, workerUid: string, recordUid: string): string { + throw new Error('Not implemented for base class'); + } + + protected getAllRecords(workplaceUid: string, workerUid: string): Observable { + throw new Error('Not implemented for base class'); + } + + protected certificatesInRecord(record: Qualification | TrainingRecord): Certificate[] { + throw new Error('Not implemented for base class'); + } + + public addCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToUpload: File[], + ): Observable { + const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); + const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; + const endpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); + + return this.http.post(endpoint, requestBody).pipe( + mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), + map((allFileInfoWithETag) => this.buildConfirmUploadRequestBody(allFileInfoWithETag)), + mergeMap((confirmUploadRequestBody) => + this.confirmCertificateUpload(workplaceUid, workerUid, recordUid, confirmUploadRequestBody), + ), + ); + } + + protected uploadAllCertificatestoS3( + signedUrlResponse: UploadCertificateSignedUrlResponse, + filesToUpload: File[], + ): Observable { + const allUploadResults$ = signedUrlResponse.files.map(({ signedUrl, fileId, filename, key }, index) => { + const fileToUpload = filesToUpload[index]; + if (!fileToUpload.name || fileToUpload.name !== filename) { + throw new Error('Invalid response from backend'); + } + return this.uploadOneCertificateToS3(signedUrl, fileId, fileToUpload, key); + }); + + return forkJoin(allUploadResults$); + } + + protected uploadOneCertificateToS3( + signedUrl: string, + fileId: string, + uploadFile: File, + key: string, + ): Observable { + return this.http.put(signedUrl, uploadFile, { observe: 'response' }).pipe( + map((s3response) => ({ + etag: s3response?.headers?.get('etag'), + fileId, + filename: uploadFile.name, + key, + })), + ); + } + + protected buildConfirmUploadRequestBody(allFileInfoWithETag: FileInfoWithETag[]): ConfirmUploadRequest { + return { files: allFileInfoWithETag }; + } + + protected confirmCertificateUpload( + workplaceUid: string, + workerUid: string, + recordUid: string, + confirmUploadRequestBody: ConfirmUploadRequest, + ) { + const endpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.put(endpoint, confirmUploadRequestBody); + } + + public downloadCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], + ): Observable { + return this.getCertificateDownloadUrls(workplaceUid, workerUid, recordUid, filesToDownload).pipe( + mergeMap((res) => this.triggerCertificateDownloads(res['files'])), + ); + } + + public getCertificateDownloadUrls( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], + ): Observable { + const certificateEndpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.post(`${certificateEndpoint}/download`, { + files: filesToDownload, + }); + } + + public triggerCertificateDownloads(files: { signedUrl: string; filename: string }[]): Observable { + const blobsAndFilenames = this.downloadBlobsFromBucket(files); + + return from(blobsAndFilenames).pipe( + mergeAll(), + tap(({ fileBlob, filename }) => FileUtil.triggerSingleFileDownload(fileBlob, filename)), + ); + } + + public downloadBlobsFromBucket(files: { signedUrl: string; filename: string }[]): Observable[] { + const downloadedBlobs = files.map((file) => this.http.get(file.signedUrl, { responseType: 'blob' })); + const blobsAndFilenames = downloadedBlobs.map((fileBlob$, index) => + fileBlob$.pipe(map((fileBlob) => ({ fileBlob, filename: files[index].filename }))), + ); + + return blobsAndFilenames; + } + + public downloadAllCertificatesAsBlobs(workplaceUid: string, workerUid: string): Observable { + return this.getAllRecords(workplaceUid, workerUid).pipe( + mergeAll(), + filter((record) => this.certificatesInRecord(record).length > 0), + concatMap((record) => + this.downloadCertificatesForOneRecordAsBlobs( + workplaceUid, + workerUid, + record.uid, + this.certificatesInRecord(record), + ), + ), + map((file) => this.addFolderName(file)), + ); + } + + public downloadCertificatesForOneRecordAsBlobs( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], + ): Observable { + return this.getCertificateDownloadUrls(workplaceUid, workerUid, recordUid, filesToDownload).pipe( + mergeMap((res) => this.downloadBlobsFromBucket(res['files'])), + mergeAll(), + ); + } + + protected addFolderName(file: NamedFileBlob): NamedFileBlob { + const { filename, fileBlob } = file; + return { filename: `${capitalize(this.recordType)} certificates/${filename}`, fileBlob }; + } + + public deleteCertificates( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDelete: Certificate[], + ): Observable { + const certificateEndpoint = this.certificateEndpoint(workplaceUid, workerUid, recordUid); + return this.http.post(`${certificateEndpoint}/delete`, { files: filesToDelete }); + } +} + +@Injectable() +export class TrainingCertificateService extends BaseCertificateService { + recordType = 'training'; + + protected recordsEndpoint(workplaceUid: string, workerUid: string): string { + return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training`; + } + + protected certificateEndpoint(workplaceUid: string, workerUid: string, trainingUid: string): string { + const recordsEndpoint = this.recordsEndpoint(workplaceUid, workerUid); + return `${recordsEndpoint}/${trainingUid}/certificate`; + } + + protected getAllRecords(workplaceUid: string, workerUid: string): Observable { + const recordsEndpoint = this.recordsEndpoint(workplaceUid, workerUid); + return this.http.get(recordsEndpoint).pipe(map((response: TrainingResponse) => response.training)); + } + + protected certificatesInRecord(record: TrainingRecord): TrainingCertificate[] { + return record?.trainingCertificates ?? []; + } +} + +@Injectable() +export class QualificationCertificateService extends BaseCertificateService { + recordType = 'qualification'; + + protected recordsEndpoint(workplaceUid: string, workerUid: string): string { + return `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/qualification`; + } + + protected certificateEndpoint(workplaceUid: string, workerUid: string, qualificationUid: string): string { + const recordsEndpoint = this.recordsEndpoint(workplaceUid, workerUid); + return `${recordsEndpoint}/${qualificationUid}/certificate`; + } + + protected getAllRecords(workplaceUid: string, workerUid: string): Observable { + const recordsEndpoint = this.recordsEndpoint(workplaceUid, workerUid); + return this.http.get(recordsEndpoint).pipe(map((response: QualificationsResponse) => response.qualifications)); + } + + protected certificatesInRecord(record: Qualification): QualificationCertificate[] { + return record?.qualificationCertificates ?? []; + } +} diff --git a/frontend/src/app/core/services/pdf-training-and-qualification.service.ts b/frontend/src/app/core/services/pdf-training-and-qualification.service.ts index ab888baf2f..75ed1d1560 100644 --- a/frontend/src/app/core/services/pdf-training-and-qualification.service.ts +++ b/frontend/src/app/core/services/pdf-training-and-qualification.service.ts @@ -133,7 +133,7 @@ export class PdfTrainingAndQualificationService { html.append(this.createSpacer(this.width, this.spacing)); } } - private async saveHtmlToPdf(filename, doc: jsPDF, html, scale, width, save): Promise { + private async saveHtmlToPdf(filename, doc: jsPDF, html: HTMLElement, scale, width, save): Promise { const widthHtml = width * scale; const x = (doc.internal.pageSize.getWidth() - widthHtml) / 2; let y = 0; @@ -142,6 +142,10 @@ export class PdfTrainingAndQualificationService { scale, width, windowWidth: width, + ignoreElements: (element: HTMLElement) => { + // ignore svg as jspdf does not support svg in html and will cause other contents to render incorrectly + return element.tagName.toLowerCase() === 'img' && element.getAttribute('src')?.endsWith('.svg'); + }, }; await doc.html(html, { diff --git a/frontend/src/app/core/services/training.service.spec.ts b/frontend/src/app/core/services/training.service.spec.ts index 9a58115e2e..243b877138 100644 --- a/frontend/src/app/core/services/training.service.spec.ts +++ b/frontend/src/app/core/services/training.service.spec.ts @@ -1,8 +1,8 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { environment } from 'src/environments/environment'; + import { TrainingService } from './training.service'; -import { TrainingCertificate } from '@core/model/training.model'; describe('TrainingService', () => { let service: TrainingService; @@ -100,204 +100,4 @@ describe('TrainingService', () => { expect(service.getUpdatingSelectedStaffForMultipleTraining()).toBe(null); }); }); - - describe('addCertificateToTraining', () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - - const mockUploadFiles = [new File([''], 'certificate.pdf')]; - const mockFileId = 'mockFileId'; - const mockEtagFromS3 = 'mock-etag'; - const mockSignedUrl = 'http://localhost/mock-signed-url-for-upload'; - - const certificateEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate`; - - it('should call to backend to retreive a signed url to upload certificate', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - expect(signedUrlRequest.request.method).toBe('POST'); - }); - - it('should have request body that contains filename', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - - const expectedRequestBody = { files: [{ filename: 'certificate.pdf' }] }; - - expect(signedUrlRequest.request.body).toEqual(expectedRequestBody); - }); - - it('should upload the file with the signed url received from backend', async () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - signedUrlRequest.flush({ - files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId }], - }); - - const uploadToS3Request = http.expectOne(mockSignedUrl); - expect(uploadToS3Request.request.method).toBe('PUT'); - expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[0]); - }); - - it('should call to backend to confirm upload complete', async () => { - const mockKey = 'abcd/adsadsadvfdv/123dsvf'; - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne(certificateEndpoint); - signedUrlRequest.flush({ - files: [{ filename: 'certificate.pdf', signedUrl: mockSignedUrl, fileId: mockFileId, key: mockKey }], - }); - - const uploadToS3Request = http.expectOne(mockSignedUrl); - uploadToS3Request.flush(null, { headers: { etag: mockEtagFromS3 } }); - - const confirmUploadRequest = http.expectOne(certificateEndpoint); - const expectedconfirmUploadReqBody = { - files: [{ filename: mockUploadFiles[0].name, fileId: mockFileId, etag: mockEtagFromS3, key: mockKey }], - }; - - expect(confirmUploadRequest.request.method).toBe('PUT'); - expect(confirmUploadRequest.request.body).toEqual(expectedconfirmUploadReqBody); - }); - - describe('multiple files upload', () => { - const mockUploadFilenames = ['certificate1.pdf', 'certificate2.pdf', 'certificate3.pdf']; - const mockUploadFiles = mockUploadFilenames.map((filename) => new File([''], filename)); - const mockFileIds = ['fileId1', 'fileId2', 'fileId3']; - const mockEtags = ['etag1', 'etag2', 'etag3']; - const mockSignedUrls = mockFileIds.map((fileId) => `${mockSignedUrl}/${fileId}`); - const mockKeys = mockFileIds.map((fileId) => `${fileId}/mockKey`); - - const mockSignedUrlResponse = { - files: mockUploadFilenames.map((filename, index) => ({ - filename, - signedUrl: mockSignedUrls[index], - fileId: mockFileIds[index], - key: mockKeys[index], - })), - }; - - const expectedSignedUrlReqBody = { - files: [ - { filename: mockUploadFiles[0].name }, - { filename: mockUploadFiles[1].name }, - { filename: mockUploadFiles[2].name }, - ], - }; - const expectedConfirmUploadReqBody = { - files: [ - { filename: mockUploadFiles[0].name, fileId: mockFileIds[0], etag: mockEtags[0], key: mockKeys[0] }, - { filename: mockUploadFiles[1].name, fileId: mockFileIds[1], etag: mockEtags[1], key: mockKeys[1] }, - { filename: mockUploadFiles[2].name, fileId: mockFileIds[2], etag: mockEtags[2], key: mockKeys[2] }, - ], - }; - - it('should be able to upload multiple files at the same time', () => { - service.addCertificateToTraining(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockUploadFiles).subscribe(); - - const signedUrlRequest = http.expectOne({ method: 'POST', url: certificateEndpoint }); - expect(signedUrlRequest.request.body).toEqual(expectedSignedUrlReqBody); - - signedUrlRequest.flush(mockSignedUrlResponse); - - mockSignedUrls.forEach((signedUrl, index) => { - const uploadToS3Request = http.expectOne(signedUrl); - expect(uploadToS3Request.request.method).toBe('PUT'); - expect(uploadToS3Request.request.body).toEqual(mockUploadFiles[index]); - - uploadToS3Request.flush(null, { headers: { etag: mockEtags[index] } }); - }); - - const confirmUploadRequest = http.expectOne({ method: 'PUT', url: certificateEndpoint }); - expect(confirmUploadRequest.request.body).toEqual(expectedConfirmUploadReqBody); - }); - }); - }); - - describe('downloadCertificates', () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - - const mockFiles = [{ uid: 'mockCertificateUid123', filename: 'mockCertificateName' }]; - - const certificateDownloadEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/download`; - - it('should make call to expected backend endpoint', async () => { - service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); - - const downloadRequest = http.expectOne(certificateDownloadEndpoint); - expect(downloadRequest.request.method).toBe('POST'); - }); - - it('should have request body that contains file uid', async () => { - service.downloadCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFiles).subscribe(); - - const downloadRequest = http.expectOne(certificateDownloadEndpoint); - const expectedRequestBody = { filesToDownload: mockFiles }; - - expect(downloadRequest.request.body).toEqual(expectedRequestBody); - }); - }); - - describe('triggerCertificateDownloads', () => { - it('should download certificates by creating and triggering anchor tag, then cleaning DOM', () => { - const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; - const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); - const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; - - const createElementSpy = spyOn(document, 'createElement').and.callThrough(); - const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); - const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); - const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); - spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); - - service.triggerCertificateDownloads(mockCertificates).subscribe(); - - const downloadReq = http.expectOne(mockCertificates[0].signedUrl); - downloadReq.flush(mockBlob); - - // Assert anchor element appended - expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(appendChildSpy).toHaveBeenCalled(); - - // Assert anchor element has correct attributes - const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; - expect(createdAnchor.href).toBe(mockBlobUrl); - expect(createdAnchor.download).toBe(mockCertificates[0].filename); - - // Assert DOM is cleaned up after download - expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); - expect(removeChildSpy).toHaveBeenCalled(); - }); - - describe('deleteCertificates', () => { - it('should call the endpoint for deleting training certificates', async () => { - const mockWorkplaceUid = 'mockWorkplaceUid'; - const mockWorkerUid = 'mockWorkerUid'; - const mockTrainingUid = 'mockTrainingUid'; - const mockFilesToDelete = [ - { - uid: 'uid-1', - filename: 'first_aid_v1.pdf', - uploadDate: '2024-09-23T11:02:10.000Z', - }, - ]; - - const deleteCertificatesEndpoint = `${environment.appRunnerEndpoint}/api/establishment/${mockWorkplaceUid}/worker/${mockWorkerUid}/training/${mockTrainingUid}/certificate/delete`; - - service.deleteCertificates(mockWorkplaceUid, mockWorkerUid, mockTrainingUid, mockFilesToDelete).subscribe(); - - const deleteRequest = http.expectOne(deleteCertificatesEndpoint); - const expectedRequestBody = { filesToDelete: mockFilesToDelete }; - - expect(deleteRequest.request.method).toBe('POST'); - expect(deleteRequest.request.body).toEqual(expectedRequestBody); - }); - }); - }); }); diff --git a/frontend/src/app/core/services/training.service.ts b/frontend/src/app/core/services/training.service.ts index db468023b8..c9e0635028 100644 --- a/frontend/src/app/core/services/training.service.ts +++ b/frontend/src/app/core/services/training.service.ts @@ -1,22 +1,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; -import { - allMandatoryTrainingCategories, - CertificateDownload, - UploadCertificateSignedUrlRequest, - UploadCertificateSignedUrlResponse, - ConfirmUploadRequest, - FileInfoWithETag, - S3UploadResponse, - SelectedTraining, - TrainingCategory, - DownloadCertificateSignedUrlResponse, - TrainingCertificate, -} from '@core/model/training.model'; +import { allMandatoryTrainingCategories, SelectedTraining, TrainingCategory } from '@core/model/training.model'; import { Worker } from '@core/model/worker.model'; -import { BehaviorSubject, forkJoin, from, Observable } from 'rxjs'; -import { map, mergeAll, mergeMap, tap } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; import { environment } from 'src/environments/environment'; @Injectable({ @@ -137,140 +124,4 @@ export class TrainingService { public clearUpdatingSelectedStaffForMultipleTraining(): void { this.updatingSelectedStaffForMultipleTraining = null; } - - public addCertificateToTraining(workplaceUid: string, workerUid: string, trainingUid: string, filesToUpload: File[]) { - const listOfFilenames = filesToUpload.map((file) => ({ filename: file.name })); - const requestBody: UploadCertificateSignedUrlRequest = { files: listOfFilenames }; - - return this.http - .post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, - requestBody, - ) - .pipe( - mergeMap((response) => this.uploadAllCertificatestoS3(response, filesToUpload)), - map((allFileInfoWithETag) => this.buildConfirmUploadRequestBody(allFileInfoWithETag)), - mergeMap((confirmUploadRequestBody) => - this.confirmCertificateUpload(workplaceUid, workerUid, trainingUid, confirmUploadRequestBody), - ), - ); - } - - private uploadAllCertificatestoS3( - signedUrlResponse: UploadCertificateSignedUrlResponse, - filesToUpload: File[], - ): Observable { - const allUploadResults$ = signedUrlResponse.files.map(({ signedUrl, fileId, filename, key }, index) => { - const fileToUpload = filesToUpload[index]; - if (!fileToUpload.name || fileToUpload.name !== filename) { - throw new Error('Invalid response from backend'); - } - return this.uploadOneCertificateToS3(signedUrl, fileId, fileToUpload, key); - }); - - return forkJoin(allUploadResults$); - } - - private uploadOneCertificateToS3( - signedUrl: string, - fileId: string, - uploadFile: File, - key: string, - ): Observable { - return this.http.put(signedUrl, uploadFile, { observe: 'response' }).pipe( - map((s3response) => ({ - etag: s3response?.headers?.get('etag'), - fileId, - filename: uploadFile.name, - key, - })), - ); - } - - public downloadCertificates( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDownload: CertificateDownload[], - ): Observable { - return this.getCertificateDownloadUrls(workplaceUid, workerUid, trainingUid, filesToDownload).pipe( - mergeMap((res) => this.triggerCertificateDownloads(res['files'])), - ); - } - - public getCertificateDownloadUrls( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDownload: CertificateDownload[], - ) { - return this.http.post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/download`, - { filesToDownload }, - ); - } - - public triggerCertificateDownloads(files: { signedUrl: string; filename: string }[]): Observable<{ - blob: Blob; - filename: string; - }> { - const downloadedBlobs = files.map((file) => this.http.get(file.signedUrl, { responseType: 'blob' })); - const blobsAndFilenames = downloadedBlobs.map((blob$, index) => - blob$.pipe(map((blob) => ({ blob, filename: files[index].filename }))), - ); - return from(blobsAndFilenames).pipe( - mergeAll(), - tap(({ blob, filename }) => this.triggerSingleCertificateDownload(blob, filename)), - ); - } - - private triggerSingleCertificateDownload(fileBlob: Blob, filename: string): void { - const blobUrl = window.URL.createObjectURL(fileBlob); - const link = this.createHiddenDownloadLink(blobUrl, filename); - - // Append the link to the body and click to trigger download - document.body.appendChild(link); - link.click(); - - // Remove the link - document.body.removeChild(link); - window.URL.revokeObjectURL(blobUrl); - } - - private createHiddenDownloadLink(blobUrl: string, filename: string): HTMLAnchorElement { - const link = document.createElement('a'); - - link.href = blobUrl; - link.download = filename; - link.style.display = 'none'; - return link; - } - - private buildConfirmUploadRequestBody(allFileInfoWithETag: FileInfoWithETag[]): ConfirmUploadRequest { - return { files: allFileInfoWithETag }; - } - - private confirmCertificateUpload( - workplaceUid: string, - workerUid: string, - trainingUid: string, - confirmUploadRequestBody: ConfirmUploadRequest, - ) { - return this.http.put( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate`, - confirmUploadRequestBody, - ); - } - - public deleteCertificates( - workplaceUid: string, - workerUid: string, - trainingUid: string, - filesToDelete: TrainingCertificate[], - ): Observable { - return this.http.post( - `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerUid}/training/${trainingUid}/certificate/delete`, - { filesToDelete }, - ); - } } diff --git a/frontend/src/app/core/services/worker.service.ts b/frontend/src/app/core/services/worker.service.ts index 79c67bd49e..158456d339 100644 --- a/frontend/src/app/core/services/worker.service.ts +++ b/frontend/src/app/core/services/worker.service.ts @@ -210,7 +210,7 @@ export class WorkerService { } createQualification(workplaceUid: string, workerId: string, record: QualificationRequest) { - return this.http.post( + return this.http.post( `${environment.appRunnerEndpoint}/api/establishment/${workplaceUid}/worker/${workerId}/qualification`, record, ); diff --git a/frontend/src/app/core/test-utils/MockCertificateService.ts b/frontend/src/app/core/test-utils/MockCertificateService.ts new file mode 100644 index 0000000000..0eb2bf63e7 --- /dev/null +++ b/frontend/src/app/core/test-utils/MockCertificateService.ts @@ -0,0 +1,145 @@ +import { of } from 'rxjs'; + +import { Injectable } from '@angular/core'; +import { TrainingResponse } from '@core/model/training.model'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; +import { QualificationsResponse } from '@core/model/qualification.model'; +import { Certificate, CertificateDownload } from '@core/model/trainingAndQualifications.model'; +import { build, fake } from '@jackfranklin/test-data-bot'; + +const mockWorkerUid = 'mockWorkerUid'; + +const certificateBuilder = build({ + fields: { + filename: fake((f) => f.lorem.word() + '.pdf'), + uid: fake((f) => f.datatype.uuid()), + uploadDate: fake((f) => f.date.recent().toISOString()), + }, +}); + +const buildCertificates = (howMany: number): Certificate[] => { + return Array(howMany).fill('').map(certificateBuilder); +}; + +const mockTrainingCertA = buildCertificates(1); +const mockTrainingCertsB = buildCertificates(3); +const mockQualificationCertsA = buildCertificates(1); +const mockQualificationCertsB = buildCertificates(3); +export const mockCertificateFileBlob = new Blob(['mockdata'], { type: 'application/pdf' }); +export const mockTrainingCertificates = [...mockTrainingCertA, ...mockTrainingCertsB]; +export const mockQualificationCertificates = [...mockQualificationCertsA, ...mockQualificationCertsB]; + +export const mockTrainingRecordsResponse: TrainingResponse = { + workerUid: mockWorkerUid, + count: 3, + lastUpdated: '2024-10-23T09:38:32.990Z', + training: [ + //@ts-ignore + { + uid: 'uid-missing-mandatory-training', + trainingCategory: { id: 11, category: 'Diabetes' }, + missing: true, + created: new Date(), + updated: new Date(), + updatedBy: 'admin3', + }, + { + uid: 'uid-training-with-no-certs', + trainingCategory: { id: 7, category: 'Continence care' }, + trainingCertificates: [], + title: 'training with no certs', + created: new Date(), + updated: new Date(), + updatedBy: 'admin3', + }, + { + uid: 'uid-training-with-certs', + trainingCategory: { id: 1, category: 'Activity provision, wellbeing' }, + trainingCertificates: mockTrainingCertA, + title: 'Training with certs', + created: new Date(), + updated: new Date(), + updatedBy: 'admin3', + }, + { + uid: 'uid-another-training-with-certs', + trainingCategory: { id: 13, category: 'Duty of care' }, + trainingCertificates: mockTrainingCertsB, + title: 'another training with certs', + created: new Date(), + updated: new Date(), + updatedBy: 'admin3', + }, + ], +}; + +export const trainingUidsWithCerts = ['uid-training-with-certs', 'uid-another-training-with-certs']; +export const trainingUidsWithoutCerts = ['uid-training-with-no-certs', 'uid-missing-mandatory-training']; + +export const mockQualificationRecordsResponse: QualificationsResponse = { + workerUid: '3fdc3fd9-4030-440c-96b5-716251cc23c8', + count: 1, + lastUpdated: '2024-10-23T09:38:17.469Z', + qualifications: [ + { + id: 1, + uid: 'qual-with-no-certs', + qualification: {}, + qualificationCertificates: [], + }, + { + id: 2, + uid: 'qual-with-some-certs', + qualification: {}, + qualificationCertificates: mockQualificationCertsA, + }, + { + id: 3, + uid: 'another-qual-with-some-certs', + qualification: {}, + qualificationCertificates: mockQualificationCertsB, + }, + ], +}; + +export const qualificationUidsWithCerts = ['qual-with-some-certs', 'another-qual-with-some-certs']; +export const qualificationUidsWithoutCerts = ['qual-with-no-certs']; + +const mockGetCertificateDownloadUrls = ( + workplaceUid: string, + workerUid: string, + recordUid: string, + filesToDownload: CertificateDownload[], +) => { + const mockResponsePayload = filesToDownload.map((file) => ({ + filename: file.filename, + signedUrl: `https://localhost/${file.uid}`, + })); + return of({ files: mockResponsePayload }); +}; + +const mockDownloadBlobsFromBucket = (files: { signedUrl: string; filename: string }[]) => { + return files.map((file) => of({ fileBlob: mockCertificateFileBlob, filename: file.filename })); +}; + +@Injectable() +export class MockTrainingCertificateService extends TrainingCertificateService { + protected getAllRecords(workplaceUid: string, workerUid: string) { + return of(mockTrainingRecordsResponse.training); + } + + public getCertificateDownloadUrls = mockGetCertificateDownloadUrls; + + public downloadBlobsFromBucket = mockDownloadBlobsFromBucket; +} + +@Injectable() +export class MockQualificationCertificateService extends QualificationCertificateService { + protected getAllRecords(workplaceUid: string, workerUid: string) { + return of(mockQualificationRecordsResponse.qualifications); + } + + public getCertificateDownloadUrls = mockGetCertificateDownloadUrls; + + public downloadBlobsFromBucket = mockDownloadBlobsFromBucket; +} diff --git a/frontend/src/app/core/test-utils/MockWorkerService.ts b/frontend/src/app/core/test-utils/MockWorkerService.ts index 7a6f97c59b..201fc5d47d 100644 --- a/frontend/src/app/core/test-utils/MockWorkerService.ts +++ b/frontend/src/app/core/test-utils/MockWorkerService.ts @@ -205,40 +205,43 @@ export const AllWorkers = [ export const getAllWorkersResponse = { workers: AllWorkers, workerCount: AllWorkers.length }; -export const qualificationsByGroup = { +export const qualificationsByGroup = Object.freeze({ count: 3, lastUpdated: new Date('2020-01-02'), groups: [ { - group: 'Health', + group: QualificationType.Award, records: [ { year: 2020, - notes: 'This is a test note for the first row in the Health group', - title: 'Health qualification', - uid: 'firstHealthQualUid', + notes: 'This is a test note for the first row in the Award group', + title: 'Award qualification', + uid: 'firstAwardQualUid', + qualificationCertificates: [], }, ], }, { - group: 'Certificate', + group: QualificationType.Certificate, records: [ { year: 2021, notes: 'Test notes needed', title: 'Cert qualification', uid: 'firstCertificateUid', + qualificationCertificates: [], }, { year: 2012, notes: 'These are some more notes in the second row of the cert table', title: 'Another name for qual', uid: 'secondCertificateUid', + qualificationCertificates: [], }, ], }, ], -} as QualificationsByGroup; +}) as QualificationsByGroup; export const mockAvailableQualifications: AvailableQualificationsResponse[] = [ { diff --git a/frontend/src/app/core/utils/file-util.spec.ts b/frontend/src/app/core/utils/file-util.spec.ts new file mode 100644 index 0000000000..a3f2df3bf1 --- /dev/null +++ b/frontend/src/app/core/utils/file-util.spec.ts @@ -0,0 +1,116 @@ +import { FileUtil } from './file-util'; +import { BlobReader, ZipReader } from '@zip.js/zip.js'; + +describe('FileUtil', () => { + const mockFiles = [ + { filename: 'training/First Aid.pdf', fileBlob: new Blob(['first aid'], { type: 'application/pdf' }) }, + { filename: 'training/First Aid 2024.pdf', fileBlob: new Blob(['First Aid 2024'], { type: 'application/pdf' }) }, + { + filename: 'qualification/Level 2 Care Cert.pdf', + fileBlob: new Blob(['Level 2 Care Cert'], { type: 'application/pdf' }), + }, + ]; + + describe('triggerSingleFileDownload', () => { + it('should download files by creating and triggering anchor tag, then cleaning DOM', () => { + const mockCertificates = [{ signedUrl: 'https://example.com/file1.pdf', filename: 'file1.pdf' }]; + const mockBlob = new Blob(['file content'], { type: 'application/pdf' }); + const mockBlobUrl = 'blob:http://signed-url-example.com/blob-url'; + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const appendChildSpy = spyOn(document.body, 'appendChild').and.callThrough(); + const removeChildSpy = spyOn(document.body, 'removeChild').and.callThrough(); + const revokeObjectURLSpy = spyOn(window.URL, 'revokeObjectURL').and.callThrough(); + spyOn(window.URL, 'createObjectURL').and.returnValue(mockBlobUrl); + + FileUtil.triggerSingleFileDownload(mockBlob, mockCertificates[0].filename); + + // Assert anchor element appended + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalled(); + + // Assert anchor element has correct attributes + const createdAnchor = createElementSpy.calls.mostRecent().returnValue as HTMLAnchorElement; + expect(createdAnchor.href).toBe(mockBlobUrl); + expect(createdAnchor.download).toBe(mockCertificates[0].filename); + + // Assert DOM is cleaned up after download + expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockBlobUrl); + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); + + describe('saveFilesAsZip', () => { + it('should do nothing if the input files array is empty', async () => { + const triggerDownloadSpy = spyOn(FileUtil, 'triggerSingleFileDownload'); + + await FileUtil.saveFilesAsZip([], 'zipped file.zip'); + expect(triggerDownloadSpy).not.toHaveBeenCalled(); + }); + + it('should zip all the given files as a blob, then trigger a download of the zipped file', async () => { + const zipFilesAsBlobSpy = spyOn(FileUtil, 'zipFilesAsBlob').and.callThrough(); + const triggerDownloadSpy = spyOn(FileUtil, 'triggerSingleFileDownload'); + + await FileUtil.saveFilesAsZip(mockFiles, 'zipped file.zip'); + + expect(zipFilesAsBlobSpy).toHaveBeenCalledWith(mockFiles); + + const zippedBlob = await zipFilesAsBlobSpy.calls.mostRecent().returnValue; + expect(triggerDownloadSpy).toHaveBeenCalledWith(zippedBlob, 'zipped file.zip'); + }); + }); + + describe('zipFilesAsBlob', () => { + it('should zip all given file blobs into one zip file and return as a single file blob', async () => { + const zippedBlob = await FileUtil.zipFilesAsBlob(mockFiles); + expect(zippedBlob).toBeInstanceOf(Blob); + + const contentsOfZipFile = await new ZipReader(new BlobReader(zippedBlob)).getEntries(); + expect(contentsOfZipFile).toHaveSize(mockFiles.length); + + const fileNamesInZipFile = contentsOfZipFile.map((file) => file.filename); + mockFiles.forEach((mockfile) => { + expect(fileNamesInZipFile).toContain(mockfile.filename); + }); + }); + + it('should be able to handle same filenames', async () => { + const mockFileWithSameFileNames = [ + ...mockFiles, + { filename: 'training/First Aid.pdf', fileBlob: new Blob(['another first aid'], { type: 'application/pdf' }) }, + { filename: 'training/First Aid.pdf', fileBlob: new Blob(['3rd first aid'], { type: 'application/pdf' }) }, + ]; + + const zippedBlob = await FileUtil.zipFilesAsBlob(mockFileWithSameFileNames); + + const contentsOfZipFile = await new ZipReader(new BlobReader(zippedBlob)).getEntries(); + expect(contentsOfZipFile).toHaveSize(5); + + const fileNamesInZipFile = contentsOfZipFile.map((file) => file.filename); + expect(fileNamesInZipFile).toContain('training/First Aid.pdf'); + expect(fileNamesInZipFile).toContain('training/First Aid (1).pdf'); + expect(fileNamesInZipFile).toContain('training/First Aid (2).pdf'); + }); + }); + + describe('makeAnotherFilename', () => { + const testCases = [ + { inputFilename: 'certificate.pdf', expected: 'certificate (1).pdf' }, + { inputFilename: 'certificate (1).pdf', expected: 'certificate (2).pdf' }, + { inputFilename: 'certificate (2).pdf', expected: 'certificate (3).pdf' }, + { inputFilename: 'certificate (9).pdf', expected: 'certificate (10).pdf' }, + { inputFilename: 'certificate (10).pdf', expected: 'certificate (11).pdf' }, + { inputFilename: 'certificate 1.pdf', expected: 'certificate 1 (1).pdf' }, + { inputFilename: 'certificate (2023).pdf', expected: 'certificate (2023) (1).pdf' }, + { inputFilename: 'Adult care worker (level 1).pdf', expected: 'Adult care worker (level 1) (1).pdf' }, + ]; + + testCases.forEach(({ inputFilename, expected }) => { + it(`should give a new filename as expected: ${inputFilename} --> ${expected}`, () => { + const actual = FileUtil.makeAnotherFilename(inputFilename); + expect(actual).toEqual(expected); + }); + }); + }); +}); diff --git a/frontend/src/app/core/utils/file-util.ts b/frontend/src/app/core/utils/file-util.ts index f1e97b0624..a80ba0b6a3 100644 --- a/frontend/src/app/core/utils/file-util.ts +++ b/frontend/src/app/core/utils/file-util.ts @@ -1,7 +1,85 @@ +import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'; + +export interface NamedFileBlob { + fileBlob: Blob; + filename: string; +} + export class FileUtil { public static getFileName(response) { const filenameRegEx = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const filenameMatches = response.headers.get('content-disposition').match(filenameRegEx); return filenameMatches && filenameMatches.length > 1 ? filenameMatches[1] : null; } + + public static triggerSingleFileDownload(fileBlob: Blob, filename: string): void { + const blobUrl = window.URL.createObjectURL(fileBlob); + const link = this.createHiddenDownloadLink(blobUrl, filename); + + // Append the link to the body and click to trigger download + document.body.appendChild(link); + link.click(); + + // Remove the link + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + } + + private static createHiddenDownloadLink(blobUrl: string, filename: string): HTMLAnchorElement { + const link = document.createElement('a'); + + link.href = blobUrl; + link.download = filename; + link.style.display = 'none'; + return link; + } + + public static async saveFilesAsZip(files: NamedFileBlob[], nameOfZippedFile: string) { + if (!files.length) { + return; + } + + const zippedFileAsBlob = await this.zipFilesAsBlob(files); + this.triggerSingleFileDownload(zippedFileAsBlob, nameOfZippedFile); + } + + public static async zipFilesAsBlob(files: NamedFileBlob[]): Promise { + const filenameUsed = new Set(); + + const zipWriter = new ZipWriter(new BlobWriter('application/zip')); + for (const file of files) { + let { filename, fileBlob } = file; + + while (filenameUsed.has(filename)) { + filename = this.makeAnotherFilename(filename); + } + await zipWriter.add(filename, new BlobReader(fileBlob)); + + filenameUsed.add(filename); + } + const zippedFileBlob = await zipWriter.close(); + + return zippedFileBlob; + } + + public static makeAnotherFilename(filename: string): string { + /* + * Append a number to filename to avoid duplicated names causing error during zip + * examples: + * "filename.pdf" --> "filename (1).pdf" + * "filename (1).pdf" --> "filename (2).pdf" + */ + const basename = filename.slice(0, filename.lastIndexOf('.')); + const extname = filename.slice(filename.lastIndexOf('.') + 1); + const numberInBracket = /\(([0-9]{1,2})\)$/; + + const sameFilenameCount = basename.match(numberInBracket); + if (sameFilenameCount) { + const newNumber = Number(sameFilenameCount[1]) + 1; + const newBasename = basename.replace(numberInBracket, `(${newNumber})`); + return `${newBasename}.${extname}`; + } + + return `${basename} (1).${extname}`; + } } diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html index 7bd3cb3401..d0738f6f6b 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.html @@ -13,17 +13,11 @@

- + - Delete this qualification record + Delete this qualification record
@@ -39,114 +33,108 @@

Type: {{ qualificationType }}

{{ qualificationTitle }} - Change + Change
-
-
- -
- - - Error: {{ getFirstErrorMessage('year') }} + +
+
+ + + Error: {{ getFirstErrorMessage('year') }} + + +
+
+ +
+ + +
+ +
+
+
+ + + Error: {{ getFirstErrorMessage('notes') }} - -
-
-
- - - Error: {{ getFirstErrorMessage('notes') }} - - -
- - + + {{ !notesOpen ? 'Open notes' : 'Close notes' }} + +
+ + - - You have {{ remainingCharacterCount | absoluteNumber | number }} - {{ - remainingCharacterCount - | absoluteNumber - | i18nPlural - : { - '=1': 'character', - other: 'characters' - } - }} + aria-live="polite" + > + + You have {{ remainingCharacterCount | absoluteNumber | number }} + {{ + remainingCharacterCount + | absoluteNumber + | i18nPlural + : { + '=1': 'character', + other: 'characters' + } + }} - {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} - - -
+ {{ remainingCharacterCount >= 0 ? 'remaining' : 'too many' }} + +
- +
-
+
- + \ No newline at end of file diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts index a0858f3be0..7b0209c12b 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.spec.ts @@ -4,23 +4,25 @@ import { ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { QualificationResponse, QualificationType } from '@core/model/qualification.model'; +import { QualificationCertificateService } from '@core/services/certificate.service'; import { QualificationService } from '@core/services/qualification.service'; import { WorkerService } from '@core/services/worker.service'; import { MockActivatedRoute } from '@core/test-utils/MockActivatedRoute'; +import { MockQualificationCertificateService } from '@core/test-utils/MockCertificateService'; import { MockFeatureFlagsService } from '@core/test-utils/MockFeatureFlagService'; import { qualificationRecord } from '@core/test-utils/MockWorkerService'; import { MockWorkerServiceWithWorker } from '@core/test-utils/MockWorkerServiceWithWorker'; import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; -import { fireEvent, render } from '@testing-library/angular'; +import { fireEvent, render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AddEditQualificationComponent } from './add-edit-qualification.component'; describe('AddEditQualificationComponent', () => { - async function setup(qualificationId = '1', qualificationInService = null) { - const { fixture, getByText, getByTestId, queryByText, getByLabelText, getAllByText } = await render( + async function setup(qualificationId = '1', qualificationInService = null, override: any = {}) { + const { fixture, getByText, getByTestId, queryByText, queryByTestId, getByLabelText, getAllByText } = await render( AddEditQualificationComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], @@ -51,6 +53,10 @@ describe('AddEditQualificationComponent', () => { clearSelectedQualification: () => {}, }, }, + { + provide: QualificationCertificateService, + useClass: MockQualificationCertificateService, + }, ], }, ); @@ -61,6 +67,7 @@ describe('AddEditQualificationComponent', () => { const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); const workerService = injector.inject(WorkerService) as WorkerService; const qualificationService = injector.inject(QualificationService) as QualificationService; + const certificateService = injector.inject(QualificationCertificateService) as QualificationCertificateService; return { component, @@ -68,14 +75,50 @@ describe('AddEditQualificationComponent', () => { getByText, getByTestId, queryByText, + queryByTestId, getByLabelText, routerSpy, getAllByText, workerService, qualificationService, + certificateService, }; } + const mockQualificationData = { + created: '2024-10-01T08:53:35.143Z', + notes: 'ihoihio', + qualification: { + group: 'Degree', + id: 136, + level: '6', + title: 'Health and social care degree (level 6)', + }, + + uid: 'fd50276b-e27c-48a6-9015-f0c489302666', + updated: '2024-10-01T08:53:35.143Z', + updatedBy: 'duncan', + year: 1999, + } as QualificationResponse; + + const setupWithExistingQualification = async (override: any = {}) => { + const setupOutputs = await setup('mockQualificationId'); + + const { component, workerService, fixture } = setupOutputs; + const { qualificationCertificates } = override; + const mockGetQualificationResponse: QualificationResponse = qualificationCertificates + ? { ...mockQualificationData, qualificationCertificates } + : mockQualificationData; + + spyOn(workerService, 'getQualification').and.returnValue(of(mockGetQualificationResponse)); + const updateQualificationSpy = spyOn(workerService, 'updateQualification').and.returnValue(of(null)); + + component.ngOnInit(); + fixture.detectChanges(); + + return { ...setupOutputs, updateQualificationSpy }; + }; + it('should create', async () => { const { component } = await setup(); expect(component).toBeTruthy(); @@ -231,6 +274,295 @@ describe('AddEditQualificationComponent', () => { }); }); + describe('qualification certificates', () => { + const mockQualification = { group: QualificationType.NVQ, id: 10, title: 'Worker safety qualification' }; + const mockUploadFile1 = new File(['file content'], 'worker safety 2023.pdf', { type: 'application/pdf' }); + const mockUploadFile2 = new File(['file content'], 'worker safety 2024.pdf', { type: 'application/pdf' }); + + describe('certificates to be uploaded', () => { + it('should add a new upload file to the certification table when a file is selected', async () => { + const { component, fixture, getByTestId } = await setup(null, mockQualification); + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = within(uploadSection).getByTestId('fileInput'); + + expect(fileInput).toBeTruthy(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + fixture.detectChanges(); + + const certificationTable = getByTestId('qualificationCertificatesTable'); + + expect(certificationTable).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(component.filesToUpload).toEqual([mockUploadFile1]); + }); + + it('should remove an upload file when its remove button is clicked', async () => { + const { component, fixture, getByTestId, getByText } = await setup(); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + const certificationTable = getByTestId('qualificationCertificatesTable'); + expect(within(certificationTable).getByText(mockUploadFile1.name)).toBeTruthy(); + expect(within(certificationTable).getByText(mockUploadFile2.name)).toBeTruthy(); + + const rowForFile1 = getByText(mockUploadFile1.name).parentElement; + const removeButtonForFile1 = within(rowForFile1).getByText('Remove'); + + userEvent.click(removeButtonForFile1); + + expect(within(certificationTable).queryByText(mockUploadFile1.name)).toBeFalsy(); + + expect(within(certificationTable).queryByText(mockUploadFile2.name)).toBeTruthy(); + expect(component.filesToUpload).toHaveSize(1); + expect(component.filesToUpload[0]).toEqual(mockUploadFile2); + }); + + it('should call certificateService with the selected files on form submit (for new qualification)', async () => { + const mockNewQualificationResponse = { + uid: 'newQualificationUid', + qualification: mockQualification, + created: '', + updated: '', + updatedBy: '', + }; + + const { component, fixture, getByTestId, getByText, certificateService, workerService } = await setup( + null, + mockQualification, + ); + + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of('null')); + spyOn(workerService, 'createQualification').and.returnValue(of(mockNewQualificationResponse)); + fixture.autoDetectChanges(); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.upload(getByTestId('fileInput'), mockUploadFile2); + + userEvent.click(getByText('Save record')); + + expect(addCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + mockNewQualificationResponse.uid, + [mockUploadFile1, mockUploadFile2], + ); + }); + + it('should call both `addCertificates` and `updateQualification` if an upload file is selected (for existing qualification)', async () => { + const { component, getByTestId, getByText, certificateService, getByLabelText, updateQualificationSpy } = + await setupWithExistingQualification(); + + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of('null')); + + userEvent.upload(getByTestId('fileInput'), mockUploadFile1); + userEvent.clear(getByLabelText('Year achieved')); + userEvent.type(getByLabelText('Year achieved'), '2023'); + + userEvent.click(getByText('Save and return')); + + expect(addCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [mockUploadFile1], + ); + + expect(updateQualificationSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + jasmine.objectContaining({ year: 2023 }), + ); + }); + + it('should disable the submit button to prevent it being triggered more than once', async () => { + const { fixture, getByText } = await setup(null, mockQualification); + + const submitButton = getByText('Save record') as HTMLButtonElement; + userEvent.click(submitButton); + fixture.detectChanges(); + + expect(submitButton.disabled).toBe(true); + }); + }); + + describe('saved certificates', () => { + const savedCertificates = [ + { + uid: 'uid-1', + filename: 'worker_safety_v1.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-2', + filename: 'worker_safety_v2.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + { + uid: 'uid-3', + filename: 'worker_safety_v3.pdf', + uploadDate: '2024-04-12T14:44:29.151Z', + }, + ]; + + const setupWithSavedCertificates = () => + setupWithExistingQualification({ qualificationCertificates: savedCertificates }); + + it('should show the table when there are certificates', async () => { + const { getByTestId } = await setupWithSavedCertificates(); + + expect(getByTestId('qualificationCertificatesTable')).toBeTruthy(); + }); + + it('should not show the table when there are no certificates', async () => { + const { queryByTestId } = await setup('mockQualificationId'); + + expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + }); + + it('should display a row for each certificate', async () => { + const { getByTestId } = await setupWithSavedCertificates(); + + savedCertificates.forEach((certificate, index) => { + const certificateRow = getByTestId(`certificate-row-${index}`); + expect(certificateRow).toBeTruthy(); + expect(within(certificateRow).getByText(certificate.filename)).toBeTruthy(); + expect(within(certificateRow).getByText('Download')).toBeTruthy(); + expect(within(certificateRow).getByText('Remove')).toBeTruthy(); + }); + }); + + describe('download certificates', () => { + it('should make call to downloadCertificates with required uids and file uid in array when Download button clicked', async () => { + const { component, fixture, getByTestId, certificateService } = await setupWithSavedCertificates(); + + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue(of(null)); + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const firstCertDownloadButton = within(certificatesTable).getAllByText('Download')[0]; + firstCertDownloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [{ uid: savedCertificates[0].uid, filename: savedCertificates[0].filename }], + ); + }); + + it('should make call to downloadCertificates with all certificate file uids in array when Download all button clicked', async () => { + const { component, fixture, getByTestId, certificateService } = await setupWithSavedCertificates(); + + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( + of({ files: ['abc123'] }), + ); + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadButton = within(certificatesTable).getByText('Download all'); + downloadButton.click(); + + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + component.qualificationId, + [ + { uid: savedCertificates[0].uid, filename: savedCertificates[0].filename }, + { uid: savedCertificates[1].uid, filename: savedCertificates[1].filename }, + { uid: savedCertificates[2].uid, filename: savedCertificates[2].filename }, + ], + ); + }); + + it('should display error message when Download fails', async () => { + const { component, fixture, getByText, getByTestId, certificateService } = await setupWithSavedCertificates(); + + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + component.qualificationCertificates = savedCertificates; + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadButton = within(certificatesTable).getAllByText('Download')[1]; + downloadButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); + + it('should display error message when Download all fails', async () => { + const { fixture, getByText, getByTestId, certificateService } = await setupWithSavedCertificates(); + + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('some download error')); + + const certificatesTable = getByTestId('qualificationCertificatesTable'); + const downloadAllButton = within(certificatesTable).getByText('Download all'); + downloadAllButton.click(); + fixture.detectChanges(); + + const expectedErrorMessage = getByText( + "There's a problem with this download. Try again later or contact us for help.", + ); + expect(expectedErrorMessage).toBeTruthy(); + }); + }); + + describe('remove certificates', () => { + it('should remove a file from the table when the remove button is clicked', async () => { + const { component, fixture, getByTestId, queryByText } = await setupWithSavedCertificates(); + + fixture.detectChanges(); + + const certificateRow2 = getByTestId('certificate-row-2'); + const removeButtonForRow2 = within(certificateRow2).getByText('Remove'); + + userEvent.click(removeButtonForRow2); + fixture.detectChanges(); + + expect(component.qualificationCertificates.length).toBe(2); + expect(queryByText(savedCertificates[2].filename)).toBeFalsy(); + }); + + it('should not show the table when all files are removed', async () => { + const { fixture, getByText, queryByTestId } = await setupWithSavedCertificates(); + + fixture.autoDetectChanges(); + + savedCertificates.forEach((certificate) => { + const certificateRow = getByText(certificate.filename).parentElement; + const removeButton = within(certificateRow).getByText('Remove'); + userEvent.click(removeButton); + }); + + expect(queryByTestId('qualificationCertificatesTable')).toBeFalsy(); + }); + + it('should call certificateService with the files to be removed', async () => { + const { component, fixture, getByTestId, getByText, certificateService } = await setupWithSavedCertificates(); + fixture.autoDetectChanges(); + + const deleteCertificatesSpy = spyOn(certificateService, 'deleteCertificates').and.returnValue(of(null)); + + const certificateRow1 = getByTestId('certificate-row-1'); + const removeButtonForRow1 = within(certificateRow1).getByText('Remove'); + + userEvent.click(removeButtonForRow1); + userEvent.click(getByText('Save and return')); + + expect(deleteCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + 'mockQualificationId', + [savedCertificates[1]], + ); + }); + }); + }); + }); + describe('setting data from qualification service', () => { const mockQualification = { group: QualificationType.NVQ, id: 10, title: 'Worker safety qualification' }; @@ -315,36 +647,6 @@ describe('AddEditQualificationComponent', () => { }); describe('prefilling data for existing qualification', () => { - const mockQualificationData = { - created: '2024-10-01T08:53:35.143Z', - notes: 'ihoihio', - qualification: { - group: 'Degree', - id: 136, - level: '6', - title: 'Health and social care degree (level 6)', - }, - - uid: 'fd50276b-e27c-48a6-9015-f0c489302666', - updated: '2024-10-01T08:53:35.143Z', - updatedBy: 'duncan', - year: 1999, - } as QualificationResponse; - - const setupWithExistingQualification = async () => { - const { component, workerService, fixture, getByText, queryByText, getByTestId } = await setup( - 'mockQualificationId', - ); - - spyOn(workerService, 'getQualification').and.returnValue(of(mockQualificationData)); - const updateQualificationSpy = spyOn(workerService, 'updateQualification').and.returnValue(of(null)); - - component.ngOnInit(); - fixture.detectChanges(); - - return { component, workerService, fixture, getByText, queryByText, updateQualificationSpy, getByTestId }; - }; - it('should display qualification title and lower case group', async () => { const { getByText } = await setupWithExistingQualification(); @@ -469,5 +771,85 @@ describe('AddEditQualificationComponent', () => { expect(getByText('Close notes')).toBeTruthy(); expect(notesSection.getAttribute('class')).not.toContain('govuk-visually-hidden'); }); + + describe('uploadCertificate errors', () => { + it('should show an error message if the selected file is over 5MB', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'large-file.pdf', { type: 'application/pdf' }); + Object.defineProperty(mockUploadFile, 'size', { + value: 10 * 1024 * 1024, // 10MB + }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, mockUploadFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be no larger than 5MB')).toBeTruthy(); + }); + + it('should show an error message if the selected file is not a pdf file', async () => { + const { fixture, getByTestId, getByText } = await setup(null); + + const mockUploadFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + + const fileInputButton = getByTestId('fileInput'); + + userEvent.upload(fileInputButton, [mockUploadFile]); + + fixture.detectChanges(); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + }); + + it('should clear the error message when user select a valid file instead', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const invalidFile = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + const validFile = new File(['some file content'], 'certificate.pdf', { type: 'application/pdf' }); + + const fileInputButton = getByTestId('fileInput'); + userEvent.upload(fileInputButton, [invalidFile]); + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + userEvent.upload(fileInputButton, [validFile]); + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); + + it('should provide aria description to screen reader users when error happen', async () => { + const { fixture, getByTestId } = await setup(null); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'non-pdf-file.csv')); + + const uploadButton = within(uploadSection).getByRole('button', { + description: /Error: The certificate must be a PDF file/, + }); + expect(uploadButton).toBeTruthy(); + }); + + it('should clear any error message when remove button of an upload file is clicked', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const mockUploadFileValid = new File(['some file content'], 'cerfificate.pdf', { type: 'application/pdf' }); + const mockUploadFileInvalid = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileValid]); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileInvalid]); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + const removeButton = within(getByText('cerfificate.pdf').parentElement).getByText('Remove'); + userEvent.click(removeButton); + + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); + }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts index da7d940951..26306d5b72 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-qualification/add-edit-qualification.component.ts @@ -1,3 +1,8 @@ +import dayjs from 'dayjs'; +import { Subscription } from 'rxjs'; +import { Observable } from 'rxjs-compat'; +import { mergeMap } from 'rxjs/operators'; + import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -6,18 +11,20 @@ import { ErrorDetails } from '@core/model/errorSummary.model'; import { Establishment } from '@core/model/establishment.model'; import { Qualification, + QualificationCertificate, QualificationRequest, QualificationResponse, QualificationType, } from '@core/model/qualification.model'; +import { Certificate, CertificateDownload } from '@core/model/trainingAndQualifications.model'; import { Worker } from '@core/model/worker.model'; import { BackLinkService } from '@core/services/backLink.service'; +import { QualificationCertificateService } from '@core/services/certificate.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { QualificationService } from '@core/services/qualification.service'; import { TrainingService } from '@core/services/training.service'; import { WorkerService } from '@core/services/worker.service'; -import dayjs from 'dayjs'; -import { Subscription } from 'rxjs'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; @Component({ selector: 'app-add-edit-qualification', @@ -46,6 +53,11 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { public selectedQualification: Qualification; public qualificationType: string; public qualificationTitle: string; + public qualificationCertificates: QualificationCertificate[] = []; + private _filesToUpload: File[]; + public filesToRemove: QualificationCertificate[] = []; + public certificateErrors: string[] | null; + public submitButtonDisabled: boolean = false; constructor( private trainingService: TrainingService, @@ -56,6 +68,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { private workerService: WorkerService, private backLinkService: BackLinkService, private qualificationService: QualificationService, + private certificateService: QualificationCertificateService, ) { this.yearValidators = [Validators.max(dayjs().year()), Validators.min(dayjs().subtract(100, 'years').year())]; } @@ -95,6 +108,10 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { this.notesOpen = true; this.remainingCharacterCount = this.notesMaxLength - this.record.notes.length; } + + if (this.record.qualificationCertificates) { + this.qualificationCertificates = this.record.qualificationCertificates; + } } }, (error) => { @@ -171,6 +188,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { return; } + this.submitButtonDisabled = true; this.qualificationService.clearSelectedQualification(); const { year, notes } = this.form.value; @@ -184,23 +202,64 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { notes, }; - if (this.qualificationId) { - this.subscriptions.add( - this.workerService - .updateQualification(this.workplace.uid, this.worker.uid, this.qualificationId, record) - .subscribe( - () => this.onSuccess(), - (error) => this.onError(error), - ), + let submitQualificationRecord: Observable = this.qualificationId + ? this.workerService.updateQualification(this.workplace.uid, this.worker.uid, this.qualificationId, record) + : this.workerService.createQualification(this.workplace.uid, this.worker.uid, record); + + if (this.filesToUpload?.length > 0) { + submitQualificationRecord = submitQualificationRecord.pipe( + mergeMap((response) => this.uploadNewCertificate(response)), ); - } else { - this.subscriptions.add( - this.workerService.createQualification(this.workplace.uid, this.worker.uid, record).subscribe( - () => this.onSuccess(), - (error) => this.onError(error), + } + + if (this.filesToRemove?.length > 0) { + this.deleteQualificationCertificate(this.filesToRemove); + } + + this.subscriptions.add( + submitQualificationRecord.subscribe( + () => this.onSuccess(), + (error) => this.onError(error), + ), + ); + } + + private uploadNewCertificate(response: QualificationResponse): Observable { + const qualifcationId = this.qualificationId ? this.qualificationId : response.uid; + return this.certificateService.addCertificates( + this.workplace.uid, + this.worker.uid, + qualifcationId, + this.filesToUpload, + ); + } + + public downloadCertificates(fileIndex: number | null): void { + const filesToDownload = this.getFilesToDownload(fileIndex); + + this.subscriptions.add( + this.certificateService + .downloadCertificates(this.workplace.uid, this.worker.uid, this.qualificationId, filesToDownload) + .subscribe( + () => { + this.certificateErrors = []; + }, + (_error) => { + this.certificateErrors = ["There's a problem with this download. Try again later or contact us for help."]; + }, ), - ); + ); + } + + private getFilesToDownload(fileIndex: number | null): CertificateDownload[] { + if (fileIndex !== null) { + return [this.formatForCertificateDownload(this.qualificationCertificates[fileIndex])]; } + return this.qualificationCertificates.map(this.formatForCertificateDownload); + } + + private formatForCertificateDownload(certificate: Certificate): CertificateDownload { + return { uid: certificate.uid, filename: certificate.filename }; } private onSuccess(): void { @@ -224,6 +283,7 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { } private onError(error): void { + this.submitButtonDisabled = false; console.log(error); } @@ -231,6 +291,52 @@ export class AddEditQualificationComponent implements OnInit, OnDestroy { this.notesOpen = !this.notesOpen; } + get filesToUpload(): File[] { + return this._filesToUpload ?? []; + } + + private set filesToUpload(files: File[]) { + this._filesToUpload = files ?? []; + } + + private resetUploadFilesError(): void { + this.certificateErrors = null; + } + + public onSelectFiles(newFiles: File[]): void { + this.resetUploadFilesError(); + const errors = CustomValidators.validateUploadCertificates(newFiles); + + if (errors) { + this.certificateErrors = errors; + return; + } + + const combinedFiles = [...newFiles, ...this.filesToUpload]; + this.filesToUpload = combinedFiles; + } + + public removeFileToUpload(fileIndexToRemove: number): void { + const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); + this.filesToUpload = filesToKeep; + this.certificateErrors = []; + } + + public removeSavedFile(fileIndexToRemove: number): void { + this.filesToRemove.push(this.qualificationCertificates[fileIndexToRemove]); + this.qualificationCertificates = this.qualificationCertificates.filter( + (_certificate, index) => index !== fileIndexToRemove, + ); + } + + private deleteQualificationCertificate(files: QualificationCertificate[]) { + this.subscriptions.add( + this.certificateService + .deleteCertificates(this.workplace.uid, this.worker.uid, this.qualificationId, files) + .subscribe(), + ); + } + protected navigateToDeleteQualificationRecord(): void { this.router.navigate([ '/workplace', diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts index ba90233ddb..e38e5c7b8d 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.spec.ts @@ -4,11 +4,13 @@ import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { AlertService } from '@core/services/alert.service'; +import { TrainingCertificateService } from '@core/services/certificate.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; +import { MockTrainingCertificateService } from '@core/test-utils/MockCertificateService'; import { MockTrainingCategoryService, trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; import { MockTrainingService } from '@core/test-utils/MockTrainingService'; import { trainingRecord } from '@core/test-utils/MockWorkerService'; @@ -55,6 +57,10 @@ describe('AddEditTrainingComponent', () => { { provide: TrainingService, useClass: MockTrainingService }, { provide: WorkerService, useClass: MockWorkerServiceWithWorker }, { provide: TrainingCategoryService, useClass: MockTrainingCategoryService }, + { + provide: TrainingCertificateService, + useClass: MockTrainingCertificateService, + }, ], }, ); @@ -72,6 +78,7 @@ describe('AddEditTrainingComponent', () => { const alertServiceSpy = spyOn(alertService, 'addAlert'); const trainingService = injector.inject(TrainingService) as TrainingService; + const certificateService = injector.inject(TrainingCertificateService) as TrainingCertificateService; return { component, @@ -88,6 +95,7 @@ describe('AddEditTrainingComponent', () => { workerService, alertServiceSpy, trainingService, + certificateService, }; } @@ -563,8 +571,8 @@ describe('AddEditTrainingComponent', () => { describe('upload certificate of an existing training', () => { const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); - it('should call both `addCertificateToTraining` and `updateTrainingRecord` if an upload file is selected', async () => { - const { component, fixture, getByText, getByLabelText, getByTestId, updateSpy, routerSpy, trainingService } = + it('should call both `addCertificates` and `updateTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, updateSpy, routerSpy, certificateService } = await setup(); component.previousUrl = ['/goToPreviousUrl']; @@ -572,9 +580,7 @@ describe('AddEditTrainingComponent', () => { openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( - of(null), - ); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of(null)); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); userEvent.upload(getByTestId('fileInput'), mockUploadFile); @@ -595,7 +601,7 @@ describe('AddEditTrainingComponent', () => { }, ); - expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + expect(addCertificatesSpy).toHaveBeenCalledWith( component.workplace.uid, component.worker.uid, component.trainingRecordId, @@ -605,28 +611,28 @@ describe('AddEditTrainingComponent', () => { expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); - it('should not call addCertificateToTraining if no file was selected', async () => { - const { component, fixture, getByText, getByLabelText, trainingService } = await setup(); + it('should not call addCertificates if no file was selected', async () => { + const { component, fixture, getByText, getByLabelText, certificateService } = await setup(); component.previousUrl = ['/goToPreviousUrl']; const openNotesButton = getByText('Open notes'); openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates'); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); fireEvent.click(getByText('Save and return')); - expect(addCertificateToTrainingSpy).not.toHaveBeenCalled(); + expect(addCertificatesSpy).not.toHaveBeenCalled(); }); }); describe('add a new training record and upload certificate together', async () => { const mockUploadFile = new File(['some file content'], 'First aid 2022.pdf', { type: 'application/pdf' }); - it('should call both `addCertificateToTraining` and `createTrainingRecord` if an upload file is selected', async () => { - const { component, fixture, getByText, getByLabelText, getByTestId, createSpy, routerSpy, trainingService } = + it('should call both `addCertificates` and `createTrainingRecord` if an upload file is selected', async () => { + const { component, fixture, getByText, getByLabelText, getByTestId, createSpy, routerSpy, certificateService } = await setup(null); component.previousUrl = ['/goToPreviousUrl']; @@ -637,9 +643,7 @@ describe('AddEditTrainingComponent', () => { fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue( - of(null), - ); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates').and.returnValue(of(null)); userEvent.type(getByLabelText('Training name'), 'Understanding Autism'); userEvent.click(getByLabelText('Yes')); @@ -657,7 +661,7 @@ describe('AddEditTrainingComponent', () => { notes: null, }); - expect(addCertificateToTrainingSpy).toHaveBeenCalledWith( + expect(addCertificatesSpy).toHaveBeenCalledWith( component.workplace.uid, component.worker.uid, trainingRecord.uid, @@ -667,8 +671,8 @@ describe('AddEditTrainingComponent', () => { expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); - it('should not call `addCertificateToTraining` when no upload file was selected', async () => { - const { component, fixture, getByText, getByLabelText, createSpy, routerSpy, trainingService } = await setup( + it('should not call `addCertificates` when no upload file was selected', async () => { + const { component, fixture, getByText, getByLabelText, createSpy, routerSpy, certificateService } = await setup( null, ); @@ -681,14 +685,14 @@ describe('AddEditTrainingComponent', () => { openNotesButton.click(); fixture.detectChanges(); - const addCertificateToTrainingSpy = spyOn(trainingService, 'addCertificateToTraining'); + const addCertificatesSpy = spyOn(certificateService, 'addCertificates'); userEvent.type(getByLabelText('Add a note'), 'Some notes added to this training'); fireEvent.click(getByText('Save record')); expect(createSpy).toHaveBeenCalled; - expect(addCertificateToTrainingSpy).not.toHaveBeenCalled; + expect(addCertificatesSpy).not.toHaveBeenCalled; expect(routerSpy).toHaveBeenCalledWith(['/goToPreviousUrl']); }); @@ -983,6 +987,23 @@ describe('AddEditTrainingComponent', () => { }); expect(uploadButton).toBeTruthy(); }); + + it('should clear any error message when remove button of an upload file is clicked', async () => { + const { fixture, getByTestId, getByText, queryByText } = await setup(null); + fixture.autoDetectChanges(); + + const mockUploadFileValid = new File(['some file content'], 'cerfificate.pdf', { type: 'application/pdf' }); + const mockUploadFileInvalid = new File(['some file content'], 'non-pdf.png', { type: 'image/png' }); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileValid]); + userEvent.upload(getByTestId('fileInput'), [mockUploadFileInvalid]); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + + const removeButton = within(getByText('cerfificate.pdf').parentElement).getByText('Remove'); + userEvent.click(removeButton); + + expect(queryByText('The certificate must be a PDF file')).toBeFalsy(); + }); }); }); @@ -1052,9 +1073,9 @@ describe('AddEditTrainingComponent', () => { }); it('should make call to downloadCertificates with required uids and file uid in array when Download button clicked', async () => { - const { component, fixture, getByTestId, trainingService } = await setup(); + const { component, fixture, getByTestId, certificateService } = await setup(); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( of({ files: ['abc123'] }), ); component.trainingCertificates = [mockTrainingCertificate]; @@ -1073,9 +1094,9 @@ describe('AddEditTrainingComponent', () => { }); it('should make call to downloadCertificates with all certificate file uids in array when Download all button clicked', async () => { - const { component, fixture, getByTestId, trainingService } = await setup(); + const { component, fixture, getByTestId, certificateService } = await setup(); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue( + const downloadCertificatesSpy = spyOn(certificateService, 'downloadCertificates').and.returnValue( of({ files: ['abc123'] }), ); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; @@ -1097,9 +1118,9 @@ describe('AddEditTrainingComponent', () => { }); it('should display error message when Download fails', async () => { - const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + const { component, fixture, getByText, getByTestId, certificateService } = await setup(); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; fixture.detectChanges(); @@ -1116,9 +1137,9 @@ describe('AddEditTrainingComponent', () => { }); it('should display error message when Download all fails', async () => { - const { component, fixture, getByText, getByTestId, trainingService } = await setup(); + const { component, fixture, getByText, getByTestId, certificateService } = await setup(); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('some download error')); + spyOn(certificateService, 'downloadCertificates').and.returnValue(throwError('some download error')); component.trainingCertificates = [mockTrainingCertificate, mockTrainingCertificate2]; fixture.detectChanges(); @@ -1174,33 +1195,6 @@ describe('AddEditTrainingComponent', () => { expect(component.filesToUpload).toHaveSize(1); expect(component.filesToUpload[0]).toEqual(mockUploadFile1); }); - - xit('should allow a file to be added again after removal', async () => { - /* TODO: unskip this test when we bump up @testing-library/user-event to >= 14.0.0 in the future - * - * This test is skipped because the version of @testing-library/user-event we are using - * does not handle fileinput.value = '' or fileinput.files = null correctly. - */ - const { component, fixture, getByText, getByTestId, queryByText } = await setup(); - fixture.autoDetectChanges(); - - const fileInput = getByTestId('fileInput') as HTMLInputElement; - await userEvent.upload(fileInput, [mockUploadFile1]); - - const certificateRow = getByText(mockUploadFile1.name).parentElement; - const removeButton = within(certificateRow).getByText('Remove'); - await userEvent.click(removeButton); - - expect(queryByText(mockUploadFile1.name)).toBeFalsy(); - expect(component.filesToUpload).toEqual([]); - expect(fileInput.value).toBeFalsy(); - - // select the removed file again - await userEvent.upload(fileInput, [mockUploadFile1]); - - expect(getByText(mockUploadFile1.name)).toBeTruthy(); - expect(component.filesToUpload).toEqual([mockUploadFile1]); - }); }); describe('saved files to be removed', () => { @@ -1278,7 +1272,7 @@ describe('AddEditTrainingComponent', () => { }); it('should call the training service when save and return is clicked', async () => { - const { component, fixture, getByTestId, getByText, trainingService } = await setup(); + const { component, fixture, getByTestId, getByText, certificateService } = await setup(); component.trainingCertificates = [ { @@ -1298,7 +1292,7 @@ describe('AddEditTrainingComponent', () => { const certificateRow = getByTestId('certificate-row-0'); const removeButtonForRow = within(certificateRow).getByText('Remove'); - const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + const trainingServiceSpy = spyOn(certificateService, 'deleteCertificates').and.callThrough(); fireEvent.click(removeButtonForRow); fireEvent.click(getByText('Save and return')); @@ -1313,13 +1307,13 @@ describe('AddEditTrainingComponent', () => { }); it('should not call the training service when save and return is clicked and there are no files to remove ', async () => { - const { component, fixture, getByText, trainingService } = await setup(); + const { component, fixture, getByText, certificateService } = await setup(); component.trainingCertificates = []; fixture.detectChanges(); - const trainingServiceSpy = spyOn(trainingService, 'deleteCertificates').and.callThrough(); + const trainingServiceSpy = spyOn(certificateService, 'deleteCertificates').and.callThrough(); fireEvent.click(getByText('Save and return')); diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts index df99062d84..6f9bde206f 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/add-edit-training.component.ts @@ -3,19 +3,20 @@ import { AfterViewInit, Component, OnInit } from '@angular/core'; import { UntypedFormBuilder } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { DATE_PARSE_FORMAT } from '@core/constants/constants'; -import { CertificateDownload, TrainingCertificate } from '@core/model/training.model'; +import { TrainingCertificate } from '@core/model/training.model'; +import { CertificateDownload } from '@core/model/trainingAndQualifications.model'; import { AlertService } from '@core/services/alert.service'; import { BackLinkService } from '@core/services/backLink.service'; +import { TrainingCertificateService } from '@core/services/certificate.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WorkerService } from '@core/services/worker.service'; +import { AddEditTrainingDirective } from '@shared/directives/add-edit-training/add-edit-training.directive'; import { CustomValidators } from '@shared/validators/custom-form-validators'; import dayjs from 'dayjs'; import { mergeMap } from 'rxjs/operators'; -import { AddEditTrainingDirective } from '../../../shared/directives/add-edit-training/add-edit-training.directive'; - @Component({ selector: 'app-add-edit-training', templateUrl: '../../../shared/directives/add-edit-training/add-edit-training.component.html', @@ -36,6 +37,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement protected errorSummaryService: ErrorSummaryService, protected trainingService: TrainingService, protected trainingCategoryService: TrainingCategoryService, + protected certificateService: TrainingCertificateService, protected workerService: WorkerService, protected alertService: AlertService, protected http: HttpClient, @@ -205,12 +207,13 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement public removeFileToUpload(fileIndexToRemove: number): void { const filesToKeep = this.filesToUpload.filter((_file, index) => index !== fileIndexToRemove); this.filesToUpload = filesToKeep; + this.certificateErrors = []; } private uploadNewCertificate(trainingRecordResponse: any) { const trainingRecordId = this.trainingRecordId ?? trainingRecordResponse.uid; - return this.trainingService.addCertificateToTraining( + return this.certificateService.addCertificates( this.workplace.uid, this.worker.uid, trainingRecordId, @@ -226,7 +229,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement return this.formatForCertificateDownload(certificate); }); this.subscriptions.add( - this.trainingService + this.certificateService .downloadCertificates(this.workplace.uid, this.worker.uid, this.trainingRecordId, filesToDownload) .subscribe( () => { @@ -245,7 +248,7 @@ export class AddEditTrainingComponent extends AddEditTrainingDirective implement private deleteTrainingCertificate(files: TrainingCertificate[]) { this.subscriptions.add( - this.trainingService + this.certificateService .deleteCertificates(this.establishmentUid, this.workerId, this.trainingRecordId, files) .subscribe(() => {}), ); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.html index a898ce74ba..d1b038004b 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.html @@ -1,5 +1,5 @@ - Download training and qualifications (PDF, 430KB, {{ pdfCount }} pages) + Download this training and qualifications summary diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.ts index 1c408efc15..ad31a0cac8 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/download-pdf/download-pdf-training-and-qualification.component.ts @@ -1,12 +1,11 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; @Component({ - selector: 'app-download-pdf-traininf-and-qualification', + selector: 'app-download-pdf-training-and-qualification', templateUrl: './download-pdf-training-and-qualification.component.html', }) export class DownloadPdfTrainingAndQualificationComponent { @Input() linkUrl: string; - @Input() pdfCount: number; @Output() downloadPDF = new EventEmitter(); public downloadAsPDF(event: Event): void { diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.html index 4d7862ca28..09f073c771 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.html @@ -2,21 +2,28 @@

Qualifications

-
- -
+
+ + + + + + - - + + + - + +
{{ - qualificationType.group + qualificationGroup.group }}
Certificate nameYear achieved + {{ qualificationGroup.group }} name + Year achievedCertificate
Qualifications {{ qualificationRecord?.year ? qualificationRecord.year : '-' }} + + + + Download + the certificate {{ qualificationRecord.qualificationCertificates[0].filename }} + + + + + Select a download + + + + + + +
diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.ts index 90c02273fa..1574a977a0 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.component.ts @@ -1,5 +1,12 @@ -import { Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { QualificationsByGroup } from '@core/model/qualification.model'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { + QualificationsByGroup, + QualificationCertificateDownloadEvent, + QualificationCertificateUploadEvent, + BasicQualificationRecord, + QualificationType, + QualificationGroup, +} from '@core/model/qualification.model'; @Component({ selector: 'app-new-qualifications', @@ -8,5 +15,43 @@ import { QualificationsByGroup } from '@core/model/qualification.model'; export class NewQualificationsComponent { @Input() qualificationsByGroup: QualificationsByGroup; @Input() canEditWorker: boolean; + @Input() public certificateErrors: Record = {}; + @Output() public downloadFile = new EventEmitter(); + @Output() public uploadFile = new EventEmitter(); @ViewChild('content') public content: ElementRef; + + public handleDownloadCertificate( + event: Event, + qualificationGroup: QualificationGroup, + qualificationRecord: BasicQualificationRecord, + ) { + event.preventDefault(); + + const filesToDownload = [ + { + uid: qualificationRecord.qualificationCertificates[0].uid, + filename: qualificationRecord.qualificationCertificates[0].filename, + }, + ]; + + this.downloadFile.emit({ + recordType: 'qualification', + recordUid: qualificationRecord.uid, + qualificationType: qualificationGroup.group as QualificationType, + filesToDownload, + }); + } + + public handleUploadCertificate( + files: File[], + qualificationGroup: QualificationGroup, + qualificationRecord: BasicQualificationRecord, + ) { + this.uploadFile.emit({ + recordType: 'qualification', + recordUid: qualificationRecord.uid, + qualificationType: qualificationGroup.group as QualificationType, + files, + }); + } } diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.spec.ts index 22ed7a4d2d..5657bc5a55 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-qualifications/new-qualifications.spec.ts @@ -1,20 +1,29 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; +import { + QualificationCertificateDownloadEvent, + QualificationCertificateUploadEvent, + QualificationType, +} from '@core/model/qualification.model'; +import { Certificate } from '@core/model/trainingAndQualifications.model'; import { qualificationsByGroup } from '@core/test-utils/MockWorkerService'; import { SharedModule } from '@shared/shared.module'; -import { render } from '@testing-library/angular'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { cloneDeep } from 'lodash'; import { NewQualificationsComponent } from './new-qualifications.component'; describe('NewQualificationsComponent', () => { - async function setup() { - const { fixture, getByText, getAllByText, queryByText } = await render(NewQualificationsComponent, { + async function setup(override: any = {}) { + const { fixture, getByText, getAllByText, queryByText, getByTestId } = await render(NewQualificationsComponent, { imports: [SharedModule, RouterTestingModule, HttpClientTestingModule], providers: [], componentProperties: { canEditWorker: true, - qualificationsByGroup, + qualificationsByGroup: cloneDeep(qualificationsByGroup), + ...override, }, }); @@ -26,6 +35,7 @@ describe('NewQualificationsComponent', () => { getByText, getAllByText, queryByText, + getByTestId, }; } @@ -34,17 +44,22 @@ describe('NewQualificationsComponent', () => { expect(component).toBeTruthy(); }); - it('should show qualification table headings for each type with records (2)', async () => { - const { getAllByText } = await setup(); + it('should show qualification table headings for each type with records', async () => { + const { getByText } = await setup(); - expect(getAllByText('Certificate name').length).toBe(2); - expect(getAllByText('Year achieved').length).toBe(2); + qualificationsByGroup.groups.forEach((qualificationGroup) => { + const type = qualificationGroup.group; + expect(getByText(`${type} name`)).toBeTruthy(); + const tableHeaders = getByText(`${type} name`).parentElement; + expect(within(tableHeaders).getByText('Year achieved')).toBeTruthy; + expect(within(tableHeaders).getByText('Certificate')).toBeTruthy; + }); }); - it('should show Health table row with details of record', async () => { + it('should show Award table row with details of record', async () => { const { getByText } = await setup(); - expect(getByText('Health qualification')).toBeTruthy(); + expect(getByText('Award qualification')).toBeTruthy(); expect(getByText('2020')).toBeTruthy(); }); @@ -90,17 +105,15 @@ describe('NewQualificationsComponent', () => { }); describe('Link titles', () => { - it('should contain link in qualification name in first Health table row', async () => { + it('should contain link in qualification name in first Award table row', async () => { const { component, fixture } = await setup(); component.canEditWorker = true; fixture.detectChanges(); - const healthQualificationTitle = fixture.debugElement.query( - By.css('[data-testid="Title-firstHealthQualUid"]'), - ).nativeElement; + const awardTitle = fixture.debugElement.query(By.css('[data-testid="Title-firstAwardQualUid"]')).nativeElement; - expect(healthQualificationTitle.getAttribute('href')).toBe('/qualification/firstHealthQualUid'); + expect(awardTitle.getAttribute('href')).toBe('/qualification/firstAwardQualUid'); }); it('should contain link in qualification name in first certificate table row', async () => { @@ -143,7 +156,7 @@ describe('NewQualificationsComponent', () => { }); }); - describe('no training', () => { + describe('no qualification', () => { it('should render an add a qualification link if canEditWorker is true', async () => { const { fixture, component, getByText } = await setup(); component.qualificationsByGroup.count = 0; @@ -163,4 +176,120 @@ describe('NewQualificationsComponent', () => { expect(addQualificationLink).toBeFalsy(); }); }); + + describe('Qualification certificates', () => { + const setupWithCertificates = async (certificates: Certificate[], canEditWorker: boolean = true) => { + const qualificationsWithCertificate = cloneDeep(qualificationsByGroup); + qualificationsWithCertificate.groups[0].records[0].qualificationCertificates = certificates; + return setup({ qualificationsByGroup: qualificationsWithCertificate, canEditWorker }); + }; + const qualificationUid = qualificationsByGroup.groups[0].records[0].uid; + const singleQualificationCertificate = () => [ + { uid: 'certificate1uid', filename: 'First aid award 2024.pdf', uploadDate: '20240101T123456Z' }, + ]; + + const multipleQualificationCertificates = () => [ + { uid: 'certificate1uid', filename: 'First aid award 2023.pdf', uploadDate: '20230101T123456Z' }, + { uid: 'certificate2uid', filename: 'First aid award 2024.pdf', uploadDate: '20240101T234516Z' }, + ]; + + it('should display Download link when qualification record has one certificate associated with it', async () => { + const { getByTestId } = await setupWithCertificates(singleQualificationCertificate()); + + const recordRow = getByTestId(qualificationUid); + expect(within(recordRow).getByText('Download')).toBeTruthy(); + }); + + it('should not display Download link when qualification record has one certificate associated with it but user does not have edit permissions', async () => { + const { getByTestId } = await setupWithCertificates(singleQualificationCertificate(), false); + + const recordRow = getByTestId(qualificationUid); + expect(within(recordRow).queryByText('Download')).toBeFalsy(); + }); + + it('should trigger download file emitter when Download link is clicked', async () => { + const { getByTestId, component } = await setupWithCertificates(singleQualificationCertificate()); + const downloadFileSpy = spyOn(component.downloadFile, 'emit'); + + const recordRow = getByTestId(qualificationUid); + userEvent.click(within(recordRow).getByText('Download')); + + const expectedDownloadEvent: QualificationCertificateDownloadEvent = { + recordType: 'qualification', + recordUid: qualificationUid, + qualificationType: QualificationType.Award, + filesToDownload: [{ uid: 'certificate1uid', filename: 'First aid award 2024.pdf' }], + }; + + expect(downloadFileSpy).toHaveBeenCalledWith(expectedDownloadEvent); + }); + + it('should display Select a download link when qualification record has more than one certificate associated with it', async () => { + const { getByTestId } = await setupWithCertificates(multipleQualificationCertificates()); + + const recordRow = getByTestId('firstAwardQualUid'); + expect(within(recordRow).getByText('Select a download')).toBeTruthy(); + }); + + it('should not display Select a download link when qualification record has more than one certificate associated with it but user does not have edit permissions', async () => { + const { getByTestId } = await setupWithCertificates(multipleQualificationCertificates(), false); + + const recordRow = getByTestId('firstAwardQualUid'); + expect(within(recordRow).queryByText('Select a download')).toBeFalsy(); + }); + + it('should have href of qualification record on Select a download link', async () => { + const { getByTestId } = await setupWithCertificates(multipleQualificationCertificates()); + + const recordRow = getByTestId('firstAwardQualUid'); + const selectADownloadLink = within(recordRow).getByText('Select a download'); + expect(selectADownloadLink.getAttribute('href')).toEqual(`/qualification/firstAwardQualUid`); + }); + + it('should display Upload file button when qualification record has no certificates associated with it', async () => { + const { getByTestId } = await setupWithCertificates([]); + + const recordRow = getByTestId(qualificationUid); + expect(within(recordRow).getByRole('button', { name: 'Upload file' })).toBeTruthy(); + }); + + it('should not display Upload file button when qualification record has no certificates associated with it but user does not have edit permissions', async () => { + const { getByTestId } = await setupWithCertificates([], false); + + const recordRow = getByTestId(qualificationUid); + expect(within(recordRow).queryByRole('button', { name: 'Upload file' })).toBeFalsy(); + }); + + it('should trigger the upload file emitter when a file is selected by the Upload file button', async () => { + const { component, getByTestId } = await setupWithCertificates([]); + const fileToUpload = new File(['file content'], 'updated certificate 2024.pdf', { type: 'application/pdf' }); + const uploadFileSpy = spyOn(component.uploadFile, 'emit'); + + const recordRow = getByTestId(qualificationUid); + const fileInput = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(fileInput, [fileToUpload]); + + const expectedUploadEvent: QualificationCertificateUploadEvent = { + recordType: 'qualification', + recordUid: qualificationUid, + qualificationType: QualificationType.Award, + files: [fileToUpload], + }; + + expect(uploadFileSpy).toHaveBeenCalledWith(expectedUploadEvent); + }); + + it('should display an error message above the category when download certificate fails', async () => { + const certificateErrors = { + Award: "There's a problem with this download. Try again later or contact us for help.", + }; + const { getByTestId } = await setup({ certificateErrors }); + + const awardSection = getByTestId('Award-section'); + expect( + within(awardSection).getByText("There's a problem with this download. Try again later or contact us for help."), + ).toBeTruthy(); + }); + }); }); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html index 7bae1b7b8d..5cfd582125 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.html @@ -85,11 +85,21 @@

Training and qualifications< *ngIf="mandatoryTraining.length + nonMandatoryTraining.length + qualificationsByGroup.count" class="govuk-!-margin-bottom-8" > - + > + + + Download all their training and qualification certificates +

@@ -215,8 +225,8 @@

Training and qualifications< [canEditWorker]="canEditWorker" [setReturnRoute]="setReturnRoute" [missingMandatoryTraining]="missingMandatoryTraining.length > 0" - (downloadFile)="downloadTrainingCertificate($event)" - (uploadFile)="uploadTrainingCertificate($event)" + (downloadFile)="downloadCertificate($event)" + (uploadFile)="uploadCertificate($event)" [certificateErrors]="certificateErrors" > @@ -226,8 +236,8 @@

Training and qualifications< [trainingType]="'Non-mandatory training'" [canEditWorker]="canEditWorker" [setReturnRoute]="setReturnRoute" - (downloadFile)="downloadTrainingCertificate($event)" - (uploadFile)="uploadTrainingCertificate($event)" + (downloadFile)="downloadCertificate($event)" + (uploadFile)="uploadCertificate($event)" [certificateErrors]="certificateErrors" > @@ -235,6 +245,9 @@

Training and qualifications< *ngIf="currentFragment === fragmentsObject.allRecords || currentFragment === fragmentsObject.qualifications" [canEditWorker]="canEditWorker" [qualificationsByGroup]="qualificationsByGroup" + (downloadFile)="downloadCertificate($event)" + (uploadFile)="uploadCertificate($event)" + [certificateErrors]="certificateErrors" >

diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts index 077c47e2c3..15c7d3d79a 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.spec.ts @@ -4,10 +4,12 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { JourneyType } from '@core/breadcrumb/breadcrumb.model'; import { Establishment } from '@core/model/establishment.model'; -import { TrainingRecord, TrainingRecords } from '@core/model/training.model'; +import { QualificationsByGroup } from '@core/model/qualification.model'; +import { TrainingRecord, TrainingRecordCategory, TrainingRecords } from '@core/model/training.model'; import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; import { AlertService } from '@core/services/alert.service'; import { BreadcrumbService } from '@core/services/breadcrumb.service'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; import { EstablishmentService } from '@core/services/establishment.service'; import { PdfTrainingAndQualificationService } from '@core/services/pdf-training-and-qualification.service'; import { PermissionsService } from '@core/services/permissions/permissions.service'; @@ -16,15 +18,24 @@ import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; import { MockActivatedRoute } from '@core/test-utils/MockActivatedRoute'; import { MockBreadcrumbService } from '@core/test-utils/MockBreadcrumbService'; +import { + mockCertificateFileBlob, + MockQualificationCertificateService, + mockTrainingCertificates, + MockTrainingCertificateService, +} from '@core/test-utils/MockCertificateService'; import { establishmentBuilder, MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; import { MockPermissionsService } from '@core/test-utils/MockPermissionsService'; import { MockWorkerService, qualificationsByGroup } from '@core/test-utils/MockWorkerService'; +import { FileUtil } from '@core/utils/file-util'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; import { SharedModule } from '@shared/shared.module'; import { fireEvent, render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; +import { cloneDeep } from 'lodash'; import { of, throwError } from 'rxjs'; +import { mockQualificationCertificates } from '../../../core/test-utils/MockCertificateService'; import { WorkersModule } from '../../workers/workers.module'; import { NewTrainingAndQualificationsRecordComponent } from './new-training-and-qualifications-record.component'; @@ -110,15 +121,31 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { ], }; - async function setup( - otherJob = false, - careCert = true, - mandatoryTraining = [], - jobRoleMandatoryTraining = [], - noQualifications = false, - fragment = 'all-records', - isOwnWorkplace = true, - ) { + interface SetupOptions { + otherJob: boolean; + careCert: boolean; + mandatoryTraining: TrainingRecordCategory[]; + nonMandatoryTraining: TrainingRecordCategory[]; + fragment: 'all-records' | 'mandatory-training' | 'non-mandatory-training' | 'qualifications'; + isOwnWorkplace: boolean; + qualifications: QualificationsByGroup; + } + const defaults: SetupOptions = { + otherJob: false, + careCert: true, + mandatoryTraining: [], + nonMandatoryTraining: mockTrainingData.nonMandatory, + fragment: 'all-records', + isOwnWorkplace: true, + qualifications: { count: 0, groups: [], lastUpdated: qualificationsByGroup.lastUpdated }, + }; + + async function setup(options: Partial = {}) { + const { otherJob, careCert, mandatoryTraining, nonMandatoryTraining, fragment, isOwnWorkplace, qualifications } = { + ...defaults, + ...options, + }; + const { fixture, getByText, getAllByText, queryByText, getByTestId } = await render( NewTrainingAndQualificationsRecordComponent, { @@ -149,10 +176,12 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { careCertificate: careCert ? 'Yes, in progress or partially completed' : null, }, trainingAndQualificationRecords: { - training: { ...mockTrainingData, jobRoleMandatoryTraining, mandatory: mandatoryTraining }, - qualifications: noQualifications - ? { count: 0, groups: [], lastUpdated: null } - : qualificationsByGroup, + training: { + ...mockTrainingData, + mandatory: mandatoryTraining, + nonMandatory: nonMandatoryTraining, + }, + qualifications: qualifications, }, expiresSoonAlertDate: { expiresSoonAlertDate: '90', @@ -315,6 +344,10 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { { provide: EstablishmentService, useClass: MockEstablishmentService }, { provide: BreadcrumbService, useClass: MockBreadcrumbService }, { provide: PermissionsService, useFactory: MockPermissionsService.factory(['canEditWorker']) }, + { provide: TrainingCertificateService, useClass: MockTrainingCertificateService }, + { provide: QualificationCertificateService, useClass: MockQualificationCertificateService }, + // suppress the distracting error msg of "reading 'nativeElement'" from PdfTrainingAndQualificationService + { provide: PdfTrainingAndQualificationService, useValue: { BuildTrainingAndQualsPdf: () => {} } }, ], }, ); @@ -332,6 +365,10 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const workerService = injector.inject(WorkerService) as WorkerService; const trainingService = injector.inject(TrainingService) as TrainingService; + const trainingCertificateService = injector.inject(TrainingCertificateService) as TrainingCertificateService; + const qualificationCertificateService = injector.inject( + QualificationCertificateService, + ) as QualificationCertificateService; const workerSpy = spyOn(workerService, 'setReturnTo'); workerSpy.and.callThrough(); @@ -361,6 +398,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { alertSpy, pdfTrainingAndQualsService, parentSubsidiaryViewService, + trainingCertificateService, + qualificationCertificateService, }; } @@ -416,7 +455,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should show not answered if no care certificate value', async () => { - const { getByText } = await setup(false, false); + const { getByText } = await setup({ otherJob: false, careCert: false }); expect(getByText('Care Certificate:', { exact: false })).toBeTruthy(); expect(getByText('Not answered', { exact: false })).toBeTruthy(); @@ -633,7 +672,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { describe('mandatory training tab', async () => { it('should show the Mandatory training tab as active when on training record page with fragment mandatory-training', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'mandatory-training'); + const { getByTestId } = await setup({ fragment: 'mandatory-training' }); expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); @@ -650,7 +689,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should render 1 instances of the new-training component when on mandatory training tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'mandatory-training'); + const { fixture } = await setup({ fragment: 'mandatory-training' }); expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); }); @@ -674,7 +713,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { describe('non mandatory training tab', async () => { it('should show the non Mandatory training tab as active when on training record page with fragment non-mandatory-training', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'non-mandatory-training'); + const { getByTestId } = await setup({ fragment: 'non-mandatory-training' }); expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); @@ -687,7 +726,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should render 1 instances of the new-training component when on non mandatory training tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'non-mandatory-training'); + const { fixture } = await setup({ fragment: 'non-mandatory-training' }); expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); }); @@ -711,7 +750,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { describe('qualifications tab', async () => { it('should show the qualifications tab as active when on training record page with fragment qualifications', async () => { - const { getByTestId } = await setup(false, true, [], [], false, 'qualifications'); + const { getByTestId } = await setup({ fragment: 'qualifications' }); expect(getByTestId('allRecordsTab').getAttribute('class')).not.toContain('asc-tabs__list-item--active'); expect(getByTestId('allRecordsTabLink').getAttribute('class')).not.toContain('asc-tabs__link--active'); @@ -728,7 +767,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should render 1 instances of the new-qualifications component when on qualification tab', async () => { - const { fixture } = await setup(false, true, [], [], false, 'non-mandatory-training'); + const { fixture } = await setup({ fragment: 'non-mandatory-training' }); expect(fixture.debugElement.nativeElement.querySelector('app-new-training')).not.toBe(null); }); @@ -760,11 +799,9 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { 'BuildTrainingAndQualsPdf', ).and.callThrough(); - component.pdfCount = 1; - fixture.detectChanges(); - fireEvent.click(getByText('Download training and qualifications', { exact: false })); + fireEvent.click(getByText('Download this training and qualifications summary')); expect(downloadFunctionSpy).toHaveBeenCalled(); expect(pdfTrainingAndQualsServiceSpy).toHaveBeenCalled(); @@ -785,15 +822,15 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should return all workplaces journey when is not own workplace and not in parent sub view', async () => { - const { component } = await setup(false, true, [], [], false, 'all-records', false); + const { component } = await setup({ isOwnWorkplace: false }); expect(component.getBreadcrumbsJourney()).toBe(JourneyType.ALL_WORKPLACES); }); }); - describe('certificates', () => { + describe('training certificates', () => { describe('Download button', () => { - const mockTrainings = [ + const mockTrainings: TrainingRecordCategory[] = [ { category: 'HealthWithCertificate', id: 1, @@ -819,13 +856,17 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { ]; it('should download a certificate file when download link of a certificate row is clicked', async () => { - const { getByTestId, component, trainingService } = await setup(false, true, mockTrainings); + const { getByTestId, component, trainingCertificateService } = await setup({ + mandatoryTraining: mockTrainings, + }); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); const downloadLink = within(trainingRecordRow).getByText('Download'); - const downloadCertificatesSpy = spyOn(trainingService, 'downloadCertificates').and.returnValue(of(null)); + const downloadCertificatesSpy = spyOn(trainingCertificateService, 'downloadCertificates').and.returnValue( + of(null), + ); userEvent.click(downloadLink); expect(downloadCertificatesSpy).toHaveBeenCalledWith( @@ -837,7 +878,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should call triggerCertificateDownloads with file returned from downloadCertificates', async () => { - const { getByTestId, trainingService } = await setup(false, true, mockTrainings); + const { getByTestId, trainingCertificateService } = await setup({ mandatoryTraining: mockTrainings }); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); @@ -846,10 +887,11 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { { filename: 'test.pdf', signedUrl: 'signedUrl.com/1872ec19-510d-41de-995d-6abfd3ae888a' }, ]; - const triggerCertificateDownloadsSpy = spyOn(trainingService, 'triggerCertificateDownloads').and.returnValue( - of(null), - ); - spyOn(trainingService, 'getCertificateDownloadUrls').and.returnValue( + const triggerCertificateDownloadsSpy = spyOn( + trainingCertificateService, + 'triggerCertificateDownloads', + ).and.returnValue(of(null)); + spyOn(trainingCertificateService, 'getCertificateDownloadUrls').and.returnValue( of({ files: filesReturnedFromDownloadCertificates }), ); @@ -859,14 +901,16 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should display an error message on the training category when certificate download fails', async () => { - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, mockTrainings); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup({ + mandatoryTraining: mockTrainings, + }); const uidForTrainingRecord = 'someHealthuidWithCertificate'; const trainingRecordRow = getByTestId(uidForTrainingRecord); const downloadLink = within(trainingRecordRow).getByText('Download'); - spyOn(trainingService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); + spyOn(trainingCertificateService, 'downloadCertificates').and.returnValue(throwError('403 forbidden')); userEvent.click(downloadLink); fixture.detectChanges(); @@ -879,8 +923,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const mockUploadFile = new File(['some file content'], 'certificate.pdf'); it('should upload a file when a file is selected from Upload file button', async () => { - const { component, getByTestId, trainingService } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + const { component, getByTestId, trainingCertificateService } = await setup(); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates').and.returnValue(of(null)); const trainingRecordRow = getByTestId('someHealthuid'); @@ -899,8 +943,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { it('should show an error message when a non pdf file is selected', async () => { const invalidFile = new File(['some file content'], 'certificate.csv'); - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates'); const trainingRecordRow = getByTestId('someHealthuid'); @@ -920,8 +964,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { value: 6 * 1024 * 1024, // 6MB }); - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - const uploadCertificateSpy = spyOn(trainingService, 'addCertificateToTraining'); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(); + const uploadCertificateSpy = spyOn(trainingCertificateService, 'addCertificates'); const trainingRecordRow = getByTestId('someHealthuid'); @@ -936,7 +980,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should refresh the training record and display an alert of "Certificate uploaded" on successful upload', async () => { - const { fixture, alertSpy, getByTestId, workerService, trainingService } = await setup(false, true, []); + const { fixture, alertSpy, getByTestId, workerService, trainingCertificateService } = await setup(); const mockUpdatedData = { training: mockTrainingData, qualifications: { count: 0, groups: [], lastUpdated: null }, @@ -944,7 +988,7 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { const workerSpy = spyOn(workerService, 'getAllTrainingAndQualificationRecords').and.returnValue( of(mockUpdatedData), ); - spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + spyOn(trainingCertificateService, 'addCertificates').and.returnValue(of(null)); const trainingRecordRow = getByTestId('someHealthuid'); const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); @@ -961,13 +1005,15 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should reset the actions list with returned training data when file successfully uploaded', async () => { - const { fixture, getByTestId, workerService, trainingService } = await setup(false, true, []); + const { fixture, getByTestId, workerService, trainingCertificateService } = await setup({ + mandatoryTraining: [], + }); const mockUpdatedData = { training: mockTrainingData, qualifications: { count: 0, groups: [], lastUpdated: null }, } as TrainingAndQualificationRecords; spyOn(workerService, 'getAllTrainingAndQualificationRecords').and.returnValue(of(mockUpdatedData)); - spyOn(trainingService, 'addCertificateToTraining').and.returnValue(of(null)); + spyOn(trainingCertificateService, 'addCertificates').and.returnValue(of(null)); const trainingRecordRow = getByTestId('someHealthuid'); const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); @@ -989,8 +1035,8 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); it('should display an error message on the training category when certificate upload fails', async () => { - const { fixture, getByTestId, trainingService, getByText } = await setup(false, true, []); - spyOn(trainingService, 'addCertificateToTraining').and.returnValue(throwError('failed to upload')); + const { fixture, getByTestId, trainingCertificateService, getByText } = await setup(); + spyOn(trainingCertificateService, 'addCertificates').and.returnValue(throwError('failed to upload')); const trainingRecordRow = getByTestId('someHealthuid'); const uploadButton = within(trainingRecordRow).getByTestId('fileInput'); @@ -1003,4 +1049,287 @@ describe('NewTrainingAndQualificationsRecordComponent', () => { }); }); }); + + describe('qualification certificates', () => { + const mockQualifications = cloneDeep(qualificationsByGroup); + mockQualifications.groups[0].records[0].qualificationCertificates = [ + { + filename: 'Award 2024.pdf', + uid: 'quals-cert-uid1', + uploadDate: '2024-09-20T08:57:45.000Z', + }, + ]; + + const uidForRecordWithOneCert = mockQualifications.groups[0].records[0].uid; + const uidForRecordWithNoCerts = mockQualifications.groups[1].records[0].uid; + + const setupWithQualificationCerts = () => setup({ qualifications: mockQualifications }); + + describe('Download button', () => { + it('should download a certificate file when download link of a certificate row is clicked', async () => { + const { component, getByTestId, qualificationCertificateService } = await setupWithQualificationCerts(); + const recordRow = getByTestId(uidForRecordWithOneCert); + const downloadLink = within(recordRow).getByText('Download'); + + const downloadCertificatesSpy = spyOn(qualificationCertificateService, 'downloadCertificates').and.returnValue( + of(null), + ); + + userEvent.click(downloadLink); + expect(downloadCertificatesSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + uidForRecordWithOneCert, + [ + { + filename: 'Award 2024.pdf', + uid: 'quals-cert-uid1', + }, + ], + ); + }); + + it('should call triggerCertificateDownloads with file returned from downloadCertificates', async () => { + const { getByTestId, qualificationCertificateService } = await setupWithQualificationCerts(); + + const recordRow = getByTestId(uidForRecordWithOneCert); + const downloadLink = within(recordRow).getByText('Download'); + const filesReturnedFromDownloadCertificates = [{ filename: 'test.pdf', signedUrl: 'localhost/mock-sign-url' }]; + + const triggerCertificateDownloadsSpy = spyOn( + qualificationCertificateService, + 'triggerCertificateDownloads', + ).and.returnValue(of(null)); + + spyOn(qualificationCertificateService, 'getCertificateDownloadUrls').and.returnValue( + of({ files: filesReturnedFromDownloadCertificates }), + ); + + userEvent.click(downloadLink); + + expect(triggerCertificateDownloadsSpy).toHaveBeenCalledWith(filesReturnedFromDownloadCertificates); + }); + + it('should display an error message on the training category when certificate download fails', async () => { + const { fixture, getByText, getByTestId, qualificationCertificateService } = + await setupWithQualificationCerts(); + const recordRow = getByTestId(uidForRecordWithOneCert); + const downloadLink = within(recordRow).getByText('Download'); + + spyOn(qualificationCertificateService, 'downloadCertificates').and.returnValue( + throwError('404 file not found'), + ); + + userEvent.click(downloadLink); + fixture.detectChanges(); + + expect(getByText("There's a problem with this download. Try again later or contact us for help.")).toBeTruthy(); + }); + }); + + describe('Upload button', () => { + const mockUploadFile = new File(['some file content'], 'certificate.pdf'); + + it('should upload a file when a file is selected from Upload file button', async () => { + const { component, getByTestId, qualificationCertificateService } = await setupWithQualificationCerts(); + const uploadCertificateSpy = spyOn(qualificationCertificateService, 'addCertificates').and.returnValue( + of(null), + ); + + const recordRow = getByTestId(uidForRecordWithNoCerts); + + const uploadButton = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + expect(uploadCertificateSpy).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + uidForRecordWithNoCerts, + [mockUploadFile], + ); + }); + + it('should show an error message when a non pdf file is selected', async () => { + const invalidFile = new File(['some file content'], 'certificate.csv'); + + const { fixture, getByText, getByTestId, qualificationCertificateService } = + await setupWithQualificationCerts(); + const uploadCertificateSpy = spyOn(qualificationCertificateService, 'addCertificates'); + + const recordRow = getByTestId(uidForRecordWithNoCerts); + + const uploadButton = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, invalidFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be a PDF file')).toBeTruthy(); + expect(uploadCertificateSpy).not.toHaveBeenCalled(); + }); + + it('should show an error message when a file of > 5MB is selected', async () => { + const invalidFile = new File(['some file content'], 'certificate.pdf'); + Object.defineProperty(invalidFile, 'size', { + value: 6 * 1024 * 1024, // 6MB + }); + + const { fixture, getByText, getByTestId, qualificationCertificateService } = + await setupWithQualificationCerts(); + const uploadCertificateSpy = spyOn(qualificationCertificateService, 'addCertificates'); + + const recordRow = getByTestId(uidForRecordWithNoCerts); + + const uploadButton = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, invalidFile); + + fixture.detectChanges(); + + expect(getByText('The certificate must be no larger than 5MB')).toBeTruthy(); + expect(uploadCertificateSpy).not.toHaveBeenCalled(); + }); + + it('should refresh the qualification record and display an alert of "Certificate uploaded" on successful upload', async () => { + const { fixture, alertSpy, getByTestId, workerService, qualificationCertificateService } = + await setupWithQualificationCerts(); + const updatedQualifications: QualificationsByGroup = cloneDeep(mockQualifications); + updatedQualifications.groups[1].records[0].qualificationCertificates = [ + { uid: 'mock-uid', filename: mockUploadFile.name, uploadDate: '2024-10-15' }, + ]; + + const mockUpdatedData = { + training: mockTrainingData, + qualifications: updatedQualifications, + } as TrainingAndQualificationRecords; + + const workerSpy = spyOn(workerService, 'getAllTrainingAndQualificationRecords').and.returnValue( + of(mockUpdatedData), + ); + spyOn(qualificationCertificateService, 'addCertificates').and.returnValue(of(null)); + + const recordRow = getByTestId(uidForRecordWithNoCerts); + const uploadButton = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(workerSpy).toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Certificate uploaded', + }); + + // the record that had no certs now should have one cert and display a download link + const updatedRow = getByTestId(uidForRecordWithNoCerts); + expect(within(updatedRow).getByText('Download')).toBeTruthy(); + }); + + it('should display an error message on the training category when certificate upload fails', async () => { + const { fixture, getByText, getByTestId, qualificationCertificateService } = + await setupWithQualificationCerts(); + spyOn(qualificationCertificateService, 'addCertificates').and.returnValue(throwError('403 forbidden')); + + const recordRow = getByTestId(uidForRecordWithNoCerts); + const uploadButton = within(recordRow).getByTestId('fileInput'); + + userEvent.upload(uploadButton, mockUploadFile); + + fixture.detectChanges(); + + expect(getByText("There's a problem with this upload. Try again later or contact us for help.")).toBeTruthy(); + }); + }); + }); + + describe('download all certificates', () => { + it('should display a link for downloading all certificates', async () => { + const { getByText } = await setup(); + + expect(getByText('Download all their training and qualification certificates')).toBeTruthy(); + }); + + it('should not display the download all link if worker has got no certificates', async () => { + const nonMandatorytrainingWithNoCerts = cloneDeep(mockTrainingData.nonMandatory); + nonMandatorytrainingWithNoCerts.forEach((category) => + category.trainingRecords.forEach((record) => (record.trainingCertificates = [])), + ); + + const { queryByText } = await setup({ + nonMandatoryTraining: nonMandatorytrainingWithNoCerts, + }); + + expect(queryByText('Download all their training and qualification certificates')).toBeFalsy(); + }); + + it('should download all training and qualification certificates for the worker when clicked', async () => { + const { component, getByText, trainingCertificateService, qualificationCertificateService } = await setup(); + + spyOn(trainingCertificateService, 'downloadAllCertificatesAsBlobs').and.callThrough(); + spyOn(qualificationCertificateService, 'downloadAllCertificatesAsBlobs').and.callThrough(); + + const downloadAllButton = getByText('Download all their training and qualification certificates'); + userEvent.click(downloadAllButton); + + expect(trainingCertificateService.downloadAllCertificatesAsBlobs).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + ); + expect(qualificationCertificateService.downloadAllCertificatesAsBlobs).toHaveBeenCalledWith( + component.workplace.uid, + component.worker.uid, + ); + }); + + it('should call saveFilesAsZip with all the downloaded certificates', async () => { + const { component, getByText } = await setup(); + const expectedZipFileName = `All certificates - ${component.worker.nameOrId}.zip`; + + const fileUtilSpy = spyOn(FileUtil, 'saveFilesAsZip').and.callThrough(); + + const downloadAllButton = getByText('Download all their training and qualification certificates'); + userEvent.click(downloadAllButton); + + expect(fileUtilSpy).toHaveBeenCalled(); + + const contentsOfZipFile = fileUtilSpy.calls.mostRecent().args[0]; + const nameOfZipFile = fileUtilSpy.calls.mostRecent().args[1]; + + expect(nameOfZipFile).toEqual(expectedZipFileName); + + mockTrainingCertificates.forEach((certificate) => { + expect(contentsOfZipFile).toContain( + jasmine.objectContaining({ + filename: 'Training certificates/' + certificate.filename, + fileBlob: mockCertificateFileBlob, + }), + ); + }); + mockQualificationCertificates.forEach((certificate) => { + expect(contentsOfZipFile).toContain( + jasmine.objectContaining({ + filename: 'Qualification certificates/' + certificate.filename, + fileBlob: mockCertificateFileBlob, + }), + ); + }); + }); + + it('should not start a new download on click if still downloading certificates in the background', async () => { + const { getByText, trainingCertificateService, qualificationCertificateService } = await setup(); + + const downloadAllButton = getByText('Download all their training and qualification certificates'); + spyOn(trainingCertificateService, 'downloadAllCertificatesAsBlobs').and.callThrough(); + spyOn(qualificationCertificateService, 'downloadAllCertificatesAsBlobs').and.callThrough(); + + userEvent.click(downloadAllButton); + userEvent.click(downloadAllButton); + + expect(trainingCertificateService.downloadAllCertificatesAsBlobs).toHaveBeenCalledTimes(1); + expect(qualificationCertificateService.downloadAllCertificatesAsBlobs).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts index 87617d69cc..803caf4135 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training-and-qualifications-record.component.ts @@ -1,23 +1,30 @@ +import { from, merge, Subscription } from 'rxjs'; +import { mergeMap, tap, toArray } from 'rxjs/operators'; + import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { JourneyType } from '@core/breadcrumb/breadcrumb.model'; import { Establishment, mandatoryTraining } from '@core/model/establishment.model'; import { QualificationsByGroup } from '@core/model/qualification.model'; -import { CertificateUpload, TrainingRecord, TrainingRecordCategory } from '@core/model/training.model'; -import { TrainingAndQualificationRecords } from '@core/model/trainingAndQualifications.model'; +import { TrainingRecordCategory, TrainingRecords } from '@core/model/training.model'; +import { + CertificateDownloadEvent, + CertificateUploadEvent, + TrainingAndQualificationRecords, +} from '@core/model/trainingAndQualifications.model'; import { Worker } from '@core/model/worker.model'; import { AlertService } from '@core/services/alert.service'; import { BreadcrumbService } from '@core/services/breadcrumb.service'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; import { EstablishmentService } from '@core/services/establishment.service'; import { PdfTrainingAndQualificationService } from '@core/services/pdf-training-and-qualification.service'; import { PermissionsService } from '@core/services/permissions/permissions.service'; import { TrainingService } from '@core/services/training.service'; import { TrainingStatusService } from '@core/services/trainingStatus.service'; import { WorkerService } from '@core/services/worker.service'; +import { FileUtil } from '@core/utils/file-util'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; -import { Subscription } from 'rxjs'; - -import { CustomValidators } from '../../../shared/validators/custom-form-validators'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; @Component({ selector: 'app-new-training-and-qualifications-record', @@ -47,9 +54,10 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe nonMandatoryTraining: 'non-mandatory-training', qualifications: 'qualifications', }; - public pdfCount: number; public certificateErrors: Record = {}; // {categoryName: errorMessage} - private trainingRecords: any; + private trainingRecords: TrainingRecords; + private downloadingAllCertsInBackground = false; + public workerHasCertificate = false; constructor( private breadcrumbService: BreadcrumbService, @@ -59,6 +67,8 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe private router: Router, private trainingStatusService: TrainingStatusService, private trainingService: TrainingService, + private trainingCertificateService: TrainingCertificateService, + private qualificationCertificateService: QualificationCertificateService, private workerService: WorkerService, private alertService: AlertService, public viewContainerRef: ViewContainerRef, @@ -74,9 +84,9 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe this.setUpTabSubscription(); this.updateTrainingExpiresSoonDate(); this.setTraining(); + this.checkWorkerHasCertificateOrNot(); this.setUpAlertSubscription(); this.setReturnRoute(); - this.getPdfCount(); } public async downloadAsPDF(save: boolean = true) { @@ -99,13 +109,6 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe } } - private async getPdfCount() { - const pdf = await this.downloadAsPDF(false); - const numberOfPages = pdf?.getNumberOfPages(); - - return (this.pdfCount = numberOfPages); - } - private setPageData(): void { this.workplace = this.route.parent.snapshot.data.establishment; this.worker = this.route.snapshot.data.worker; @@ -340,43 +343,53 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe this.subscriptions.unsubscribe(); } - public downloadTrainingCertificate(trainingRecord: TrainingRecord): void { - this.trainingService - .downloadCertificates( - this.workplace.uid, - this.worker.uid, - trainingRecord.uid, - trainingRecord.trainingCertificates, - ) + private getCertificateService(event: CertificateDownloadEvent | CertificateUploadEvent) { + switch (event.recordType) { + case 'qualification': + return this.qualificationCertificateService; + case 'training': + return this.trainingCertificateService; + } + } + + public downloadCertificate(event: CertificateDownloadEvent) { + const certificateService = this.getCertificateService(event); + const { recordUid, filesToDownload: files } = event; + + const subscription = certificateService + .downloadCertificates(this.workplace.uid, this.worker.uid, recordUid, files) .subscribe( () => { this.certificateErrors = {}; }, (_error) => { - const categoryName = trainingRecord.trainingCategory.category; + const categoryName = event.recordType === 'training' ? event.categoryName : event.qualificationType; this.certificateErrors = { [categoryName]: "There's a problem with this download. Try again later or contact us for help.", }; }, ); + this.subscriptions.add(subscription); } - public uploadTrainingCertificate(event: CertificateUpload): void { - const { files, trainingRecord } = event; + public uploadCertificate(event: CertificateUploadEvent) { + const { recordUid, files } = event; + const categoryName = event.recordType === 'training' ? event.categoryName : event.qualificationType; const errors = CustomValidators.validateUploadCertificates(files); if (errors?.length > 0) { - const categoryName = trainingRecord.trainingCategory.category; this.certificateErrors = { [categoryName]: errors[0] }; return; } - this.trainingService - .addCertificateToTraining(this.workplace.uid, this.worker.uid, trainingRecord.uid, files) + const certificateService = this.getCertificateService(event); + + const subscription = certificateService + .addCertificates(this.workplace.uid, this.worker.uid, recordUid, files) .subscribe( () => { this.certificateErrors = {}; - this.refreshTraining().then(() => { + this.refreshTrainingAndQualificationRecords().then(() => { this.alertService.addAlert({ type: 'success', message: 'Certificate uploaded', @@ -384,19 +397,73 @@ export class NewTrainingAndQualificationsRecordComponent implements OnInit, OnDe }); }, (_error) => { - const categoryName = trainingRecord.trainingCategory.category; this.certificateErrors = { [categoryName]: "There's a problem with this upload. Try again later or contact us for help.", }; }, ); + this.subscriptions.add(subscription); } - private async refreshTraining() { + public downloadAllCertificates(event: Event) { + event.preventDefault(); + + if (this.downloadingAllCertsInBackground) { + return; + } + + this.downloadingAllCertsInBackground = true; + + const allTrainingCerts$ = this.trainingCertificateService.downloadAllCertificatesAsBlobs( + this.workplace.uid, + this.worker.uid, + ); + const allQualificationCerts$ = this.qualificationCertificateService.downloadAllCertificatesAsBlobs( + this.workplace.uid, + this.worker.uid, + ); + + const zipFileName = this.worker.nameOrId + ? `All certificates - ${this.worker.nameOrId}.zip` + : 'All certificates.zip'; + + const downloadAllCertificatesAsZip$ = merge(allTrainingCerts$, allQualificationCerts$).pipe( + toArray(), + mergeMap((allFileBlobs) => from(FileUtil.saveFilesAsZip(allFileBlobs, zipFileName))), + ); + + this.subscriptions.add( + downloadAllCertificatesAsZip$.subscribe( + () => { + this.downloadingAllCertsInBackground = false; + }, + (err) => { + console.error('Error occurred when downloading all certificates: ', err); + this.downloadingAllCertsInBackground = false; + }, + ), + ); + } + + private async refreshTrainingAndQualificationRecords() { const updatedData: TrainingAndQualificationRecords = await this.workerService .getAllTrainingAndQualificationRecords(this.workplace.uid, this.worker.uid) .toPromise(); this.trainingRecords = updatedData.training; + this.qualificationsByGroup = updatedData.qualifications; this.setTraining(); + this.checkWorkerHasCertificateOrNot(); + } + + private checkWorkerHasCertificateOrNot() { + const allTrainingRecords = [...this.trainingRecords.mandatory, ...this.trainingRecords.nonMandatory]; + const hasTrainingCertificate = allTrainingRecords.some((record) => + record?.trainingRecords?.some((record) => record?.trainingCertificates?.length > 0), + ); + const hasQualificationCertificate = this.qualificationsByGroup.groups.some((group) => + group?.records?.some((record) => record?.qualificationCertificates?.length > 0), + ); + + this.workerHasCertificate = hasTrainingCertificate || hasQualificationCertificate; } } diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html index 2e621cbee1..b0b2a2ea1b 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.html @@ -2,15 +2,15 @@

{{ trainingType }}

-
+
- + - +
diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss deleted file mode 100644 index 1e78c1a751..0000000000 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -td.govuk-table__cell:has(button) { - padding-top: 6px; - padding-bottom: 6px; -} diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts index 5367535377..1d3d74ea07 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.spec.ts @@ -1,6 +1,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; +import { TrainingCertificateDownloadEvent, TrainingCertificateUploadEvent } from '@core/model/training.model'; import { SharedModule } from '@shared/shared.module'; import { render, within } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; @@ -94,15 +95,16 @@ describe('NewTrainingComponent', async () => { }, ]; - async function setup(canEditWorker = true, certificateErrors = null) { + async function setup(override: any = {}) { const { fixture, getByTestId, getByLabelText } = await render(NewTrainingComponent, { imports: [RouterTestingModule, HttpClientTestingModule, SharedModule], providers: [], componentProperties: { - canEditWorker, + canEditWorker: true, trainingCategories: trainingCategories, isMandatoryTraining: false, - certificateErrors, + certificateErrors: null, + ...override, }, }); const component = fixture.componentInstance; @@ -190,7 +192,7 @@ describe('NewTrainingComponent', async () => { }); it('training title should not link to training records if you are a read only user', async () => { - const { fixture } = await setup(false); + const { fixture } = await setup({ canEditWorker: false }); const autismTrainingTitleLink = fixture.debugElement.query(By.css('[data-testid="Title-no-link-someAutismUid"]')); const communicationTrainingTitleLink = fixture.debugElement.query( @@ -211,11 +213,8 @@ describe('NewTrainingComponent', async () => { describe('no training', async () => { it('should display a no training found link when there is no training and isMandatoryTraining is false and canEditWorker is true', async () => { - const { component, fixture } = await setup(); + const { fixture } = await setup({ trainingCategories: [] }); - component.trainingCategories = []; - component.ngOnChanges(); - fixture.detectChanges(); const noTrainingLink = fixture.debugElement.query(By.css('[data-testid="no-training-link"]')).nativeElement; expect(noTrainingLink).toBeTruthy(); @@ -223,21 +222,16 @@ describe('NewTrainingComponent', async () => { }); it('should not display a no training found link when there is no training and isMandatoryTraining is false and canEditWorker is false', async () => { - const { component, fixture } = await setup(); + const { fixture } = await setup({ trainingCategories: [], canEditWorker: false }); - component.trainingCategories = []; - component.canEditWorker = false; - fixture.detectChanges(); const noTrainingLink = fixture.debugElement.query(By.css('[data-testid="no-training-link"]')); expect(noTrainingLink).toBeFalsy(); }); it('should display a no mandatory training found link when there is no mandatory training and isMandatoryTraining is true and canEditWorker is true', async () => { - const { component, fixture } = await setup(); + const { component, fixture } = await setup({ trainingCategories: [], isMandatoryTraining: true }); - component.trainingCategories = []; - component.isMandatoryTraining = true; component.workplaceUid = '123'; component.ngOnChanges(); fixture.detectChanges(); @@ -263,13 +257,15 @@ describe('NewTrainingComponent', async () => { }); it('should display a no mandatory training for job role message when mandatory training is not required for the job role', async () => { - const { component, fixture } = await setup(); - component.trainingCategories = []; - component.isMandatoryTraining = true; + const { component, fixture } = await setup({ + trainingCategories: [], + isMandatoryTraining: true, + missingMandatoryTraining: false, + }); + component.workplaceUid = '123'; - component.missingMandatoryTraining = false; - component.ngOnChanges(); fixture.detectChanges(); + const mandatoryTrainingMissingLink = fixture.debugElement.query( By.css('[data-testid="no-mandatory-training-link"]'), ); @@ -283,13 +279,15 @@ describe('NewTrainingComponent', async () => { }); it('should display a no mandatory training for job role message when mandatory training is missing', async () => { - const { component, fixture } = await setup(); - component.trainingCategories = []; - component.isMandatoryTraining = true; + const { component, fixture } = await setup({ + trainingCategories: [], + isMandatoryTraining: true, + missingMandatoryTraining: true, + }); + component.workplaceUid = '123'; - component.missingMandatoryTraining = true; - component.ngOnChanges(); fixture.detectChanges(); + const mandatoryTrainingMissingLink = fixture.debugElement.query( By.css('[data-testid="mandatory-training-missing-link"]'), ); @@ -418,7 +416,7 @@ describe('NewTrainingComponent', async () => { }); it('should not display Download link when training record has one certificate associated with it but user does not have edit permissions', async () => { - const { component, fixture, getByTestId } = await setup(false); + const { component, fixture, getByTestId } = await setup({ canEditWorker: false }); component.trainingCategories[0].trainingRecords[0].trainingCertificates = singleTrainingCertificate(); fixture.detectChanges(); @@ -439,8 +437,14 @@ describe('NewTrainingComponent', async () => { userEvent.click(downloadLink); const expectedTrainingRecord = component.trainingCategories[0].trainingRecords[0]; + const expectedDownloadEvent: TrainingCertificateDownloadEvent = { + recordType: 'training', + recordUid: expectedTrainingRecord.uid, + categoryName: expectedTrainingRecord.trainingCategory.category, + filesToDownload: expectedTrainingRecord.trainingCertificates, + }; - expect(downloadFileSpy).toHaveBeenCalledOnceWith(expectedTrainingRecord); + expect(downloadFileSpy).toHaveBeenCalledOnceWith(expectedDownloadEvent); }); it('should display Select a download link when training record has more than one certificate associated with it', async () => { @@ -454,7 +458,7 @@ describe('NewTrainingComponent', async () => { }); it('should not display Select a download link when training record has more than one certificate associated with it but user does not have edit permissions', async () => { - const { component, fixture, getByTestId } = await setup(false); + const { component, fixture, getByTestId } = await setup({ canEditWorker: false }); component.trainingCategories[0].trainingRecords[0].trainingCertificates = multipleTrainingCertificates(); fixture.detectChanges(); @@ -477,7 +481,7 @@ describe('NewTrainingComponent', async () => { expect(selectADownloadLink.getAttribute('href')).toEqual(`/training/${trainingRecordUid}`); }); - it('should display Upload file button when training record has no certificates associated with it but user does not have edit permissions', async () => { + it('should display Upload file button when training record has no certificates associated with it', async () => { const { component, fixture, getByTestId } = await setup(); component.trainingCategories[0].trainingRecords[0].trainingCertificates = []; @@ -487,8 +491,8 @@ describe('NewTrainingComponent', async () => { expect(within(trainingRecordWithCertificateRow).getByText('Upload file')).toBeTruthy(); }); - it('should not display Upload file button when training record has no certificates associated with it', async () => { - const { component, fixture, getByTestId } = await setup(false); + it('should not display Upload file button when training record has no certificates associated with it but user does not have edit permissions', async () => { + const { component, fixture, getByTestId } = await setup({ canEditWorker: false }); component.trainingCategories[0].trainingRecords[0].trainingCertificates = []; fixture.detectChanges(); @@ -510,17 +514,22 @@ describe('NewTrainingComponent', async () => { userEvent.upload(fileInput, [mockUploadFile]); - expect(uploadFileSpy).toHaveBeenCalledWith({ + const expectedTrainingRecord = component.trainingCategories[0].trainingRecords[0]; + const expectedUploadEvent: TrainingCertificateUploadEvent = { + recordType: 'training', + recordUid: expectedTrainingRecord.uid, + categoryName: expectedTrainingRecord.trainingCategory.category, files: [mockUploadFile], - trainingRecord: component.trainingCategories[0].trainingRecords[0], - }); + }; + + expect(uploadFileSpy).toHaveBeenCalledWith(expectedUploadEvent); }); it('should display an error message above the category when download certificate fails', async () => { const certificateErrors = { Autism: "There's a problem with this download. Try again later or contact us for help.", }; - const { getByTestId } = await setup(true, certificateErrors); + const { getByTestId } = await setup({ certificateErrors }); const categorySection = getByTestId('Autism-section'); expect( diff --git a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts index 4ef2ba67e3..053be9f0d1 100644 --- a/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/new-training-qualifications-record/new-training/new-training.component.ts @@ -1,12 +1,16 @@ import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { CertificateUpload, TrainingRecord, TrainingRecordCategory } from '@core/model/training.model'; +import { + TrainingCertificateDownloadEvent, + TrainingCertificateUploadEvent, + TrainingRecord, + TrainingRecordCategory, +} from '@core/model/training.model'; import { TrainingStatusService } from '@core/services/trainingStatus.service'; @Component({ selector: 'app-new-training', templateUrl: './new-training.component.html', - styleUrls: ['./new-training.component.scss'], }) export class NewTrainingComponent implements OnChanges { @Input() public trainingCategories: TrainingRecordCategory[]; @@ -16,8 +20,8 @@ export class NewTrainingComponent implements OnChanges { @Input() public canEditWorker: boolean; @Input() public missingMandatoryTraining = false; @Input() public certificateErrors: Record = {}; - @Output() public downloadFile = new EventEmitter(); - @Output() public uploadFile = new EventEmitter(); + @Output() public downloadFile = new EventEmitter(); + @Output() public uploadFile = new EventEmitter(); public trainingCategoryToDisplay: (TrainingRecordCategory & { error?: string })[]; @@ -26,7 +30,7 @@ export class NewTrainingComponent implements OnChanges { constructor(protected trainingStatusService: TrainingStatusService, private route: ActivatedRoute) {} - ngOnInit(): void { + ngOnInit() { this.workplaceUid = this.route.snapshot.params.establishmentuid; this.addErrorsToTrainingCategories(); } @@ -35,15 +39,6 @@ export class NewTrainingComponent implements OnChanges { this.addErrorsToTrainingCategories(); } - handleDownloadCertificate(event: Event, trainingRecord: TrainingRecord) { - event.preventDefault(); - this.downloadFile.emit(trainingRecord); - } - - handleUploadCertificate(files: File[], trainingRecord: TrainingRecord) { - this.uploadFile.emit({ files, trainingRecord }); - } - addErrorsToTrainingCategories() { this.trainingCategoryToDisplay = this.trainingCategories.map((trainingCategory) => { if (this.certificateErrors && trainingCategory.category in this.certificateErrors) { @@ -54,4 +49,23 @@ export class NewTrainingComponent implements OnChanges { } }); } + + handleDownloadCertificate(event: Event, trainingRecord: TrainingRecord) { + event.preventDefault(); + this.downloadFile.emit({ + recordType: 'training', + recordUid: trainingRecord.uid, + categoryName: trainingRecord.trainingCategory.category, + filesToDownload: trainingRecord.trainingCertificates, + }); + } + + handleUploadCertificate(files: File[], trainingRecord: TrainingRecord) { + this.uploadFile.emit({ + recordType: 'training', + recordUid: trainingRecord.uid, + categoryName: trainingRecord.trainingCategory.category, + files, + }); + } } diff --git a/frontend/src/app/features/workers/workers.module.ts b/frontend/src/app/features/workers/workers.module.ts index 723532b32c..428e853e79 100644 --- a/frontend/src/app/features/workers/workers.module.ts +++ b/frontend/src/app/features/workers/workers.module.ts @@ -68,6 +68,7 @@ import { TotalStaffChangeComponent } from './total-staff-change/total-staff-chan import { WeeklyContractedHoursComponent } from './weekly-contracted-hours/weekly-contracted-hours.component'; import { WorkersRoutingModule } from './workers-routing.module'; import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.component'; +import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; @NgModule({ imports: [CommonModule, OverlayModule, FormsModule, ReactiveFormsModule, SharedModule, WorkersRoutingModule], @@ -138,6 +139,8 @@ import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.compon TrainingRecordsForCategoryResolver, MandatoryTrainingCategoriesResolver, AvailableQualificationsResolver, + TrainingCertificateService, + QualificationCertificateService, ], }) export class WorkersModule {} diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts b/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts index e91706b8ca..8c5c5ed771 100644 --- a/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.spec.ts @@ -144,7 +144,7 @@ describe('CertificationsTableComponent', () => { (filename) => new File(['some file content'], filename, { type: 'application/pdf' }), ); - it("should show the file name, today's date and remove link for the new files to be uploaded", async () => { + it("should show the file name, today's date and remove link for the new files to be uploaded", async () => { const { getByTestId } = await setup([], mockUploadFiles); const datePipe = new DatePipe('en-GB'); diff --git a/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts index 7f9ef005a7..5b46b3589b 100644 --- a/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts +++ b/frontend/src/app/shared/components/certifications-table/certifications-table.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { TrainingCertificate } from '@core/model/training.model'; +import { Certificate } from '@core/model/trainingAndQualifications.model'; @Component({ selector: 'app-certifications-table', @@ -7,7 +7,7 @@ import { TrainingCertificate } from '@core/model/training.model'; styleUrls: ['./certifications-table.component.scss'], }) export class CertificationsTableComponent implements OnInit { - @Input() certificates: TrainingCertificate[] = []; + @Input() certificates: Certificate[] = []; @Input() filesToUpload: File[] = []; @Output() removeFileToUpload = new EventEmitter(); @Output() removeSavedFile = new EventEmitter(); diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html new file mode 100644 index 0000000000..8547a1fa15 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.html @@ -0,0 +1,33 @@ +
+

Certificates

+
+ +
+ The certificate must be a PDF file that's no larger than 5MB +
+
+ + + {{ filesToUpload?.length > 0 ? filesToUpload.length + ' file chosen' : 'No file chosen' }} + +
+
+ +
+
+
diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.scss b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts new file mode 100644 index 0000000000..61f6144db1 --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.spec.ts @@ -0,0 +1,96 @@ +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { SelectUploadCertificateComponent } from './select-upload-certificate.component'; + +describe('SelectUploadCertificateComponent', () => { + let filesToUpload = []; + beforeEach(() => { + filesToUpload = []; + }); + + const setup = async (inputOverride: any = {}) => { + const mockOnSelectFiles = (files: File[]) => { + filesToUpload.push(...files); + }; + + const { fixture, getByText, getByTestId, getByRole } = await render(SelectUploadCertificateComponent, { + imports: [SharedModule], + componentProperties: { + onSelectFiles: mockOnSelectFiles, + filesToUpload, + ...inputOverride, + }, + }); + + const component = fixture.componentInstance; + + return { + component, + fixture, + getByText, + getByRole, + getByTestId, + }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should render a subheading', async () => { + const { getByRole } = await setup(); + expect(getByRole('heading', { name: 'Certificates' })).toBeTruthy; + }); + + describe('Upload file button', () => { + it('should render a file input element', async () => { + const { getByTestId } = await setup(); + + const fileInput = getByTestId('fileInput'); + expect(fileInput).toBeTruthy(); + }); + + it('should render "No file chosen" beside the file input', async () => { + const { getByText } = await setup(); + const text = getByText('No file chosen'); + expect(text).toBeTruthy(); + }); + + it('should not render "No file chosen" when a file is chosen', async () => { + const { fixture, getByTestId } = await setup(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + fixture.detectChanges(); + + const text = within(uploadSection).queryByText('No file chosen'); + expect(text).toBeFalsy(); + }); + + it('should provide aria description to screen reader users', async () => { + const { fixture, getByTestId } = await setup(); + fixture.autoDetectChanges(); + + const uploadSection = getByTestId('uploadCertificate'); + const fileInput = getByTestId('fileInput'); + + let uploadButton = within(uploadSection).getByRole('button', { + description: /The certificate must be a PDF file that's no larger than 5MB/, + }); + expect(uploadButton).toBeTruthy(); + + userEvent.upload(fileInput, new File(['some file content'], 'cert.pdf')); + + uploadButton = within(uploadSection).getByRole('button', { + description: '1 file chosen', + }); + expect(uploadButton).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts new file mode 100644 index 0000000000..d25480a1fd --- /dev/null +++ b/frontend/src/app/shared/components/select-upload-certificate/select-upload-certificate.component.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-select-upload-certificate', + templateUrl: './select-upload-certificate.component.html', + styleUrls: ['./select-upload-certificate.component.scss'], +}) +export class SelectUploadCertificateComponent { + @Input() filesToUpload: File[]; + @Input() certificateErrors: string[] | null; + @Output() selectFiles = new EventEmitter; + + public getUploadComponentAriaDescribedBy(): string { + if (this.certificateErrors) { + return 'uploadCertificate-errors uploadCertificate-aria-text'; + } else if (this.filesToUpload?.length > 0) { + return 'uploadCertificate-aria-text'; + } else { + return 'uploadCertificate-hint uploadCertificate-aria-text'; + } + } + + public onSelectFiles(newFiles: File[]): void { + this.selectFiles.emit(newFiles) + } +} diff --git a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts index 35c02c3bc0..a4bbc3e374 100644 --- a/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts +++ b/frontend/src/app/shared/components/select-upload-file/select-upload-file.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; @Component({ selector: 'app-select-upload-file', @@ -25,7 +25,6 @@ export class SelectUploadFileComponent implements OnInit { const selectedFiles = Array.from(event.target.files); if (selectedFiles?.length) { this.selectFiles.emit(selectedFiles); - event.target.value = ''; } } diff --git a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html index 1458a276f6..20a3af54bf 100644 --- a/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html +++ b/frontend/src/app/shared/directives/add-edit-training/add-edit-training.component.html @@ -182,41 +182,7 @@

-
-

Certificates

-
- -
- The certificate must be a PDF file that's no larger than 5MB -
-
- - - {{ filesToUpload?.length > 0 ? filesToUpload.length + ' file chosen' : 'No file chosen' }} - -
-
- -
-
-
+ Certificates

-
+