From 02567b0f5268cb26b0e663de378fea10f43d5705 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 23 Aug 2024 11:38:12 -0400 Subject: [PATCH 01/45] LF-4380 Add edit route on animals and batches --- packages/api/src/routes/animalBatchRoute.js | 12 +++++++++++- packages/api/src/routes/animalRoute.js | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/api/src/routes/animalBatchRoute.js b/packages/api/src/routes/animalBatchRoute.js index 80841b2fd6..3fef25f9c1 100644 --- a/packages/api/src/routes/animalBatchRoute.js +++ b/packages/api/src/routes/animalBatchRoute.js @@ -26,7 +26,10 @@ import { } 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, +} from '../middleware/validation/checkAnimalOrBatch.js'; router.get('/', checkScope(['get:animal_batches']), AnimalBatchController.getFarmAnimalBatches()); router.post( @@ -35,6 +38,13 @@ router.post( validateAnimalBatchCreationBody('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']), diff --git a/packages/api/src/routes/animalRoute.js b/packages/api/src/routes/animalRoute.js index 50f869d555..68e8a7f141 100644 --- a/packages/api/src/routes/animalRoute.js +++ b/packages/api/src/routes/animalRoute.js @@ -26,7 +26,10 @@ import { } 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, +} from '../middleware/validation/checkAnimalOrBatch.js'; router.get('/', checkScope(['get:animals']), AnimalController.getFarmAnimals()); router.post( @@ -35,6 +38,13 @@ router.post( validateAnimalBatchCreationBody(), 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']), From f0907a2c1e97b34d3bab94f9d3549a8a552636a0 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Mon, 26 Aug 2024 15:22:17 -0400 Subject: [PATCH 02/45] LF-4380 Make edit endpoints for controller Essentially seems to be working. Groups needs to be provided in obect format not array of ids. TODO check identifier what happens on edit. --- .../src/controllers/animalBatchController.js | 56 ++++++++++++++ .../api/src/controllers/animalController.js | 74 +++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 49921be7de..2f1c01335d 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -128,6 +128,62 @@ const animalBatchController = { }; }, + editAnimalBatches() { + return async (req, res) => { + const trx = await transaction.start(Model.knex()); + + try { + // select only allowed properties to edit + for (const animalBatch of req.body) { + 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, + 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, + group_ids, + animal_batch_use_relationships, + }, + req, + { trx }, + ); + } + await trx.commit(); + 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..94811acb62 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -177,7 +177,81 @@ const animalController = { } }; }, + editAnimals() { + return async (req, res) => { + const trx = await transaction.start(Model.knex()); + + try { + // select only allowed properties to edit + for (const animal of req.body) { + 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 }, + ); + } + await trx.commit(); + return res.status(204).send(); + } catch (error) { + handleObjectionError(error, res, trx); + } + }; + }, removeAnimals() { return async (req, res) => { const trx = await transaction.start(Model.knex()); From 909ec4b967b0e698b366c1d8c5dd54c4e0cba816 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 27 Sep 2024 11:31:55 -0400 Subject: [PATCH 03/45] LF-4380 Allow specifiying enum key for tests --- packages/api/tests/mock.factories.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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({ From d7ec5c00b2b6e887512a3fe2161b305ad91ce83f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 27 Sep 2024 11:33:00 -0400 Subject: [PATCH 04/45] LF-4380 Add use relationships to tableCleanup --- packages/api/tests/testEnvironment.js | 2 ++ 1 file changed, 2 insertions(+) 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"; From a9005fdecc5b144f9694311302d288dcf56eed7f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 27 Sep 2024 12:51:17 -0400 Subject: [PATCH 05/45] LF-4380 Add edit test and use async await on requests --- packages/api/tests/animal.test.js | 335 +++++++++++++++++++++++++++--- 1 file changed, 302 insertions(+), 33 deletions(-) diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index 572e02c1e6..52c963e720 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,286 @@ 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).toMatchObject({ + status: 400, + error: { + text: '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(); + }); + }); + describe('Remove animal tests', () => { test('Admin users should be able to remove animals', async () => { const roles = [1, 2, 5]; From 6ecee8056542afe88009fcffac4ebfd9584374d5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 27 Sep 2024 18:16:57 -0400 Subject: [PATCH 06/45] LF-4380 Add tests for animal batches and add async await syntax to requests To do: - fix orgiin_id being editable - sex detail has no constraints on multiple batch_id sex_id combination - handle new types and breeds on animals and batches - handle verifying count when sex details change and vice versa --- packages/api/tests/animal_batch.test.js | 356 ++++++++++++++++++++++-- 1 file changed, 331 insertions(+), 25 deletions(-) diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index c957414412..b9dff53fc6 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,319 @@ 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, + }, + ); + + // Test for failure + expect(res).toMatchObject({ + status: 400, + error: { + text: '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]; From 6b879c932665089060ea1a8ebe36da1e0bfab1ef Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 27 Sep 2024 18:21:25 -0400 Subject: [PATCH 07/45] LF-4380 Allow origin_id to be edited --- packages/api/src/controllers/animalBatchController.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 2f1c01335d..48177e2443 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -149,6 +149,7 @@ const animalBatchController = { supplier, price, sex_detail, + origin_id, group_ids, animal_batch_use_relationships, } = animalBatch; @@ -169,6 +170,7 @@ const animalBatchController = { supplier, price, sex_detail, + origin_id, group_ids, animal_batch_use_relationships, }, From 12fd4b1f2b40a0c8e7b6d3a2205a4795b7692e22 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 08:14:40 -0400 Subject: [PATCH 08/45] LF-4380 Add comment for delineating Remove tests --- packages/api/tests/animal.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index 52c963e720..83e043ccb6 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -998,6 +998,7 @@ describe('Animal Tests', () => { }); }); + // REMOVE tests describe('Remove animal tests', () => { test('Admin users should be able to remove animals', async () => { const roles = [1, 2, 5]; From 41e2a67631254d283ad4b8306958878cb1c6fbf0 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 08:15:34 -0400 Subject: [PATCH 09/45] LF-4380 Move add animal functions to be reused in edit to utility function --- packages/api/src/util/animal.js | 103 ++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) 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 }]; + } +}; From 8581feebc4bcd75d89a1be9d49f91f73eef51484 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 08:16:41 -0400 Subject: [PATCH 10/45] LF-4380 Replace code with utility function to be reused in edit, and change plain insert with insertGraph --- .../api/src/controllers/animalController.js | 118 +++--------------- 1 file changed, 20 insertions(+), 98 deletions(-) diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index 94811acb62..afca36ed6a 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,119 +56,43 @@ 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); + 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; + // 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); From f994fa6af63dcc17222313a4ccdab0cfe53859a6 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 09:10:02 -0400 Subject: [PATCH 11/45] LF-4380 Add utility functions to add batch and comments about groups too --- .../src/controllers/animalBatchController.js | 61 +++++-------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 48177e2443..f321c7b173 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(); From 4338df8db64159ff2342e9654565776a469236d5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 12:27:59 -0400 Subject: [PATCH 12/45] LF-4380 Update animals and batch edit endpoint to use types breeds and groups utilities --- .../api/src/controllers/animalBatchController.js | 16 ++++++++++++++++ packages/api/src/controllers/animalController.js | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index f321c7b173..229eb52999 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -104,8 +104,18 @@ const animalBatchController = { 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, @@ -149,7 +159,13 @@ const animalBatchController = { { 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); diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index afca36ed6a..ff5c74edea 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -66,6 +66,7 @@ const animalController = { 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); // Remove farm_id if it happens to be set in animal object since it should be obtained from header @@ -77,6 +78,8 @@ const animalController = { req, { trx }, ); + + // TODO: Comment out for animals v1? // Format group_ids const groupIdMap = individualAnimalResult.group_ids?.map((group) => group.animal_group_id) || []; @@ -104,8 +107,16 @@ const animalController = { 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, @@ -167,7 +178,12 @@ const animalController = { { 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); From 304d1ee2804ac2a2205afe9f863ee706a86f15b7 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 2 Oct 2024 13:43:14 -0400 Subject: [PATCH 13/45] LF-4380 Consolidate middleware for reuse in edit endpoint --- .../api/src/middleware/checkAnimalEntities.js | 267 ------------------ .../validation/checkAnimalOrBatch.js | 267 ++++++++++++++++++ packages/api/src/routes/animalBatchRoute.js | 11 +- packages/api/src/routes/animalRoute.js | 11 +- 4 files changed, 275 insertions(+), 281 deletions(-) delete mode 100644 packages/api/src/middleware/checkAnimalEntities.js 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..4b2a93255b 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -13,8 +13,15 @@ * 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, @@ -91,3 +98,263 @@ export function checkRemoveAnimalOrBatch(animalOrBatchKey) { } }; } + +/** + * 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; + + 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 AnimalOrBatchModel[animalOrBatchKey] + .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 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 { + 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 (animalOrBatchKey === '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 = + animalOrBatchKey === '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/routes/animalBatchRoute.js b/packages/api/src/routes/animalBatchRoute.js index 3fef25f9c1..736b3703eb 100644 --- a/packages/api/src/routes/animalBatchRoute.js +++ b/packages/api/src/routes/animalBatchRoute.js @@ -19,23 +19,20 @@ 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, 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( @@ -55,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 68e8a7f141..4a55cb2877 100644 --- a/packages/api/src/routes/animalRoute.js +++ b/packages/api/src/routes/animalRoute.js @@ -19,23 +19,20 @@ 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, 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( @@ -55,7 +52,7 @@ router.patch( router.delete( '/', checkScope(['delete:animals']), - checkAnimalEntities(AnimalModel), + checkDeleteAnimalOrBatch('animal'), AnimalController.deleteAnimals(), ); router.post( From 3e4543bad7e9dda7d03b8c80016ac07fb2927c90 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 3 Oct 2024 20:13:06 -0400 Subject: [PATCH 14/45] LF-4380 Extract logical checks for reuse --- .../validation/checkAnimalOrBatch.js | 614 ++++++++++-------- packages/api/tests/animal.test.js | 18 +- packages/api/tests/animal_batch.test.js | 18 +- 3 files changed, 356 insertions(+), 294 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 4b2a93255b..26c0c5943d 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -28,152 +28,260 @@ const AnimalOrBatchModel = { batch: AnimalBatchModel, }; -export function checkEditAnimalOrBatch(animalOrBatchKey) { - return async (req, res, next) => { - try { - const { farm_id } = req.headers; +// Utils +const hasOneValue = (values) => { + const nonNullValues = values.filter(Boolean); + return nonNullValues.length === 1; +}; - if (!Array.isArray(req.body)) { - return res.status(400).send('Request body should be an array'); - } +const allFalsy = (values) => values.every((value) => !value); - // 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 = []; +const checkIdExistsAndIsNumber = (id) => { + if (!id || isNaN(Number(id))) { + throw newCustomError('Must send valid ids'); + } +}; - for (const animalOrBatch of req.body) { - if (!animalOrBatch.id) { - return res.status(400).send('Must send animal or batch id'); - } +const newCustomError = (message, code = 400, body = undefined) => { + const error = new Error(message); + error.code = code; + error.body = body; + error.type = 'LiteFarmCustom'; + return error; +}; - const animalOrBatchRecord = await AnimalOrBatchModel[animalOrBatchKey] - .query() - .findById(animalOrBatch.id) - .where({ farm_id }) - .whereNotDeleted(); +// Body checks +const checkIsArray = (array, descriptiveErrorText = '') => { + if (!Array.isArray(array)) { + throw newCustomError(`${descriptiveErrorText} should be an array`); + } +}; - if (!animalOrBatchRecord) { - invalidIds.push(animalOrBatch.id); - } - } +const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) => { + if (!ids || !ids.length) { + throw newCustomError('Must send ids'); + } - 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.', - }); - } + const idsSet = new Set(ids.split(',')); - next(); - } catch (error) { - console.error(error); - return res.status(500).json({ - error, - }); + // 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 + checkIdExistsAndIsNumber(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 }, + ); + } +}; -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'); - } +// AnimalOrBatch checks +const checkOneAnimalTypeProvided = (animalOrBatch) => { + const { default_type_id, custom_type_id, type_name } = animalOrBatch; + if (!hasOneValue([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', + ); + } +}; - 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'); - } - } - checkEditAnimalOrBatch(animalOrBatchKey)(req, res, next); - } catch (error) { - console.error(error); - return res.status(500).json({ - error, - }); +const checkMaxOneAnimalBreedProvided = (animalOrBatch) => { + const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; + if ( + !hasOneValue([default_breed_id, custom_breed_id, breed_name]) && + !allFalsy([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 checkCustomTypeBelongsToFarm = async (animalOrBatch, farm_id) => { + const { custom_type_id } = animalOrBatch; + if (custom_type_id) { + const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); + if (customType && customType.farm_id !== farm_id) { + throw newCustomError('Forbidden custom type does not belong to this farm', 403); } - }; -} + } +}; -/** - * 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()); +const checkBreedMatchesType = async (animalOrBatch) => { + const { default_breed_id, default_type_id } = animalOrBatch; + if (default_breed_id && default_type_id) { + const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); - try { - const { farm_id } = req.headers; - const { ids } = req.query; + if (defaultBreed && defaultBreed.default_type_id !== default_type_id) { + throw newCustomError('Breed does not match type'); + } + } +}; - if (!ids || !ids.length) { - await trx.rollback(); - return res.status(400).send('Must send ids'); - } +const checkDefaultBreedDoesNotUseCustomType = (animalOrBatch) => { + const { default_breed_id, custom_type_id } = animalOrBatch; + if (default_breed_id && custom_type_id) { + throw newCustomError('Default breed does not use custom type'); + } +}; - const idsSet = new Set(ids.split(',')); +const checkCustomBreed = async (animalOrBatch, farm_id) => { + const { custom_breed_id, default_type_id, custom_type_id } = animalOrBatch; + if (custom_breed_id) { + const customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(custom_breed_id); - // Check that all animals/batches exist and belong to the farm - const invalidIds = []; + if (customBreed && customBreed.farm_id !== farm_id) { + throw newCustomError('Forbidden custom breed does not belong to this farm', 403); + } - 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'); - } + if (customBreed.default_type_id && customBreed.default_type_id !== default_type_id) { + throw newCustomError('Breed does not match type'); + } + + if (customBreed.custom_type_id && customBreed.custom_type_id !== custom_type_id) { + throw newCustomError('Breed does not match type'); + } + } +}; - const existingRecord = await AnimalOrBatchModel[animalOrBatchKey] - .query(trx) - .findById(id) - .where({ farm_id }) - .whereNotDeleted(); // prohibiting re-delete +const checkBatchSexDetail = async (animalOrBatch, animalOrBatchKey) => { + if (animalOrBatchKey === 'batch') { + const { count, sex_detail } = animalOrBatch; - if (!existingRecord) { - invalidIds.push(id); - } + 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) { + throw newCustomError('Batch count must be greater than or equal to sex detail count'); + } + if (sex_detail.length != sexIdSet.size) { + throw newCustomError('Duplicate sex ids in detail'); } + } + } +}; - 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.', - }); +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'); } + } + } +}; - await trx.commit(); - next(); - } catch (error) { - handleObjectionError(error, res, trx); +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 hasOneValue = (values) => { - const nonNullValues = values.filter(Boolean); - return nonNullValues.length === 1; +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 allFalsy = (values) => values.every((value) => !value); +const checkIfRecordExists = async (animalOrBatch, animalOrBatchKey, invalidIds, farm_id) => { + const animalOrBatchRecord = await AnimalOrBatchModel[animalOrBatchKey] + .query() + .findById(animalOrBatch.id) + .where({ farm_id }) + .whereNotDeleted(); + + if (!animalOrBatchRecord) { + invalidIds.push(animalOrBatch.id); + } +}; + +// 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) => { @@ -185,176 +293,150 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { 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'); - } + const { type_name, breed_name } = animalOrBatch; - 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'); - } + checkOneAnimalTypeProvided(animalOrBatch); + checkMaxOneAnimalBreedProvided(animalOrBatch); + await checkCustomTypeBelongsToFarm(animalOrBatch, farm_id); + await checkBreedMatchesType(animalOrBatch); + checkDefaultBreedDoesNotUseCustomType(animalOrBatch); + await checkCustomBreed(animalOrBatch, farm_id); + await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); + await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); - 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'); - } + // Skip the process if type_name and breed_name are not passed + if (!type_name && !breed_name) { + continue; } + checkAndAddCustomTypesOrBreeds(animalOrBatch, newTypesSet, newBreedsSet); + } - if (default_breed_id && custom_type_id) { - await trx.rollback(); - return res.status(400).send('Default breed does not use custom type'); - } + await checkCustomTypeAndBreedConflicts(newTypesSet, newBreedsSet, farm_id, trx); - 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'); - } - } + 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); + } + } + }; +} - if (animalOrBatchKey === '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'); - } - } - } +export function checkEditAnimalOrBatch(animalOrBatchKey) { + return async (req, res, next) => { + try { + const { farm_id } = req.headers; - const relationshipsKey = - animalOrBatchKey === 'batch' - ? 'animal_batch_use_relationships' - : 'animal_use_relationships'; + 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 = []; - if (animalOrBatch[relationshipsKey]) { - if (!Array.isArray(animalOrBatch[relationshipsKey])) { - return res.status(400).send(`${relationshipsKey} must be an array`); - } + for (const animalOrBatch of req.body) { + checkIdExistsAndIsNumber(animalOrBatch.id); + await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); + } - const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); + await checkInvalidIds(invalidIds); - 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'); - } - } - } + next(); + } catch (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, + }); + } + } + }; +} - // Skip the process if type_name and breed_name are not passed - if (!type_name && !breed_name) { - continue; - } +export function checkRemoveAnimalOrBatch(animalOrBatchKey) { + return async (req, res, next) => { + try { + const { farm_id } = req.headers; - 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'); - } + 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 = []; - newTypesSet.add(type_name); - } + for (const animalOrBatch of req.body) { + // Removal specific + checkRemovalDataProvided(animalOrBatch); - // 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}`; + // From Edit + checkIdExistsAndIsNumber(animalOrBatch.id); + await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); + } - newBreedsSet.add(breedDetails); - } + await checkInvalidIds(invalidIds); + next(); + } catch (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, + }); } + } + }; +} - if (newTypesSet.size) { - const record = await CustomAnimalTypeModel.getTypesByFarmAndTypes( - farm_id, - [...newTypesSet], - trx, - ); +/** + * 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()); - if (record.length) { - await trx.rollback(); - return res.status(409).send('Animal type already exists'); - } - } + try { + const { farm_id } = req.headers; + const { ids } = req.query; - 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 checkValidAnimalOrBatchIds(animalOrBatchKey, ids, farm_id, trx); await trx.commit(); next(); } catch (error) { - handleObjectionError(error, res, trx); + 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/tests/animal.test.js b/packages/api/tests/animal.test.js index 83e043ccb6..f14d64ba73 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -955,12 +955,8 @@ describe('Animal Tests', () => { ); // Test for failure - 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 edit an animal belonging to a different farm', async () => { @@ -1098,7 +1094,6 @@ describe('Animal Tests', () => { user_id: user.user_id, farm_id: mainFarm.farm_id, }, - { id: animal.id, animal_removal_reason_id: animalRemovalReasonId, @@ -1107,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 () => { @@ -1135,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 b9dff53fc6..2c2528040c 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -951,13 +951,8 @@ describe('Animal Batch Tests', () => { }, ); - // Test for failure - 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 edit a batch belonging to a different farm', async () => { @@ -1121,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); @@ -1154,7 +1145,6 @@ describe('Animal Batch Tests', () => { }, ], ); - expect(res.status).toBe(400); expect(res.error.text).toBe('Must send reason and date of removal'); From 2d398ee171b90edb694bf3b9229ed7f141d747b5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 3 Oct 2024 22:06:43 -0400 Subject: [PATCH 15/45] LF-4380 WIP - Add type and breed checks to edit and refine functions - change utility functions to use positive language versus negating the language - commetns will be removed at the end --- .../validation/checkAnimalOrBatch.js | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 26c0c5943d..3a294cea94 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -29,13 +29,11 @@ const AnimalOrBatchModel = { }; // Utils -const hasOneValue = (values) => { +const hasMultipleValues = (values) => { const nonNullValues = values.filter(Boolean); - return nonNullValues.length === 1; + return !(nonNullValues.length === 1); }; -const allFalsy = (values) => values.every((value) => !value); - const checkIdExistsAndIsNumber = (id) => { if (!id || isNaN(Number(id))) { throw newCustomError('Must send valid ids'); @@ -92,26 +90,38 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = }; // AnimalOrBatch checks -const checkOneAnimalTypeProvided = (animalOrBatch) => { +const checkExactlyOneAnimalTypeProvided = (animalOrBatch) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; - if (!hasOneValue([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 checkMaxOneAnimalBreedProvided = (animalOrBatch) => { +const checksIfTypeProvided = (animalOrBatch) => { + const { default_type_id, custom_type_id, type_name } = animalOrBatch; + if (default_type_id || custom_type_id || type_name) { + checkExactlyOneAnimalTypeProvided(animalOrBatch); + } +}; + +const checkExactlyOneAnimalBreedProvided = (animalOrBatch) => { const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; - if ( - !hasOneValue([default_breed_id, custom_breed_id, breed_name]) && - !allFalsy([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 checksIfBreedProvided = (animalOrBatch) => { + const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; + if (default_breed_id || custom_breed_id || breed_name) { + checkExactlyOneAnimalBreedProvided(animalOrBatch); + } +}; + const checkCustomTypeBelongsToFarm = async (animalOrBatch, farm_id) => { const { custom_type_id } = animalOrBatch; if (custom_type_id) { @@ -295,8 +305,10 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { for (const animalOrBatch of req.body) { const { type_name, breed_name } = animalOrBatch; - checkOneAnimalTypeProvided(animalOrBatch); - checkMaxOneAnimalBreedProvided(animalOrBatch); + // also edit + checkExactlyOneAnimalTypeProvided(animalOrBatch); + checksIfBreedProvided(animalOrBatch); + await checkCustomTypeBelongsToFarm(animalOrBatch, farm_id); await checkBreedMatchesType(animalOrBatch); checkDefaultBreedDoesNotUseCustomType(animalOrBatch); @@ -342,6 +354,11 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { for (const animalOrBatch of req.body) { checkIdExistsAndIsNumber(animalOrBatch.id); await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); + + checksIfTypeProvided(animalOrBatch); + // nullTypesExistingOnRecord(); + checksIfBreedProvided(animalOrBatch); + // nullBreedsExistingOnRecord(); } await checkInvalidIds(invalidIds); From 7e2dfa84cce709a63e8c29deb73847cac633139f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 3 Oct 2024 22:22:17 -0400 Subject: [PATCH 16/45] LF-4380 Genericize type function --- .../validation/checkAnimalOrBatch.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 3a294cea94..01e11d95b5 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -90,8 +90,7 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = }; // AnimalOrBatch checks -const checkExactlyOneAnimalTypeProvided = (animalOrBatch) => { - const { default_type_id, custom_type_id, type_name } = animalOrBatch; +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', @@ -99,15 +98,14 @@ const checkExactlyOneAnimalTypeProvided = (animalOrBatch) => { } }; -const checksIfTypeProvided = (animalOrBatch) => { +const checksIfTypeProvided = (animalOrBatch, required = true) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; - if (default_type_id || custom_type_id || type_name) { - checkExactlyOneAnimalTypeProvided(animalOrBatch); + if (default_type_id || custom_type_id || type_name || required) { + checkExactlyOneAnimalTypeProvided(default_type_id, custom_type_id, type_name); } }; -const checkExactlyOneAnimalBreedProvided = (animalOrBatch) => { - const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; +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', @@ -118,7 +116,7 @@ const checkExactlyOneAnimalBreedProvided = (animalOrBatch) => { const checksIfBreedProvided = (animalOrBatch) => { const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; if (default_breed_id || custom_breed_id || breed_name) { - checkExactlyOneAnimalBreedProvided(animalOrBatch); + checkExactlyOneAnimalBreedProvided(default_breed_id, custom_breed_id, breed_name); } }; @@ -306,7 +304,7 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { const { type_name, breed_name } = animalOrBatch; // also edit - checkExactlyOneAnimalTypeProvided(animalOrBatch); + checksIfTypeProvided(animalOrBatch); checksIfBreedProvided(animalOrBatch); await checkCustomTypeBelongsToFarm(animalOrBatch, farm_id); @@ -355,7 +353,7 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { checkIdExistsAndIsNumber(animalOrBatch.id); await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); - checksIfTypeProvided(animalOrBatch); + checksIfTypeProvided(animalOrBatch, false); // nullTypesExistingOnRecord(); checksIfBreedProvided(animalOrBatch); // nullBreedsExistingOnRecord(); From 969044351c45484d85375db312d8741cba4ecab2 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 3 Oct 2024 23:19:24 -0400 Subject: [PATCH 17/45] LF-4380 Reword check breed matched type to account for editing --- .../validation/checkAnimalOrBatch.js | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 01e11d95b5..56f56552a2 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -98,11 +98,24 @@ const checkExactlyOneAnimalTypeProvided = (default_type_id, custom_type_id, type } }; -const checksIfTypeProvided = (animalOrBatch, required = true) => { +const checkCustomTypeBelongsToFarm = async (custom_type_id, farm_id) => { + const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); + if (customType && customType.farm_id !== farm_id) { + throw newCustomError('Forbidden custom type does not belong to this farm', 403); + } +}; + +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) { + await checkCustomTypeBelongsToFarm(custom_type_id, farm_id); + } + if (type_name) { + // Check type_name does not already exist or replace custom type id? + } }; const checkExactlyOneAnimalBreedProvided = (default_breed_id, custom_breed_id, breed_name) => { @@ -113,31 +126,45 @@ const checkExactlyOneAnimalBreedProvided = (default_breed_id, custom_breed_id, b } }; -const checksIfBreedProvided = (animalOrBatch) => { - const { default_breed_id, custom_breed_id, breed_name } = animalOrBatch; +const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) => { + const { default_breed_id, custom_breed_id, breed_name, id, default_type_id } = animalOrBatch; if (default_breed_id || custom_breed_id || breed_name) { checkExactlyOneAnimalBreedProvided(default_breed_id, custom_breed_id, breed_name); } -}; - -const checkCustomTypeBelongsToFarm = async (animalOrBatch, farm_id) => { - const { custom_type_id } = animalOrBatch; - if (custom_type_id) { - const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); - if (customType && customType.farm_id !== farm_id) { - throw newCustomError('Forbidden custom type does not belong to this farm', 403); - } + if (default_breed_id) { + await checkDefaultBreedMatchesType( + animalOrBatchKey, + farm_id, + id, + default_breed_id, + default_type_id, + ); } }; -const checkBreedMatchesType = async (animalOrBatch) => { - const { default_breed_id, default_type_id } = animalOrBatch; - if (default_breed_id && default_type_id) { +const checkDefaultBreedMatchesType = async ( + animalOrBatchKey, + farm_id, + id, + default_breed_id, + default_type_id, +) => { + let defaultTypeId = default_type_id; + // If editing + if (!defaultTypeId && id) { + defaultTypeId = await AnimalOrBatchModel[animalOrBatchKey] + .query() + .findById(id) + .where({ farm_id }) + .whereNotDeleted(); + } + if (defaultTypeId) { const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); - - if (defaultBreed && defaultBreed.default_type_id !== default_type_id) { + if (defaultBreed && defaultBreed.default_type_id !== defaultTypeId) { throw newCustomError('Breed does not match type'); } + } else { + throw newCustomError('Default breed must use default type'); } }; @@ -304,11 +331,9 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { const { type_name, breed_name } = animalOrBatch; // also edit - checksIfTypeProvided(animalOrBatch); - checksIfBreedProvided(animalOrBatch); + await checksIfTypeProvided(animalOrBatch, farm_id); + await checksIfBreedProvided(animalOrBatch, animalOrBatchKey); - await checkCustomTypeBelongsToFarm(animalOrBatch, farm_id); - await checkBreedMatchesType(animalOrBatch); checkDefaultBreedDoesNotUseCustomType(animalOrBatch); await checkCustomBreed(animalOrBatch, farm_id); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); @@ -353,9 +378,9 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { checkIdExistsAndIsNumber(animalOrBatch.id); await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); - checksIfTypeProvided(animalOrBatch, false); + await checksIfTypeProvided(animalOrBatch, farm_id, false); // nullTypesExistingOnRecord(); - checksIfBreedProvided(animalOrBatch); + await checksIfBreedProvided(animalOrBatch, animalOrBatchKey, farm_id); // nullBreedsExistingOnRecord(); } From 3eb111ae4f9a5c510e2ce6a2100a5379d5d27233 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 4 Oct 2024 14:12:24 -0400 Subject: [PATCH 18/45] LF-4380 Finish consolidating breed checks to mirror type checks --- .../validation/checkAnimalOrBatch.js | 121 +++++++++++------- 1 file changed, 78 insertions(+), 43 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 56f56552a2..a2813844bc 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -98,23 +98,28 @@ const checkExactlyOneAnimalTypeProvided = (default_type_id, custom_type_id, type } }; -const checkCustomTypeBelongsToFarm = async (custom_type_id, farm_id) => { - const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); - if (customType && customType.farm_id !== farm_id) { - throw newCustomError('Forbidden custom type does not belong to this farm', 403); +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) { - await checkCustomTypeBelongsToFarm(custom_type_id, farm_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) { - // Check type_name does not already exist or replace custom type id? + // TODO: Check type_name does not already exist or replace custom type id? } }; @@ -126,22 +131,6 @@ const checkExactlyOneAnimalBreedProvided = (default_breed_id, custom_breed_id, b } }; -const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) => { - const { default_breed_id, custom_breed_id, breed_name, id, default_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( - animalOrBatchKey, - farm_id, - id, - default_breed_id, - default_type_id, - ); - } -}; - const checkDefaultBreedMatchesType = async ( animalOrBatchKey, farm_id, @@ -150,7 +139,7 @@ const checkDefaultBreedMatchesType = async ( default_type_id, ) => { let defaultTypeId = default_type_id; - // If editing + // If not editing type, check record type if (!defaultTypeId && id) { defaultTypeId = await AnimalOrBatchModel[animalOrBatchKey] .query() @@ -164,35 +153,83 @@ const checkDefaultBreedMatchesType = async ( 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 checkDefaultBreedDoesNotUseCustomType = (animalOrBatch) => { - const { default_breed_id, custom_type_id } = animalOrBatch; - if (default_breed_id && custom_type_id) { - throw newCustomError('Default breed does not use custom type'); +const checkCustomBreedMatchesType = async ( + animalOrBatchKey, + farm_id, + id, + customBreed, + default_type_id, + custom_type_id, +) => { + let defaultTypeId = default_type_id; + let customTypeId = custom_type_id; + let record; + // If not editing type, check record type + if (!defaultTypeId && !customTypeId && id) { + record = await AnimalOrBatchModel[animalOrBatchKey] + .query() + .findById(id) + .where({ farm_id }) + .whereNotDeleted(); + defaultTypeId = record.default_type_id; + customTypeId = record.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 checkCustomBreed = async (animalOrBatch, farm_id) => { - const { custom_breed_id, default_type_id, custom_type_id } = animalOrBatch; +const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) => { + const { + default_breed_id, + custom_breed_id, + breed_name, + id, + 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( + animalOrBatchKey, + farm_id, + id, + default_breed_id, + default_type_id, + ); + } if (custom_breed_id) { const customBreed = await CustomAnimalBreedModel.query() .whereNotDeleted() .findById(custom_breed_id); - - if (customBreed && customBreed.farm_id !== farm_id) { - throw newCustomError('Forbidden custom breed does not belong to this farm', 403); - } - - if (customBreed.default_type_id && customBreed.default_type_id !== default_type_id) { - throw newCustomError('Breed does not match type'); - } - - if (customBreed.custom_type_id && customBreed.custom_type_id !== custom_type_id) { - throw newCustomError('Breed does not match type'); + if (!customBreed) { + // TODO : new error add test + throw newCustomError('Custom breed does not exist'); } + await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); + await checkCustomBreedMatchesType( + animalOrBatchKey, + farm_id, + id, + customBreed, + default_type_id, + custom_type_id, + ); + } + if (breed_name) { + // TODO: Check breed_name does not already exist or replace custom type id? } }; @@ -332,10 +369,8 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { // also edit await checksIfTypeProvided(animalOrBatch, farm_id); - await checksIfBreedProvided(animalOrBatch, animalOrBatchKey); + await checksIfBreedProvided(animalOrBatch, animalOrBatchKey, farm_id); - checkDefaultBreedDoesNotUseCustomType(animalOrBatch); - await checkCustomBreed(animalOrBatch, farm_id); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); From dddfdc2cfd95da154061c19e2cff4c606ddcce2b Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 4 Oct 2024 14:59:42 -0400 Subject: [PATCH 19/45] LF-4380 Pass animalOrBatchRecord as prop, rename function, add comments --- .../validation/checkAnimalOrBatch.js | 86 +++++++++---------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index a2813844bc..212862538b 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -34,7 +34,7 @@ const hasMultipleValues = (values) => { return !(nonNullValues.length === 1); }; -const checkIdExistsAndIsNumber = (id) => { +const checkIdIsNumber = (id) => { if (!id || isNaN(Number(id))) { throw newCustomError('Must send valid ids'); } @@ -67,7 +67,7 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = for (const id of idsSet) { // For query syntax like ids=,,, which will pass the above check - checkIdExistsAndIsNumber(id); + checkIdIsNumber(id); const existingRecord = await AnimalOrBatchModel[animalOrBatchKey] .query(trx) @@ -132,20 +132,14 @@ const checkExactlyOneAnimalBreedProvided = (default_breed_id, custom_breed_id, b }; const checkDefaultBreedMatchesType = async ( - animalOrBatchKey, - farm_id, - id, + animalOrBatchRecord, default_breed_id, default_type_id, ) => { let defaultTypeId = default_type_id; // If not editing type, check record type - if (!defaultTypeId && id) { - defaultTypeId = await AnimalOrBatchModel[animalOrBatchKey] - .query() - .findById(id) - .where({ farm_id }) - .whereNotDeleted(); + if (!defaultTypeId && animalOrBatchRecord) { + defaultTypeId = animalOrBatchRecord.default_type_id; } if (defaultTypeId) { const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); @@ -159,25 +153,17 @@ const checkDefaultBreedMatchesType = async ( }; const checkCustomBreedMatchesType = async ( - animalOrBatchKey, - farm_id, - id, + animalOrBatchRecord, customBreed, default_type_id, custom_type_id, ) => { let defaultTypeId = default_type_id; let customTypeId = custom_type_id; - let record; // If not editing type, check record type - if (!defaultTypeId && !customTypeId && id) { - record = await AnimalOrBatchModel[animalOrBatchKey] - .query() - .findById(id) - .where({ farm_id }) - .whereNotDeleted(); - defaultTypeId = record.default_type_id; - customTypeId = record.custom_type_id; + if (!defaultTypeId && !customTypeId && animalOrBatchRecord) { + defaultTypeId = animalOrBatchRecord.default_type_id; + customTypeId = animalOrBatchRecord.custom_type_id; } if (customBreed.default_type_id && customBreed.default_type_id !== defaultTypeId) { @@ -189,12 +175,11 @@ const checkCustomBreedMatchesType = async ( } }; -const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) => { +const checksIfBreedProvided = async (animalOrBatch, farm_id, animalOrBatchRecord = undefined) => { const { default_breed_id, custom_breed_id, breed_name, - id, default_type_id, custom_type_id, } = animalOrBatch; @@ -202,13 +187,7 @@ const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) = checkExactlyOneAnimalBreedProvided(default_breed_id, custom_breed_id, breed_name); } if (default_breed_id) { - await checkDefaultBreedMatchesType( - animalOrBatchKey, - farm_id, - id, - default_breed_id, - default_type_id, - ); + await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); } if (custom_breed_id) { const customBreed = await CustomAnimalBreedModel.query() @@ -220,9 +199,7 @@ const checksIfBreedProvided = async (animalOrBatch, animalOrBatchKey, farm_id) = } await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); await checkCustomBreedMatchesType( - animalOrBatchKey, - farm_id, - id, + animalOrBatchRecord, customBreed, default_type_id, custom_type_id, @@ -305,16 +282,12 @@ const checkRemovalDataProvided = (animalOrBatch) => { } }; -const checkIfRecordExists = async (animalOrBatch, animalOrBatchKey, invalidIds, farm_id) => { - const animalOrBatchRecord = await AnimalOrBatchModel[animalOrBatchKey] +const getRecordIfExists = async (animalOrBatch, animalOrBatchKey, farm_id) => { + return await AnimalOrBatchModel[animalOrBatchKey] .query() .findById(animalOrBatch.id) .where({ farm_id }) .whereNotDeleted(); - - if (!animalOrBatchRecord) { - invalidIds.push(animalOrBatch.id); - } }; // Post loop checks @@ -369,7 +342,7 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { // also edit await checksIfTypeProvided(animalOrBatch, farm_id); - await checksIfBreedProvided(animalOrBatch, animalOrBatchKey, farm_id); + await checksIfBreedProvided(animalOrBatch, farm_id); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); @@ -410,15 +383,24 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { const invalidIds = []; for (const animalOrBatch of req.body) { - checkIdExistsAndIsNumber(animalOrBatch.id); - await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); + checkIdIsNumber(animalOrBatch.id); + const animalOrBatchRecord = await getRecordIfExists( + animalOrBatch, + animalOrBatchKey, + farm_id, + ); + if (!animalOrBatchRecord) { + invalidIds.push(animalOrBatch.id); + continue; + } await checksIfTypeProvided(animalOrBatch, farm_id, false); // nullTypesExistingOnRecord(); - await checksIfBreedProvided(animalOrBatch, animalOrBatchKey, farm_id); + await checksIfBreedProvided(animalOrBatch, farm_id, animalOrBatchRecord); // nullBreedsExistingOnRecord(); } + //TODO: should this error be actually in loop and not outside? await checkInvalidIds(invalidIds); next(); @@ -453,10 +435,20 @@ export function checkRemoveAnimalOrBatch(animalOrBatchKey) { checkRemovalDataProvided(animalOrBatch); // From Edit - checkIdExistsAndIsNumber(animalOrBatch.id); - await checkIfRecordExists(animalOrBatch, animalOrBatchKey, invalidIds, farm_id); + checkIdIsNumber(animalOrBatch.id); + const animalOrBatchRecord = await getRecordIfExists( + animalOrBatch, + animalOrBatchKey, + farm_id, + ); + if (!animalOrBatchRecord) { + invalidIds.push(animalOrBatch.id); + continue; + } + // No record should skip this loop } + //TODO: should this error be actually in loop and not outside? await checkInvalidIds(invalidIds); next(); } catch (error) { From a4310da7721a8489b82a85309eecdf9234f780ff Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 4 Oct 2024 15:22:40 -0400 Subject: [PATCH 20/45] LF-4380 Add sex detail check to edit and adapt logical check for existing record --- .../validation/checkAnimalOrBatch.js | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 212862538b..d3f497f39e 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -210,21 +210,31 @@ const checksIfBreedProvided = async (animalOrBatch, farm_id, animalOrBatchRecord } }; -const checkBatchSexDetail = async (animalOrBatch, animalOrBatchKey) => { +const checkBatchSexDetail = async ( + animalOrBatch, + animalOrBatchKey, + animalOrBatchRecord = undefined, +) => { if (animalOrBatchKey === 'batch') { - const { count, sex_detail } = animalOrBatch; - - if (sex_detail?.length) { + 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(); - sex_detail.forEach((detail) => { + 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 (sex_detail.length != sexIdSet.size) { + if (sexDetail.length != sexIdSet.size) { throw newCustomError('Duplicate sex ids in detail'); } } @@ -398,6 +408,7 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { // nullTypesExistingOnRecord(); await checksIfBreedProvided(animalOrBatch, farm_id, animalOrBatchRecord); // nullBreedsExistingOnRecord(); + await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); } //TODO: should this error be actually in loop and not outside? From f0b5bf2ad68585d9f1c9beb5c78e2ee22992906a Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Mon, 7 Oct 2024 11:00:36 -0400 Subject: [PATCH 21/45] LF-4380 Add use rleationship check to edit --- .../validation/checkAnimalOrBatch.js | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index d3f497f39e..a582a894ba 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -241,20 +241,23 @@ const checkBatchSexDetail = async ( } }; +const checkOtherUseRelationshipNotes = async (relationships) => { + const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); + + for (const relationship of relationships) { + if (relationship.use_id != otherUse.id && relationship.other_use) { + throw newCustomError('other_use notes is for other use type'); + } + } +}; + 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'); - } - } + checkOtherUseRelationshipNotes(animalOrBatch[relationshipsKey]); } }; @@ -350,10 +353,8 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { for (const animalOrBatch of req.body) { const { type_name, breed_name } = animalOrBatch; - // also edit await checksIfTypeProvided(animalOrBatch, farm_id); await checksIfBreedProvided(animalOrBatch, farm_id); - await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); @@ -409,6 +410,8 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { await checksIfBreedProvided(animalOrBatch, farm_id, animalOrBatchRecord); // nullBreedsExistingOnRecord(); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); + await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); + // Null other use if type changed } //TODO: should this error be actually in loop and not outside? From 3be3d5057fcee92a401d317d9e85b9e3dd318ae2 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 8 Oct 2024 12:17:29 -0400 Subject: [PATCH 22/45] LF-4380 Add null values to type and breed editing --- .../validation/checkAnimalOrBatch.js | 169 ++++++++++++------ 1 file changed, 114 insertions(+), 55 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index a582a894ba..7b20683a5d 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -34,6 +34,14 @@ const hasMultipleValues = (values) => { return !(nonNullValues.length === 1); }; +// Checks that at least one of the properties is defined +const oneExists = (values, object) => { + return !values.every((prop) => prop in object); +}; + +// Checks that at least one value is truthy +const oneTruthy = (values) => !values.every((value) => !value); + const checkIdIsNumber = (id) => { if (!id || isNaN(Number(id))) { throw newCustomError('Must send valid ids'); @@ -90,11 +98,15 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = }; // 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 checkExactlyOneIsProvided = (array, descriptiveErrorMessage) => { + if (oneTruthy(array) && hasMultipleValues(array)) { + throw newCustomError(`Exactly one of ${descriptiveErrorMessage} must be sent`); + } +}; + +const setFalsyValuesToNull = (array, obj) => { + for (const val of array) { + obj[val] = obj[val] || null; } }; @@ -105,29 +117,30 @@ const checkRecordBelongsToFarm = async (record, farm_id, descriptiveErrorMessage }; // For edit mode set required to false -const checksIfTypeProvided = async (animalOrBatch, farm_id, required = true) => { +const checkAnimalType = async (animalOrBatch, farm_id, creating = 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 typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; + // Skip if all undefined or !creating (editing) + if (creating || oneExists(typeKeyOptions, animalOrBatch)) { + checkExactlyOneIsProvided( + [default_type_id, custom_type_id, type_name], + 'default_type_id, custom_type_id, or type_name', ); + if (!creating) { + // Overwrite with null in db if editing + setFalsyValuesToNull(typeKeyOptions, animalOrBatch); + } + 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? + } } }; @@ -136,32 +149,43 @@ const checkDefaultBreedMatchesType = async ( default_breed_id, default_type_id, ) => { + // One of these two should exist at this point + let defaultBreedId = default_breed_id; let defaultTypeId = default_type_id; - // If not editing type, check record type + + // If breed or type is not changed get from record + if (!defaultBreedId && animalOrBatchRecord) { + defaultBreedId = animalOrBatchRecord.default_breed_id; + } if (!defaultTypeId && animalOrBatchRecord) { defaultTypeId = animalOrBatchRecord.default_type_id; } - if (defaultTypeId) { - const defaultBreed = await DefaultAnimalBreedModel.query().findById(default_breed_id); + + if (defaultTypeId && defaultBreedId) { + const defaultBreed = await DefaultAnimalBreedModel.query().findById(defaultBreedId); if (defaultBreed && defaultBreed.default_type_id !== defaultTypeId) { throw newCustomError('Breed does not match type'); } - } else { + } else if (!defaultTypeId) { // TODO: new error untested should prevents need for pre-existing checkDefaultBreedDoesNotUseCustomType throw newCustomError('Default breed must use default type'); } }; const checkCustomBreedMatchesType = async ( + animalOrBatch, animalOrBatchRecord, customBreed, default_type_id, custom_type_id, ) => { + // customBreed exists at this point let defaultTypeId = default_type_id; let customTypeId = custom_type_id; + const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; + // If not editing type, check record type - if (!defaultTypeId && !customTypeId && animalOrBatchRecord) { + if (!oneExists(typeKeyOptions, animalOrBatch) && animalOrBatchRecord) { defaultTypeId = animalOrBatchRecord.default_type_id; customTypeId = animalOrBatchRecord.custom_type_id; } @@ -175,7 +199,12 @@ const checkCustomBreedMatchesType = async ( } }; -const checksIfBreedProvided = async (animalOrBatch, farm_id, animalOrBatchRecord = undefined) => { +const checkAnimalBreed = async ( + animalOrBatch, + farm_id, + animalOrBatchRecord = undefined, + creating = true, +) => { const { default_breed_id, custom_breed_id, @@ -183,27 +212,57 @@ const checksIfBreedProvided = async (animalOrBatch, farm_id, animalOrBatchRecord 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); + const breedKeyOptions = ['default_breed_id', 'custom_breed_id', 'breed_name']; + const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; + // Skip if all undefined + if (oneExists(breedKeyOptions, animalOrBatch)) { + checkExactlyOneIsProvided( + [default_breed_id, custom_breed_id, breed_name], + 'default_breed_id, custom_breed_id, or breed_name', + ); + if (!creating) { + // Overwrite with null in db if editing + setFalsyValuesToNull(breedKeyOptions, animalOrBatch); + } } - if (default_breed_id) { + // Check if default breed or default type is present + if ( + (oneExists(breedKeyOptions, animalOrBatch) && default_breed_id) || + (oneExists(typeKeyOptions, animalOrBatch) && default_type_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'); + // Check if custom breed or custom type is present + if ( + (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) || + (oneExists(typeKeyOptions, animalOrBatch) && (default_type_id || custom_type_id)) + ) { + let customBreed; + // Find customBreed if exists + if (custom_breed_id) { + customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(custom_breed_id); + if (!customBreed) { + // TODO : new error add test + throw newCustomError('Custom breed does not exist'); + } + } else if (animalOrBatchRecord?.custom_breed_id) { + customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(animalOrBatchRecord.custom_breed_id); + } + // Check custom breed if exists + if (customBreed) { + await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); + await checkCustomBreedMatchesType( + animalOrBatch, + animalOrBatchRecord, + customBreed, + default_type_id, + custom_type_id, + ); } - 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? @@ -353,8 +412,8 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { for (const animalOrBatch of req.body) { const { type_name, breed_name } = animalOrBatch; - await checksIfTypeProvided(animalOrBatch, farm_id); - await checksIfBreedProvided(animalOrBatch, farm_id); + await checkAnimalType(animalOrBatch, farm_id); + await checkAnimalBreed(animalOrBatch, farm_id); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); @@ -405,13 +464,13 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { continue; } - await checksIfTypeProvided(animalOrBatch, farm_id, false); - // nullTypesExistingOnRecord(); - await checksIfBreedProvided(animalOrBatch, farm_id, animalOrBatchRecord); - // nullBreedsExistingOnRecord(); + await checkAnimalType(animalOrBatch, farm_id, false); + await checkAnimalBreed(animalOrBatch, farm_id, animalOrBatchRecord, false); + await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); // Null other use if type changed + // Null brought in date if origin_id changes from brought in } //TODO: should this error be actually in loop and not outside? From be1867152d74dad9ba7e74f111784afdcc5fa128 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 8 Oct 2024 14:19:38 -0400 Subject: [PATCH 23/45] LF-4380 Add animal origin check and fix logical checks --- .../validation/checkAnimalOrBatch.js | 24 ++++++++++++++++--- packages/api/tests/animal_origin.test.js | 4 +--- packages/api/tests/mock.factories.js | 4 ++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 7b20683a5d..50fa003bf4 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -22,6 +22,7 @@ import CustomAnimalTypeModel from '../../models/customAnimalTypeModel.js'; import DefaultAnimalBreedModel from '../../models/defaultAnimalBreedModel.js'; import CustomAnimalBreedModel from '../../models/customAnimalBreedModel.js'; import AnimalUseModel from '../../models/animalUseModel.js'; +import AnimalOriginModel from '../../models/animalOriginModel.js'; const AnimalOrBatchModel = { animal: AnimalModel, @@ -36,11 +37,11 @@ const hasMultipleValues = (values) => { // Checks that at least one of the properties is defined const oneExists = (values, object) => { - return !values.every((prop) => prop in object); + return values.some((prop) => prop in object); }; // Checks that at least one value is truthy -const oneTruthy = (values) => !values.every((value) => !value); +const oneTruthy = (values) => values.some((value) => !!value); const checkIdIsNumber = (id) => { if (!id || isNaN(Number(id))) { @@ -304,6 +305,7 @@ const checkOtherUseRelationshipNotes = async (relationships) => { const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); for (const relationship of relationships) { + // TODO: Add test what happens when editing use (must all pre-existing be provided?) if (relationship.use_id != otherUse.id && relationship.other_use) { throw newCustomError('other_use notes is for other use type'); } @@ -320,6 +322,21 @@ const checkAnimalUseRelationship = async (animalOrBatch, animalOrBatchKey) => { } }; +const checkAnimalOrigin = async (animalOrBatch, creating = true) => { + const { origin_id, brought_in_date } = animalOrBatch; + if (oneExists(['origin_id', 'brought_in_date'], animalOrBatch)) { + if (!creating) { + // Overwrite with null in db if editing + setFalsyValuesToNull(['origin_id', 'brought_in_date'], animalOrBatch); + } + const broughtInOrigin = await AnimalOriginModel.query().where({ key: 'BROUGHT_IN' }).first(); + if (origin_id != broughtInOrigin.id && brought_in_date) { + // TODO: Add new test supplying either origin or brought in date + throw newCustomError('Brought in date must be used with brought in origin'); + } + } +}; + const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet) => { const { type_name, @@ -416,6 +433,7 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { await checkAnimalBreed(animalOrBatch, farm_id); await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); + await checkAnimalOrigin(animalOrBatch); // Skip the process if type_name and breed_name are not passed if (!type_name && !breed_name) { @@ -469,8 +487,8 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); - // Null other use if type changed // Null brought in date if origin_id changes from brought in + await checkAnimalOrigin(animalOrBatch, false); } //TODO: should this error be actually in loop and not outside? diff --git a/packages/api/tests/animal_origin.test.js b/packages/api/tests/animal_origin.test.js index 1f3773d4ec..c6cd5ee38f 100644 --- a/packages/api/tests/animal_origin.test.js +++ b/packages/api/tests/animal_origin.test.js @@ -67,9 +67,7 @@ describe('Animal Origin Tests', () => { } async function makeAnimalOrigin(properties) { - const [animalOrigin] = await mocks.animal_originFactory({ - properties, - }); + const [animalOrigin] = await mocks.animal_originFactory(); return animalOrigin; } diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 99720506d9..71a8df1397 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2411,8 +2411,8 @@ async function animal_sexFactory() { return knex('animal_sex').insert({ key: faker.lorem.word() }).returning('*'); } -async function animal_originFactory() { - return knex('animal_origin').insert({ key: faker.lorem.word() }).returning('*'); +async function animal_originFactory(key = 'BROUGHT_IN') { + return knex('animal_origin').insert({ key }).returning('*'); } function fakeAnimalGroup(defaultData = {}) { From 9b5219352e86c89c139b58e86dcb0fb47eb74f89 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Tue, 8 Oct 2024 15:16:01 -0400 Subject: [PATCH 24/45] LF-4380 Update comments --- .../api/src/middleware/validation/checkAnimalOrBatch.js | 2 -- packages/api/tests/animal.test.js | 6 +++--- packages/api/tests/animal_batch.test.js | 6 +++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 50fa003bf4..4418e395b9 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -484,10 +484,8 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { await checkAnimalType(animalOrBatch, farm_id, false); await checkAnimalBreed(animalOrBatch, farm_id, animalOrBatchRecord, false); - await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); - // Null brought in date if origin_id changes from brought in await checkAnimalOrigin(animalOrBatch, false); } diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index f14d64ba73..fea3cfb456 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -786,7 +786,7 @@ describe('Animal Tests', () => { // Make edits to animals - does not test all top level animal columns, but all relationships const updatedFirstAnimal = mocks.fakeAnimal({ - // should fail + // Extra properties are silently removed extra_non_existant_property: 'hello', id: returnedFirstAnimal.id, default_type_id: defaultTypeId, @@ -796,7 +796,7 @@ describe('Animal Tests', () => { identifier: '2', identifier_color_id: animalIdentifierColor.id, origin_id: animalOrigin.id, - // should fail + // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, identifier_type_id: animalIdentifierType.id, organic_status: 'Organic', @@ -812,7 +812,7 @@ describe('Animal Tests', () => { identifier: '2', identifier_color_id: animalIdentifierColor.id, origin_id: animalOrigin.id, - // should fail + // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, identifier_type_id: animalIdentifierType.id, organic_status: 'Organic', diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index 2c2528040c..9afeaf1b78 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -765,7 +765,7 @@ describe('Animal Batch Tests', () => { // Make edits to batches - does not test all top level batch columns, but all relationships const updatedFirstBatch = mocks.fakeAnimalBatch({ - // should fail + // Extra properties are silently removed extra_non_existant_property: 'hello', id: returnedFirstBatch.id, default_type_id: defaultTypeId, @@ -787,7 +787,7 @@ describe('Animal Batch Tests', () => { ], count: 5, origin_id: animalOrigin.id, - // should fail + // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', animal_batch_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], @@ -810,7 +810,7 @@ describe('Animal Batch Tests', () => { ], count: 5, origin_id: animalOrigin.id, - // should fail + // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', animal_batch_use_relationships: [{ use_id: animalUse2.id }, { use_id: animalUse3.id }], From 49656b7959c67a049938a1a72d72a375bed29ecc Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 9 Oct 2024 19:57:31 -0400 Subject: [PATCH 25/45] LF-4380 Move out generic utilities to their own files --- .../validation/checkAnimalOrBatch.js | 96 +++++-------------- packages/api/src/util/customErrors.js | 35 +++++++ packages/api/src/util/middleware.js | 21 ++++ 3 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 packages/api/src/util/customErrors.js create mode 100644 packages/api/src/util/middleware.js diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 4418e395b9..652eff2955 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -15,6 +15,14 @@ import { Model, transaction } from 'objection'; import { handleObjectionError } from '../../util/errorCodes.js'; +import { oneExists, setFalsyValuesToNull } from '../../util/middleware.js'; +import { + customError, + checkIsArray, + checkIdIsNumber, + checkExactlyOneIsProvided, + checkRecordBelongsToFarm, +} from '../../util/customErrors.js'; import AnimalModel from '../../models/animalModel.js'; import AnimalBatchModel from '../../models/animalBatchModel.js'; @@ -29,44 +37,9 @@ const AnimalOrBatchModel = { batch: AnimalBatchModel, }; -// Utils -const hasMultipleValues = (values) => { - const nonNullValues = values.filter(Boolean); - return !(nonNullValues.length === 1); -}; - -// Checks that at least one of the properties is defined -const oneExists = (values, object) => { - return values.some((prop) => prop in object); -}; - -// Checks that at least one value is truthy -const oneTruthy = (values) => values.some((value) => !!value); - -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'); + throw customError('Must send ids'); } const idsSet = new Set(ids.split(',')); @@ -90,7 +63,7 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = } if (invalidIds.length) { - throw newCustomError( + throw customError( 'Some entities do not exist, are already deleted, or are not associated with the given farm.', 400, { error: 'Invalid ids', invalidIds }, @@ -98,25 +71,6 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = } }; -// AnimalOrBatch checks -const checkExactlyOneIsProvided = (array, descriptiveErrorMessage) => { - if (oneTruthy(array) && hasMultipleValues(array)) { - throw newCustomError(`Exactly one of ${descriptiveErrorMessage} must be sent`); - } -}; - -const setFalsyValuesToNull = (array, obj) => { - for (const val of array) { - obj[val] = obj[val] || null; - } -}; - -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 checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; @@ -135,7 +89,7 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); if (!customType) { // TODO: new error add test - throw newCustomError('Custom type does not exist'); + throw customError('Custom type does not exist'); } await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); } @@ -165,11 +119,11 @@ const checkDefaultBreedMatchesType = async ( if (defaultTypeId && defaultBreedId) { const defaultBreed = await DefaultAnimalBreedModel.query().findById(defaultBreedId); if (defaultBreed && defaultBreed.default_type_id !== defaultTypeId) { - throw newCustomError('Breed does not match type'); + throw customError('Breed does not match type'); } } else if (!defaultTypeId) { // TODO: new error untested should prevents need for pre-existing checkDefaultBreedDoesNotUseCustomType - throw newCustomError('Default breed must use default type'); + throw customError('Default breed must use default type'); } }; @@ -192,11 +146,11 @@ const checkCustomBreedMatchesType = async ( } if (customBreed.default_type_id && customBreed.default_type_id !== defaultTypeId) { - throw newCustomError('Breed does not match type'); + throw customError('Breed does not match type'); } if (customBreed.custom_type_id && customBreed.custom_type_id !== customTypeId) { - throw newCustomError('Breed does not match type'); + throw customError('Breed does not match type'); } }; @@ -246,7 +200,7 @@ const checkAnimalBreed = async ( .findById(custom_breed_id); if (!customBreed) { // TODO : new error add test - throw newCustomError('Custom breed does not exist'); + throw customError('Custom breed does not exist'); } } else if (animalOrBatchRecord?.custom_breed_id) { customBreed = await CustomAnimalBreedModel.query() @@ -292,10 +246,10 @@ const checkBatchSexDetail = async ( sexIdSet.add(detail.sex_id); }); if (sexCount > count) { - throw newCustomError('Batch count must be greater than or equal to sex detail count'); + throw customError('Batch count must be greater than or equal to sex detail count'); } if (sexDetail.length != sexIdSet.size) { - throw newCustomError('Duplicate sex ids in detail'); + throw customError('Duplicate sex ids in detail'); } } } @@ -307,7 +261,7 @@ const checkOtherUseRelationshipNotes = async (relationships) => { for (const relationship of relationships) { // TODO: Add test what happens when editing use (must all pre-existing be provided?) if (relationship.use_id != otherUse.id && relationship.other_use) { - throw newCustomError('other_use notes is for other use type'); + throw customError('other_use notes is for other use type'); } } }; @@ -332,7 +286,7 @@ const checkAnimalOrigin = async (animalOrBatch, creating = true) => { const broughtInOrigin = await AnimalOriginModel.query().where({ key: 'BROUGHT_IN' }).first(); if (origin_id != broughtInOrigin.id && brought_in_date) { // TODO: Add new test supplying either origin or brought in date - throw newCustomError('Brought in date must be used with brought in origin'); + throw customError('Brought in date must be used with brought in origin'); } } }; @@ -348,7 +302,7 @@ const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet } = animalOrBatch; if (type_name) { if (default_breed_id || custom_breed_id) { - throw newCustomError('Cannot create a new type associated with an existing breed'); + throw customError('Cannot create a new type associated with an existing breed'); } newTypesSet.add(type_name); } @@ -367,7 +321,7 @@ const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet 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'); + throw customError('Must send reason and date of removal'); } }; @@ -389,7 +343,7 @@ const checkCustomTypeAndBreedConflicts = async (newTypesSet, newBreedsSet, farm_ ); if (record.length) { - throw newCustomError('Animal type already exists', 409); + throw customError('Animal type already exists', 409); } } @@ -402,14 +356,14 @@ const checkCustomTypeAndBreedConflicts = async (newTypesSet, newBreedsSet, farm_ ); if (record.length) { - throw newCustomError('Animal breed already exists', 409); + throw customError('Animal breed already exists', 409); } } }; const checkInvalidIds = async (invalidIds) => { if (invalidIds.length) { - throw newCustomError( + throw customError( 'Some animals or batches do not exist or are not associated with the given farm.', 400, { error: 'Invalid ids', invalidIds }, diff --git a/packages/api/src/util/customErrors.js b/packages/api/src/util/customErrors.js new file mode 100644 index 0000000000..98ad582048 --- /dev/null +++ b/packages/api/src/util/customErrors.js @@ -0,0 +1,35 @@ +import { oneTruthy, hasMultipleValues } from './middleware.js'; + +// Constructs a reusable error object +export const customError = (message, code = 400, body = undefined) => { + const error = new Error(message); + error.code = code; + error.body = body; + error.type = 'LiteFarmCustom'; + return error; +}; + +export const checkIsArray = (someValue, errorText = '') => { + if (!Array.isArray(someValue)) { + throw customError(`${errorText} should be an array`); + } +}; + +export const checkIdIsNumber = (id) => { + if (!id || isNaN(Number(id))) { + throw customError('Must send valid ids'); + } +}; + +export const checkExactlyOneIsProvided = (values, errorText) => { + if (oneTruthy(values) && hasMultipleValues(values)) { + throw customError(`Exactly one of ${errorText} must be sent`); + } +}; + +// This checks if the record belongs to the farm -- hasFarmAccess does not handle collections (bulk endpoints) +export const checkRecordBelongsToFarm = async (record, farm_id, errorText) => { + if (record && record.farm_id !== farm_id) { + throw customError(`Forbidden ${errorText} does not belong to this farm`, 403); + } +}; diff --git a/packages/api/src/util/middleware.js b/packages/api/src/util/middleware.js new file mode 100644 index 0000000000..6ec0dec5ab --- /dev/null +++ b/packages/api/src/util/middleware.js @@ -0,0 +1,21 @@ +// Utils +// Checks an array has more than one truthy value +export const hasMultipleValues = (values) => { + const nonNullValues = values.filter(Boolean); + return nonNullValues.length > 1; +}; + +// Checks an array of object keys against object -- at least one of the properties is defined +export const oneExists = (keys, object) => { + return keys.some((key) => key in object); +}; + +// Checks an array for at least one truthy value +export const oneTruthy = (values) => values.some((value) => !!value); + +// Sets falsy values to null for editing values that may have values for exclusive constraints -- does not handle zeros yet +export const setFalsyValuesToNull = (array, obj) => { + for (const val of array) { + obj[val] = obj[val] || null; + } +}; From 3b18ebcfe6a0826ee4644d38c2180817631b2326 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 10 Oct 2024 19:26:24 -0400 Subject: [PATCH 26/45] LF-4380 Absolute freaking mystery to me why this happens... Plain patches '{ id, default_type_id }' were resulting in an not-null contraint failures on organic_status. Commenting out organic_status from the basecontroller.upsertGraph data portion fixes the problem -- but why? - organic_status was not defined ever! (not necessary on patch, default value on post) - the model should not validate patch the same as post (tried different setting on upsert graph and default on model) - the model could not possibly validate anything until after removeAdditionalProperties() runs which also results in organic_status property not being present -- not defined This commit corrects the problem, but I really cant see why it is happening. --- .../src/controllers/animalBatchController.js | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 229eb52999..323a2d5a59 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -19,6 +19,7 @@ import baseController from './baseController.js'; import { handleObjectionError } from '../util/errorCodes.js'; import { assignInternalIdentifiers, checkAndAddCustomTypeAndBreed } from '../util/animal.js'; import { uploadPublicImage } from '../util/imageUpload.js'; +import _pick from 'lodash/pick.js'; const animalBatchController = { getFarmAnimalBatches() { @@ -116,48 +117,28 @@ const animalBatchController = { // 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 }, - ); + const desiredKeys = [ + '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', + ]; + const keysExisting = desiredKeys.filter((key) => key in animalBatch); + const data = _pick(animalBatch, keysExisting); + + await baseController.upsertGraph(AnimalBatchModel, data, req, { trx }); } // delete utility objects From 77fa4a7c1363944ada667e26f8a35a02ba212d78 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 11 Oct 2024 18:12:02 -0400 Subject: [PATCH 27/45] LF-4380 WIP test coverage up till sex and count - sex and count failing --- .../validation/checkAnimalOrBatch.js | 17 +- packages/api/tests/animal_batch.test.js | 436 ++++++++++++++++++ packages/api/tests/mock.factories.js | 15 +- 3 files changed, 459 insertions(+), 9 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 652eff2955..fc5c602f95 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -86,9 +86,9 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { setFalsyValuesToNull(typeKeyOptions, animalOrBatch); } if (custom_type_id) { + checkIdIsNumber(custom_type_id); const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); if (!customType) { - // TODO: new error add test throw customError('Custom type does not exist'); } await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); @@ -117,8 +117,12 @@ const checkDefaultBreedMatchesType = async ( } if (defaultTypeId && defaultBreedId) { + checkIdIsNumber(defaultBreedId); const defaultBreed = await DefaultAnimalBreedModel.query().findById(defaultBreedId); - if (defaultBreed && defaultBreed.default_type_id !== defaultTypeId) { + if (!defaultBreed) { + throw customError('Default breed does not exist'); + } + if (defaultBreed.default_type_id !== defaultTypeId) { throw customError('Breed does not match type'); } } else if (!defaultTypeId) { @@ -194,18 +198,23 @@ const checkAnimalBreed = async ( ) { let customBreed; // Find customBreed if exists - if (custom_breed_id) { + if (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) { + checkIdIsNumber(custom_breed_id); customBreed = await CustomAnimalBreedModel.query() .whereNotDeleted() .findById(custom_breed_id); if (!customBreed) { - // TODO : new error add test throw customError('Custom breed does not exist'); } } else if (animalOrBatchRecord?.custom_breed_id) { + checkIdIsNumber(animalOrBatchRecord?.custom_breed_id); customBreed = await CustomAnimalBreedModel.query() .whereNotDeleted() .findById(animalOrBatchRecord.custom_breed_id); + if (!customBreed) { + // This should not be possible + throw customError('Custom breed does not exist'); + } } // Check custom breed if exists if (customBreed) { diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index 9afeaf1b78..54443d113a 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -60,6 +60,7 @@ describe('Animal Batch Tests', () => { return await chai .request(server) .get('/animal_batches') + .set('Content-Type', 'application/json') .set('user_id', user_id) .set('farm_id', farm_id); } @@ -696,6 +697,8 @@ describe('Animal Batch Tests', () => { let animalUse1; let animalUse2; let animalUse3; + let animalBreed; + let animalBreed2; beforeEach(async () => { [animalGroup1] = await mocks.animal_groupFactory(); @@ -710,6 +713,8 @@ describe('Animal Batch Tests', () => { [animalUse1] = await mocks.animal_useFactory('OTHER'); [animalUse2] = await mocks.animal_useFactory(); [animalUse3] = await mocks.animal_useFactory(); + [animalBreed] = await mocks.default_animal_breedFactory(); + [animalBreed2] = await mocks.default_animal_breedFactory(); }); async function addAnimalBatches(mainFarm, user) { @@ -988,6 +993,437 @@ describe('Animal Batch Tests', () => { const batchRecord = await AnimalBatchModel.query().findById(batch.id); expect(batchRecord.sire).toBeNull(); }); + + const customErrors = [ + { + testName: 'Exactly one type provided', + getPatchBody: (batch) => [ + { + id: batch.id, + default_type_id: batch.default_type_id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_type_id, custom_type_id, or type_name must be sent', + }, + }, + { + testName: 'Custom type id is number', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_type_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Custom id exists', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_type_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom type does not exist', + }, + }, + { + testName: 'Custom type does not belong to farm', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: batch.id, + custom_type_id: customs.otherFarm.otherCustomAnimalType.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom type does not belong to this farm', + }, + }, + { + testName: 'Exactly one breed provided', + getPatchBody: (batch) => [ + { + id: batch.id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + breed_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_breed_id, custom_breed_id, or breed_name must be sent', + }, + }, + { + testName: 'Default type matches default breed -- default type is changed', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed2.default_type_id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches default breed -- default breed is changed', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default breed is a number', + getPatchBody: (batch) => [ + { + id: batch.id, + default_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Default breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + default_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Default breed does not exist', + }, + }, + { + testName: 'Default type matches default breed -- both are changed but mismatch', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Custom type cannot be used with default breed', + getPostBody: (customs) => [ + { + custom_type_id: customs.customAnimalType.id, + default_breed_id: animalBreed.id, + }, + ], + postErr: { + code: 400, + message: 'Default breed must use default type', + }, + }, + { + testName: 'Custom breed is a number', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', + }, + }, + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', + }, + }, + { + testName: 'Custom breed does not belong to farm', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: batch.id, + custom_breed_id: customs.otherFarm.otherCustomAnimalBreed.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom breed does not belong to this farm', + }, + }, + { + testName: 'Default type matches custom breed -- default type is changed', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed.default_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches custom breed -- custom type is changed', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + custom_type_id: customs.customAnimalBreed2.custom_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches custom breed -- breed and type are changed', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed2.id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Check create batch sex detail', + getPostBody: (customs) => [ + { + count: 3, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + postErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', + }, + }, + { + testName: 'Check create batch sex detail -- change count', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + count: 3, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', + }, + }, + { + testName: 'Check create batch sex detail -- change sex_detail', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + count: 3, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', + }, + }, + ]; + + customErrors.forEach(async (error) => { + await test(`CustomError: ${error.testName}`, async () => { + const { mainFarm, user } = await returnUserFarms(1); + const { mainFarm: otherFarm } = await returnUserFarms(1); + const batch = await makeAnimalBatch(mainFarm, { + default_type_id: defaultTypeId, + }); + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + const [customAnimalBreed] = await mocks.custom_animal_breedFactory( + { + promisedFarm: [mainFarm], + }, + undefined, + false, + ); + const [customAnimalBreed2] = await mocks.custom_animal_breedFactory({ + promisedFarm: [mainFarm], + }); + const [otherCustomAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [otherFarm], + }); + const [otherCustomAnimalBreed] = await mocks.custom_animal_breedFactory({ + promisedFarm: [otherFarm], + }); + const customs = { + customAnimalType, + customAnimalBreed, + customAnimalBreed2, + otherFarm: { otherCustomAnimalType, otherCustomAnimalBreed }, + }; + const makeCheckGetBatch = async (getPostBody) => { + // Default type matches default breed + const batches = getPostBody(customs).map((batch) => mocks.fakeAnimalBatch(batch)); + const postRes = await postRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...batches], + ); + console.log(error.testName); + console.log(postRes); + expect(postRes.status).toBe(error.postErr?.code || 201); + expect(postRes.error.text).toBe(error.postErr?.message || undefined); + return postRes.body; + }; + + const existingBatches = error.getPostBody + ? await makeCheckGetBatch(error.getPostBody) + : undefined; + + const editCheckBatch = async (getPatchBody) => { + const batches = getPatchBody(batch, existingBatches, customs).map((batch) => + mocks.fakeAnimalBatch(batch), + ); + const patchRes = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...batches], + ); + expect(patchRes.status).toBe(error.patchErr?.code || 204); + expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); + }; + if (error.getPatchBody) { + await editCheckBatch(error.getPatchBody); + } + }); + }); }); // REMOVE tests diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 71a8df1397..8fcc3ca629 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2281,22 +2281,27 @@ function fakeCustomAnimalBreed(defaultData = {}) { async function custom_animal_breedFactory( { promisedFarm = farmFactory(), - promisedAnimalType = custom_animal_typeFactory({ promisedFarm }), + promisedCustomAnimalType = custom_animal_typeFactory({ promisedFarm }), + promisedDefaultAnimalType = default_animal_typeFactory({ promisedFarm }), properties = {}, } = {}, animalBreed = fakeCustomAnimalBreed(properties), + customType = true, ) { - const [farm, user, animalType] = await Promise.all([ + const [farm, user, customAnimalType, defaultAnimalType] = await Promise.all([ promisedFarm, usersFactory(), - promisedAnimalType, + promisedCustomAnimalType, + promisedDefaultAnimalType, ]); const [{ farm_id }] = farm; const [{ user_id }] = user; - const [{ id: custom_type_id }] = animalType; + const [{ id: custom_type_id }] = customAnimalType; + const [{ id: default_type_id }] = defaultAnimalType; + const type = customType ? { custom_type_id } : { default_type_id }; const base = baseProperties(user_id); return knex('custom_animal_breed') - .insert({ farm_id, custom_type_id, ...animalBreed, ...base }) + .insert({ farm_id, ...type, ...animalBreed, ...base }) .returning('*'); } From 9954d5f0e942392e2fdaa504d663a9d8f34807da Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 14:11:00 -0400 Subject: [PATCH 28/45] LF-4380 Cleanup comments, small fixes based on testing, augment type_name and breed_name tests for edit --- .../validation/checkAnimalOrBatch.js | 114 +++++++++++------- 1 file changed, 72 insertions(+), 42 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index fc5c602f95..81bd034c24 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -71,11 +71,11 @@ const checkValidAnimalOrBatchIds = async (animalOrBatchKey, ids, farm_id, trx) = } }; -// For edit mode set required to false +// For edit mode set creating to false const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; - // Skip if all undefined or !creating (editing) + // Skip if all undefined or editing (!creating) if (creating || oneExists(typeKeyOptions, animalOrBatch)) { checkExactlyOneIsProvided( [default_type_id, custom_type_id, type_name], @@ -93,9 +93,6 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { } await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); } - if (type_name) { - // TODO: Check type_name does not already exist or replace custom type id? - } } }; @@ -126,12 +123,11 @@ const checkDefaultBreedMatchesType = async ( throw customError('Breed does not match type'); } } else if (!defaultTypeId) { - // TODO: new error untested should prevents need for pre-existing checkDefaultBreedDoesNotUseCustomType throw customError('Default breed must use default type'); } }; -const checkCustomBreedMatchesType = async ( +const checkCustomBreedMatchesType = ( animalOrBatch, animalOrBatchRecord, customBreed, @@ -149,11 +145,11 @@ const checkCustomBreedMatchesType = async ( customTypeId = animalOrBatchRecord.custom_type_id; } - if (customBreed.default_type_id && customBreed.default_type_id !== defaultTypeId) { - throw customError('Breed does not match type'); - } - - if (customBreed.custom_type_id && customBreed.custom_type_id !== customTypeId) { + // Custom breed does not match type if defaultId OR customTypeId does not match + if ( + (customBreed.default_type_id && customBreed.default_type_id !== defaultTypeId) || + (customBreed.custom_type_id && customBreed.custom_type_id !== customTypeId) + ) { throw customError('Breed does not match type'); } }; @@ -173,7 +169,7 @@ const checkAnimalBreed = async ( } = animalOrBatch; const breedKeyOptions = ['default_breed_id', 'custom_breed_id', 'breed_name']; const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; - // Skip if all undefined + // Check if breed is present if (oneExists(breedKeyOptions, animalOrBatch)) { checkExactlyOneIsProvided( [default_breed_id, custom_breed_id, breed_name], @@ -219,7 +215,7 @@ const checkAnimalBreed = async ( // Check custom breed if exists if (customBreed) { await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); - await checkCustomBreedMatchesType( + checkCustomBreedMatchesType( animalOrBatch, animalOrBatchRecord, customBreed, @@ -228,9 +224,6 @@ const checkAnimalBreed = async ( ); } } - if (breed_name) { - // TODO: Check breed_name does not already exist or replace custom type id? - } }; const checkBatchSexDetail = async ( @@ -241,10 +234,10 @@ const checkBatchSexDetail = async ( if (animalOrBatchKey === 'batch') { let count = animalOrBatch.count; let sexDetail = animalOrBatch.sex_detail; - if (!count) { + if (!count && animalOrBatchRecord) { count = animalOrBatchRecord.count; } - if (!sexDetail) { + if (!sexDetail && animalOrBatchRecord) { sexDetail = animalOrBatchRecord.sex_detail; } if (sexDetail?.length) { @@ -268,7 +261,6 @@ const checkOtherUseRelationshipNotes = async (relationships) => { const otherUse = await AnimalUseModel.query().where({ key: 'OTHER' }).first(); for (const relationship of relationships) { - // TODO: Add test what happens when editing use (must all pre-existing be provided?) if (relationship.use_id != otherUse.id && relationship.other_use) { throw customError('other_use notes is for other use type'); } @@ -281,26 +273,31 @@ const checkAnimalUseRelationship = async (animalOrBatch, animalOrBatchKey) => { if (animalOrBatch[relationshipsKey]) { checkIsArray(animalOrBatch[relationshipsKey], relationshipsKey); - checkOtherUseRelationshipNotes(animalOrBatch[relationshipsKey]); + await checkOtherUseRelationshipNotes(animalOrBatch[relationshipsKey]); } }; const checkAnimalOrigin = async (animalOrBatch, creating = true) => { const { origin_id, brought_in_date } = animalOrBatch; if (oneExists(['origin_id', 'brought_in_date'], animalOrBatch)) { - if (!creating) { - // Overwrite with null in db if editing - setFalsyValuesToNull(['origin_id', 'brought_in_date'], animalOrBatch); - } const broughtInOrigin = await AnimalOriginModel.query().where({ key: 'BROUGHT_IN' }).first(); + // Overwrite date with null in db if editing origin_id + if (!creating && origin_id != broughtInOrigin.id) { + setFalsyValuesToNull(['brought_in_date'], animalOrBatch); + } + if (origin_id != broughtInOrigin.id && brought_in_date) { - // TODO: Add new test supplying either origin or brought in date throw customError('Brought in date must be used with brought in origin'); } } }; -const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet) => { +const checkAndAddCustomTypesOrBreeds = ( + animalOrBatch, + newTypesSet, + newBreedsSet, + animalOrBatchRecord = undefined, +) => { const { type_name, breed_name, @@ -310,7 +307,15 @@ const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet custom_breed_id, } = animalOrBatch; if (type_name) { - if (default_breed_id || custom_breed_id) { + let defaultBreedId = default_breed_id; + let customBreedId = custom_breed_id; + + if (!oneExists(['default_breed_id', 'custom_breed_id'], animalOrBatch) && animalOrBatchRecord) { + defaultBreedId = animalOrBatchRecord.default_breed_id; + customBreedId = animalOrBatchRecord.custom_breed_id; + } + + if (defaultBreedId || customBreedId) { throw customError('Cannot create a new type associated with an existing breed'); } newTypesSet.add(type_name); @@ -319,9 +324,17 @@ const checkAndAddCustomTypesOrBreeds = (animalOrBatch, newTypesSet, newBreedsSet // 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}`; + let defaultTypeId = default_type_id; + let customTypeId = custom_type_id; + + if (!oneExists(['default_type_id', 'custom_type_id'], animalOrBatch) && animalOrBatchRecord) { + defaultTypeId = animalOrBatchRecord.default_type_id; + customTypeId = animalOrBatchRecord.custom_type_id; + } + + const breedDetails = customTypeId + ? `custom_type_id/${customTypeId}/${breed_name}` + : `default_type_id/${defaultTypeId}/${breed_name}`; newBreedsSet.add(breedDetails); } @@ -339,7 +352,12 @@ const getRecordIfExists = async (animalOrBatch, animalOrBatchKey, farm_id) => { .query() .findById(animalOrBatch.id) .where({ farm_id }) - .whereNotDeleted(); + .whereNotDeleted() + .withGraphFetched({ + group_ids: true, + sex_detail: animalOrBatchKey === 'batch' ? true : false, + animal_batch_use_relationships: true, + }); }; // Post loop checks @@ -389,6 +407,8 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { const newTypesSet = new Set(); const newBreedsSet = new Set(); + checkIsArray(req.body, 'Request body'); + for (const animalOrBatch of req.body) { const { type_name, breed_name } = animalOrBatch; @@ -425,8 +445,11 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { export function checkEditAnimalOrBatch(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(); checkIsArray(req.body, 'Request body'); // Check that all animals exist and belong to the farm @@ -434,6 +457,7 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { const invalidIds = []; for (const animalOrBatch of req.body) { + const { type_name, breed_name } = animalOrBatch; checkIdIsNumber(animalOrBatch.id); const animalOrBatchRecord = await getRecordIfExists( animalOrBatch, @@ -450,23 +474,34 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); await checkAnimalOrigin(animalOrBatch, false); + + // Skip the process if type_name and breed_name are not passed + if (!type_name && !breed_name) { + continue; + } + checkAndAddCustomTypesOrBreeds( + animalOrBatch, + newTypesSet, + newBreedsSet, + animalOrBatchRecord, + ); } - //TODO: should this error be actually in loop and not outside? await checkInvalidIds(invalidIds); + 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({ ...error.body, message: error.message }) : res.status(error.code).send(error.message); } else { - console.error(error); - return res.status(500).json({ - error, - }); + handleObjectionError(error, res, trx); } } }; @@ -483,10 +518,8 @@ export function checkRemoveAnimalOrBatch(animalOrBatchKey) { const invalidIds = []; for (const animalOrBatch of req.body) { - // Removal specific checkRemovalDataProvided(animalOrBatch); - // From Edit checkIdIsNumber(animalOrBatch.id); const animalOrBatchRecord = await getRecordIfExists( animalOrBatch, @@ -495,12 +528,9 @@ export function checkRemoveAnimalOrBatch(animalOrBatchKey) { ); if (!animalOrBatchRecord) { invalidIds.push(animalOrBatch.id); - continue; } - // No record should skip this loop } - //TODO: should this error be actually in loop and not outside? await checkInvalidIds(invalidIds); next(); } catch (error) { From e17cbd04e54b5bdca0a275c0d7326e05275e377f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 14:12:58 -0400 Subject: [PATCH 29/45] LF-4380 Fix enum factories with specific db id constraints --- packages/api/tests/mock.factories.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 8fcc3ca629..b9a40d7a0c 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2416,8 +2416,10 @@ async function animal_sexFactory() { return knex('animal_sex').insert({ key: faker.lorem.word() }).returning('*'); } -async function animal_originFactory(key = 'BROUGHT_IN') { - return knex('animal_origin').insert({ key }).returning('*'); +async function animal_originFactory(key = faker.lorem.word()) { + return knex('animal_origin') + .insert({ key, id: key === 'BROUGHT_IN' ? 1 : undefined }) + .returning('*'); } function fakeAnimalGroup(defaultData = {}) { @@ -2483,7 +2485,9 @@ async function animal_removal_reasonFactory() { } async function animal_useFactory(key = faker.lorem.word()) { - return knex('animal_use').insert({ key }).returning('*'); + return knex('animal_use') + .insert({ key, id: key === 'OTHER' ? 10 : undefined }) + .returning('*'); } async function animal_type_use_relationshipFactory({ From 87fcbeaf33343b54c67237ac124aec682ed7d4d5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 14:17:37 -0400 Subject: [PATCH 30/45] LF-4380 Complete test coverage on animal_batches endpoint --- packages/api/tests/animal_batch.test.js | 403 ++++++++++++++++++++++-- 1 file changed, 382 insertions(+), 21 deletions(-) diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index 54443d113a..162c5d56ce 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -37,6 +37,8 @@ import { makeFarmsWithAnimalsAndBatches } from './utils/animalUtils.js'; import AnimalBatchModel from '../src/models/animalBatchModel.js'; import CustomAnimalTypeModel from '../src/models/customAnimalTypeModel.js'; import CustomAnimalBreedModel from '../src/models/customAnimalBreedModel.js'; +import AnimalBatchSexDetailModel from '../src/models/animalBatchSexDetailModel.js'; +import AnimalBatchUseRelationshipModel from '../src/models/animalBatchUseRelationshipModel.js'; describe('Animal Batch Tests', () => { let farm; @@ -327,7 +329,8 @@ describe('Animal Batch Tests', () => { animalBatch, ); - expect(res.status).toBe(500); + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); }); test('Unique internal_identifier should be added within the same farm_id between animals and animalBatches', async () => { @@ -692,7 +695,8 @@ describe('Animal Batch Tests', () => { let animalSex2; let animalIdentifierColor; let animalIdentifierType; - let animalOrigin; + let animalOrigin1; + let animalOrigin2; let animalRemovalReason; let animalUse1; let animalUse2; @@ -700,6 +704,11 @@ describe('Animal Batch Tests', () => { let animalBreed; let animalBreed2; + beforeAll(async () => { + [animalUse1] = await mocks.animal_useFactory('OTHER'); + [animalOrigin1] = await mocks.animal_originFactory('BROUGHT_IN'); + }); + beforeEach(async () => { [animalGroup1] = await mocks.animal_groupFactory(); [animalGroup2] = await mocks.animal_groupFactory(); @@ -708,9 +717,8 @@ describe('Animal Batch Tests', () => { [animalSex2] = await mocks.animal_sexFactory(); [animalIdentifierColor] = await mocks.animal_identifier_colorFactory(); [animalIdentifierType] = await mocks.animal_identifier_typeFactory(); - [animalOrigin] = await mocks.animal_originFactory(); + [animalOrigin2] = 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(); [animalBreed] = await mocks.default_animal_breedFactory(); @@ -791,7 +799,7 @@ describe('Animal Batch Tests', () => { }, ], count: 5, - origin_id: animalOrigin.id, + origin_id: animalOrigin1.id, // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', @@ -814,7 +822,7 @@ describe('Animal Batch Tests', () => { }, ], count: 5, - origin_id: animalOrigin.id, + origin_id: animalOrigin1.id, // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', @@ -1293,7 +1301,7 @@ describe('Animal Batch Tests', () => { }, }, { - testName: 'Check create batch sex detail -- change count', + testName: 'Check edit batch sex detail -- change count', getPatchBody: (batch, existingBatches) => [ { id: existingBatches[0].id, @@ -1322,11 +1330,20 @@ describe('Animal Batch Tests', () => { }, }, { - testName: 'Check create batch sex detail -- change sex_detail', + testName: 'Check edit batch sex detail -- change sex_detail', getPatchBody: (batch, existingBatches) => [ { id: existingBatches[0].id, - count: 3, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 5, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], }, ], getPostBody: () => [ @@ -1350,15 +1367,337 @@ describe('Animal Batch Tests', () => { message: 'Batch count must be greater than or equal to sex detail count', }, }, + { + testName: 'Check edit batch sex detail -- duplicate sex ids not allowed', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 1, + }, + { + sex_id: animalSex1.id, + count: 1, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 1, + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'Duplicate sex ids in detail', + }, + }, + { + testName: 'Check edit batch sex detail -- patching sex id with record id updates record', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 1, + animal_batch_id: existingBatches[0].id, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [ + { + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 1, + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + ], + }, + ], + }, + { + testName: + 'Check edit batch sex detail -- patching sex id without record id deletes previous record and adds new one', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + const record1 = records.find( + (record) => record.id === existingBatches[0].sex_detail[0].id, + ); + const record2 = records.find( + (record) => record.id != existingBatches[0].sex_detail[0].id, + ); + return [ + { + ...record1, + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 2, + animal_batch_id: existingBatches[0].id, + deleted: true, + }, + { + ...record2, + id: record2.id, + sex_id: animalSex1.id, + count: 1, + animal_batch_id: existingBatches[0].id, + deleted: false, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 1, + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + ], + }, + ], + }, + { + testName: + 'Check edit batch sex detail -- patching sex detail with empty array deletes sex details', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + deleted: true, + }, + { + ...records[1], + deleted: true, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + }, + { + testName: 'Use relationships is an array', + getPatchBody: (batch) => [ + { + id: batch.id, + animal_batch_use_relationships: 'string', + }, + ], + patchErr: { + code: 400, + message: 'animal_batch_use_relationships should be an array', + }, + }, + { + testName: 'Other use notes is for other use type', + getPatchBody: (batch) => [ + { + id: batch.id, + animal_batch_use_relationships: [ + { + use_id: animalUse2.id, + other_use: 'Leather', + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'other_use notes is for other use type', + }, + }, + { + testName: 'Check edit use -- patching use relationship with empty array hard deletes use', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchUseRelationshipModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return []; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + animal_batch_use_relationships: [], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + animal_batch_use_relationships: [ + { + use_id: animalUse2.id, + }, + { + use_id: animalUse3.id, + }, + ], + }, + ], + }, + { + testName: + 'Check edit use -- patching use relationship requires all pre-existing uses to be present hard deletes missing', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchUseRelationshipModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + use_id: animalUse1.id, + other_use: 'Leather', + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + animal_batch_use_relationships: [ + { + use_id: animalUse1.id, + other_use: 'Leather', + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + animal_batch_use_relationships: [ + { + use_id: animalUse1.id, + }, + { + use_id: animalUse2.id, + }, + ], + }, + ], + }, + { + testName: 'Origin id must be brought in to have brought in date', + getPatchBody: (batch) => [ + { + id: batch.id, + origin_id: animalOrigin2.id, + brought_in_date: new Date(), + }, + ], + patchErr: { + code: 400, + message: 'Brought in date must be used with brought in origin', + }, + }, + { + testName: 'Cannot create a new type associated with an existing breed', + getPatchBody: (batch) => [ + { + id: batch.id, + defaultBreedId: animalBreed.id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Cannot create a new type associated with an existing breed', + }, + }, ]; customErrors.forEach(async (error) => { await test(`CustomError: ${error.testName}`, async () => { + // Create userFarms needed for tests const { mainFarm, user } = await returnUserFarms(1); const { mainFarm: otherFarm } = await returnUserFarms(1); - const batch = await makeAnimalBatch(mainFarm, { - default_type_id: defaultTypeId, - }); + + // Make, then group, farm specific resources const [customAnimalType] = await mocks.custom_animal_typeFactory({ promisedFarm: [mainFarm], }); @@ -1384,8 +1723,8 @@ describe('Animal Batch Tests', () => { customAnimalBreed2, otherFarm: { otherCustomAnimalType, otherCustomAnimalBreed }, }; + const makeCheckGetBatch = async (getPostBody) => { - // Default type matches default breed const batches = getPostBody(customs).map((batch) => mocks.fakeAnimalBatch(batch)); const postRes = await postRequest( { @@ -1394,21 +1733,24 @@ describe('Animal Batch Tests', () => { }, [...batches], ); - console.log(error.testName); - console.log(postRes); + + // If checking error body on post expect(postRes.status).toBe(error.postErr?.code || 201); expect(postRes.error.text).toBe(error.postErr?.message || undefined); return postRes.body; }; - const existingBatches = error.getPostBody - ? await makeCheckGetBatch(error.getPostBody) - : undefined; + let existingBatches; + if (error.getPostBody) { + existingBatches = await makeCheckGetBatch(error.getPostBody); + } const editCheckBatch = async (getPatchBody) => { - const batches = getPatchBody(batch, existingBatches, customs).map((batch) => - mocks.fakeAnimalBatch(batch), - ); + // for skipping makeCheckGetBatch + const batch = await makeAnimalBatch(mainFarm, { + default_type_id: defaultTypeId, + }); + const batches = getPatchBody(batch, existingBatches, customs); const patchRes = await patchRequest( { user_id: user.user_id, @@ -1416,12 +1758,31 @@ describe('Animal Batch Tests', () => { }, [...batches], ); + // If checking error body on patch expect(patchRes.status).toBe(error.patchErr?.code || 204); expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); }; + if (error.getPatchBody) { await editCheckBatch(error.getPatchBody); } + + // If checking for errors on record object + const rawGetMatch = async (getRawRecordMismatch) => { + const rawRecordMatch = getRawRecordMismatch(existingBatches); + // Include deleted + const records = await rawRecordMatch.model + .query() + .where(rawRecordMatch.where) + .context({ showHidden: true }); + const expectedBody = rawRecordMatch.getMatchingBody(existingBatches, records); + // No fallback if provided + expect(records).toEqual(expectedBody); + }; + + if (error.getRawRecordMismatch) { + await rawGetMatch(error.getRawRecordMismatch); + } }); }); }); From ac4c3205aa1a82917d902c9a7bbd50591b8ce743 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 15:11:37 -0400 Subject: [PATCH 31/45] LF-4380 Move middleware tests out of EDIT tests and comment functions --- packages/api/tests/animal_batch.test.js | 2127 ++++++++++++----------- 1 file changed, 1079 insertions(+), 1048 deletions(-) diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index 162c5d56ce..ba412777a6 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -46,6 +46,8 @@ describe('Animal Batch Tests', () => { let defaultBreedId; let defaultTypeId; let animalRemovalReasonId; + let animalUse1; + let animalOrigin1; const mockDate = new Date('2024/3/12').toISOString(); @@ -56,6 +58,9 @@ describe('Animal Batch Tests', () => { const [animalRemovalReason] = await mocks.animal_removal_reasonFactory(); animalRemovalReasonId = animalRemovalReason.id; + + [animalUse1] = await mocks.animal_useFactory('OTHER'); + [animalOrigin1] = await mocks.animal_originFactory('BROUGHT_IN'); }); async function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }) { @@ -693,21 +698,10 @@ describe('Animal Batch Tests', () => { let animalGroup2; let animalSex1; let animalSex2; - let animalIdentifierColor; - let animalIdentifierType; - let animalOrigin1; let animalOrigin2; let animalRemovalReason; - let animalUse1; let animalUse2; let animalUse3; - let animalBreed; - let animalBreed2; - - beforeAll(async () => { - [animalUse1] = await mocks.animal_useFactory('OTHER'); - [animalOrigin1] = await mocks.animal_originFactory('BROUGHT_IN'); - }); beforeEach(async () => { [animalGroup1] = await mocks.animal_groupFactory(); @@ -715,14 +709,10 @@ describe('Animal Batch Tests', () => { // Populate enums [animalSex1] = await mocks.animal_sexFactory(); [animalSex2] = await mocks.animal_sexFactory(); - [animalIdentifierColor] = await mocks.animal_identifier_colorFactory(); - [animalIdentifierType] = await mocks.animal_identifier_typeFactory(); [animalOrigin2] = await mocks.animal_originFactory(); [animalRemovalReason] = await mocks.animal_removal_reasonFactory(); [animalUse2] = await mocks.animal_useFactory(); [animalUse3] = await mocks.animal_useFactory(); - [animalBreed] = await mocks.default_animal_breedFactory(); - [animalBreed2] = await mocks.default_animal_breedFactory(); }); async function addAnimalBatches(mainFarm, user) { @@ -734,7 +724,7 @@ describe('Animal Batch Tests', () => { const firstBatch = mocks.fakeAnimalBatch({ name: 'edit test 1', default_type_id: defaultTypeId, - animal_batch_use_relationships: [{ use_id: animalUse1.id }], + animal_batch_use_relationships: [{ use_id: animalUse2.id }], sire: 'Unchanged', count: 4, sex_detail: [ @@ -752,7 +742,7 @@ describe('Animal Batch Tests', () => { const secondBatch = mocks.fakeAnimalBatch({ name: 'edit test 2', custom_type_id: customAnimalType.id, - animal_batch_use_relationships: [{ use_id: animalUse1.id }], + animal_batch_use_relationships: [{ use_id: animalUse2.id }], sire: 'Unchanged', count: 5, }); @@ -799,7 +789,7 @@ describe('Animal Batch Tests', () => { }, ], count: 5, - origin_id: animalOrigin1.id, + origin_id: animalOrigin2.id, // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', @@ -822,7 +812,7 @@ describe('Animal Batch Tests', () => { }, ], count: 5, - origin_id: animalOrigin1.id, + origin_id: animalOrigin2.id, // Extra properties are silently removed animal_removal_reason_id: animalRemovalReason.id, organic_status: 'Organic', @@ -1001,1133 +991,1174 @@ describe('Animal Batch Tests', () => { const batchRecord = await AnimalBatchModel.query().findById(batch.id); expect(batchRecord.sire).toBeNull(); }); + }); - const customErrors = [ - { - testName: 'Exactly one type provided', - getPatchBody: (batch) => [ - { - id: batch.id, - default_type_id: batch.default_type_id, - type_name: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Exactly one of default_type_id, custom_type_id, or type_name must be sent', - }, - }, - { - testName: 'Custom type id is number', - getPatchBody: (batch) => [ - { - id: batch.id, - custom_type_id: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Must send valid ids', - }, - }, - { - testName: 'Custom id exists', - getPatchBody: (batch) => [ - { - id: batch.id, - custom_type_id: 1000000, - }, - ], - patchErr: { - code: 400, - message: 'Custom type does not exist', - }, - }, - { - testName: 'Custom type does not belong to farm', - getPatchBody: (batch, existingBatches, customs) => [ - { - id: batch.id, - custom_type_id: customs.otherFarm.otherCustomAnimalType.id, - }, - ], - patchErr: { - code: 403, - message: 'Forbidden custom type does not belong to this farm', - }, - }, - { - testName: 'Exactly one breed provided', - getPatchBody: (batch) => [ - { - id: batch.id, - default_type_id: animalBreed.default_type_id, - default_breed_id: animalBreed.id, - breed_name: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Exactly one of default_breed_id, custom_breed_id, or breed_name must be sent', - }, - }, - { - testName: 'Default type matches default breed -- default type is changed', - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - default_type_id: animalBreed2.default_type_id, - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - default_breed_id: animalBreed.id, - }, - ], - patchErr: { - code: 400, - message: 'Breed does not match type', - }, - }, - { - testName: 'Default type matches default breed -- default breed is changed', - getPatchBody: (batch, existingBatches) => [ + // REMOVE tests + describe('Remove animal batch tests', () => { + test('Admin users should be able to remove animal batches', async () => { + const roles = [1, 2, 5]; + const animalSex1 = await makeAnimalSex(); + const animalSex2 = await makeAnimalSex(); + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const firstAnimalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + count: 6, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 4, + }, + ], + }); + + const secondAnimalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await removeRequest( { - id: existingBatches[0].id, - default_breed_id: animalBreed2.id, + user_id: user.user_id, + farm_id: mainFarm.farm_id, }, - ], - getPostBody: () => [ + + [ + { + id: firstAnimalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', + removal_date: mockDate, + }, + { + id: secondAnimalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', + removal_date: mockDate, + }, + ], + ); + + expect(res.status).toBe(204); + + // Check database to make sure property has been updated + const batchRecords = await AnimalBatchModel.query().whereIn('id', [ + firstAnimalBatch.id, + secondAnimalBatch.id, + ]); + + batchRecords.forEach((record) => { + expect(record.animal_removal_reason_id).toBe(animalRemovalReasonId); + }); + } + }); + + test('Non-admin users should not be able to remove animal batches', async () => { + const roles = [3]; + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const animalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await removeRequest( { - default_type_id: animalBreed.default_type_id, - default_breed_id: animalBreed.id, + user_id: user.user_id, + farm_id: mainFarm.farm_id, }, - ], - patchErr: { - code: 400, - message: 'Breed does not match type', + [ + { + id: animalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', + removal_date: mockDate, + }, + ], + ); + + expect(res.status).toBe(403); + expect(res.error.text).toBe( + 'User does not have the following permission(s): edit:animal_batches', + ); + + // Check database + const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); + expect(batchRecord.animal_removal_reason_id).toBeNull(); + } + }); + + test('Should not be able to send out an individual animal batch instead of an array', async () => { + const { mainFarm, user } = await returnUserFarms(1); + + const animalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await removeRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, }, - }, - { - testName: 'Default breed is a number', - getPatchBody: (batch) => [ - { - id: batch.id, - default_breed_id: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Must send valid ids', + + { + id: animalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', + removal_date: mockDate, }, - }, - { - testName: 'Default breed provided exists (optional to provide)', - getPatchBody: (batch) => [ - { - id: batch.id, - default_breed_id: 1000000, - }, - ], - patchErr: { - code: 400, - message: 'Default breed does not exist', + ); + + 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); + expect(batchRecord.animal_removal_reason_id).toBeNull(); + }); + + test('Should not be able to remove an animal batch without providing a removal_date', async () => { + const { mainFarm, user } = await returnUserFarms(1); + + const animalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await removeRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, }, - }, - { - testName: 'Default type matches default breed -- both are changed but mismatch', - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - default_type_id: animalBreed.default_type_id, - default_breed_id: animalBreed2.id, - }, - ], - getPostBody: () => [ + [ { - default_type_id: animalBreed.default_type_id, - default_breed_id: animalBreed.id, + id: animalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', }, ], - patchErr: { - code: 400, - message: 'Breed does not match type', + ); + expect(res.status).toBe(400); + expect(res.error.text).toBe('Must send reason and date of removal'); + + // Check database + const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); + expect(batchRecord.animal_removal_reason_id).toBeNull(); + }); + + test('Should not be able to remove an animal batch belonging to a different farm', async () => { + const { mainFarm, user } = await returnUserFarms(1); + const [secondFarm] = await mocks.farmFactory(); + + const animalBatch = await makeAnimalBatch(secondFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await removeRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, }, - }, - { - testName: 'Custom type cannot be used with default breed', - getPostBody: (customs) => [ + [ { - custom_type_id: customs.customAnimalType.id, - default_breed_id: animalBreed.id, + id: animalBatch.id, + animal_removal_reason_id: animalRemovalReasonId, + explanation: 'Gifted to neighbor', + removal_date: mockDate, }, ], - postErr: { - code: 400, - message: 'Default breed must use default type', + ); + + expect(res).toMatchObject({ + status: 400, + body: { + error: 'Invalid ids', + invalidIds: [animalBatch.id], }, - }, - { - testName: 'Custom breed is a number', - getPatchBody: (batch) => [ - { - id: batch.id, - custom_breed_id: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Must send valid ids', + }); + + // Check database + const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); + expect(batchRecord.animal_removal_reason_id).toBeNull(); + }); + }); + + // DELETE tests + describe('Delete animal batch tests', () => { + test('Admin users should be able to delete animal batches', async () => { + const roles = [1, 2, 5]; + const animalSex1 = await makeAnimalSex(); + const animalSex2 = await makeAnimalSex(); + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const firstAnimalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + count: 6, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 4, + }, + ], + }); + + const secondAnimalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: `ids=${firstAnimalBatch.id},${secondAnimalBatch.id}`, + }); + + expect(res.status).toBe(204); + } + }); + + test('Non-admin users should not be able to delete animal batches', async () => { + const roles = [3]; + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const animalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: `ids=${animalBatch.id}`, + }); + + expect(res.status).toBe(403); + expect(res.error.text).toBe( + 'User does not have the following permission(s): delete:animal_batches', + ); + } + }); + + test('Must send animal batch ids', async () => { + const { mainFarm, user } = await returnUserFarms(1); + + const res = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: ``, + }); + + expect(res).toMatchObject({ + status: 400, + error: { + text: 'Must send ids', }, - }, - { - testName: 'Custom breed provided exists (optional to provide)', - getPatchBody: (batch) => [ - { - id: batch.id, - custom_breed_id: 1000000, - }, - ], - patchErr: { - code: 400, - message: 'Custom breed does not exist', + }); + }); + + test('Must send valid queries', async () => { + const { mainFarm, user } = await returnUserFarms(1); + const animalBatch = await makeAnimalBatch(mainFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + // Two query params that are not valid batch ids + const res1 = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: `ids=${animalBatch.id},,`, + }); + + expect(res1).toMatchObject({ + status: 400, + error: { + text: 'Must send valid ids', }, - }, - { - testName: 'Custom breed provided exists (optional to provide)', - getPatchBody: (batch) => [ - { - id: batch.id, - custom_breed_id: 1000000, - }, - ], - patchErr: { - code: 400, - message: 'Custom breed does not exist', + }); + + // Three query params that are not valid animal ids + const res2 = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: `ids=},a,`, + }); + + expect(res2).toMatchObject({ + status: 400, + error: { + text: 'Must send valid ids', }, - }, - { - testName: 'Custom breed does not belong to farm', - getPatchBody: (batch, existingBatches, customs) => [ - { - id: batch.id, - custom_breed_id: customs.otherFarm.otherCustomAnimalBreed.id, - }, - ], - patchErr: { - code: 403, - message: 'Forbidden custom breed does not belong to this farm', + }); + }); + + test('Should not be able to remove an animal batch belonging to a different farm', async () => { + const { mainFarm, user } = await returnUserFarms(1); + const [secondFarm] = await mocks.farmFactory(); + + const animalBatch = await makeAnimalBatch(secondFarm, { + default_breed_id: defaultBreedId, + default_type_id: defaultTypeId, + }); + + const res = await deleteRequest({ + user_id: user.user_id, + farm_id: mainFarm.farm_id, + query: `ids=${animalBatch.id}`, + }); + + expect(res).toMatchObject({ + status: 400, + body: { + error: 'Invalid ids', + invalidIds: [`${animalBatch.id}`], }, - }, - { - testName: 'Default type matches custom breed -- default type is changed', - getPatchBody: (batch, existingBatches, customs) => [ - { - id: existingBatches[0].id, - default_type_id: animalBreed.default_type_id, - }, - ], - getPostBody: (customs) => [ - { - default_type_id: customs.customAnimalBreed.default_type_id, - custom_breed_id: customs.customAnimalBreed.id, + }); + }); + }); + + // MIDDLEWARE tests + describe('Edit animal batch tests', () => { + let animalSex1; + let animalSex2; + let animalOrigin2; + let animalUse2; + let animalUse3; + let animalBreed; + let animalBreed2; + + beforeEach(async () => { + // Populate enums + [animalSex1] = await mocks.animal_sexFactory(); + [animalSex2] = await mocks.animal_sexFactory(); + [animalOrigin2] = await mocks.animal_originFactory(); + [animalUse2] = await mocks.animal_useFactory(); + [animalUse3] = await mocks.animal_useFactory(); + [animalBreed] = await mocks.default_animal_breedFactory(); + [animalBreed2] = await mocks.default_animal_breedFactory(); + }); + + // Top level structure is endpoint string with value as in caps, value is an array of tests + // Example: {'CREATE': [{test1}, {test2},...],'EDIT': [{test1}, {test2},...} + // Test structure to test 'CREATE' middleware is: + // { testName: 'name', getPostBody: function() {return [batch]}, postErr: {code: 400, message: 'errorMessage'}} + // Test structure to test 'EDIT' middleware is: + // { testName: 'name', getPostBody?: function() {return [batch]}, getPatchBody?: function() {return [editedBatch]}, patchErr: {code: 400, message: 'errorMessage'}} } + // Test structure to test raw data expectations is: + // { testName: 'name', getPostBody?: function() {return [batch]}, getPatchBody?: function() {return [editedBatch]}, getRawRecordMismatch: function() return { model: Model, where: {id}, getMatchingBody: function() return [records] } } } + const middlewareErrors = { + CREATE: [ + { + testName: 'Custom type cannot be used with default breed', + getPostBody: (customs) => [ + { + custom_type_id: customs.customAnimalType.id, + default_breed_id: animalBreed.id, + }, + ], + postErr: { + code: 400, + message: 'Default breed must use default type', }, - ], - patchErr: { - code: 400, - message: 'Breed does not match type', }, - }, - { - testName: 'Default type matches custom breed -- custom type is changed', - getPatchBody: (batch, existingBatches, customs) => [ - { - id: existingBatches[0].id, - custom_type_id: customs.customAnimalBreed2.custom_type_id, - }, - ], - getPostBody: (customs) => [ - { - default_type_id: customs.customAnimalBreed.default_type_id, - custom_breed_id: customs.customAnimalBreed.id, + { + testName: 'Check create batch sex detail', + getPostBody: (customs) => [ + { + count: 3, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], + postErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', }, - ], - patchErr: { - code: 400, - message: 'Breed does not match type', }, - }, - { - testName: 'Default type matches custom breed -- breed and type are changed', - getPatchBody: (batch, existingBatches, customs) => [ - { - id: existingBatches[0].id, - default_type_id: customs.customAnimalBreed.default_type_id, - custom_breed_id: customs.customAnimalBreed2.id, - }, - ], - getPostBody: (customs) => [ - { - default_type_id: customs.customAnimalBreed.default_type_id, - custom_breed_id: customs.customAnimalBreed.id, + ], + EDIT: [ + { + testName: 'Exactly one type provided', + getPatchBody: (batch) => [ + { + id: batch.id, + default_type_id: batch.default_type_id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_type_id, custom_type_id, or type_name must be sent', }, - ], - patchErr: { - code: 400, - message: 'Breed does not match type', }, - }, - { - testName: 'Check create batch sex detail', - getPostBody: (customs) => [ - { - count: 3, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], + { + testName: 'Custom type id is number', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_type_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', }, - ], - postErr: { - code: 400, - message: 'Batch count must be greater than or equal to sex detail count', }, - }, - { - testName: 'Check edit batch sex detail -- change count', - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - count: 3, - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], + { + testName: 'Custom id exists', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_type_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom type does not exist', }, - ], - patchErr: { - code: 400, - message: 'Batch count must be greater than or equal to sex detail count', }, - }, - { - testName: 'Check edit batch sex detail -- change sex_detail', - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 5, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], + { + testName: 'Custom type does not belong to farm', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: batch.id, + custom_type_id: customs.otherFarm.otherCustomAnimalType.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom type does not belong to this farm', }, - ], - patchErr: { - code: 400, - message: 'Batch count must be greater than or equal to sex detail count', }, - }, - { - testName: 'Check edit batch sex detail -- duplicate sex ids not allowed', - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 1, - }, - { - sex_id: animalSex1.id, - count: 1, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - { - sex_id: animalSex2.id, - count: 1, - }, - ], + { + testName: 'Exactly one breed provided', + getPatchBody: (batch) => [ + { + id: batch.id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + breed_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_breed_id, custom_breed_id, or breed_name must be sent', }, - ], - patchErr: { - code: 400, - message: 'Duplicate sex ids in detail', }, - }, - { - testName: 'Check edit batch sex detail -- patching sex id with record id updates record', - getRawRecordMismatch: (existingBatches) => { - return { - model: AnimalBatchSexDetailModel, - where: { animal_batch_id: existingBatches[0].id }, - getMatchingBody: (existingBatches, records) => { - return [ - { - ...records[0], - id: existingBatches[0].sex_detail[0].id, - sex_id: animalSex1.id, - count: 1, - animal_batch_id: existingBatches[0].id, - }, - ]; + { + testName: 'Default type matches default breed -- default type is changed', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed2.default_type_id, }, - }; - }, - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - sex_detail: [ - { - id: existingBatches[0].sex_detail[0].id, - sex_id: animalSex1.id, - count: 1, - }, - ], - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - ], - }, - ], - }, - { - testName: - 'Check edit batch sex detail -- patching sex id without record id deletes previous record and adds new one', - getRawRecordMismatch: (existingBatches) => { - return { - model: AnimalBatchSexDetailModel, - where: { animal_batch_id: existingBatches[0].id }, - getMatchingBody: (existingBatches, records) => { - const record1 = records.find( - (record) => record.id === existingBatches[0].sex_detail[0].id, - ); - const record2 = records.find( - (record) => record.id != existingBatches[0].sex_detail[0].id, - ); - return [ - { - ...record1, - id: existingBatches[0].sex_detail[0].id, - sex_id: animalSex1.id, - count: 2, - animal_batch_id: existingBatches[0].id, - deleted: true, - }, - { - ...record2, - id: record2.id, - sex_id: animalSex1.id, - count: 1, - animal_batch_id: existingBatches[0].id, - deleted: false, - }, - ]; + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, }, - }; - }, - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 1, - }, - ], - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - ], + ], + patchErr: { + code: 400, + message: 'Breed does not match type', }, - ], - }, - { - testName: - 'Check edit batch sex detail -- patching sex detail with empty array deletes sex details', - getRawRecordMismatch: (existingBatches) => { - return { - model: AnimalBatchSexDetailModel, - where: { animal_batch_id: existingBatches[0].id }, - getMatchingBody: (existingBatches, records) => { - return [ - { - ...records[0], - deleted: true, - }, - { - ...records[1], - deleted: true, - }, - ]; - }, - }; }, - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - sex_detail: [], - }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - count: 4, - sex_detail: [ - { - sex_id: animalSex1.id, - count: 2, - }, - { - sex_id: animalSex2.id, - count: 2, - }, - ], - }, - ], - }, - { - testName: 'Use relationships is an array', - getPatchBody: (batch) => [ - { - id: batch.id, - animal_batch_use_relationships: 'string', + { + testName: 'Default type matches default breed -- default breed is changed', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', }, - ], - patchErr: { - code: 400, - message: 'animal_batch_use_relationships should be an array', }, - }, - { - testName: 'Other use notes is for other use type', - getPatchBody: (batch) => [ - { - id: batch.id, - animal_batch_use_relationships: [ - { - use_id: animalUse2.id, - other_use: 'Leather', - }, - ], + { + testName: 'Default breed is a number', + getPatchBody: (batch) => [ + { + id: batch.id, + default_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', }, - ], - patchErr: { - code: 400, - message: 'other_use notes is for other use type', }, - }, - { - testName: 'Check edit use -- patching use relationship with empty array hard deletes use', - getRawRecordMismatch: (existingBatches) => { - return { - model: AnimalBatchUseRelationshipModel, - where: { animal_batch_id: existingBatches[0].id }, - getMatchingBody: (existingBatches, records) => { - return []; + { + testName: 'Default breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + default_breed_id: 1000000, }, - }; + ], + patchErr: { + code: 400, + message: 'Default breed does not exist', + }, }, - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - animal_batch_use_relationships: [], + { + testName: 'Default type matches default breed -- both are changed but mismatch', + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - animal_batch_use_relationships: [ - { - use_id: animalUse2.id, - }, - { - use_id: animalUse3.id, - }, - ], + }, + + { + testName: 'Custom breed is a number', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', }, - ], - }, - { - testName: - 'Check edit use -- patching use relationship requires all pre-existing uses to be present hard deletes missing', - getRawRecordMismatch: (existingBatches) => { - return { - model: AnimalBatchUseRelationshipModel, - where: { animal_batch_id: existingBatches[0].id }, - getMatchingBody: (existingBatches, records) => { - return [ - { - ...records[0], - use_id: animalUse1.id, - other_use: 'Leather', - }, - ]; + }, + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 1000000, }, - }; + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', + }, }, - getPatchBody: (batch, existingBatches) => [ - { - id: existingBatches[0].id, - animal_batch_use_relationships: [ - { - use_id: animalUse1.id, - other_use: 'Leather', - }, - ], + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (batch) => [ + { + id: batch.id, + custom_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', }, - ], - getPostBody: () => [ - { - default_type_id: animalBreed.default_type_id, - animal_batch_use_relationships: [ - { - use_id: animalUse1.id, - }, - { - use_id: animalUse2.id, - }, - ], + }, + { + testName: 'Custom breed does not belong to farm', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: batch.id, + custom_breed_id: customs.otherFarm.otherCustomAnimalBreed.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom breed does not belong to this farm', }, - ], - }, - { - testName: 'Origin id must be brought in to have brought in date', - getPatchBody: (batch) => [ - { - id: batch.id, - origin_id: animalOrigin2.id, - brought_in_date: new Date(), + }, + { + testName: 'Default type matches custom breed -- default type is changed', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + default_type_id: animalBreed.default_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', }, - ], - patchErr: { - code: 400, - message: 'Brought in date must be used with brought in origin', }, - }, - { - testName: 'Cannot create a new type associated with an existing breed', - getPatchBody: (batch) => [ - { - id: batch.id, - defaultBreedId: animalBreed.id, - type_name: 'string', + { + testName: 'Default type matches custom breed -- custom type is changed', + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + custom_type_id: customs.customAnimalBreed2.custom_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', }, - ], - patchErr: { - code: 400, - message: 'Cannot create a new type associated with an existing breed', }, - }, - ]; - - customErrors.forEach(async (error) => { - await test(`CustomError: ${error.testName}`, async () => { - // Create userFarms needed for tests - const { mainFarm, user } = await returnUserFarms(1); - const { mainFarm: otherFarm } = await returnUserFarms(1); - - // Make, then group, farm specific resources - const [customAnimalType] = await mocks.custom_animal_typeFactory({ - promisedFarm: [mainFarm], - }); - const [customAnimalBreed] = await mocks.custom_animal_breedFactory( - { - promisedFarm: [mainFarm], - }, - undefined, - false, - ); - const [customAnimalBreed2] = await mocks.custom_animal_breedFactory({ - promisedFarm: [mainFarm], - }); - const [otherCustomAnimalType] = await mocks.custom_animal_typeFactory({ - promisedFarm: [otherFarm], - }); - const [otherCustomAnimalBreed] = await mocks.custom_animal_breedFactory({ - promisedFarm: [otherFarm], - }); - const customs = { - customAnimalType, - customAnimalBreed, - customAnimalBreed2, - otherFarm: { otherCustomAnimalType, otherCustomAnimalBreed }, - }; - - const makeCheckGetBatch = async (getPostBody) => { - const batches = getPostBody(customs).map((batch) => mocks.fakeAnimalBatch(batch)); - const postRes = await postRequest( + { + testName: 'Default type matches custom breed -- breed and type are changed', + getPatchBody: (batch, existingBatches, customs) => [ { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + id: existingBatches[0].id, + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed2.id, }, - [...batches], - ); - - // If checking error body on post - expect(postRes.status).toBe(error.postErr?.code || 201); - expect(postRes.error.text).toBe(error.postErr?.message || undefined); - return postRes.body; - }; - - let existingBatches; - if (error.getPostBody) { - existingBatches = await makeCheckGetBatch(error.getPostBody); - } - - const editCheckBatch = async (getPatchBody) => { - // for skipping makeCheckGetBatch - const batch = await makeAnimalBatch(mainFarm, { - default_type_id: defaultTypeId, - }); - const batches = getPatchBody(batch, existingBatches, customs); - const patchRes = await patchRequest( + ], + getPostBody: (customs) => [ { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, }, - [...batches], - ); - // If checking error body on patch - expect(patchRes.status).toBe(error.patchErr?.code || 204); - expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); - }; - - if (error.getPatchBody) { - await editCheckBatch(error.getPatchBody); - } - - // If checking for errors on record object - const rawGetMatch = async (getRawRecordMismatch) => { - const rawRecordMatch = getRawRecordMismatch(existingBatches); - // Include deleted - const records = await rawRecordMatch.model - .query() - .where(rawRecordMatch.where) - .context({ showHidden: true }); - const expectedBody = rawRecordMatch.getMatchingBody(existingBatches, records); - // No fallback if provided - expect(records).toEqual(expectedBody); - }; - - if (error.getRawRecordMismatch) { - await rawGetMatch(error.getRawRecordMismatch); - } - }); - }); - }); - - // REMOVE tests - describe('Remove animal batch tests', () => { - test('Admin users should be able to remove animal batches', async () => { - const roles = [1, 2, 5]; - const animalSex1 = await makeAnimalSex(); - const animalSex2 = await makeAnimalSex(); - - for (const role of roles) { - const { mainFarm, user } = await returnUserFarms(role); + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, - const firstAnimalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - count: 6, - sex_detail: [ + { + testName: 'Check edit batch sex detail -- change count', + getPatchBody: (batch, existingBatches) => [ { - sex_id: animalSex1.id, - count: 2, + id: existingBatches[0].id, + count: 3, }, + ], + getPostBody: () => [ { - sex_id: animalSex2.id, + default_type_id: animalBreed.default_type_id, count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], }, ], - }); - - const secondAnimalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await removeRequest( - { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + patchErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', }, - - [ + }, + { + testName: 'Check edit batch sex detail -- change sex_detail', + getPatchBody: (batch, existingBatches) => [ { - id: firstAnimalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', - removal_date: mockDate, + id: existingBatches[0].id, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 5, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], }, + ], + getPostBody: () => [ { - id: secondAnimalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', - removal_date: mockDate, + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], }, ], - ); - - expect(res.status).toBe(204); - - // Check database to make sure property has been updated - const batchRecords = await AnimalBatchModel.query().whereIn('id', [ - firstAnimalBatch.id, - secondAnimalBatch.id, - ]); - - batchRecords.forEach((record) => { - expect(record.animal_removal_reason_id).toBe(animalRemovalReasonId); - }); - } - }); - - test('Non-admin users should not be able to remove animal batches', async () => { - const roles = [3]; - - for (const role of roles) { - const { mainFarm, user } = await returnUserFarms(role); - - const animalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await removeRequest( - { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + patchErr: { + code: 400, + message: 'Batch count must be greater than or equal to sex detail count', }, - [ + }, + { + testName: 'Check edit batch sex detail -- duplicate sex ids not allowed', + getPatchBody: (batch, existingBatches) => [ { - id: animalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', - removal_date: mockDate, + id: existingBatches[0].id, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 1, + }, + { + sex_id: animalSex1.id, + count: 1, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], }, ], - ); - - expect(res.status).toBe(403); - expect(res.error.text).toBe( - 'User does not have the following permission(s): edit:animal_batches', - ); - - // Check database - const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); - expect(batchRecord.animal_removal_reason_id).toBeNull(); - } - }); - - test('Should not be able to send out an individual animal batch instead of an array', async () => { - const { mainFarm, user } = await returnUserFarms(1); - - const animalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await removeRequest( + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 1, + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'Duplicate sex ids in detail', + }, + }, { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + testName: 'Check edit batch sex detail -- patching sex id with record id updates record', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 1, + animal_batch_id: existingBatches[0].id, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [ + { + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 1, + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + ], + }, + ], }, - { - id: animalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', - removal_date: mockDate, + testName: + 'Check edit batch sex detail -- patching sex id without record id deletes previous record and adds new one', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + const record1 = records.find( + (record) => record.id === existingBatches[0].sex_detail[0].id, + ); + const record2 = records.find( + (record) => record.id != existingBatches[0].sex_detail[0].id, + ); + return [ + { + ...record1, + id: existingBatches[0].sex_detail[0].id, + sex_id: animalSex1.id, + count: 2, + animal_batch_id: existingBatches[0].id, + deleted: true, + }, + { + ...record2, + id: record2.id, + sex_id: animalSex1.id, + count: 1, + animal_batch_id: existingBatches[0].id, + deleted: false, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 1, + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + ], + }, + ], }, - ); - - 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); - expect(batchRecord.animal_removal_reason_id).toBeNull(); - }); - - test('Should not be able to remove an animal batch without providing a removal_date', async () => { - const { mainFarm, user } = await returnUserFarms(1); - - const animalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await removeRequest( { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + testName: + 'Check edit batch sex detail -- patching sex detail with empty array deletes sex details', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchSexDetailModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + deleted: true, + }, + { + ...records[1], + deleted: true, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + sex_detail: [], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + count: 4, + sex_detail: [ + { + sex_id: animalSex1.id, + count: 2, + }, + { + sex_id: animalSex2.id, + count: 2, + }, + ], + }, + ], }, - [ - { - id: animalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', + { + testName: 'Use relationships is an array', + getPatchBody: (batch) => [ + { + id: batch.id, + animal_batch_use_relationships: 'string', + }, + ], + patchErr: { + code: 400, + message: 'animal_batch_use_relationships should be an array', }, - ], - ); - expect(res.status).toBe(400); - expect(res.error.text).toBe('Must send reason and date of removal'); - - // Check database - const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); - expect(batchRecord.animal_removal_reason_id).toBeNull(); - }); - - test('Should not be able to remove an animal batch belonging to a different farm', async () => { - const { mainFarm, user } = await returnUserFarms(1); - const [secondFarm] = await mocks.farmFactory(); - - const animalBatch = await makeAnimalBatch(secondFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await removeRequest( + }, { - user_id: user.user_id, - farm_id: mainFarm.farm_id, + testName: 'Other use notes is for other use type', + getPatchBody: (batch) => [ + { + id: batch.id, + animal_batch_use_relationships: [ + { + use_id: animalUse2.id, + other_use: 'Leather', + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'other_use notes is for other use type', + }, }, - [ - { - id: animalBatch.id, - animal_removal_reason_id: animalRemovalReasonId, - explanation: 'Gifted to neighbor', - removal_date: mockDate, + { + testName: 'Check edit use -- patching use relationship with empty array hard deletes use', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchUseRelationshipModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return []; + }, + }; }, - ], - ); - - expect(res).toMatchObject({ - status: 400, - body: { - error: 'Invalid ids', - invalidIds: [animalBatch.id], + getPatchBody: (batch, existingBatches) => [ + { + id: existingBatches[0].id, + animal_batch_use_relationships: [], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + animal_batch_use_relationships: [ + { + use_id: animalUse2.id, + }, + { + use_id: animalUse3.id, + }, + ], + }, + ], }, - }); - - // Check database - const batchRecord = await AnimalBatchModel.query().findById(animalBatch.id); - expect(batchRecord.animal_removal_reason_id).toBeNull(); - }); - }); - - // DELETE tests - describe('Delete animal batch tests', () => { - test('Admin users should be able to delete animal batches', async () => { - const roles = [1, 2, 5]; - const animalSex1 = await makeAnimalSex(); - const animalSex2 = await makeAnimalSex(); - - for (const role of roles) { - const { mainFarm, user } = await returnUserFarms(role); - - const firstAnimalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - count: 6, - sex_detail: [ + { + testName: + 'Check edit use -- patching use relationship requires all pre-existing uses to be present hard deletes missing', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchUseRelationshipModel, + where: { animal_batch_id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records) => { + return [ + { + ...records[0], + use_id: animalUse1.id, + other_use: 'Leather', + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches) => [ { - sex_id: animalSex1.id, - count: 2, + id: existingBatches[0].id, + animal_batch_use_relationships: [ + { + use_id: animalUse1.id, + other_use: 'Leather', + }, + ], }, + ], + getPostBody: () => [ { - sex_id: animalSex2.id, - count: 4, + default_type_id: animalBreed.default_type_id, + animal_batch_use_relationships: [ + { + use_id: animalUse1.id, + }, + { + use_id: animalUse2.id, + }, + ], }, ], - }); - - const secondAnimalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: `ids=${firstAnimalBatch.id},${secondAnimalBatch.id}`, - }); - - expect(res.status).toBe(204); - } - }); - - test('Non-admin users should not be able to delete animal batches', async () => { - const roles = [3]; - - for (const role of roles) { - const { mainFarm, user } = await returnUserFarms(role); - - const animalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - const res = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: `ids=${animalBatch.id}`, - }); - - expect(res.status).toBe(403); - expect(res.error.text).toBe( - 'User does not have the following permission(s): delete:animal_batches', - ); - } - }); - - test('Must send animal batch ids', async () => { - const { mainFarm, user } = await returnUserFarms(1); - - const res = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: ``, - }); - - expect(res).toMatchObject({ - status: 400, - error: { - text: 'Must send ids', }, - }); - }); - - test('Must send valid queries', async () => { - const { mainFarm, user } = await returnUserFarms(1); - const animalBatch = await makeAnimalBatch(mainFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); - - // Two query params that are not valid batch ids - const res1 = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: `ids=${animalBatch.id},,`, - }); - - expect(res1).toMatchObject({ - status: 400, - error: { - text: 'Must send valid ids', + { + testName: 'Origin id must be brought in to have brought in date', + getPatchBody: (batch) => [ + { + id: batch.id, + origin_id: animalOrigin2.id, + brought_in_date: new Date(), + }, + ], + patchErr: { + code: 400, + message: 'Brought in date must be used with brought in origin', + }, }, - }); - - // Three query params that are not valid animal ids - const res2 = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: `ids=},a,`, - }); - - expect(res2).toMatchObject({ - status: 400, - error: { - text: 'Must send valid ids', + { + testName: 'Cannot create a new type associated with an existing breed', + getPatchBody: (batch) => [ + { + id: batch.id, + defaultBreedId: animalBreed.id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Cannot create a new type associated with an existing breed', + }, }, - }); - }); + ], + }; + + // Takes middleWareErrors object and makes it into individual tests + for (const errorEndpoint in middlewareErrors) { + middlewareErrors[errorEndpoint].forEach(async (error) => { + await test(`${errorEndpoint} Middleware: ${error.testName}`, async () => { + // Create userFarms needed for tests + const { mainFarm, user } = await returnUserFarms(1); + const { mainFarm: otherFarm } = await returnUserFarms(1); + + // Make, then group, farm specific resources + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + const [customAnimalBreed] = await mocks.custom_animal_breedFactory( + { + promisedFarm: [mainFarm], + }, + undefined, + false, + ); + const [customAnimalBreed2] = await mocks.custom_animal_breedFactory({ + promisedFarm: [mainFarm], + }); + const [otherCustomAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [otherFarm], + }); + const [otherCustomAnimalBreed] = await mocks.custom_animal_breedFactory({ + promisedFarm: [otherFarm], + }); + const customs = { + customAnimalType, + customAnimalBreed, + customAnimalBreed2, + otherFarm: { otherCustomAnimalType, otherCustomAnimalBreed }, + }; - test('Should not be able to remove an animal batch belonging to a different farm', async () => { - const { mainFarm, user } = await returnUserFarms(1); - const [secondFarm] = await mocks.farmFactory(); + // Post endpoint for testing CREATE or testing EDIT against existing record + const makeCheckGetBatch = async (getPostBody) => { + const batches = getPostBody(customs).map((batch) => mocks.fakeAnimalBatch(batch)); + const postRes = await postRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...batches], + ); - const animalBatch = await makeAnimalBatch(secondFarm, { - default_breed_id: defaultBreedId, - default_type_id: defaultTypeId, - }); + // If checking error body on post + expect(postRes.status).toBe(error.postErr?.code || 201); + expect(postRes.error.text).toBe(error.postErr?.message || undefined); + return postRes.body; + }; - const res = await deleteRequest({ - user_id: user.user_id, - farm_id: mainFarm.farm_id, - query: `ids=${animalBatch.id}`, - }); + let existingBatches; + if (error.getPostBody) { + existingBatches = await makeCheckGetBatch(error.getPostBody); + } + + // Patch endpoint for testing EDIT or testing raw successful records against + const editCheckBatch = async (getPatchBody) => { + // for skipping makeCheckGetBatch + const batch = await makeAnimalBatch(mainFarm, { + default_type_id: defaultTypeId, + }); + const batches = getPatchBody(batch, existingBatches, customs); + const patchRes = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...batches], + ); + // If checking error body on patch + expect(patchRes.status).toBe(error.patchErr?.code || 204); + expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); + }; - expect(res).toMatchObject({ - status: 400, - body: { - error: 'Invalid ids', - invalidIds: [`${animalBatch.id}`], - }, + if (error.getPatchBody) { + await editCheckBatch(error.getPatchBody); + } + + // For checking raw records made in CREATE or EDIT + const rawGetMatch = async (getRawRecordMismatch) => { + const rawRecordMatch = getRawRecordMismatch(existingBatches); + // Include deleted + const records = await rawRecordMatch.model + .query() + .where(rawRecordMatch.where) + .context({ showHidden: true }); + const expectedBody = rawRecordMatch.getMatchingBody(existingBatches, records); + // No fallback if provided + expect(records).toEqual(expectedBody); + }; + + if (error.getRawRecordMismatch) { + await rawGetMatch(error.getRawRecordMismatch); + } + }); }); - }); + } }); }); From dcfb01c7dbba0421822aa8263a42eae16994f5e2 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 18:52:56 -0400 Subject: [PATCH 32/45] LF-4380 Add mystery fix to animal controller --- .../api/src/controllers/animalController.js | 96 +++++++------------ 1 file changed, 36 insertions(+), 60 deletions(-) diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index ff5c74edea..e084015bb8 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -23,6 +23,7 @@ import { } from '../util/animal.js'; import { handleObjectionError } from '../util/errorCodes.js'; import { uploadPublicImage } from '../util/imageUpload.js'; +import _pick from 'lodash/pick.js'; const animalController = { getFarmAnimals() { @@ -117,66 +118,41 @@ const animalController = { 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 }, - ); + + const desiredKeys = [ + 'id', + 'custom_breed_id', + 'custom_type_id', + 'default_breed_id', + 'default_type_id', + 'sex_id', + 'name', + 'birth_date', + 'identifier', + 'identifier_color_id', + 'identifier_placement_id', + 'identifier_type_id', + 'identifier_type_other', + 'origin_id', + 'dam', + 'sire', + 'brought_in_date', + 'weaning_date', + 'notes', + 'photo_url', + 'organic_status', + 'supplier', + 'price', + 'sex_detail', + 'origin_id', + 'group_ids', + 'animal_use_relationships', + ]; + + const keysExisting = desiredKeys.filter((key) => key in animal); + const data = _pick(animal, keysExisting); + + await baseController.upsertGraph(AnimalModel, data, req, { trx }); } // delete utility objects delete req.body.typeIdsMap; From 8d633d9c92f39e00506e984c705158a603acfbd5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 18:53:38 -0400 Subject: [PATCH 33/45] LF-4380 Copy middleware tests to animal test --- packages/api/tests/animal.test.js | 541 +++++++++++++++++++++++++++++- 1 file changed, 538 insertions(+), 3 deletions(-) diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index fea3cfb456..89d0991859 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -39,12 +39,15 @@ jest.mock('../src/middleware/acl/checkJwt.js', () => import mocks from './mock.factories.js'; import CustomAnimalTypeModel from '../src/models/customAnimalTypeModel.js'; import CustomAnimalBreedModel from '../src/models/customAnimalBreedModel.js'; +import AnimalUseRelationshipModel from '../src/models/animalUseRelationshipModel.js'; describe('Animal Tests', () => { let farm; let newOwner; let defaultTypeId; let animalRemovalReasonId; + let animalUse1; + let animalOrigin1; const mockDate = new Date('2024/3/12').toISOString(); @@ -55,6 +58,9 @@ describe('Animal Tests', () => { // Alternatively the enum table could be kept (not cleaned up) const [animalRemovalReason] = await mocks.animal_removal_reasonFactory(); animalRemovalReasonId = animalRemovalReason.id; + + [animalUse1] = await mocks.animal_useFactory('OTHER'); + [animalOrigin1] = await mocks.animal_originFactory('BROUGHT_IN'); }); async function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }) { @@ -272,7 +278,8 @@ describe('Animal Tests', () => { animal, ); - expect(res.status).toBe(500); + expect(res.status).toBe(400); + expect(res.error.text).toBe('Request body should be an array'); }); test('Unique internal_identifier should be added within the same farm_id between animals and animalBatches', async () => { @@ -727,7 +734,6 @@ describe('Animal Tests', () => { let animalIdentifierType; let animalOrigin; let animalRemovalReason; - let animalUse1; let animalUse2; let animalUse3; @@ -740,7 +746,6 @@ describe('Animal Tests', () => { [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(); }); @@ -1321,4 +1326,534 @@ describe('Animal Tests', () => { }); }); }); + + // MIDDLEWARE tests + describe('Edit animal animal tests', () => { + let animalOrigin2; + let animalUse2; + let animalUse3; + let animalBreed; + let animalBreed2; + + beforeEach(async () => { + // Populate enums + [animalOrigin2] = await mocks.animal_originFactory(); + [animalUse2] = await mocks.animal_useFactory(); + [animalUse3] = await mocks.animal_useFactory(); + [animalBreed] = await mocks.default_animal_breedFactory(); + [animalBreed2] = await mocks.default_animal_breedFactory(); + }); + + // Top level structure is endpoint string with value as in caps, value is an array of tests + // Example: {'CREATE': [{test1}, {test2},...],'EDIT': [{test1}, {test2},...} + // Test structure to test 'CREATE' middleware is: + // { testName: 'name', getPostBody: function() {return [animal]}, postErr: {code: 400, message: 'errorMessage'}} + // Test structure to test 'EDIT' middleware is: + // { testName: 'name', getPostBody?: function() {return [animal]}, getPatchBody?: function() {return [editedAnimal]}, patchErr: {code: 400, message: 'errorMessage'}} } + // Test structure to test raw data expectations is: + // { testName: 'name', getPostBody?: function() {return [animal]}, getPatchBody?: function() {return [editedAnimal]}, getRawRecordMismatch: function() return { model: Model, where: {id}, getMatchingBody: function() return [records] } } } + const middlewareErrors = { + CREATE: [ + { + testName: 'Custom type cannot be used with default breed', + getPostBody: (customs) => [ + { + custom_type_id: customs.customAnimalType.id, + default_breed_id: animalBreed.id, + }, + ], + postErr: { + code: 400, + message: 'Default breed must use default type', + }, + }, + ], + EDIT: [ + { + testName: 'Exactly one type provided', + getPatchBody: (animal) => [ + { + id: animal.id, + default_type_id: animal.default_type_id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_type_id, custom_type_id, or type_name must be sent', + }, + }, + { + testName: 'Custom type id is number', + getPatchBody: (animal) => [ + { + id: animal.id, + custom_type_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Custom id exists', + getPatchBody: (animal) => [ + { + id: animal.id, + custom_type_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom type does not exist', + }, + }, + { + testName: 'Custom type does not belong to farm', + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: animal.id, + custom_type_id: customs.otherFarm.otherCustomAnimalType.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom type does not belong to this farm', + }, + }, + { + testName: 'Exactly one breed provided', + getPatchBody: (animal) => [ + { + id: animal.id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + breed_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Exactly one of default_breed_id, custom_breed_id, or breed_name must be sent', + }, + }, + { + testName: 'Default type matches default breed -- default type is changed', + getPatchBody: (animal, existingAnimals) => [ + { + id: existingAnimals[0].id, + default_type_id: animalBreed2.default_type_id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches default breed -- default breed is changed', + getPatchBody: (animal, existingAnimals) => [ + { + id: existingAnimals[0].id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default breed is a number', + getPatchBody: (animal) => [ + { + id: animal.id, + default_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Default breed provided exists (optional to provide)', + getPatchBody: (animal) => [ + { + id: animal.id, + default_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Default breed does not exist', + }, + }, + { + testName: 'Default type matches default breed -- both are changed but mismatch', + getPatchBody: (animal, existingAnimals) => [ + { + id: existingAnimals[0].id, + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed2.id, + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + default_breed_id: animalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + + { + testName: 'Custom breed is a number', + getPatchBody: (animal) => [ + { + id: animal.id, + custom_breed_id: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Must send valid ids', + }, + }, + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (animal) => [ + { + id: animal.id, + custom_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', + }, + }, + { + testName: 'Custom breed provided exists (optional to provide)', + getPatchBody: (animal) => [ + { + id: animal.id, + custom_breed_id: 1000000, + }, + ], + patchErr: { + code: 400, + message: 'Custom breed does not exist', + }, + }, + { + testName: 'Custom breed does not belong to farm', + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: animal.id, + custom_breed_id: customs.otherFarm.otherCustomAnimalBreed.id, + }, + ], + patchErr: { + code: 403, + message: 'Forbidden custom breed does not belong to this farm', + }, + }, + { + testName: 'Default type matches custom breed -- default type is changed', + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: existingAnimals[0].id, + default_type_id: animalBreed.default_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches custom breed -- custom type is changed', + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: existingAnimals[0].id, + custom_type_id: customs.customAnimalBreed2.custom_type_id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Default type matches custom breed -- breed and type are changed', + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: existingAnimals[0].id, + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed2.id, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + patchErr: { + code: 400, + message: 'Breed does not match type', + }, + }, + { + testName: 'Use relationships is an array', + getPatchBody: (animal) => [ + { + id: animal.id, + animal_use_relationships: 'string', + }, + ], + patchErr: { + code: 400, + message: 'animal_use_relationships should be an array', + }, + }, + { + testName: 'Other use notes is for other use type', + getPatchBody: (animal) => [ + { + id: animal.id, + animal_use_relationships: [ + { + use_id: animalUse2.id, + other_use: 'Leather', + }, + ], + }, + ], + patchErr: { + code: 400, + message: 'other_use notes is for other use type', + }, + }, + { + testName: 'Check edit use -- patching use relationship with empty array hard deletes use', + getRawRecordMismatch: (existingAnimals) => { + return { + model: AnimalUseRelationshipModel, + where: { animal_id: existingAnimals[0].id }, + getMatchingBody: (existingAnimals, records) => { + return []; + }, + }; + }, + getPatchBody: (animal, existingAnimals) => [ + { + id: existingAnimals[0].id, + animal_use_relationships: [], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + animal_use_relationships: [ + { + use_id: animalUse2.id, + }, + { + use_id: animalUse3.id, + }, + ], + }, + ], + }, + { + testName: + 'Check edit use -- patching use relationship requires all pre-existing uses to be present hard deletes missing', + getRawRecordMismatch: (existingAnimals) => { + return { + model: AnimalUseRelationshipModel, + where: { animal_id: existingAnimals[0].id }, + getMatchingBody: (existingAnimals, records) => { + return [ + { + ...records[0], + use_id: animalUse1.id, + other_use: 'Leather', + }, + ]; + }, + }; + }, + getPatchBody: (animal, existingAnimals) => [ + { + id: existingAnimals[0].id, + animal_use_relationships: [ + { + use_id: animalUse1.id, + other_use: 'Leather', + }, + ], + }, + ], + getPostBody: () => [ + { + default_type_id: animalBreed.default_type_id, + animal_use_relationships: [ + { + use_id: animalUse1.id, + }, + { + use_id: animalUse2.id, + }, + ], + }, + ], + }, + { + testName: 'Origin id must be brought in to have brought in date', + getPatchBody: (animal) => [ + { + id: animal.id, + origin_id: animalOrigin2.id, + brought_in_date: new Date(), + }, + ], + patchErr: { + code: 400, + message: 'Brought in date must be used with brought in origin', + }, + }, + ], + }; + + // Takes middleWareErrors object and makes it into individual tests + for (const errorEndpoint in middlewareErrors) { + middlewareErrors[errorEndpoint].forEach(async (error) => { + await test(`${errorEndpoint} Middleware: ${error.testName}`, async () => { + // Create userFarms needed for tests + const { mainFarm, user } = await returnUserFarms(1); + const { mainFarm: otherFarm } = await returnUserFarms(1); + + // Make, then group, farm specific resources + const [customAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [mainFarm], + }); + const [customAnimalBreed] = await mocks.custom_animal_breedFactory( + { + promisedFarm: [mainFarm], + }, + undefined, + false, + ); + const [customAnimalBreed2] = await mocks.custom_animal_breedFactory({ + promisedFarm: [mainFarm], + }); + const [otherCustomAnimalType] = await mocks.custom_animal_typeFactory({ + promisedFarm: [otherFarm], + }); + const [otherCustomAnimalBreed] = await mocks.custom_animal_breedFactory({ + promisedFarm: [otherFarm], + }); + const customs = { + customAnimalType, + customAnimalBreed, + customAnimalBreed2, + otherFarm: { otherCustomAnimalType, otherCustomAnimalBreed }, + }; + + // Post endpoint for testing CREATE or testing EDIT against existing record + const makeCheckGetAnimal = async (getPostBody) => { + const animals = getPostBody(customs).map((animal) => mocks.fakeAnimal(animal)); + const postRes = await postRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...animals], + ); + + // If checking error body on post + expect(postRes.status).toBe(error.postErr?.code || 201); + expect(postRes.error.text).toBe(error.postErr?.message || undefined); + return postRes.body; + }; + + let existingAnimals; + if (error.getPostBody) { + existingAnimals = await makeCheckGetAnimal(error.getPostBody); + } + + // Patch endpoint for testing EDIT or testing raw successful records against + const editCheckAnimal = async (getPatchBody) => { + // for skipping makeCheckGetAnimal + const animal = await makeAnimal(mainFarm, { + default_type_id: defaultTypeId, + }); + const animals = getPatchBody(animal, existingAnimals, customs); + const patchRes = await patchRequest( + { + user_id: user.user_id, + farm_id: mainFarm.farm_id, + }, + [...animals], + ); + console.log(error.testName); + console.log(patchRes); + // If checking error body on patch + expect(patchRes.status).toBe(error.patchErr?.code || 204); + expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); + }; + + if (error.getPatchBody) { + await editCheckAnimal(error.getPatchBody); + } + + // For checking raw records made in CREATE or EDIT + const rawGetMatch = async (getRawRecordMismatch) => { + const rawRecordMatch = getRawRecordMismatch(existingAnimals); + // Include deleted + const records = await rawRecordMatch.model + .query() + .where(rawRecordMatch.where) + .context({ showHidden: true }); + const expectedBody = rawRecordMatch.getMatchingBody(existingAnimals, records); + // No fallback if provided + expect(records).toEqual(expectedBody); + }; + + if (error.getRawRecordMismatch) { + await rawGetMatch(error.getRawRecordMismatch); + } + }); + }); + } + }); }); From c32864a3495f4e69ccee5613353d85c19b291d8a Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 18:54:17 -0400 Subject: [PATCH 34/45] LF-4380 Remove unreachable test --- packages/api/tests/animal_batch.test.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index ba412777a6..89ba05b674 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -2044,20 +2044,6 @@ describe('Animal Batch Tests', () => { message: 'Brought in date must be used with brought in origin', }, }, - { - testName: 'Cannot create a new type associated with an existing breed', - getPatchBody: (batch) => [ - { - id: batch.id, - defaultBreedId: animalBreed.id, - type_name: 'string', - }, - ], - patchErr: { - code: 400, - message: 'Cannot create a new type associated with an existing breed', - }, - }, ], }; From 2a1c5254c26520b6e429d751730ca0d7ac1175e5 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 18:56:16 -0400 Subject: [PATCH 35/45] LF-4380 Fix relation for findById function -- add comments to unreachable error --- .../validation/checkAnimalOrBatch.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 81bd034c24..26adc73c0e 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -316,6 +316,9 @@ const checkAndAddCustomTypesOrBreeds = ( } if (defaultBreedId || customBreedId) { + // Currently unreachable - test removed + // default breed - default breed must use default type - error already exists + // custom breed - breed does not match type - custom breed already has type associated could not possibly match a not existing type throw customError('Cannot create a new type associated with an existing breed'); } newTypesSet.add(type_name); @@ -348,16 +351,23 @@ const checkRemovalDataProvided = (animalOrBatch) => { }; const getRecordIfExists = async (animalOrBatch, animalOrBatchKey, farm_id) => { + const relations = + animalOrBatchKey === 'batch' + ? { + group_ids: true, + sex_detail: true, + animal_batch_use_relationships: true, + } + : { + group_ids: true, + animal_use_relationships: true, + }; return await AnimalOrBatchModel[animalOrBatchKey] .query() .findById(animalOrBatch.id) .where({ farm_id }) .whereNotDeleted() - .withGraphFetched({ - group_ids: true, - sex_detail: animalOrBatchKey === 'batch' ? true : false, - animal_batch_use_relationships: true, - }); + .withGraphFetched(relations); }; // Post loop checks From ece3996e38274b64d28c55eeccbbab9af8254b8d Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 19:15:53 -0400 Subject: [PATCH 36/45] LF-4380 Add animal identifier middleware and test --- .../validation/checkAnimalOrBatch.js | 20 +++++++++++++++++++ packages/api/tests/animal.test.js | 18 +++++++++++++++++ packages/api/tests/mock.factories.js | 4 ++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 26adc73c0e..ce7ed887a6 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -31,6 +31,7 @@ import DefaultAnimalBreedModel from '../../models/defaultAnimalBreedModel.js'; import CustomAnimalBreedModel from '../../models/customAnimalBreedModel.js'; import AnimalUseModel from '../../models/animalUseModel.js'; import AnimalOriginModel from '../../models/animalOriginModel.js'; +import AnimalIdentifierType from '../../models/animalIdentifierTypeModel.js'; const AnimalOrBatchModel = { animal: AnimalModel, @@ -292,6 +293,23 @@ const checkAnimalOrigin = async (animalOrBatch, creating = true) => { } }; +const checkAnimalIdentifier = async (animalOrBatch, animalOrBatchKey, creating = true) => { + if (animalOrBatchKey === 'animal') { + const { identifier_type_id, identifier_type_other } = animalOrBatch; + if (oneExists(['identifier_type_id', 'identifier_type_other'], animalOrBatch)) { + const otherIdentifier = await AnimalIdentifierType.query().where({ key: 'OTHER' }).first(); + // Overwrite date with null in db if editing origin_id + if (!creating && identifier_type_id != otherIdentifier.id) { + setFalsyValuesToNull(['identifier_type_other'], animalOrBatch); + } + + if (identifier_type_id != otherIdentifier.id && identifier_type_other) { + throw customError('Other identifier notes must be used with "other" identifier'); + } + } + } +}; + const checkAndAddCustomTypesOrBreeds = ( animalOrBatch, newTypesSet, @@ -427,6 +445,7 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); await checkAnimalOrigin(animalOrBatch); + await checkAnimalIdentifier(animalOrBatch, animalOrBatchKey); // Skip the process if type_name and breed_name are not passed if (!type_name && !breed_name) { @@ -484,6 +503,7 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); await checkAnimalOrigin(animalOrBatch, false); + await checkAnimalIdentifier(animalOrBatch, animalOrBatchKey, false); // Skip the process if type_name and breed_name are not passed if (!type_name && !breed_name) { diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index 89d0991859..c9302622da 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -48,6 +48,7 @@ describe('Animal Tests', () => { let animalRemovalReasonId; let animalUse1; let animalOrigin1; + let animalIdentifier1; const mockDate = new Date('2024/3/12').toISOString(); @@ -61,6 +62,7 @@ describe('Animal Tests', () => { [animalUse1] = await mocks.animal_useFactory('OTHER'); [animalOrigin1] = await mocks.animal_originFactory('BROUGHT_IN'); + [animalIdentifier1] = await mocks.animal_identifier_typeFactory(undefined, 'OTHER'); }); async function getRequest({ user_id = newOwner.user_id, farm_id = farm.farm_id }) { @@ -1334,6 +1336,7 @@ describe('Animal Tests', () => { let animalUse3; let animalBreed; let animalBreed2; + let animalIdentifier2; beforeEach(async () => { // Populate enums @@ -1342,6 +1345,7 @@ describe('Animal Tests', () => { [animalUse3] = await mocks.animal_useFactory(); [animalBreed] = await mocks.default_animal_breedFactory(); [animalBreed2] = await mocks.default_animal_breedFactory(); + [animalIdentifier2] = await mocks.animal_identifier_typeFactory(); }); // Top level structure is endpoint string with value as in caps, value is an array of tests @@ -1751,6 +1755,20 @@ describe('Animal Tests', () => { message: 'Brought in date must be used with brought in origin', }, }, + { + testName: 'Other identifier notes must be used with "other" identifier', + getPatchBody: (animal) => [ + { + id: animal.id, + identifier_type_id: animalIdentifier2.id, + identifier_type_other: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Other identifier notes must be used with "other" identifier', + }, + }, ], }; diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index b9a40d7a0c..24b5954212 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -2402,9 +2402,9 @@ async function animal_batchFactory( }); } -async function animal_identifier_typeFactory(rows = 1) { +async function animal_identifier_typeFactory(rows = 1, key = faker.lorem.word()) { return knex('animal_identifier_type') - .insert(Array(rows).fill({ key: faker.lorem.word() })) + .insert(Array(rows).fill({ key, id: key === 'OTHER' ? 3 : undefined })) .returning('*'); } From b79fc72f195be82f29f4f9aa4dc2e1dda0cca801 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 20:40:46 -0400 Subject: [PATCH 37/45] LF-4380 Cleanup groupIds comments --- packages/api/src/controllers/animalBatchController.js | 10 ---------- packages/api/src/controllers/animalController.js | 3 +-- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index 323a2d5a59..b388f726c2 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -65,8 +65,6 @@ const animalBatchController = { for (const animalBatch of req.body) { 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; @@ -78,12 +76,6 @@ 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 @@ -114,8 +106,6 @@ const animalBatchController = { // 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 desiredKeys = [ 'id', diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index e084015bb8..c5379319a4 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -67,7 +67,7 @@ const animalController = { 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); // Remove farm_id if it happens to be set in animal object since it should be obtained from header @@ -80,7 +80,6 @@ const animalController = { { trx }, ); - // TODO: Comment out for animals v1? // Format group_ids const groupIdMap = individualAnimalResult.group_ids?.map((group) => group.animal_group_id) || []; From 533c8c656d8c74244bb26689156920aa1c2a74e3 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Wed, 16 Oct 2024 20:41:06 -0400 Subject: [PATCH 38/45] LF-4380 Add licence --- packages/api/src/util/customErrors.js | 15 +++++++++++++++ packages/api/src/util/middleware.js | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/api/src/util/customErrors.js b/packages/api/src/util/customErrors.js index 98ad582048..2dcaae2de7 100644 --- a/packages/api/src/util/customErrors.js +++ b/packages/api/src/util/customErrors.js @@ -1,3 +1,18 @@ +/* + * Copyright 2024 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + import { oneTruthy, hasMultipleValues } from './middleware.js'; // Constructs a reusable error object diff --git a/packages/api/src/util/middleware.js b/packages/api/src/util/middleware.js index 6ec0dec5ab..f7730f34b6 100644 --- a/packages/api/src/util/middleware.js +++ b/packages/api/src/util/middleware.js @@ -1,3 +1,18 @@ +/* + * Copyright 2024 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + // Utils // Checks an array has more than one truthy value export const hasMultipleValues = (values) => { From a0de1e4356f67c3e4e474efc16d7e0c6f644e75f Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 17 Oct 2024 11:25:42 -0400 Subject: [PATCH 39/45] LF-4380 Add fix to new breed creation logic -- add back in previously though unreachable test --- .../middleware/validation/checkAnimalOrBatch.js | 5 +---- packages/api/tests/animal.test.js | 16 +++++++++++++++- packages/api/tests/animal_batch.test.js | 16 +++++++++++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index ce7ed887a6..41eed3f800 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -191,7 +191,7 @@ const checkAnimalBreed = async ( // Check if custom breed or custom type is present if ( (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) || - (oneExists(typeKeyOptions, animalOrBatch) && (default_type_id || custom_type_id)) + (oneExists(typeKeyOptions, animalOrBatch) && (default_type_id || custom_type_id) && !breed_name) ) { let customBreed; // Find customBreed if exists @@ -334,9 +334,6 @@ const checkAndAddCustomTypesOrBreeds = ( } if (defaultBreedId || customBreedId) { - // Currently unreachable - test removed - // default breed - default breed must use default type - error already exists - // custom breed - breed does not match type - custom breed already has type associated could not possibly match a not existing type throw customError('Cannot create a new type associated with an existing breed'); } newTypesSet.add(type_name); diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index c9302622da..30838f856d 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -1769,6 +1769,20 @@ describe('Animal Tests', () => { message: 'Other identifier notes must be used with "other" identifier', }, }, + { + testName: 'Cannot create a new type associated with an existing breed', + getPatchBody: (animal) => [ + { + id: animal.id, + defaultBreedId: animalBreed.id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Cannot create a new type associated with an existing breed', + }, + }, ], }; @@ -1862,7 +1876,7 @@ describe('Animal Tests', () => { .query() .where(rawRecordMatch.where) .context({ showHidden: true }); - const expectedBody = rawRecordMatch.getMatchingBody(existingAnimals, records); + const expectedBody = rawRecordMatch.getMatchingBody(existingAnimals, records, customs); // No fallback if provided expect(records).toEqual(expectedBody); }; diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index 89ba05b674..e8ee86711f 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -2044,6 +2044,20 @@ describe('Animal Batch Tests', () => { message: 'Brought in date must be used with brought in origin', }, }, + { + testName: 'Cannot create a new type associated with an existing breed', + getPatchBody: (batch) => [ + { + id: batch.id, + defaultBreedId: animalBreed.id, + type_name: 'string', + }, + ], + patchErr: { + code: 400, + message: 'Cannot create a new type associated with an existing breed', + }, + }, ], }; @@ -2135,7 +2149,7 @@ describe('Animal Batch Tests', () => { .query() .where(rawRecordMatch.where) .context({ showHidden: true }); - const expectedBody = rawRecordMatch.getMatchingBody(existingBatches, records); + const expectedBody = rawRecordMatch.getMatchingBody(existingBatches, records, customs); // No fallback if provided expect(records).toEqual(expectedBody); }; From 948d95678371cc4f98330ee8aaf13e624614bca3 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 17 Oct 2024 19:39:59 -0400 Subject: [PATCH 40/45] LF-4380 Simplify core existence logic remove extra functions --- packages/api/src/util/customErrors.js | 4 ++-- packages/api/src/util/middleware.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/src/util/customErrors.js b/packages/api/src/util/customErrors.js index 2dcaae2de7..451b2d577c 100644 --- a/packages/api/src/util/customErrors.js +++ b/packages/api/src/util/customErrors.js @@ -13,7 +13,7 @@ * GNU General Public License for more details, see . */ -import { oneTruthy, hasMultipleValues } from './middleware.js'; +import { notExactlyOneValue } from './middleware.js'; // Constructs a reusable error object export const customError = (message, code = 400, body = undefined) => { @@ -37,7 +37,7 @@ export const checkIdIsNumber = (id) => { }; export const checkExactlyOneIsProvided = (values, errorText) => { - if (oneTruthy(values) && hasMultipleValues(values)) { + if (notExactlyOneValue(values)) { throw customError(`Exactly one of ${errorText} must be sent`); } }; diff --git a/packages/api/src/util/middleware.js b/packages/api/src/util/middleware.js index f7730f34b6..df2cece691 100644 --- a/packages/api/src/util/middleware.js +++ b/packages/api/src/util/middleware.js @@ -14,10 +14,10 @@ */ // Utils -// Checks an array has more than one truthy value -export const hasMultipleValues = (values) => { +// Checks an array has exactly one truthy value +export const notExactlyOneValue = (values) => { const nonNullValues = values.filter(Boolean); - return nonNullValues.length > 1; + return !(nonNullValues.length === 1); }; // Checks an array of object keys against object -- at least one of the properties is defined From f8f8c055019f93bd0fc81ac6c12b2ade14f89325 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 17 Oct 2024 19:44:40 -0400 Subject: [PATCH 41/45] LF-4380 Fix case where nulling existing breed, fix case where adding new custom type or breed --- .../validation/checkAnimalOrBatch.js | 126 ++++++++++-------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 41eed3f800..748a98ac89 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -15,7 +15,7 @@ import { Model, transaction } from 'objection'; import { handleObjectionError } from '../../util/errorCodes.js'; -import { oneExists, setFalsyValuesToNull } from '../../util/middleware.js'; +import { oneExists, oneTruthy, setFalsyValuesToNull } from '../../util/middleware.js'; import { customError, checkIsArray, @@ -77,23 +77,23 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; // Skip if all undefined or editing (!creating) - if (creating || oneExists(typeKeyOptions, animalOrBatch)) { + if (creating || oneTruthy([default_type_id, custom_type_id, type_name])) { checkExactlyOneIsProvided( [default_type_id, custom_type_id, type_name], 'default_type_id, custom_type_id, or type_name', ); - if (!creating) { - // Overwrite with null in db if editing - setFalsyValuesToNull(typeKeyOptions, animalOrBatch); - } - if (custom_type_id) { - checkIdIsNumber(custom_type_id); - const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); - if (!customType) { - throw customError('Custom type does not exist'); - } - await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); + } + if (!creating && oneExists(typeKeyOptions, animalOrBatch)) { + // Overwrite with null in db if editing + setFalsyValuesToNull(typeKeyOptions, animalOrBatch); + } + if (custom_type_id) { + checkIdIsNumber(custom_type_id); + const customType = await CustomAnimalTypeModel.query().findById(custom_type_id); + if (!customType) { + throw customError('Custom type does not exist'); } + await checkRecordBelongsToFarm(customType, farm_id, 'custom type'); } }; @@ -167,63 +167,79 @@ const checkAnimalBreed = async ( breed_name, default_type_id, custom_type_id, + type_name, } = animalOrBatch; const breedKeyOptions = ['default_breed_id', 'custom_breed_id', 'breed_name']; const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; + // Check if breed is present - if (oneExists(breedKeyOptions, animalOrBatch)) { + if ( + (creating && oneExists(breedKeyOptions, animalOrBatch)) || + oneTruthy([default_breed_id, custom_breed_id, breed_name]) + ) { checkExactlyOneIsProvided( [default_breed_id, custom_breed_id, breed_name], 'default_breed_id, custom_breed_id, or breed_name', ); - if (!creating) { - // Overwrite with null in db if editing - setFalsyValuesToNull(breedKeyOptions, animalOrBatch); - } } - // Check if default breed or default type is present - if ( - (oneExists(breedKeyOptions, animalOrBatch) && default_breed_id) || - (oneExists(typeKeyOptions, animalOrBatch) && default_type_id) - ) { - await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); + // Check if breed is present + if (!creating && oneExists(breedKeyOptions, animalOrBatch)) { + // Overwrite with null in db if editing + setFalsyValuesToNull(breedKeyOptions, animalOrBatch); } - // Check if custom breed or custom type is present + if ( - (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) || - (oneExists(typeKeyOptions, animalOrBatch) && (default_type_id || custom_type_id) && !breed_name) + oneExists(breedKeyOptions, animalOrBatch) && + !oneTruthy([default_breed_id, custom_breed_id, breed_name]) ) { - let customBreed; - // Find customBreed if exists - if (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) { - checkIdIsNumber(custom_breed_id); - customBreed = await CustomAnimalBreedModel.query() - .whereNotDeleted() - .findById(custom_breed_id); - if (!customBreed) { - throw customError('Custom breed does not exist'); + // do nothing if nulling breed + } else { + // Check if default breed or default type is present + if ( + (oneExists(breedKeyOptions, animalOrBatch) && default_breed_id) || + (oneExists(typeKeyOptions, animalOrBatch) && default_type_id) + ) { + await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); + } + // Check if custom breed or custom type is present + if ( + (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id && !type_name) || + (oneExists(typeKeyOptions, animalOrBatch) && + (default_type_id || custom_type_id) && + !breed_name) + ) { + let customBreed; + // Find customBreed if exists + if (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) { + checkIdIsNumber(custom_breed_id); + customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(custom_breed_id); + if (!customBreed) { + throw customError('Custom breed does not exist'); + } + } else if (animalOrBatchRecord?.custom_breed_id) { + checkIdIsNumber(animalOrBatchRecord?.custom_breed_id); + customBreed = await CustomAnimalBreedModel.query() + .whereNotDeleted() + .findById(animalOrBatchRecord.custom_breed_id); + if (!customBreed) { + // This should not be possible + throw customError('Custom breed does not exist'); + } } - } else if (animalOrBatchRecord?.custom_breed_id) { - checkIdIsNumber(animalOrBatchRecord?.custom_breed_id); - customBreed = await CustomAnimalBreedModel.query() - .whereNotDeleted() - .findById(animalOrBatchRecord.custom_breed_id); - if (!customBreed) { - // This should not be possible - throw customError('Custom breed does not exist'); + // Check custom breed if exists + if (customBreed) { + await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); + checkCustomBreedMatchesType( + animalOrBatch, + animalOrBatchRecord, + customBreed, + default_type_id, + custom_type_id, + ); } } - // Check custom breed if exists - if (customBreed) { - await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); - checkCustomBreedMatchesType( - animalOrBatch, - animalOrBatchRecord, - customBreed, - default_type_id, - custom_type_id, - ); - } } }; From d660b06759b26bcabc01264847dea37e97bdf653 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Thu, 17 Oct 2024 19:45:11 -0400 Subject: [PATCH 42/45] LF-4380 Add tests for case where nulling existing breed, fix case where adding new custom type or breed --- packages/api/tests/animal.test.js | 75 ++++++++++++++++++++++-- packages/api/tests/animal_batch.test.js | 77 +++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 12 deletions(-) diff --git a/packages/api/tests/animal.test.js b/packages/api/tests/animal.test.js index 30838f856d..4156b8844d 100644 --- a/packages/api/tests/animal.test.js +++ b/packages/api/tests/animal.test.js @@ -1771,10 +1771,10 @@ describe('Animal Tests', () => { }, { testName: 'Cannot create a new type associated with an existing breed', - getPatchBody: (animal) => [ + getPatchBody: (animal, existingAnimals, customs) => [ { id: animal.id, - defaultBreedId: animalBreed.id, + custom_breed_id: customs.customAnimalBreed.id, type_name: 'string', }, ], @@ -1783,6 +1783,68 @@ describe('Animal Tests', () => { message: 'Cannot create a new type associated with an existing breed', }, }, + { + testName: 'Change to custom type and null pre-existing breed', + getRawRecordMismatch: (existingAnimals) => { + return { + model: AnimalModel, + where: { id: existingAnimals[0].id }, + getMatchingBody: (existingAnimals, records, customs) => { + return [ + { + ...records[0], + custom_type_id: customs.customAnimalType.id, + custom_breed_id: null, + }, + ]; + }, + }; + }, + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: existingAnimals[0].id, + custom_type_id: customs.customAnimalType.id, + custom_breed_id: null, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + }, + { + testName: + 'Successfully edit Custom type matches new breed name -- previous breed not exist', + getRawRecordMismatch: (existingAnimals, patchedAnimals) => { + return { + model: CustomAnimalBreedModel, + where: { id: patchedAnimals.custom_breed_id }, + getMatchingBody: (existingAnimals, records, customs) => { + return [ + { + ...records[0], + custom_type_id: customs.customAnimalType.id, + breed: 'New breed here', + }, + ]; + }, + }; + }, + getPatchBody: (animal, existingAnimals, customs) => [ + { + id: existingAnimals[0].id, + custom_type_id: customs.customAnimalType.id, + breed_name: 'New breed here', + }, + ], + getPostBody: () => [ + { + default_type_id: defaultTypeId, + }, + ], + }, ], }; @@ -1857,20 +1919,21 @@ describe('Animal Tests', () => { }, [...animals], ); - console.log(error.testName); - console.log(patchRes); // If checking error body on patch expect(patchRes.status).toBe(error.patchErr?.code || 204); expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); + const batchesIds = animals.map((animal) => animal.id); + return await AnimalModel.query().findById(batchesIds); }; + let patchedAnimals; if (error.getPatchBody) { - await editCheckAnimal(error.getPatchBody); + patchedAnimals = await editCheckAnimal(error.getPatchBody); } // For checking raw records made in CREATE or EDIT const rawGetMatch = async (getRawRecordMismatch) => { - const rawRecordMatch = getRawRecordMismatch(existingAnimals); + const rawRecordMatch = getRawRecordMismatch(existingAnimals, patchedAnimals); // Include deleted const records = await rawRecordMatch.model .query() diff --git a/packages/api/tests/animal_batch.test.js b/packages/api/tests/animal_batch.test.js index e8ee86711f..95a3bd69f4 100644 --- a/packages/api/tests/animal_batch.test.js +++ b/packages/api/tests/animal_batch.test.js @@ -1363,7 +1363,7 @@ describe('Animal Batch Tests', () => { // Test structure to test 'CREATE' middleware is: // { testName: 'name', getPostBody: function() {return [batch]}, postErr: {code: 400, message: 'errorMessage'}} // Test structure to test 'EDIT' middleware is: - // { testName: 'name', getPostBody?: function() {return [batch]}, getPatchBody?: function() {return [editedBatch]}, patchErr: {code: 400, message: 'errorMessage'}} } + // { testName: 'name', getPostBody?: function() {return [batch]}, getPatchBody: function() {return [editedBatch]}, patchErr: {code: 400, message: 'errorMessage'}} } // Test structure to test raw data expectations is: // { testName: 'name', getPostBody?: function() {return [batch]}, getPatchBody?: function() {return [editedBatch]}, getRawRecordMismatch: function() return { model: Model, where: {id}, getMatchingBody: function() return [records] } } } const middlewareErrors = { @@ -1385,6 +1385,7 @@ describe('Animal Batch Tests', () => { testName: 'Check create batch sex detail', getPostBody: (customs) => [ { + default_type_id: defaultTypeId, count: 3, sex_detail: [ { @@ -2046,10 +2047,10 @@ describe('Animal Batch Tests', () => { }, { testName: 'Cannot create a new type associated with an existing breed', - getPatchBody: (batch) => [ + getPatchBody: (batch, existingBatches, customs) => [ { id: batch.id, - defaultBreedId: animalBreed.id, + custom_breed_id: customs.customAnimalBreed.id, type_name: 'string', }, ], @@ -2058,6 +2059,68 @@ describe('Animal Batch Tests', () => { message: 'Cannot create a new type associated with an existing breed', }, }, + { + testName: 'Change to custom type and null pre-existing breed', + getRawRecordMismatch: (existingBatches) => { + return { + model: AnimalBatchModel, + where: { id: existingBatches[0].id }, + getMatchingBody: (existingBatches, records, customs) => { + return [ + { + ...records[0], + custom_type_id: customs.customAnimalType.id, + custom_breed_id: null, + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + custom_type_id: customs.customAnimalType.id, + custom_breed_id: null, + }, + ], + getPostBody: (customs) => [ + { + default_type_id: customs.customAnimalBreed.default_type_id, + custom_breed_id: customs.customAnimalBreed.id, + }, + ], + }, + { + testName: + 'Successfully edit Custom type matches new breed name -- previous breed not exist', + getRawRecordMismatch: (existingBatches, patchedBatches) => { + return { + model: CustomAnimalBreedModel, + where: { id: patchedBatches.custom_breed_id }, + getMatchingBody: (existingBatches, records, customs) => { + return [ + { + ...records[0], + custom_type_id: customs.customAnimalType.id, + breed: 'New breed here', + }, + ]; + }, + }; + }, + getPatchBody: (batch, existingBatches, customs) => [ + { + id: existingBatches[0].id, + custom_type_id: customs.customAnimalType.id, + breed_name: 'New breed here', + }, + ], + getPostBody: () => [ + { + default_type_id: defaultTypeId, + }, + ], + }, ], }; @@ -2106,7 +2169,6 @@ describe('Animal Batch Tests', () => { }, [...batches], ); - // If checking error body on post expect(postRes.status).toBe(error.postErr?.code || 201); expect(postRes.error.text).toBe(error.postErr?.message || undefined); @@ -2135,15 +2197,18 @@ describe('Animal Batch Tests', () => { // If checking error body on patch expect(patchRes.status).toBe(error.patchErr?.code || 204); expect(patchRes.error.text).toBe(error.patchErr?.message || undefined); + const batchesIds = batches.map((batch) => batch.id); + return await AnimalBatchModel.query().findById(batchesIds); }; + let patchedBatches; if (error.getPatchBody) { - await editCheckBatch(error.getPatchBody); + patchedBatches = await editCheckBatch(error.getPatchBody); } // For checking raw records made in CREATE or EDIT const rawGetMatch = async (getRawRecordMismatch) => { - const rawRecordMatch = getRawRecordMismatch(existingBatches); + const rawRecordMatch = getRawRecordMismatch(existingBatches, patchedBatches); // Include deleted const records = await rawRecordMatch.model .query() From d196becce9a69d293a9b947beab72d077338d877 Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 18 Oct 2024 19:47:18 -0400 Subject: [PATCH 43/45] LF-4380 Revert utility object on req --- .../src/controllers/animalBatchController.js | 33 +++++++++++-------- .../api/src/controllers/animalController.js | 33 +++++++++++-------- packages/api/src/util/animal.js | 10 ++++-- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index b388f726c2..b3c8d5b96e 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -60,11 +60,18 @@ const animalBatchController = { const result = []; // Create utility object used in type and breed - req.body.typeIdsMap = {}; - req.body.typeBreedIdsMap = {}; + const typeIdsMap = {}; + const typeBreedIdsMap = {}; for (const animalBatch of req.body) { - await checkAndAddCustomTypeAndBreed(req, animalBatch, farm_id, trx); + await checkAndAddCustomTypeAndBreed( + req, + typeIdsMap, + typeBreedIdsMap, + animalBatch, + 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; @@ -78,9 +85,6 @@ const animalBatchController = { result.push(individualAnimalBatchResult); } - // delete utility objects - delete req.body.typeIdsMap; - delete req.body.typeBreedIdsMap; await trx.commit(); @@ -100,12 +104,19 @@ const animalBatchController = { const { farm_id } = req.headers; // Create utility object used in type and breed - req.body.typeIdsMap = {}; - req.body.typeBreedIdsMap = {}; + const typeIdsMap = {}; + const typeBreedIdsMap = {}; // select only allowed properties to edit for (const animalBatch of req.body) { - await checkAndAddCustomTypeAndBreed(req, animalBatch, farm_id, trx); + await checkAndAddCustomTypeAndBreed( + req, + typeIdsMap, + typeBreedIdsMap, + animalBatch, + farm_id, + trx, + ); const desiredKeys = [ 'id', @@ -131,10 +142,6 @@ const animalBatchController = { await baseController.upsertGraph(AnimalBatchModel, data, 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(); diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index c5379319a4..a172ebc208 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -62,11 +62,18 @@ const animalController = { const result = []; // Create utility object used in type and breed - req.body.typeIdsMap = {}; - req.body.typeBreedIdsMap = {}; + const typeIdsMap = {}; + const typeBreedIdsMap = {}; for (const animal of req.body) { - await checkAndAddCustomTypeAndBreed(req, animal, farm_id, trx); + await checkAndAddCustomTypeAndBreed( + req, + typeIdsMap, + typeBreedIdsMap, + animal, + farm_id, + trx, + ); await checkAndAddGroup(req, animal, farm_id, trx); @@ -88,10 +95,6 @@ const animalController = { result.push(individualAnimalResult); } - // delete utility objects - delete req.body.typeIdsMap; - delete req.body.typeBreedIdsMap; - await trx.commit(); await assignInternalIdentifiers(result, 'animal'); @@ -109,12 +112,19 @@ const animalController = { try { const { farm_id } = req.headers; // Create utility object used in type and breed - req.body.typeIdsMap = {}; - req.body.typeBreedIdsMap = {}; + const typeIdsMap = {}; + const typeBreedIdsMap = {}; // select only allowed properties to edit for (const animal of req.body) { - await checkAndAddCustomTypeAndBreed(req, animal, farm_id, trx); + await checkAndAddCustomTypeAndBreed( + req, + typeIdsMap, + typeBreedIdsMap, + animal, + farm_id, + trx, + ); // TODO: Comment out for animals v1? await checkAndAddGroup(req, animal, farm_id, trx); @@ -153,9 +163,6 @@ const animalController = { await baseController.upsertGraph(AnimalModel, data, 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 diff --git a/packages/api/src/util/animal.js b/packages/api/src/util/animal.js index 5e14df22a2..2899997613 100644 --- a/packages/api/src/util/animal.js +++ b/packages/api/src/util/animal.js @@ -53,10 +53,16 @@ export const assignInternalIdentifiers = async (records, kind) => { * * @throws {Error} - If any database operation fails. */ -export const checkAndAddCustomTypeAndBreed = async (req, animalOrBatch, farm_id, trx) => { +export const checkAndAddCustomTypeAndBreed = async ( + req, + typeIdsMap, + typeBreedIdsMap, + 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]; From c76bc27fd32320893fcd144cbd4b618e0b212b4b Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 18 Oct 2024 20:13:15 -0400 Subject: [PATCH 44/45] LF-4380 Address PR feedback about desired keys, groupIdMap, oneTruthy naming, handling false and zero --- .../src/controllers/animalBatchController.js | 37 ++++++----- .../api/src/controllers/animalController.js | 65 +++++++++---------- .../validation/checkAnimalOrBatch.js | 39 ++++++----- packages/api/src/util/middleware.js | 10 +-- 4 files changed, 78 insertions(+), 73 deletions(-) diff --git a/packages/api/src/controllers/animalBatchController.js b/packages/api/src/controllers/animalBatchController.js index b3c8d5b96e..0a498703c8 100644 --- a/packages/api/src/controllers/animalBatchController.js +++ b/packages/api/src/controllers/animalBatchController.js @@ -107,6 +107,25 @@ const animalBatchController = { const typeIdsMap = {}; const typeBreedIdsMap = {}; + const desiredKeys = [ + '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', + ]; + // select only allowed properties to edit for (const animalBatch of req.body) { await checkAndAddCustomTypeAndBreed( @@ -118,24 +137,6 @@ const animalBatchController = { trx, ); - const desiredKeys = [ - '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', - ]; const keysExisting = desiredKeys.filter((key) => key in animalBatch); const data = _pick(animalBatch, keysExisting); diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index a172ebc208..ac91272561 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -88,9 +88,8 @@ const animalController = { ); // Format group_ids - const groupIdMap = + individualAnimalResult.group_ids = individualAnimalResult.group_ids?.map((group) => group.animal_group_id) || []; - individualAnimalResult.group_ids = groupIdMap; result.push(individualAnimalResult); } @@ -115,6 +114,36 @@ const animalController = { const typeIdsMap = {}; const typeBreedIdsMap = {}; + const desiredKeys = [ + 'id', + 'custom_breed_id', + 'custom_type_id', + 'default_breed_id', + 'default_type_id', + 'sex_id', + 'name', + 'birth_date', + 'identifier', + 'identifier_color_id', + 'identifier_placement_id', + 'identifier_type_id', + 'identifier_type_other', + 'origin_id', + 'dam', + 'sire', + 'brought_in_date', + 'weaning_date', + 'notes', + 'photo_url', + 'organic_status', + 'supplier', + 'price', + 'sex_detail', + 'origin_id', + 'group_ids', + 'animal_use_relationships', + ]; + // select only allowed properties to edit for (const animal of req.body) { await checkAndAddCustomTypeAndBreed( @@ -125,38 +154,8 @@ const animalController = { farm_id, trx, ); - // TODO: Comment out for animals v1? - await checkAndAddGroup(req, animal, farm_id, trx); - const desiredKeys = [ - 'id', - 'custom_breed_id', - 'custom_type_id', - 'default_breed_id', - 'default_type_id', - 'sex_id', - 'name', - 'birth_date', - 'identifier', - 'identifier_color_id', - 'identifier_placement_id', - 'identifier_type_id', - 'identifier_type_other', - 'origin_id', - 'dam', - 'sire', - 'brought_in_date', - 'weaning_date', - 'notes', - 'photo_url', - 'organic_status', - 'supplier', - 'price', - 'sex_detail', - 'origin_id', - 'group_ids', - 'animal_use_relationships', - ]; + await checkAndAddGroup(req, animal, farm_id, trx); const keysExisting = desiredKeys.filter((key) => key in animal); const data = _pick(animal, keysExisting); diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 748a98ac89..2f15312214 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -15,7 +15,7 @@ import { Model, transaction } from 'objection'; import { handleObjectionError } from '../../util/errorCodes.js'; -import { oneExists, oneTruthy, setFalsyValuesToNull } from '../../util/middleware.js'; +import { someExists, someTruthy, setFalsyValuesToNull } from '../../util/middleware.js'; import { customError, checkIsArray, @@ -77,13 +77,13 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { const { default_type_id, custom_type_id, type_name } = animalOrBatch; const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; // Skip if all undefined or editing (!creating) - if (creating || oneTruthy([default_type_id, custom_type_id, type_name])) { + if (creating || someTruthy([default_type_id, custom_type_id, type_name])) { checkExactlyOneIsProvided( [default_type_id, custom_type_id, type_name], 'default_type_id, custom_type_id, or type_name', ); } - if (!creating && oneExists(typeKeyOptions, animalOrBatch)) { + if (!creating && someExists(typeKeyOptions, animalOrBatch)) { // Overwrite with null in db if editing setFalsyValuesToNull(typeKeyOptions, animalOrBatch); } @@ -141,7 +141,7 @@ const checkCustomBreedMatchesType = ( const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; // If not editing type, check record type - if (!oneExists(typeKeyOptions, animalOrBatch) && animalOrBatchRecord) { + if (!someExists(typeKeyOptions, animalOrBatch) && animalOrBatchRecord) { defaultTypeId = animalOrBatchRecord.default_type_id; customTypeId = animalOrBatchRecord.custom_type_id; } @@ -174,8 +174,8 @@ const checkAnimalBreed = async ( // Check if breed is present if ( - (creating && oneExists(breedKeyOptions, animalOrBatch)) || - oneTruthy([default_breed_id, custom_breed_id, breed_name]) + (creating && someExists(breedKeyOptions, animalOrBatch)) || + someTruthy([default_breed_id, custom_breed_id, breed_name]) ) { checkExactlyOneIsProvided( [default_breed_id, custom_breed_id, breed_name], @@ -183,34 +183,34 @@ const checkAnimalBreed = async ( ); } // Check if breed is present - if (!creating && oneExists(breedKeyOptions, animalOrBatch)) { + if (!creating && someExists(breedKeyOptions, animalOrBatch)) { // Overwrite with null in db if editing setFalsyValuesToNull(breedKeyOptions, animalOrBatch); } if ( - oneExists(breedKeyOptions, animalOrBatch) && - !oneTruthy([default_breed_id, custom_breed_id, breed_name]) + someExists(breedKeyOptions, animalOrBatch) && + !someTruthy([default_breed_id, custom_breed_id, breed_name]) ) { // do nothing if nulling breed } else { // Check if default breed or default type is present if ( - (oneExists(breedKeyOptions, animalOrBatch) && default_breed_id) || - (oneExists(typeKeyOptions, animalOrBatch) && default_type_id) + (someExists(breedKeyOptions, animalOrBatch) && default_breed_id) || + (someExists(typeKeyOptions, animalOrBatch) && default_type_id) ) { await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); } // Check if custom breed or custom type is present if ( - (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id && !type_name) || - (oneExists(typeKeyOptions, animalOrBatch) && + (someExists(breedKeyOptions, animalOrBatch) && custom_breed_id && !type_name) || + (someExists(typeKeyOptions, animalOrBatch) && (default_type_id || custom_type_id) && !breed_name) ) { let customBreed; // Find customBreed if exists - if (oneExists(breedKeyOptions, animalOrBatch) && custom_breed_id) { + if (someExists(breedKeyOptions, animalOrBatch) && custom_breed_id) { checkIdIsNumber(custom_breed_id); customBreed = await CustomAnimalBreedModel.query() .whereNotDeleted() @@ -296,7 +296,7 @@ const checkAnimalUseRelationship = async (animalOrBatch, animalOrBatchKey) => { const checkAnimalOrigin = async (animalOrBatch, creating = true) => { const { origin_id, brought_in_date } = animalOrBatch; - if (oneExists(['origin_id', 'brought_in_date'], animalOrBatch)) { + if (someExists(['origin_id', 'brought_in_date'], animalOrBatch)) { const broughtInOrigin = await AnimalOriginModel.query().where({ key: 'BROUGHT_IN' }).first(); // Overwrite date with null in db if editing origin_id if (!creating && origin_id != broughtInOrigin.id) { @@ -312,7 +312,7 @@ const checkAnimalOrigin = async (animalOrBatch, creating = true) => { const checkAnimalIdentifier = async (animalOrBatch, animalOrBatchKey, creating = true) => { if (animalOrBatchKey === 'animal') { const { identifier_type_id, identifier_type_other } = animalOrBatch; - if (oneExists(['identifier_type_id', 'identifier_type_other'], animalOrBatch)) { + if (someExists(['identifier_type_id', 'identifier_type_other'], animalOrBatch)) { const otherIdentifier = await AnimalIdentifierType.query().where({ key: 'OTHER' }).first(); // Overwrite date with null in db if editing origin_id if (!creating && identifier_type_id != otherIdentifier.id) { @@ -344,7 +344,10 @@ const checkAndAddCustomTypesOrBreeds = ( let defaultBreedId = default_breed_id; let customBreedId = custom_breed_id; - if (!oneExists(['default_breed_id', 'custom_breed_id'], animalOrBatch) && animalOrBatchRecord) { + if ( + !someExists(['default_breed_id', 'custom_breed_id'], animalOrBatch) && + animalOrBatchRecord + ) { defaultBreedId = animalOrBatchRecord.default_breed_id; customBreedId = animalOrBatchRecord.custom_breed_id; } @@ -361,7 +364,7 @@ const checkAndAddCustomTypesOrBreeds = ( let defaultTypeId = default_type_id; let customTypeId = custom_type_id; - if (!oneExists(['default_type_id', 'custom_type_id'], animalOrBatch) && animalOrBatchRecord) { + if (!someExists(['default_type_id', 'custom_type_id'], animalOrBatch) && animalOrBatchRecord) { defaultTypeId = animalOrBatchRecord.default_type_id; customTypeId = animalOrBatchRecord.custom_type_id; } diff --git a/packages/api/src/util/middleware.js b/packages/api/src/util/middleware.js index df2cece691..5801be1a1d 100644 --- a/packages/api/src/util/middleware.js +++ b/packages/api/src/util/middleware.js @@ -21,16 +21,18 @@ export const notExactlyOneValue = (values) => { }; // Checks an array of object keys against object -- at least one of the properties is defined -export const oneExists = (keys, object) => { +export const someExists = (keys, object) => { return keys.some((key) => key in object); }; // Checks an array for at least one truthy value -export const oneTruthy = (values) => values.some((value) => !!value); +export const someTruthy = (values) => values.some((value) => !!value); -// Sets falsy values to null for editing values that may have values for exclusive constraints -- does not handle zeros yet +// Sets falsy values to null for editing values that may have values for exclusive constraints export const setFalsyValuesToNull = (array, obj) => { for (const val of array) { - obj[val] = obj[val] || null; + if (obj[val] !== 0 && obj[val] !== false) { + obj[val] ??= null; + } } }; From 595596daba6a83e8e1e92c6182931e03d3fd628e Mon Sep 17 00:00:00 2001 From: Duncan-Brain Date: Fri, 18 Oct 2024 21:28:08 -0400 Subject: [PATCH 45/45] LF-4380 Rename upsertGraph, checkAnimalSexDetail, preexistingAnimalOrBAtch and improve sex logic --- .../api/src/controllers/animalController.js | 6 +- .../validation/checkAnimalOrBatch.js | 89 +++++++++---------- packages/api/src/util/animal.js | 2 +- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/api/src/controllers/animalController.js b/packages/api/src/controllers/animalController.js index ac91272561..a154a02f88 100644 --- a/packages/api/src/controllers/animalController.js +++ b/packages/api/src/controllers/animalController.js @@ -18,7 +18,7 @@ import AnimalModel from '../models/animalModel.js'; import baseController from './baseController.js'; import { assignInternalIdentifiers, - checkAndAddGroup, + upsertGroup, checkAndAddCustomTypeAndBreed, } from '../util/animal.js'; import { handleObjectionError } from '../util/errorCodes.js'; @@ -75,7 +75,7 @@ const animalController = { trx, ); - await checkAndAddGroup(req, animal, farm_id, trx); + await upsertGroup(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; @@ -155,7 +155,7 @@ const animalController = { trx, ); - await checkAndAddGroup(req, animal, farm_id, trx); + await upsertGroup(req, animal, farm_id, trx); const keysExisting = desiredKeys.filter((key) => key in animal); const data = _pick(animal, keysExisting); diff --git a/packages/api/src/middleware/validation/checkAnimalOrBatch.js b/packages/api/src/middleware/validation/checkAnimalOrBatch.js index 2f15312214..9cc5fa9faf 100644 --- a/packages/api/src/middleware/validation/checkAnimalOrBatch.js +++ b/packages/api/src/middleware/validation/checkAnimalOrBatch.js @@ -98,7 +98,7 @@ const checkAnimalType = async (animalOrBatch, farm_id, creating = true) => { }; const checkDefaultBreedMatchesType = async ( - animalOrBatchRecord, + preexistingAnimalOrBatch, default_breed_id, default_type_id, ) => { @@ -107,11 +107,11 @@ const checkDefaultBreedMatchesType = async ( let defaultTypeId = default_type_id; // If breed or type is not changed get from record - if (!defaultBreedId && animalOrBatchRecord) { - defaultBreedId = animalOrBatchRecord.default_breed_id; + if (!defaultBreedId && preexistingAnimalOrBatch) { + defaultBreedId = preexistingAnimalOrBatch.default_breed_id; } - if (!defaultTypeId && animalOrBatchRecord) { - defaultTypeId = animalOrBatchRecord.default_type_id; + if (!defaultTypeId && preexistingAnimalOrBatch) { + defaultTypeId = preexistingAnimalOrBatch.default_type_id; } if (defaultTypeId && defaultBreedId) { @@ -130,7 +130,7 @@ const checkDefaultBreedMatchesType = async ( const checkCustomBreedMatchesType = ( animalOrBatch, - animalOrBatchRecord, + preexistingAnimalOrBatch, customBreed, default_type_id, custom_type_id, @@ -141,9 +141,9 @@ const checkCustomBreedMatchesType = ( const typeKeyOptions = ['default_type_id', 'custom_type_id', 'type_name']; // If not editing type, check record type - if (!someExists(typeKeyOptions, animalOrBatch) && animalOrBatchRecord) { - defaultTypeId = animalOrBatchRecord.default_type_id; - customTypeId = animalOrBatchRecord.custom_type_id; + if (!someExists(typeKeyOptions, animalOrBatch) && preexistingAnimalOrBatch) { + defaultTypeId = preexistingAnimalOrBatch.default_type_id; + customTypeId = preexistingAnimalOrBatch.custom_type_id; } // Custom breed does not match type if defaultId OR customTypeId does not match @@ -158,7 +158,7 @@ const checkCustomBreedMatchesType = ( const checkAnimalBreed = async ( animalOrBatch, farm_id, - animalOrBatchRecord = undefined, + preexistingAnimalOrBatch = undefined, creating = true, ) => { const { @@ -199,7 +199,11 @@ const checkAnimalBreed = async ( (someExists(breedKeyOptions, animalOrBatch) && default_breed_id) || (someExists(typeKeyOptions, animalOrBatch) && default_type_id) ) { - await checkDefaultBreedMatchesType(animalOrBatchRecord, default_breed_id, default_type_id); + await checkDefaultBreedMatchesType( + preexistingAnimalOrBatch, + default_breed_id, + default_type_id, + ); } // Check if custom breed or custom type is present if ( @@ -218,11 +222,11 @@ const checkAnimalBreed = async ( if (!customBreed) { throw customError('Custom breed does not exist'); } - } else if (animalOrBatchRecord?.custom_breed_id) { - checkIdIsNumber(animalOrBatchRecord?.custom_breed_id); + } else if (preexistingAnimalOrBatch?.custom_breed_id) { + checkIdIsNumber(preexistingAnimalOrBatch?.custom_breed_id); customBreed = await CustomAnimalBreedModel.query() .whereNotDeleted() - .findById(animalOrBatchRecord.custom_breed_id); + .findById(preexistingAnimalOrBatch.custom_breed_id); if (!customBreed) { // This should not be possible throw customError('Custom breed does not exist'); @@ -233,7 +237,7 @@ const checkAnimalBreed = async ( await checkRecordBelongsToFarm(customBreed, farm_id, 'custom breed'); checkCustomBreedMatchesType( animalOrBatch, - animalOrBatchRecord, + preexistingAnimalOrBatch, customBreed, default_type_id, custom_type_id, @@ -243,27 +247,17 @@ const checkAnimalBreed = async ( } }; -const checkBatchSexDetail = async ( +const checkAnimalSexDetail = async ( animalOrBatch, animalOrBatchKey, - animalOrBatchRecord = undefined, + preexistingAnimalOrBatch = undefined, ) => { if (animalOrBatchKey === 'batch') { - let count = animalOrBatch.count; - let sexDetail = animalOrBatch.sex_detail; - if (!count && animalOrBatchRecord) { - count = animalOrBatchRecord.count; - } - if (!sexDetail && animalOrBatchRecord) { - sexDetail = animalOrBatchRecord.sex_detail; - } + const count = animalOrBatch.count ?? preexistingAnimalOrBatch?.count; + const sexDetail = animalOrBatch.sex_detail ?? preexistingAnimalOrBatch?.sex_detail; if (sexDetail?.length) { - let sexCount = 0; - const sexIdSet = new Set(); - sexDetail.forEach((detail) => { - sexCount += detail.count; - sexIdSet.add(detail.sex_id); - }); + const sexCount = sexDetail.reduce((sum, detail) => sum + detail.count, 0); + const sexIdSet = new Set(sexDetail.map((detail) => detail.sex_id)); if (sexCount > count) { throw customError('Batch count must be greater than or equal to sex detail count'); } @@ -330,7 +324,7 @@ const checkAndAddCustomTypesOrBreeds = ( animalOrBatch, newTypesSet, newBreedsSet, - animalOrBatchRecord = undefined, + preexistingAnimalOrBatch = undefined, ) => { const { type_name, @@ -346,10 +340,10 @@ const checkAndAddCustomTypesOrBreeds = ( if ( !someExists(['default_breed_id', 'custom_breed_id'], animalOrBatch) && - animalOrBatchRecord + preexistingAnimalOrBatch ) { - defaultBreedId = animalOrBatchRecord.default_breed_id; - customBreedId = animalOrBatchRecord.custom_breed_id; + defaultBreedId = preexistingAnimalOrBatch.default_breed_id; + customBreedId = preexistingAnimalOrBatch.custom_breed_id; } if (defaultBreedId || customBreedId) { @@ -364,9 +358,12 @@ const checkAndAddCustomTypesOrBreeds = ( let defaultTypeId = default_type_id; let customTypeId = custom_type_id; - if (!someExists(['default_type_id', 'custom_type_id'], animalOrBatch) && animalOrBatchRecord) { - defaultTypeId = animalOrBatchRecord.default_type_id; - customTypeId = animalOrBatchRecord.custom_type_id; + if ( + !someExists(['default_type_id', 'custom_type_id'], animalOrBatch) && + preexistingAnimalOrBatch + ) { + defaultTypeId = preexistingAnimalOrBatch.default_type_id; + customTypeId = preexistingAnimalOrBatch.custom_type_id; } const breedDetails = customTypeId @@ -458,7 +455,7 @@ export function checkCreateAnimalOrBatch(animalOrBatchKey) { await checkAnimalType(animalOrBatch, farm_id); await checkAnimalBreed(animalOrBatch, farm_id); - await checkBatchSexDetail(animalOrBatch, animalOrBatchKey); + await checkAnimalSexDetail(animalOrBatch, animalOrBatchKey); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); await checkAnimalOrigin(animalOrBatch); await checkAnimalIdentifier(animalOrBatch, animalOrBatchKey); @@ -504,19 +501,19 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { for (const animalOrBatch of req.body) { const { type_name, breed_name } = animalOrBatch; checkIdIsNumber(animalOrBatch.id); - const animalOrBatchRecord = await getRecordIfExists( + const preexistingAnimalOrBatch = await getRecordIfExists( animalOrBatch, animalOrBatchKey, farm_id, ); - if (!animalOrBatchRecord) { + if (!preexistingAnimalOrBatch) { invalidIds.push(animalOrBatch.id); continue; } await checkAnimalType(animalOrBatch, farm_id, false); - await checkAnimalBreed(animalOrBatch, farm_id, animalOrBatchRecord, false); - await checkBatchSexDetail(animalOrBatch, animalOrBatchKey, animalOrBatchRecord); + await checkAnimalBreed(animalOrBatch, farm_id, preexistingAnimalOrBatch, false); + await checkAnimalSexDetail(animalOrBatch, animalOrBatchKey, preexistingAnimalOrBatch); await checkAnimalUseRelationship(animalOrBatch, animalOrBatchKey); await checkAnimalOrigin(animalOrBatch, false); await checkAnimalIdentifier(animalOrBatch, animalOrBatchKey, false); @@ -529,7 +526,7 @@ export function checkEditAnimalOrBatch(animalOrBatchKey) { animalOrBatch, newTypesSet, newBreedsSet, - animalOrBatchRecord, + preexistingAnimalOrBatch, ); } @@ -567,12 +564,12 @@ export function checkRemoveAnimalOrBatch(animalOrBatchKey) { checkRemovalDataProvided(animalOrBatch); checkIdIsNumber(animalOrBatch.id); - const animalOrBatchRecord = await getRecordIfExists( + const preexistingAnimalOrBatch = await getRecordIfExists( animalOrBatch, animalOrBatchKey, farm_id, ); - if (!animalOrBatchRecord) { + if (!preexistingAnimalOrBatch) { invalidIds.push(animalOrBatch.id); } } diff --git a/packages/api/src/util/animal.js b/packages/api/src/util/animal.js index 2899997613..d13b96671c 100644 --- a/packages/api/src/util/animal.js +++ b/packages/api/src/util/animal.js @@ -118,7 +118,7 @@ export const checkAndAddCustomTypeAndBreed = async ( * * @throws {Error} - If any database operation fails. */ -export const checkAndAddGroup = async (req, animalOrBatch, farm_id, trx) => { +export const upsertGroup = async (req, animalOrBatch, farm_id, trx) => { const groupName = checkAndTrimString(animalOrBatch.group_name); delete animalOrBatch.group_name;