diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 49921be7de..229eb52999 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -16,10 +16,8 @@ import { Model, transaction } from 'objection'; import AnimalBatchModel from '../models/animalBatchModel.js'; import baseController from './baseController.js'; -import CustomAnimalBreedModel from '../models/customAnimalBreedModel.js'; -import CustomAnimalTypeModel from '../models/customAnimalTypeModel.js'; import { handleObjectionError } from '../util/errorCodes.js'; -import { assignInternalIdentifiers } from '../util/animal.js'; +import { assignInternalIdentifiers, checkAndAddCustomTypeAndBreed } from '../util/animal.js'; import { uploadPublicImage } from '../util/imageUpload.js'; const animalBatchController = { @@ -60,50 +58,14 @@ const animalBatchController = { const { farm_id } = req.headers; const result = []; - // avoid attempts to add an already created type or breed to the DB - // where multiple batches have the same type_name or breed_name - const typeIdsMap = {}; - const typeBreedIdsMap = {}; + // Create utility object used in type and breed + req.body.typeIdsMap = {}; + req.body.typeBreedIdsMap = {}; for (const animalBatch of req.body) { - if (animalBatch.type_name) { - let typeId = typeIdsMap[animalBatch.type_name]; - - if (!typeId) { - const newType = await baseController.postWithResponse( - CustomAnimalTypeModel, - { type: animalBatch.type_name, farm_id }, - req, - { trx }, - ); - typeId = newType.id; - typeIdsMap[animalBatch.type_name] = typeId; - } - animalBatch.custom_type_id = typeId; - delete animalBatch.type_name; - } - - if (animalBatch.breed_name) { - const typeColumn = animalBatch.default_type_id ? 'default_type_id' : 'custom_type_id'; - const typeId = animalBatch.type_name - ? typeIdsMap[animalBatch.type_name] - : animalBatch.default_type_id || animalBatch.custom_type_id; - const typeBreedKey = `${typeColumn}_${typeId}_${animalBatch.breed_name}`; - let breedId = typeBreedIdsMap[typeBreedKey]; - - if (!breedId) { - const newBreed = await baseController.postWithResponse( - CustomAnimalBreedModel, - { farm_id, [typeColumn]: typeId, breed: animalBatch.breed_name }, - req, - { trx }, - ); - breedId = newBreed.id; - typeBreedIdsMap[typeBreedKey] = breedId; - } - animalBatch.custom_breed_id = breedId; - delete animalBatch.breed_name; - } + await checkAndAddCustomTypeAndBreed(req, animalBatch, farm_id, trx); + // TODO: allow animal group addition on creation like animals + // await checkAndAddGroup(req, animal, farm_id, trx); // Remove farm_id if it happens to be set in animal object since it should be obtained from header delete animalBatch.farm_id; @@ -115,8 +77,17 @@ const animalBatchController = { { trx }, ); + // TODO: allow animal group addition on creation like animals + // Format group_ids + // const groupIdMap = + // individualAnimalBatchResult.group_ids?.map((group) => group.animal_group_id) || []; + // individualAnimalBatchResult.group_ids = groupIdMap; + result.push(individualAnimalBatchResult); } + // delete utility objects + delete req.body.typeIdsMap; + delete req.body.typeBreedIdsMap; await trx.commit(); @@ -128,6 +99,80 @@ const animalBatchController = { }; }, + editAnimalBatches() { + return async (req, res) => { + const trx = await transaction.start(Model.knex()); + + try { + const { farm_id } = req.headers; + + // Create utility object used in type and breed + req.body.typeIdsMap = {}; + req.body.typeBreedIdsMap = {}; + + // select only allowed properties to edit + for (const animalBatch of req.body) { + await checkAndAddCustomTypeAndBreed(req, animalBatch, farm_id, trx); + // TODO: allow animal group editing + // await checkAndAddGroup(req, animal, farm_id, trx); + + const { + id, + count, + custom_breed_id, + custom_type_id, + default_breed_id, + default_type_id, + name, + notes, + photo_url, + organic_status, + supplier, + price, + sex_detail, + origin_id, + group_ids, + animal_batch_use_relationships, + } = animalBatch; + + await baseController.upsertGraph( + AnimalBatchModel, + { + id, + count, + custom_breed_id, + custom_type_id, + default_breed_id, + default_type_id, + name, + notes, + photo_url, + organic_status, + supplier, + price, + sex_detail, + origin_id, + group_ids, + animal_batch_use_relationships, + }, + req, + { trx }, + ); + } + + // delete utility objects + delete req.body.typeIdsMap; + delete req.body.typeBreedIdsMap; + + await trx.commit(); + // Do not send result revalidate using tags on frontend + return res.status(204).send(); + } catch (error) { + handleObjectionError(error, res, trx); + } + }; + }, + removeAnimalBatches() { return async (req, res) => { const trx = await transaction.start(Model.knex()); diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index 974472ee30..ff5c74edea 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -16,14 +16,12 @@ import { Model, transaction } from 'objection'; import AnimalModel from '../models/animalModel.js'; import baseController from './baseController.js'; -import CustomAnimalBreedModel from '../models/customAnimalBreedModel.js'; -import CustomAnimalTypeModel from '../models/customAnimalTypeModel.js'; -import AnimalGroupModel from '../models/animalGroupModel.js'; -import AnimalGroupRelationshipModel from '../models/animalGroupRelationshipModel.js'; -import { assignInternalIdentifiers } from '../util/animal.js'; +import { + assignInternalIdentifiers, + checkAndAddGroup, + checkAndAddCustomTypeAndBreed, +} from '../util/animal.js'; import { handleObjectionError } from '../util/errorCodes.js'; -import { checkAndTrimString } from '../util/util.js'; -import AnimalUseRelationshipModel from '../models/animalUseRelationshipModel.js'; import { uploadPublicImage } from '../util/imageUpload.js'; const animalController = { @@ -58,126 +56,140 @@ const animalController = { addAnimals() { return async (req, res) => { const trx = await transaction.start(Model.knex()); - try { const { farm_id } = req.headers; const result = []; - // avoid attempts to add an already created type or breed to the DB - // where multiple animals have the same type_name or breed_name - const typeIdsMap = {}; - const typeBreedIdsMap = {}; + // Create utility object used in type and breed + req.body.typeIdsMap = {}; + req.body.typeBreedIdsMap = {}; for (const animal of req.body) { - if (animal.type_name) { - let typeId = typeIdsMap[animal.type_name]; - - if (!typeId) { - const newType = await baseController.postWithResponse( - CustomAnimalTypeModel, - { type: animal.type_name, farm_id }, - req, - { trx }, - ); - typeId = newType.id; - typeIdsMap[animal.type_name] = typeId; - } - animal.custom_type_id = typeId; - delete animal.type_name; - } - - if (animal.breed_name) { - const typeColumn = animal.default_type_id ? 'default_type_id' : 'custom_type_id'; - const typeId = animal.type_name - ? typeIdsMap[animal.type_name] - : animal.default_type_id || animal.custom_type_id; - const typeBreedKey = `${typeColumn}_${typeId}_${animal.breed_name}`; - let breedId = typeBreedIdsMap[typeBreedKey]; - - if (!breedId) { - const newBreed = await baseController.postWithResponse( - CustomAnimalBreedModel, - { farm_id, [typeColumn]: typeId, breed: animal.breed_name }, - req, - { trx }, - ); - breedId = newBreed.id; - typeBreedIdsMap[typeBreedKey] = breedId; - } - animal.custom_breed_id = breedId; - delete animal.breed_name; - } + await checkAndAddCustomTypeAndBreed(req, animal, farm_id, trx); + // TODO: Comment out for animals v1? + await checkAndAddGroup(req, animal, farm_id, trx); // Remove farm_id if it happens to be set in animal object since it should be obtained from header delete animal.farm_id; - const groupName = checkAndTrimString(animal.group_name); - delete animal.group_name; - - const individualAnimalResult = await baseController.postWithResponse( + const individualAnimalResult = await baseController.insertGraphWithResponse( AnimalModel, { ...animal, farm_id }, req, { trx }, ); - const groupIds = []; - if (groupName) { - let group = await baseController.existsInTable(trx, AnimalGroupModel, { - name: groupName, - farm_id, - deleted: false, - }); - - if (!group) { - group = await baseController.postWithResponse( - AnimalGroupModel, - { name: groupName, farm_id }, - req, - { trx }, - ); - } - - groupIds.push(group.id); - - // Insert into join table - await AnimalGroupRelationshipModel.query(trx).insert({ - animal_id: individualAnimalResult.id, - animal_group_id: group.id, - }); - } - - individualAnimalResult.group_ids = groupIds; - - const animalUseRelationships = []; - if (animal.animal_use_relationships?.length) { - for (const relationship of animal.animal_use_relationships) { - animalUseRelationships.push( - await baseController.postWithResponse( - AnimalUseRelationshipModel, - { ...relationship, animal_id: individualAnimalResult.id }, - req, - { trx }, - ), - ); - } - } - - individualAnimalResult.animal_use_relationships = animalUseRelationships; + // TODO: Comment out for animals v1? + // Format group_ids + const groupIdMap = + individualAnimalResult.group_ids?.map((group) => group.animal_group_id) || []; + individualAnimalResult.group_ids = groupIdMap; result.push(individualAnimalResult); } + // delete utility objects + delete req.body.typeIdsMap; + delete req.body.typeBreedIdsMap; + await trx.commit(); await assignInternalIdentifiers(result, 'animal'); + return res.status(201).send(result); } catch (error) { await handleObjectionError(error, res, trx); } }; }, + editAnimals() { + return async (req, res) => { + const trx = await transaction.start(Model.knex()); + + try { + const { farm_id } = req.headers; + // Create utility object used in type and breed + req.body.typeIdsMap = {}; + req.body.typeBreedIdsMap = {}; + + // select only allowed properties to edit + for (const animal of req.body) { + await checkAndAddCustomTypeAndBreed(req, animal, farm_id, trx); + // TODO: Comment out for animals v1? + await checkAndAddGroup(req, animal, farm_id, trx); + const { + id, + default_type_id, + custom_type_id, + default_breed_id, + custom_breed_id, + sex_id, + name, + birth_date, + identifier, + identifier_color_id, + identifier_placement_id, + origin_id, + dam, + sire, + brought_in_date, + weaning_date, + notes, + photo_url, + identifier_type_id, + identifier_type_other, + organic_status, + supplier, + price, + group_ids, + animal_use_relationships, + } = animal; + + await baseController.upsertGraph( + AnimalModel, + { + id, + default_type_id, + custom_type_id, + default_breed_id, + custom_breed_id, + sex_id, + name, + birth_date, + identifier, + identifier_color_id, + identifier_placement_id, + origin_id, + dam, + sire, + brought_in_date, + weaning_date, + notes, + photo_url, + identifier_type_id, + identifier_type_other, + organic_status, + supplier, + price, + group_ids, + animal_use_relationships, + }, + req, + { trx }, + ); + } + // delete utility objects + delete req.body.typeIdsMap; + delete req.body.typeBreedIdsMap; + await trx.commit(); + // Do not send result revalidate using tags on frontend + return res.status(204).send(); + } catch (error) { + handleObjectionError(error, res, trx); + } + }; + }, removeAnimals() { return async (req, res) => { const trx = await transaction.start(Model.knex()); diff --git a/packages/api/src/middleware/checkAnimalEntities.js b/packages/api/src/middleware/checkAnimalEntities.js deleted file mode 100644 index 4b477333cf..0000000000 --- a/packages/api/src/middleware/checkAnimalEntities.js +++ /dev/null @@ -1,267 +0,0 @@ -import { Model, transaction } from 'objection'; -import { handleObjectionError } from '../util/errorCodes.js'; - -import CustomAnimalTypeModel from '../models/customAnimalTypeModel.js'; -import DefaultAnimalBreedModel from '../models/defaultAnimalBreedModel.js'; -import CustomAnimalBreedModel from '../models/customAnimalBreedModel.js'; -import AnimalUseModel from '../models/animalUseModel.js'; - -/** - * Middleware function to check if the provided animal entities exist and belong to the farm. The IDs must be passed as a comma-separated query string. - * - * @param {Object} model - The database model for the correct animal entity - * @returns {Function} - Express middleware function - * - * @example - * router.delete( - * '/', - * checkScope(['delete:animals']), - * checkAnimalEntities(AnimalModel), - * AnimalController.deleteAnimals(), - * ); - * - */ -export function checkAnimalEntities(model) { - return async (req, res, next) => { - const trx = await transaction.start(Model.knex()); - - try { - const { farm_id } = req.headers; - const { ids } = req.query; - - if (!ids || !ids.length) { - await trx.rollback(); - return res.status(400).send('Must send ids'); - } - - const idsSet = new Set(ids.split(',')); - - // Check that all animals/batches exist and belong to the farm - const invalidIds = []; - - for (const id of idsSet) { - // For query syntax like ids=,,, which will pass the above check - if (!id || isNaN(Number(id))) { - await trx.rollback(); - return res.status(400).send('Must send valid ids'); - } - - const existingRecord = await model - .query(trx) - .findById(id) - .where({ farm_id }) - .whereNotDeleted(); // prohibiting re-delete - - if (!existingRecord) { - invalidIds.push(id); - } - } - - if (invalidIds.length) { - await trx.rollback(); - return res.status(400).json({ - error: 'Invalid ids', - invalidIds, - message: - 'Some entities do not exist, are already deleted, or are not associated with the given farm.', - }); - } - - await trx.commit(); - next(); - } catch (error) { - handleObjectionError(error, res, trx); - } - }; -} - -const hasOneValue = (values) => { - const nonNullValues = values.filter(Boolean); - return nonNullValues.length === 1; -}; - -const allFalsy = (values) => values.every((value) => !value); - -export function validateAnimalBatchCreationBody(animalBatchKey) { - return async (req, res, next) => { - const trx = await transaction.start(Model.knex()); - - try { - const { farm_id } = req.headers; - const newTypesSet = new Set(); - const newBreedsSet = new Set(); - - for (const animalOrBatch of req.body) { - const { - default_type_id, - custom_type_id, - default_breed_id, - custom_breed_id, - type_name, - breed_name, - } = animalOrBatch; - - if (!hasOneValue([default_type_id, custom_type_id, type_name])) { - await trx.rollback(); - return res - .status(400) - .send('Exactly one of default_type_id, custom_type_id, or type_name must be sent'); - } - - if ( - !hasOneValue([default_breed_id, custom_breed_id, breed_name]) && - !allFalsy([default_breed_id, custom_breed_id, breed_name]) - ) { - await trx.rollback(); - return res - .status(400) - .send('Exactly one of default_breed_id, custom_breed_id and breed_name must be sent'); - } - - if (custom_type_id) { - const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); - - if (customType && customType.farm_id !== farm_id) { - await trx.rollback(); - return res.status(403).send('Forbidden custom type does not belong to this farm'); - } - } - - if (default_breed_id && default_type_id) { - const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); - - if (defaultBreed && defaultBreed.default_type_id !== default_type_id) { - await trx.rollback(); - return res.status(400).send('Breed does not match type'); - } - } - - if (default_breed_id && custom_type_id) { - await trx.rollback(); - return res.status(400).send('Default breed does not use custom type'); - } - - if (custom_breed_id) { - const customBreed = await CustomAnimalBreedModel.query() - .whereNotDeleted() - .findById(custom_breed_id); - - if (customBreed && customBreed.farm_id !== farm_id) { - await trx.rollback(); - return res.status(403).send('Forbidden custom breed does not belong to this farm'); - } - - if (customBreed.default_type_id && customBreed.default_type_id !== default_type_id) { - await trx.rollback(); - return res.status(400).send('Breed does not match type'); - } - - if (customBreed.custom_type_id && customBreed.custom_type_id !== custom_type_id) { - await trx.rollback(); - return res.status(400).send('Breed does not match type'); - } - } - - if (animalBatchKey === 'batch') { - const { count, sex_detail } = animalOrBatch; - - if (sex_detail?.length) { - let sexCount = 0; - const sexIdSet = new Set(); - sex_detail.forEach((detail) => { - sexCount += detail.count; - sexIdSet.add(detail.sex_id); - }); - if (sexCount > count) { - await trx.rollback(); - return res - .status(400) - .send('Batch count must be greater than or equal to sex detail count'); - } - if (sex_detail.length != sexIdSet.size) { - await trx.rollback(); - return res.status(400).send('Duplicate sex ids in detail'); - } - } - } - - const relationshipsKey = - animalBatchKey === 'batch' - ? 'animal_batch_use_relationships' - : 'animal_use_relationships'; - - if (animalOrBatch[relationshipsKey]) { - if (!Array.isArray(animalOrBatch[relationshipsKey])) { - return res.status(400).send(`${relationshipsKey} must be an array`); - } - - const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); - - for (const relationship of animalOrBatch[relationshipsKey]) { - if (relationship.use_id != otherUse.id && relationship.other_use) { - return res.status(400).send('other_use notes is for other use type'); - } - } - } - - // Skip the process if type_name and breed_name are not passed - if (!type_name && !breed_name) { - continue; - } - - if (type_name) { - if (default_breed_id || custom_breed_id) { - await trx.rollback(); - return res - .status(400) - .send('Cannot create a new type associated with an existing breed'); - } - - newTypesSet.add(type_name); - } - - // newBreedsSet will be used to check if the combination of type + breed exists in DB. - // skip the process if the type is new (= type_name is passed) - if (!type_name && breed_name) { - const breedDetails = custom_type_id - ? `custom_type_id/${custom_type_id}/${breed_name}` - : `default_type_id/${default_type_id}/${breed_name}`; - - newBreedsSet.add(breedDetails); - } - } - - if (newTypesSet.size) { - const record = await CustomAnimalTypeModel.getTypesByFarmAndTypes( - farm_id, - [...newTypesSet], - trx, - ); - - if (record.length) { - await trx.rollback(); - return res.status(409).send('Animal type already exists'); - } - } - - if (newBreedsSet.size) { - const typeBreedPairs = [...newBreedsSet].map((breed) => breed.split('/')); - const record = await CustomAnimalBreedModel.getBreedsByFarmAndTypeBreedPairs( - farm_id, - typeBreedPairs, - trx, - ); - - if (record.length) { - await trx.rollback(); - return res.status(409).send('Animal breed already exists'); - } - } - - await trx.commit(); - next(); - } catch (error) { - handleObjectionError(error, res, trx); - } - }; -} diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 69d46aea68..d3f497f39e 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -13,58 +13,420 @@ * GNU General Public License for more details, see . */ +import { Model, transaction } from 'objection'; +import { handleObjectionError } from '../../util/errorCodes.js'; + import AnimalModel from '../../models/animalModel.js'; import AnimalBatchModel from '../../models/animalBatchModel.js'; +import CustomAnimalTypeModel from '../../models/customAnimalTypeModel.js'; +import DefaultAnimalBreedModel from '../../models/defaultAnimalBreedModel.js'; +import CustomAnimalBreedModel from '../../models/customAnimalBreedModel.js'; +import AnimalUseModel from '../../models/animalUseModel.js'; const AnimalOrBatchModel = { animal: AnimalModel, batch: AnimalBatchModel, }; -export function checkEditAnimalOrBatch(animalOrBatchKey) { +// Utils +const hasMultipleValues = (values) => { + const nonNullValues = values.filter(Boolean); + return !(nonNullValues.length === 1); +}; + +const checkIdIsNumber = (id) => { + if (!id || isNaN(Number(id))) { + throw newCustomError('Must send valid ids'); + } +}; + +const newCustomError = (message, code = 400, body = undefined) => { + const error = new Error(message); + error.code = code; + error.body = body; + error.type = 'LiteFarmCustom'; + return error; +}; + +// Body checks +const checkIsArray = (array, descriptiveErrorText = '') => { + if (!Array.isArray(array)) { + throw newCustomError(`${descriptiveErrorText} should be an array`); + } +}; + +const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) => { + if (!ids || !ids.length) { + throw newCustomError('Must send ids'); + } + + const idsSet = new Set(ids.split(',')); + + // Check that all animals/batches exist and belong to the farm + const invalidIds = []; + + for (const id of idsSet) { + // For query syntax like ids=,,, which will pass the above check + checkIdIsNumber(id); + + const existingRecord = await AnimalOrBatchModel[animalOrBatchKey] + .query(trx) + .findById(id) + .where({ farm_id }) + .whereNotDeleted(); // prohibiting re-delete + + if (!existingRecord) { + invalidIds.push(id); + } + } + + if (invalidIds.length) { + throw newCustomError( + 'Some entities do not exist, are already deleted, or are not associated with the given farm.', + 400, + { error: 'Invalid ids', invalidIds }, + ); + } +}; + +// AnimalOrBatch checks +const checkExactlyOneAnimalTypeProvided = (default_type_id, custom_type_id, type_name) => { + if (hasMultipleValues([default_type_id, custom_type_id, type_name])) { + throw newCustomError( + 'Exactly one of default_type_id, custom_type_id, or type_name must be sent', + ); + } +}; + +const checkRecordBelongsToFarm = async (record, farm_id, descriptiveErrorMessage) => { + if (record && record.farm_id !== farm_id) { + throw newCustomError(`Forbidden ${descriptiveErrorMessage} does not belong to this farm`, 403); + } +}; + +// For edit mode set required to false +const checksIfTypeProvided = async (animalOrBatch, farm_id, required = true) => { + const { default_type_id, custom_type_id, type_name } = animalOrBatch; + if (default_type_id || custom_type_id || type_name || required) { + checkExactlyOneAnimalTypeProvided(default_type_id, custom_type_id, type_name); + } + if (custom_type_id) { + const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); + if (!customType) { + // TODO : new error add test + throw newCustomError('Custom type does not exist'); + } + await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); + } + if (type_name) { + // TODO: Check type_name does not already exist or replace custom type id? + } +}; + +const checkExactlyOneAnimalBreedProvided = (default_breed_id, custom_breed_id, breed_name) => { + if (hasMultipleValues([default_breed_id, custom_breed_id, breed_name])) { + throw newCustomError( + 'Exactly one of default_breed_id, custom_breed_id and breed_name must be sent', + ); + } +}; + +const checkDefaultBreedMatchesType = async ( + animalOrBatchRecord, + default_breed_id, + default_type_id, +) => { + let defaultTypeId = default_type_id; + // If not editing type, check record type + if (!defaultTypeId && animalOrBatchRecord) { + defaultTypeId = animalOrBatchRecord.default_type_id; + } + if (defaultTypeId) { + const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); + if (defaultBreed && defaultBreed.default_type_id !== defaultTypeId) { + throw newCustomError('Breed does not match type'); + } + } else { + // TODO: new error untested should prevents need for pre-existing checkDefaultBreedDoesNotUseCustomType + throw newCustomError('Default breed must use default type'); + } +}; + +const checkCustomBreedMatchesType = async ( + animalOrBatchRecord, + customBreed, + default_type_id, + custom_type_id, +) => { + let defaultTypeId = default_type_id; + let customTypeId = custom_type_id; + // If not editing type, check record type + if (!defaultTypeId && !customTypeId && animalOrBatchRecord) { + defaultTypeId = animalOrBatchRecord.default_type_id; + customTypeId = animalOrBatchRecord.custom_type_id; + } + + if (customBreed.default_type_id && customBreed.default_type_id !== defaultTypeId) { + throw newCustomError('Breed does not match type'); + } + + if (customBreed.custom_type_id && customBreed.custom_type_id !== customTypeId) { + throw newCustomError('Breed does not match type'); + } +}; + +const checksIfBreedProvided = async (animalOrBatch, farm_id, animalOrBatchRecord = undefined) => { + const { + default_breed_id, + custom_breed_id, + breed_name, + default_type_id, + custom_type_id, + } = animalOrBatch; + if (default_breed_id || custom_breed_id || breed_name) { + checkExactlyOneAnimalBreedProvided(default_breed_id, custom_breed_id, breed_name); + } + if (default_breed_id) { + await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); + } + if (custom_breed_id) { + const customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(custom_breed_id); + if (!customBreed) { + // TODO : new error add test + throw newCustomError('Custom breed does not exist'); + } + await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); + await checkCustomBreedMatchesType( + animalOrBatchRecord, + customBreed, + default_type_id, + custom_type_id, + ); + } + if (breed_name) { + // TODO: Check breed_name does not already exist or replace custom type id? + } +}; + +const checkBatchSexDetail = async ( + animalOrBatch, + animalOrBatchKey, + animalOrBatchRecord = undefined, +) => { + if (animalOrBatchKey === 'batch') { + let count = animalOrBatch.count; + let sexDetail = animalOrBatch.sex_detail; + if (!count) { + count = animalOrBatchRecord.count; + } + if (!sexDetail) { + sexDetail = animalOrBatchRecord.sex_detail; + } + if (sexDetail?.length) { + let sexCount = 0; + const sexIdSet = new Set(); + sexDetail.forEach((detail) => { + sexCount += detail.count; + sexIdSet.add(detail.sex_id); + }); + if (sexCount > count) { + throw newCustomError('Batch count must be greater than or equal to sex detail count'); + } + if (sexDetail.length != sexIdSet.size) { + throw newCustomError('Duplicate sex ids in detail'); + } + } + } +}; + +const checkAnimalUseRelationship = async (animalOrBatch, animalOrBatchKey) => { + const relationshipsKey = + animalOrBatchKey === 'batch' ? 'animal_batch_use_relationships' : 'animal_use_relationships'; + + if (animalOrBatch[relationshipsKey]) { + checkIsArray(animalOrBatch[relationshipsKey], relationshipsKey); + + const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); + + for (const relationship of animalOrBatch[relationshipsKey]) { + if (relationship.use_id != otherUse.id && relationship.other_use) { + throw newCustomError('other_use notes is for other use type'); + } + } + } +}; + +const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet) => { + const { + type_name, + breed_name, + custom_type_id, + default_type_id, + default_breed_id, + custom_breed_id, + } = animalOrBatch; + if (type_name) { + if (default_breed_id || custom_breed_id) { + throw newCustomError('Cannot create a new type associated with an existing breed'); + } + newTypesSet.add(type_name); + } + + // newBreedsSet will be used to check if the combination of type + breed exists in DB. + // skip the process if the type is new (= type_name is passed) + if (!type_name && breed_name) { + const breedDetails = custom_type_id + ? `custom_type_id/${custom_type_id}/${breed_name}` + : `default_type_id/${default_type_id}/${breed_name}`; + + newBreedsSet.add(breedDetails); + } +}; + +const checkRemovalDataProvided = (animalOrBatch) => { + const { animal_removal_reason_id, removal_date } = animalOrBatch; + if (!animal_removal_reason_id || !removal_date) { + throw newCustomError('Must send reason and date of removal'); + } +}; + +const getRecordIfExists = async (animalOrBatch, animalOrBatchKey, farm_id) => { + return await AnimalOrBatchModel[animalOrBatchKey] + .query() + .findById(animalOrBatch.id) + .where({ farm_id }) + .whereNotDeleted(); +}; + +// Post loop checks +const checkCustomTypeAndBreedConflicts = async (newTypesSet, newBreedsSet, farm_id, trx) => { + if (newTypesSet.size) { + const record = await CustomAnimalTypeModel.getTypesByFarmAndTypes( + farm_id, + [...newTypesSet], + trx, + ); + + if (record.length) { + throw newCustomError('Animal type already exists', 409); + } + } + + if (newBreedsSet.size) { + const typeBreedPairs = [...newBreedsSet].map((breed) => breed.split('/')); + const record = await CustomAnimalBreedModel.getBreedsByFarmAndTypeBreedPairs( + farm_id, + typeBreedPairs, + trx, + ); + + if (record.length) { + throw newCustomError('Animal breed already exists', 409); + } + } +}; + +const checkInvalidIds = async (invalidIds) => { + if (invalidIds.length) { + throw newCustomError( + 'Some animals or batches do not exist or are not associated with the given farm.', + 400, + { error: 'Invalid ids', invalidIds }, + ); + } +}; + +export function checkCreateAnimalOrBatch(animalOrBatchKey) { return async (req, res, next) => { + const trx = await transaction.start(Model.knex()); + try { const { farm_id } = req.headers; + const newTypesSet = new Set(); + const newBreedsSet = new Set(); + + for (const animalOrBatch of req.body) { + const { type_name, breed_name } = animalOrBatch; - if (!Array.isArray(req.body)) { - return res.status(400).send('Request body should be an array'); + // also edit + await checksIfTypeProvided(animalOrBatch, farm_id); + await checksIfBreedProvided(animalOrBatch, farm_id); + + await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); + await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); + + // Skip the process if type_name and breed_name are not passed + if (!type_name && !breed_name) { + continue; + } + checkAndAddCustomTypesOrBreeds(animalOrBatch, newTypesSet, newBreedsSet); } + await checkCustomTypeAndBreedConflicts(newTypesSet, newBreedsSet, farm_id, trx); + + await trx.commit(); + next(); + } catch (error) { + if (error.type === 'LiteFarmCustom') { + console.error(error); + await trx.rollback(); + return error.body + ? res.status(error.code).json({ body: error.body }) + : res.status(error.code).send(error.message); + } else { + handleObjectionError(error, res, trx); + } + } + }; +} + +export function checkEditAnimalOrBatch(animalOrBatchKey) { + return async (req, res, next) => { + try { + const { farm_id } = req.headers; + + checkIsArray(req.body, 'Request body'); // Check that all animals exist and belong to the farm // Done in its own loop to provide a list of all invalid ids const invalidIds = []; for (const animalOrBatch of req.body) { - if (!animalOrBatch.id) { - return res.status(400).send('Must send animal or batch id'); - } - - const animalOrBatchRecord = await AnimalOrBatchModel[animalOrBatchKey] - .query() - .findById(animalOrBatch.id) - .where({ farm_id }) - .whereNotDeleted(); - + checkIdIsNumber(animalOrBatch.id); + const animalOrBatchRecord = await getRecordIfExists( + animalOrBatch, + animalOrBatchKey, + farm_id, + ); if (!animalOrBatchRecord) { invalidIds.push(animalOrBatch.id); + continue; } - } - if (invalidIds.length) { - return res.status(400).json({ - error: 'Invalid ids', - invalidIds, - message: - 'Some animals or batches do not exist or are not associated with the given farm.', - }); + await checksIfTypeProvided(animalOrBatch, farm_id, false); + // nullTypesExistingOnRecord(); + await checksIfBreedProvided(animalOrBatch, farm_id, animalOrBatchRecord); + // nullBreedsExistingOnRecord(); + await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); } + //TODO: should this error be actually in loop and not outside? + await checkInvalidIds(invalidIds); + next(); } catch (error) { - console.error(error); - return res.status(500).json({ - error, - }); + if (error.type === 'LiteFarmCustom') { + console.error(error); + return error.body + ? res.status(error.code).json({ ...error.body, message: error.message }) + : res.status(error.code).send(error.message); + } else { + console.error(error); + return res.status(500).json({ + error, + }); + } } }; } @@ -72,22 +434,87 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { export function checkRemoveAnimalOrBatch(animalOrBatchKey) { return async (req, res, next) => { try { - if (!Array.isArray(req.body)) { - return res.status(400).send('Request body should be an array'); - } + const { farm_id } = req.headers; + + checkIsArray(req.body, 'Request body'); + // Check that all animals exist and belong to the farm + // Done in its own loop to provide a list of all invalid ids + const invalidIds = []; for (const animalOrBatch of req.body) { - const { animal_removal_reason_id, removal_date } = animalOrBatch; - if (!animal_removal_reason_id || !removal_date) { - return res.status(400).send('Must send reason and date of removal'); + // Removal specific + checkRemovalDataProvided(animalOrBatch); + + // From Edit + checkIdIsNumber(animalOrBatch.id); + const animalOrBatchRecord = await getRecordIfExists( + animalOrBatch, + animalOrBatchKey, + farm_id, + ); + if (!animalOrBatchRecord) { + invalidIds.push(animalOrBatch.id); + continue; } + // No record should skip this loop } - checkEditAnimalOrBatch(animalOrBatchKey)(req, res, next); + + //TODO: should this error be actually in loop and not outside? + await checkInvalidIds(invalidIds); + next(); } catch (error) { - console.error(error); - return res.status(500).json({ - error, - }); + if (error.type === 'LiteFarmCustom') { + console.error(error); + return error.body + ? res.status(error.code).json({ ...error.body, message: error.message }) + : res.status(error.code).send(error.message); + } else { + console.error(error); + return res.status(500).json({ + error, + }); + } + } + }; +} + +/** + * Middleware function to check if the provided animal entities exist and belong to the farm. The IDs must be passed as a comma-separated query string. + * + * @param {String} animalOrBatchKey - The key to choose a database model for the correct animal entity + * @returns {Function} - Express middleware function + * + * @example + * router.delete( + * '/', + * checkScope(['delete:animals']), + * checkDeleteAnimalOrBatch('animal'), + * AnimalController.deleteAnimals(), + * ); + * + */ +export function checkDeleteAnimalOrBatch(animalOrBatchKey) { + return async (req, res, next) => { + const trx = await transaction.start(Model.knex()); + + try { + const { farm_id } = req.headers; + const { ids } = req.query; + + await checkValidAnimalOrBatchIds(animalOrBatchKey, ids, farm_id, trx); + + await trx.commit(); + next(); + } catch (error) { + if (error.type === 'LiteFarmCustom') { + console.error(error); + await trx.rollback(); + return error.body + ? res.status(error.code).json({ ...error.body, message: error.message }) + : res.status(error.code).send(error.message); + } else { + handleObjectionError(error, res, trx); + } } }; } diff --git a/packages/api/src/routes/animalBatchRoute.js b/packages/api/src/routes/animalBatchRoute.js index 80841b2fd6..736b3703eb 100644 --- a/packages/api/src/routes/animalBatchRoute.js +++ b/packages/api/src/routes/animalBatchRoute.js @@ -19,22 +19,29 @@ const router = express.Router(); import checkScope from '../middleware/acl/checkScope.js'; import hasFarmAccess from '../middleware/acl/hasFarmAccess.js'; import AnimalBatchController from '../controllers/animalBatchController.js'; -import AnimalBatchModel from '../models/animalBatchModel.js'; -import { - checkAnimalEntities, - validateAnimalBatchCreationBody, -} from '../middleware/checkAnimalEntities.js'; import multerDiskUpload from '../util/fileUpload.js'; import validateFileExtension from '../middleware/validation/uploadImage.js'; -import { checkRemoveAnimalOrBatch } from '../middleware/validation/checkAnimalOrBatch.js'; +import { + checkRemoveAnimalOrBatch, + checkEditAnimalOrBatch, + checkCreateAnimalOrBatch, + checkDeleteAnimalOrBatch, +} from '../middleware/validation/checkAnimalOrBatch.js'; router.get('/', checkScope(['get:animal_batches']), AnimalBatchController.getFarmAnimalBatches()); router.post( '/', checkScope(['add:animal_batches']), - validateAnimalBatchCreationBody('batch'), + checkCreateAnimalOrBatch('batch'), AnimalBatchController.addAnimalBatches(), ); +router.patch( + '/', + checkScope(['edit:animal_batches']), + // Can't use hasFarmAccess because body is an array & because of non-unique id field + checkEditAnimalOrBatch('batch'), + AnimalBatchController.editAnimalBatches(), +); router.patch( '/remove', checkScope(['edit:animal_batches']), @@ -45,7 +52,7 @@ router.patch( router.delete( '/', checkScope(['delete:animal_batches']), - checkAnimalEntities(AnimalBatchModel), + checkDeleteAnimalOrBatch('batch'), AnimalBatchController.deleteAnimalBatches(), ); router.post( diff --git a/packages/api/src/routes/animalRoute.js b/packages/api/src/routes/animalRoute.js index 50f869d555..4a55cb2877 100644 --- a/packages/api/src/routes/animalRoute.js +++ b/packages/api/src/routes/animalRoute.js @@ -19,22 +19,29 @@ const router = express.Router(); import checkScope from '../middleware/acl/checkScope.js'; import hasFarmAccess from '../middleware/acl/hasFarmAccess.js'; import AnimalController from '../controllers/animalController.js'; -import AnimalModel from '../models/animalModel.js'; -import { - checkAnimalEntities, - validateAnimalBatchCreationBody, -} from '../middleware/checkAnimalEntities.js'; import multerDiskUpload from '../util/fileUpload.js'; import validateFileExtension from '../middleware/validation/uploadImage.js'; -import { checkRemoveAnimalOrBatch } from '../middleware/validation/checkAnimalOrBatch.js'; +import { + checkRemoveAnimalOrBatch, + checkEditAnimalOrBatch, + checkCreateAnimalOrBatch, + checkDeleteAnimalOrBatch, +} from '../middleware/validation/checkAnimalOrBatch.js'; router.get('/', checkScope(['get:animals']), AnimalController.getFarmAnimals()); router.post( '/', checkScope(['add:animals']), - validateAnimalBatchCreationBody(), + checkCreateAnimalOrBatch('animal'), AnimalController.addAnimals(), ); +router.patch( + '/', + checkScope(['edit:animals']), + checkEditAnimalOrBatch('animal'), + // Can't use hasFarmAccess because body is an array & because of non-unique id field + AnimalController.editAnimals(), +); router.patch( '/remove', checkScope(['edit:animals']), @@ -45,7 +52,7 @@ router.patch( router.delete( '/', checkScope(['delete:animals']), - checkAnimalEntities(AnimalModel), + checkDeleteAnimalOrBatch('animal'), AnimalController.deleteAnimals(), ); router.post( diff --git a/packages/api/src/util/animal.js b/packages/api/src/util/animal.js index 8365b04467..5e14df22a2 100644 --- a/packages/api/src/util/animal.js +++ b/packages/api/src/util/animal.js @@ -14,6 +14,11 @@ */ import knex from './knex.js'; +import baseController from '../controllers/baseController.js'; +import CustomAnimalBreedModel from '../models/customAnimalBreedModel.js'; +import CustomAnimalTypeModel from '../models/customAnimalTypeModel.js'; +import AnimalGroupModel from '../models/animalGroupModel.js'; +import { checkAndTrimString } from './util.js'; /** * Assigns internal identifiers to records. @@ -33,3 +38,101 @@ export const assignInternalIdentifiers = async (records, kind) => { }), ); }; + +/** + * Asynchronously checks if the given animal or batch has a type or breed already stored in the database. + * If not, it adds the type and/or breed to the database, updates the corresponding IDs, and removes + * the type_name or breed_name properties from the animal or batch object. + * + * @param {Object} req - The request object, containing the body with type and breed maps. + * @param {Object} animalOrBatch - The animal or batch object that contains type_name or breed_name properties. + * @param {number} farm_id - The ID of the farm to associate with the type or breed. + * @param {Object} trx - A transaction object for performing the database operations within a transaction. + * + * @returns {Promise} - A promise that resolves when the type and breed IDs have been added/updated and the object has been modified. + * + * @throws {Error} - If any database operation fails. + */ +export const checkAndAddCustomTypeAndBreed = async (req, animalOrBatch, farm_id, trx) => { + // Avoid attempts to add an already created type or breed to the DB + // where multiple animals have the same type_name or breed_name + const { typeIdsMap, typeBreedIdsMap } = req.body; + + if (animalOrBatch.type_name) { + let typeId = typeIdsMap[animalOrBatch.type_name]; + + if (!typeId) { + const newType = await baseController.postWithResponse( + CustomAnimalTypeModel, + { type: animalOrBatch.type_name, farm_id }, + req, + { trx }, + ); + typeId = newType.id; + typeIdsMap[animalOrBatch.type_name] = typeId; + } + animalOrBatch.custom_type_id = typeId; + delete animalOrBatch.type_name; + } + + if (animalOrBatch.breed_name) { + const typeColumn = animalOrBatch.default_type_id ? 'default_type_id' : 'custom_type_id'; + const typeId = animalOrBatch.type_name + ? typeIdsMap[animalOrBatch.type_name] + : animalOrBatch.default_type_id || animalOrBatch.custom_type_id; + const typeBreedKey = `${typeColumn}_${typeId}_${animalOrBatch.breed_name}`; + let breedId = typeBreedIdsMap[typeBreedKey]; + + if (!breedId) { + const newBreed = await baseController.postWithResponse( + CustomAnimalBreedModel, + { farm_id, [typeColumn]: typeId, breed: animalOrBatch.breed_name }, + req, + { trx }, + ); + breedId = newBreed.id; + typeBreedIdsMap[typeBreedKey] = breedId; + } + animalOrBatch.custom_breed_id = breedId; + delete animalOrBatch.breed_name; + } +}; + +/** + * Asynchronously checks if the specified group exists in the database for the given farm. + * If the group doesn't exist, it creates a new group and associates it with the animal or batch. + * The function then adds the group ID to the `group_ids` property of the animal or batch object and removes the `group_name` property. + * + * @param {Object} req - The request object. + * @param {Object} animalOrBatch - The animal or batch object that contains a group_name property. + * @param {number} farm_id - The ID of the farm to associate with the group. + * @param {Object} trx - A transaction object for performing the database operations within a transaction. + * + * @returns {Promise} - A promise that resolves when the group has been added or found and the object has been modified. + * + * @throws {Error} - If any database operation fails. + */ +export const checkAndAddGroup = async (req, animalOrBatch, farm_id, trx) => { + const groupName = checkAndTrimString(animalOrBatch.group_name); + delete animalOrBatch.group_name; + + if (groupName) { + let group = await baseController.existsInTable(trx, AnimalGroupModel, { + name: groupName, + farm_id, + deleted: false, + }); + + if (!group) { + group = await baseController.postWithResponse( + AnimalGroupModel, + { name: groupName, farm_id }, + req, + { trx }, + ); + } + // Frontend only allows addition of one group at a time + // TODO: handle multiple group additions + animalOrBatch.group_ids = [{ animal_group_id: group.id }]; + } +}; diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index 572e02c1e6..f14d64ba73 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -14,7 +14,6 @@ */ import chai from 'chai'; -import util from 'util'; import { faker } from '@faker-js/faker'; import chaiHttp from 'chai-http'; @@ -58,29 +57,24 @@ describe('Animal Tests', () => { animalRemovalReasonId = animalRemovalReason.id; }); - function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, callback) { - chai + async function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }) { + return await chai .request(server) .get('/animals') .set('user_id', user_id) - .set('farm_id', farm_id) - .end(callback); + .set('farm_id', farm_id); } - const getRequestAsPromise = util.promisify(getRequest); - - function postRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data, callback) { - chai + async function postRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data) { + return await chai .request(server) .post('/animals') + .set('Content-Type', 'application/json') .set('user_id', user_id) .set('farm_id', farm_id) - .send(data) - .end(callback); + .send(data); } - const postRequestAsPromise = util.promisify(postRequest); - async function removeRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data) { return await chai .request(server) @@ -114,7 +108,7 @@ describe('Animal Tests', () => { return { ...mocks.fakeUserFarm(), role_id: role }; } - async function returnUserFarms(role) { + async function returnUserFarms(role, farm = undefined) { const [mainFarm] = await mocks.farmFactory(); const [user] = await mocks.usersFactory(); @@ -168,7 +162,7 @@ describe('Animal Tests', () => { // Create a third animal belonging to a different farm await makeAnimal(secondFarm); - const res = await getRequestAsPromise({ + const res = await getRequest({ user_id: user.user_id, farm_id: mainFarm.farm_id, }); @@ -181,13 +175,13 @@ describe('Animal Tests', () => { }); expect({ ...firstAnimal, - internal_identifier: 1, + internal_identifier: res.body[0].internal_identifier, group_ids: [], animal_use_relationships: [], }).toMatchObject(res.body[0]); expect({ ...secondAnimal, - internal_identifier: 2, + internal_identifier: res.body[1].internal_identifier, group_ids: [], animal_use_relationships: [], }).toMatchObject(res.body[1]); @@ -199,7 +193,7 @@ describe('Animal Tests', () => { await makeAnimal(mainFarm); const [unAuthorizedUser] = await mocks.usersFactory(); - const res = await getRequestAsPromise({ + const res = await getRequest({ user_id: unAuthorizedUser.user_id, farm_id: mainFarm.farm_id, }); @@ -229,7 +223,7 @@ describe('Animal Tests', () => { custom_breed_id: animalBreed.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -253,7 +247,7 @@ describe('Animal Tests', () => { const { mainFarm, user } = await returnUserFarms(role); const animal = mocks.fakeAnimal(); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -270,7 +264,7 @@ describe('Animal Tests', () => { const { mainFarm, user } = await returnUserFarms(1); const animal = mocks.fakeAnimal(); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -293,9 +287,7 @@ describe('Animal Tests', () => { farm_id: farm.farm_id, default_type_id: defaultTypeId, }); - const res = await postRequestAsPromise({ user_id: user.user_id, farm_id: farm.farm_id }, [ - animal, - ]); + const res = await postRequest({ user_id: user.user_id, farm_id: farm.farm_id }, [animal]); expect(res.body[0].internal_identifier).toBe(animalCount + batchCount + 1); } @@ -308,7 +300,7 @@ describe('Animal Tests', () => { custom_type_id: null, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -331,7 +323,7 @@ describe('Animal Tests', () => { custom_type_id: animalType.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -356,7 +348,7 @@ describe('Animal Tests', () => { custom_breed_id: animalBreed.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -375,7 +367,7 @@ describe('Animal Tests', () => { default_breed_id: animalBreed.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -397,10 +389,7 @@ describe('Animal Tests', () => { }); const postAnimalsRequest = async (animals) => { - const res = await postRequestAsPromise( - { user_id: owner.user_id, farm_id: farm.farm_id }, - animals, - ); + const res = await postRequest({ user_id: owner.user_id, farm_id: farm.farm_id }, animals); return res; }; @@ -667,7 +656,7 @@ describe('Animal Tests', () => { return mocks.fakeAnimal(group_name ? { ...data, group_name } : data); }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -729,6 +718,283 @@ describe('Animal Tests', () => { }); }); + // EDIT tests + describe('Edit animal tests', () => { + let animalGroup1; + let animalGroup2; + let animalSex; + let animalIdentifierColor; + let animalIdentifierType; + let animalOrigin; + let animalRemovalReason; + let animalUse1; + let animalUse2; + let animalUse3; + + beforeEach(async () => { + [animalGroup1] = await mocks.animal_groupFactory(); + [animalGroup2] = await mocks.animal_groupFactory(); + // Populate enums + [animalSex] = await mocks.animal_sexFactory(); + [animalIdentifierColor] = await mocks.animal_identifier_colorFactory(); + [animalIdentifierType] = await mocks.animal_identifier_typeFactory(); + [animalOrigin] = await mocks.animal_originFactory(); + [animalRemovalReason] = await mocks.animal_removal_reasonFactory(); + [animalUse1] = await mocks.animal_useFactory('OTHER'); + [animalUse2] = await mocks.animal_useFactory(); + [animalUse3] = await mocks.animal_useFactory(); + }); + + async function addAnimals(mainFarm, user) { + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + + // Create two animals, one with a default type and one with a custom type + const firstAnimal = mocks.fakeAnimal({ + name: 'edit test 1', + default_type_id: defaultTypeId, + animal_use_relationships: [{ use_id: animalUse1.id }], + sire: 'Unchanged', + group_name: animalGroup1.name, + }); + const secondAnimal = mocks.fakeAnimal({ + name: 'edit test 2', + custom_type_id: customAnimalType.id, + animal_use_relationships: [{ use_id: animalUse1.id }], + sire: 'Unchanged', + }); + + const res = await postRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [firstAnimal, secondAnimal], + ); + + const returnedFirstAnimal = res.body?.find((animal) => animal.name === 'edit test 1'); + const returnedSecondAnimal = res.body?.find((animal) => animal.name === 'edit test 2'); + + return { res, returnedFirstAnimal, returnedSecondAnimal }; + } + + async function editAnimals(mainFarm, user, returnedFirstAnimal, returnedSecondAnimal) { + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + + // Make edits to animals - does not test all top level animal columns, but all relationships + const updatedFirstAnimal = mocks.fakeAnimal({ + // should fail + extra_non_existant_property: 'hello', + id: returnedFirstAnimal.id, + default_type_id: defaultTypeId, + name: 'Update Name 1', + sire: returnedFirstAnimal.sire, + sex_id: animalSex.id, + identifier: '2', + identifier_color_id: animalIdentifierColor.id, + origin_id: animalOrigin.id, + // should fail + animal_removal_reason_id: animalRemovalReason.id, + identifier_type_id: animalIdentifierType.id, + organic_status: 'Organic', + animal_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], + group_ids: [{ animal_group_id: animalGroup2.id }], + }); + const updatedSecondAnimal = mocks.fakeAnimal({ + id: returnedSecondAnimal.id, + custom_type_id: customAnimalType.id, + name: 'Update Name 1', + sire: returnedSecondAnimal.sire, + sex_id: animalSex.id, + identifier: '2', + identifier_color_id: animalIdentifierColor.id, + origin_id: animalOrigin.id, + // should fail + animal_removal_reason_id: animalRemovalReason.id, + identifier_type_id: animalIdentifierType.id, + organic_status: 'Organic', + animal_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], + group_ids: [{ animal_group_id: animalGroup2.id }], + }); + + const patchRes = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [updatedFirstAnimal, updatedSecondAnimal], + ); + + // Remove or add properties not actually expected from get request + [updatedFirstAnimal, updatedSecondAnimal].forEach((animal) => { + // Should not cause an error + delete animal.extra_non_existant_property; + // Should not be able to update on edit + animal.animal_removal_reason_id = null; + // Return format different than post format + animal.group_ids = animal.group_ids.map((groupId) => groupId.animal_group_id); + animal.animal_use_relationships.forEach((rel) => { + rel.animal_id = animal.id; + rel.other_use = null; + }); + }); + + return { res: patchRes, updatedFirstAnimal, updatedSecondAnimal }; + } + + test('Admin users should be able to edit animals', async () => { + const roles = [1, 2, 5]; + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + // Add animals to db + const { res: addRes, returnedFirstAnimal, returnedSecondAnimal } = await addAnimals( + mainFarm, + user, + ); + expect(addRes.status).toBe(201); + expect(returnedFirstAnimal).toBeTruthy(); + expect(returnedSecondAnimal).toBeTruthy(); + + // Edit animals in db + const { res: editRes, updatedFirstAnimal, updatedSecondAnimal } = await editAnimals( + mainFarm, + user, + returnedFirstAnimal, + returnedSecondAnimal, + ); + expect(editRes.status).toBe(204); + + // Get updated animals + const { body: animalRecords } = await getRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }); + const filteredAnimalRecords = animalRecords.filter((record) => + [returnedFirstAnimal.id, returnedSecondAnimal.id].includes(record.id), + ); + + // Test data matches expected changes + filteredAnimalRecords.forEach((record) => { + // Remove properties that were not updated + delete record.internal_identifier; + // Remove base properties + delete record.created_at; + delete record.created_by_user_id; + delete record.deleted; + delete record.updated_at; + delete record.updated_by; + const updatedRecord = [updatedFirstAnimal, updatedSecondAnimal].find( + (animal) => animal.id === record.id, + ); + expect(record).toMatchObject(updatedRecord); + }); + } + }); + + test('Non-admin users should not be able to edit animals', async () => { + const adminRole = 1; + const { mainFarm, user: admin } = await returnUserFarms(adminRole); + const workerRole = 3; + const [user] = await mocks.usersFactory(); + await mocks.userFarmFactory( + { + promisedUser: [user], + promisedFarm: [mainFarm], + }, + fakeUserFarm(workerRole), + ); + + // Add animals to db + const { res: addRes, returnedFirstAnimal, returnedSecondAnimal } = await addAnimals( + mainFarm, + admin, + ); + expect(addRes.status).toBe(201); + expect(returnedFirstAnimal).toBeTruthy(); + expect(returnedSecondAnimal).toBeTruthy(); + + // Edit animals in db + const { res: editRes } = await editAnimals( + mainFarm, + user, + returnedFirstAnimal, + returnedSecondAnimal, + ); + + // Test failure + expect(editRes.status).toBe(403); + expect(editRes.error.text).toBe( + 'User does not have the following permission(s): edit:animals', + ); + }); + + test('Should not be able to send out an individual animal instead of an array', async () => { + const { mainFarm, user } = await returnUserFarms(1); + + // Add animals to db + const { res: addRes, returnedFirstAnimal } = await addAnimals(mainFarm, user); + expect(addRes.status).toBe(201); + expect(returnedFirstAnimal).toBeTruthy(); + + // Change 1 thing + returnedFirstAnimal.sire = 'Changed'; + + const res = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + { + ...returnedFirstAnimal, + }, + ); + + // Test for failure + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); + }); + + test('Should not be able to edit an animal belonging to a different farm', async () => { + const { mainFarm, user } = await returnUserFarms(1); + const [secondFarm] = await mocks.farmFactory(); + + const animal = await makeAnimal(secondFarm, { + default_type_id: defaultTypeId, + }); + + const res = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [ + { + id: animal.id, + sire: 'Neighbours sire', + }, + ], + ); + + expect(res).toMatchObject({ + status: 400, + body: { + error: 'Invalid ids', + invalidIds: [animal.id], + }, + }); + + // Check database + const animalRecord = await AnimalModel.query().findById(animal.id); + expect(animalRecord.sire).toBeNull(); + }); + }); + + // REMOVE tests describe('Remove animal tests', () => { test('Admin users should be able to remove animals', async () => { const roles = [1, 2, 5]; @@ -828,7 +1094,6 @@ describe('Animal Tests', () => { user_id: user.user_id, farm_id: mainFarm.farm_id, }, - { id: animal.id, animal_removal_reason_id: animalRemovalReasonId, @@ -837,12 +1102,8 @@ describe('Animal Tests', () => { }, ); - expect(res).toMatchObject({ - status: 400, - error: { - text: 'Request body should be an array', - }, - }); + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); }); test('Should not be able to remove an animal without providng a removal_date', async () => { @@ -865,7 +1126,6 @@ describe('Animal Tests', () => { }, ], ); - expect(res.status).toBe(400); expect(res.error.text).toBe('Must send reason and date of removal'); diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index c957414412..2c2528040c 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -14,7 +14,6 @@ */ import chai from 'chai'; -import util from 'util'; import { faker } from '@faker-js/faker'; import chaiHttp from 'chai-http'; @@ -57,29 +56,23 @@ describe('Animal Batch Tests', () => { animalRemovalReasonId = animalRemovalReason.id; }); - function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, callback) { - chai + async function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }) { + return await chai .request(server) .get('/animal_batches') .set('user_id', user_id) - .set('farm_id', farm_id) - .end(callback); + .set('farm_id', farm_id); } - const getRequestAsPromise = util.promisify(getRequest); - - function postRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data, callback) { - chai + async function postRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data) { + return await chai .request(server) .post('/animal_batches') .set('user_id', user_id) .set('farm_id', farm_id) - .send(data) - .end(callback); + .send(data); } - const postRequestAsPromise = util.promisify(postRequest); - async function removeRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }, data) { return await chai .request(server) @@ -189,7 +182,7 @@ describe('Animal Batch Tests', () => { default_type_id: defaultTypeId, }); - const res = await getRequestAsPromise({ + const res = await getRequest({ user_id: user.user_id, farm_id: mainFarm.farm_id, }); @@ -224,7 +217,7 @@ describe('Animal Batch Tests', () => { }); const [unAuthorizedUser] = await mocks.usersFactory(); - const res = await getRequestAsPromise({ + const res = await getRequest({ user_id: unAuthorizedUser.user_id, farm_id: mainFarm.farm_id, }); @@ -274,7 +267,7 @@ describe('Animal Batch Tests', () => { ], }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -303,7 +296,7 @@ describe('Animal Batch Tests', () => { default_type_id: defaultTypeId, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -325,7 +318,7 @@ describe('Animal Batch Tests', () => { default_type_id: defaultTypeId, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -348,7 +341,7 @@ describe('Animal Batch Tests', () => { farm_id: farm.farm_id, default_type_id: defaultTypeId, }); - const res = await postRequestAsPromise({ user_id: user.user_id, farm_id: farm.farm_id }, [ + const res = await postRequest({ user_id: user.user_id, farm_id: farm.farm_id }, [ animalBatch, ]); @@ -364,7 +357,7 @@ describe('Animal Batch Tests', () => { custom_type_id: null, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -387,7 +380,7 @@ describe('Animal Batch Tests', () => { custom_type_id: animalType.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -412,7 +405,7 @@ describe('Animal Batch Tests', () => { custom_breed_id: animalBreed.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -433,7 +426,7 @@ describe('Animal Batch Tests', () => { default_breed_id: animalBreed.id, }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -464,7 +457,7 @@ describe('Animal Batch Tests', () => { ], }); - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: user.user_id, farm_id: mainFarm.farm_id, @@ -486,7 +479,7 @@ describe('Animal Batch Tests', () => { }); const postAnimalBatchesRequest = async (animalBatches) => { - const res = await postRequestAsPromise( + const res = await postRequest( { user_id: owner.user_id, farm_id: farm.farm_id }, animalBatches, ); @@ -690,6 +683,314 @@ describe('Animal Batch Tests', () => { }); }); + // EDIT tests + describe('Edit animal batch tests', () => { + let animalGroup1; + let animalGroup2; + let animalSex1; + let animalSex2; + let animalIdentifierColor; + let animalIdentifierType; + let animalOrigin; + let animalRemovalReason; + let animalUse1; + let animalUse2; + let animalUse3; + + beforeEach(async () => { + [animalGroup1] = await mocks.animal_groupFactory(); + [animalGroup2] = await mocks.animal_groupFactory(); + // Populate enums + [animalSex1] = await mocks.animal_sexFactory(); + [animalSex2] = await mocks.animal_sexFactory(); + [animalIdentifierColor] = await mocks.animal_identifier_colorFactory(); + [animalIdentifierType] = await mocks.animal_identifier_typeFactory(); + [animalOrigin] = await mocks.animal_originFactory(); + [animalRemovalReason] = await mocks.animal_removal_reasonFactory(); + [animalUse1] = await mocks.animal_useFactory('OTHER'); + [animalUse2] = await mocks.animal_useFactory(); + [animalUse3] = await mocks.animal_useFactory(); + }); + + async function addAnimalBatches(mainFarm, user) { + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + + // Create two batchess, one with a default type and one with a custom type + const firstBatch = mocks.fakeAnimalBatch({ + name: 'edit test 1', + default_type_id: defaultTypeId, + animal_batch_use_relationships: [{ use_id: animalUse1.id }], + sire: 'Unchanged', + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + group_name: animalGroup1.name, + }); + const secondBatch = mocks.fakeAnimalBatch({ + name: 'edit test 2', + custom_type_id: customAnimalType.id, + animal_batch_use_relationships: [{ use_id: animalUse1.id }], + sire: 'Unchanged', + count: 5, + }); + + const res = await postRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [firstBatch, secondBatch], + ); + + const returnedFirstBatch = res.body?.find((batch) => batch.name === 'edit test 1'); + const returnedSecondBatch = res.body?.find((batch) => batch.name === 'edit test 2'); + + return { res, returnedFirstBatch, returnedSecondBatch }; + } + + async function editAnimalBatches(mainFarm, user, returnedFirstBatch, returnedSecondBatch) { + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + + // Make edits to batches - does not test all top level batch columns, but all relationships + const updatedFirstBatch = mocks.fakeAnimalBatch({ + // should fail + extra_non_existant_property: 'hello', + id: returnedFirstBatch.id, + default_type_id: defaultTypeId, + name: 'Update Name 1', + sire: returnedFirstBatch.sire, + sex_detail: [ + { + id: returnedFirstBatch.sex_detail.find((detail) => detail.sex_id === animalSex1.id)?.id, + animal_batch_id: returnedFirstBatch.id, + sex_id: animalSex1.id, + count: 2, + }, + { + id: returnedFirstBatch.sex_detail.find((detail) => detail.sex_id === animalSex2.id)?.id, + animal_batch_id: returnedFirstBatch.id, + sex_id: animalSex2.id, + count: 3, + }, + ], + count: 5, + origin_id: animalOrigin.id, + // should fail + animal_removal_reason_id: animalRemovalReason.id, + organic_status: 'Organic', + animal_batch_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], + group_ids: [{ animal_group_id: animalGroup2.id }], + }); + const updatedSecondBatch = mocks.fakeAnimalBatch({ + id: returnedSecondBatch.id, + custom_type_id: customAnimalType.id, + name: 'Update Name 1', + sire: returnedSecondBatch.sire, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 3, + }, + ], + count: 5, + origin_id: animalOrigin.id, + // should fail + animal_removal_reason_id: animalRemovalReason.id, + organic_status: 'Organic', + animal_batch_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], + group_ids: [{ animal_group_id: animalGroup2.id }], + }); + + const patchRes = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [updatedFirstBatch, updatedSecondBatch], + ); + + // Remove or add properties not actually expected from get request + [updatedFirstBatch, updatedSecondBatch].forEach((batch) => { + // Should not cause an error + delete batch.extra_non_existant_property; + // Should not be able to update on edit + batch.animal_removal_reason_id = null; + // Return format different than post format + batch.group_ids = batch.group_ids.map((groupId) => groupId.animal_group_id); + batch.animal_batch_use_relationships.forEach((rel) => { + rel.animal_batch_id = batch.id; + rel.other_use = null; + }); + }); + + return { res: patchRes, updatedFirstBatch, updatedSecondBatch }; + } + + test('Admin users should be able to edit batches', async () => { + const roles = [1, 2, 5]; + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + // Add batches to db + const { res: addRes, returnedFirstBatch, returnedSecondBatch } = await addAnimalBatches( + mainFarm, + user, + ); + expect(addRes.status).toBe(201); + expect(returnedFirstBatch).toBeTruthy(); + expect(returnedSecondBatch).toBeTruthy(); + + // Edit batches in db + const { res: editRes, updatedFirstBatch, updatedSecondBatch } = await editAnimalBatches( + mainFarm, + user, + returnedFirstBatch, + returnedSecondBatch, + ); + expect(editRes.status).toBe(204); + + // Get updated batches + const { body: batchRecords } = await getRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }); + const filteredBatchRecords = batchRecords.filter((record) => + [returnedFirstBatch.id, returnedSecondBatch.id].includes(record.id), + ); + + // Test data matches expected changes + filteredBatchRecords.forEach((record) => { + // Remove properties that were not updated + delete record.internal_identifier; + // Remove base properties + delete record.created_at; + delete record.created_by_user_id; + delete record.deleted; + delete record.updated_at; + delete record.updated_by; + const updatedRecord = [updatedFirstBatch, updatedSecondBatch].find( + (batch) => batch.id === record.id, + ); + expect(record).toMatchObject(updatedRecord); + }); + } + }); + + test('Non-admin users should not be able to edit batches', async () => { + const adminRole = 1; + const { mainFarm, user: admin } = await returnUserFarms(adminRole); + const workerRole = 3; + const [user] = await mocks.usersFactory(); + await mocks.userFarmFactory( + { + promisedUser: [user], + promisedFarm: [mainFarm], + }, + fakeUserFarm(workerRole), + ); + + // Add animals to db + const { res: addRes, returnedFirstBatch, returnedSecondBatch } = await addAnimalBatches( + mainFarm, + admin, + ); + expect(addRes.status).toBe(201); + expect(returnedFirstBatch).toBeTruthy(); + expect(returnedSecondBatch).toBeTruthy(); + + // Edit animals in db + const { res: editRes } = await editAnimalBatches( + mainFarm, + user, + returnedFirstBatch, + returnedSecondBatch, + ); + + // Test failure + expect(editRes.status).toBe(403); + expect(editRes.error.text).toBe( + 'User does not have the following permission(s): edit:animal_batches', + ); + }); + + test('Should not be able to send out an individual batch instead of an array', async () => { + const { mainFarm, user } = await returnUserFarms(1); + + // Add animals to db + const { res: addRes, returnedFirstBatch } = await addAnimalBatches(mainFarm, user); + expect(addRes.status).toBe(201); + expect(returnedFirstBatch).toBeTruthy(); + + // Change 1 thing + returnedFirstBatch.sire = 'Changed'; + + const res = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + { + ...returnedFirstBatch, + }, + ); + + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); + }); + + test('Should not be able to edit a batch belonging to a different farm', async () => { + const { mainFarm, user } = await returnUserFarms(1); + const [secondFarm] = await mocks.farmFactory(); + + const batch = await makeAnimalBatch(secondFarm, { + default_type_id: defaultTypeId, + }); + + const res = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [ + { + id: batch.id, + sire: 'Neighbours sire', + }, + ], + ); + + expect(res).toMatchObject({ + status: 400, + body: { + error: 'Invalid ids', + invalidIds: [batch.id], + }, + }); + + // Check database + const batchRecord = await AnimalBatchModel.query().findById(batch.id); + expect(batchRecord.sire).toBeNull(); + }); + }); + + // REMOVE tests describe('Remove animal batch tests', () => { test('Admin users should be able to remove animal batches', async () => { const roles = [1, 2, 5]; @@ -815,12 +1116,8 @@ describe('Animal Batch Tests', () => { }, ); - expect(res).toMatchObject({ - status: 400, - error: { - text: 'Request body should be an array', - }, - }); + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); // Check database const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); @@ -848,7 +1145,6 @@ describe('Animal Batch Tests', () => { }, ], ); - expect(res.status).toBe(400); expect(res.error.text).toBe('Must send reason and date of removal'); diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 96800282a8..99720506d9 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2477,8 +2477,8 @@ async function animal_removal_reasonFactory() { return knex('animal_removal_reason').insert({ key: faker.lorem.word() }).returning('*'); } -async function animal_useFactory() { - return knex('animal_use').insert({ key: faker.lorem.word() }).returning('*'); +async function animal_useFactory(key = faker.lorem.word()) { + return knex('animal_use').insert({ key }).returning('*'); } async function animal_type_use_relationshipFactory({ diff --git a/packages/api/tests/testEnvironment.js b/packages/api/tests/testEnvironment.js index b6ddb36f88..6866416212 100644 --- a/packages/api/tests/testEnvironment.js +++ b/packages/api/tests/testEnvironment.js @@ -121,6 +121,8 @@ async function tableCleanup(knex) { DELETE FROM "pesticide"; DELETE FROM "task_type"; DELETE FROM "farmDataSchedule"; + DELETE FROM "animal_use_relationship"; + DELETE FROM "animal_batch_use_relationship"; DELETE FROM "animal_group_relationship"; DELETE FROM "animal_batch_group_relationship"; DELETE FROM "animal_group";