From 8ec1664081b4bd73b53293595660326770f5a980 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 19 Dec 2023 11:39:19 -0500 Subject: [PATCH 1/5] Add reviewRegistration controller tests --- app/scripts/controllers/reviewRegistration.js | 6 +- .../controllers/reviewRegistration.spec.js | 290 ++++++++++++++++-- 2 files changed, 266 insertions(+), 30 deletions(-) diff --git a/app/scripts/controllers/reviewRegistration.js b/app/scripts/controllers/reviewRegistration.js index ef9cfb68e..de4a09a74 100644 --- a/app/scripts/controllers/reviewRegistration.js +++ b/app/scripts/controllers/reviewRegistration.js @@ -107,10 +107,10 @@ angular // Return a boolean indicating whether the register button(s) should be disabled $scope.registerDisabled = function () { - return ( + return Boolean( $scope.registerMode === 'preview' || - !$scope.allRegistrantsValid() || - $scope.submittingRegistration + !$scope.allRegistrantsValid() || + $scope.submittingRegistration, ); }; diff --git a/test/spec/controllers/reviewRegistration.spec.js b/test/spec/controllers/reviewRegistration.spec.js index fef34dce0..d4a0d69df 100644 --- a/test/spec/controllers/reviewRegistration.spec.js +++ b/test/spec/controllers/reviewRegistration.spec.js @@ -1,43 +1,236 @@ import 'angular-mocks'; describe('Controller: ReviewRegistrationCtrl', function () { - var scope; - var mockWindow; + let scope; + let testData; + let mockWindow; + let initController; beforeEach(angular.mock.module('confRegistrationWebApp')); beforeEach( - angular.mock.inject(function ($rootScope, $controller, testData) { - scope = $rootScope.$new(); - mockWindow = { - location: { - href: '', - }, - }; - scope.answers = testData.registration.registrants[0].answers; + angular.mock.inject(function ($rootScope, $controller, _testData_) { + testData = _testData_; - $controller('ReviewRegistrationCtrl', { - $scope: scope, - currentRegistration: testData.registration, - conference: testData.conference, - $window: mockWindow, - }); + initController = (injected) => { + scope = $rootScope.$new(); + mockWindow = { + location: { + href: '', + }, + }; + scope.answers = testData.registration.registrants[0]?.answers ?? []; + + $controller('ReviewRegistrationCtrl', { + $scope: scope, + currentRegistration: testData.registration, + conference: testData.conference, + $window: mockWindow, + ...injected, + }); + }; + initController(); }), ); - it('findAnswer should return answer', function () { - expect(scope.findAnswer('9b83eebd-b064-4edf-92d0-7982a330272a').value).toBe( - 'M', - ); + describe('allowGroupRegistration', () => { + it('is false when there are no registrants', () => { + testData.registration.registrants = []; + initController(); + + expect(scope.allowGroupRegistration).toBe(false); + }); + + it('is false when allowGroupRegistrations is false for all registrant types', () => { + testData.conference.registrantTypes[1].allowGroupRegistrations = false; + initController(); + + expect(scope.allowGroupRegistration).toBe(false); + }); + + it('is true when allowGroupRegistrations is true for one registrant type', () => { + expect(scope.allowGroupRegistration).toBe(true); + }); }); - it('blockVisibleForRegistrant should be true', function () { - expect( - scope.blockVisibleForRegistrant( - scope.conference.registrationPages[1].blocks[0], - scope.currentRegistration.registrants[0], - ), - ).toBe(true); + describe('currentPayment', () => { + it('should have balance set to the remaining balance', () => { + expect(scope.currentPayment.amount).toBe( + testData.registration.remainingBalance, + ); + }); + }); + + describe('findAnswer', () => { + it('finds an answer by its block id', () => { + const answer = testData.registration.registrants[0].answers[0]; + + expect(scope.findAnswer(answer.blockId)).toBe(answer); + }); + }); + + describe('getBlock', () => { + it('finds a block by its id', () => { + const block = testData.conference.registrationPages[0].blocks[0]; + + expect(scope.getBlock(block.id)).toBe(block); + }); + }); + + describe('registerDisabled', () => { + it('is true in preview mode', () => { + scope.registerMode = 'preview'; + + expect(scope.registerDisabled()).toBe(true); + }); + + it('is true with invalid registrants', () => { + spyOn(scope, 'allRegistrantsValid').and.returnValue(false); + + expect(scope.registerDisabled()).toBe(true); + }); + + it('is true while submitting', () => { + scope.confirmRegistration(); + + expect(scope.registerDisabled()).toBe(true); + }); + + it('is false otherwise', () => { + spyOn(scope, 'allRegistrantsValid').and.returnValue(true); + + expect(scope.registerDisabled()).toBe(false); + }); + }); + + describe('pageIsVisible', () => { + it('returns true if any blocks are visible', () => { + initController({ + validateRegistrant: { + blockVisible: () => true, + validate: () => [], + }, + }); + + expect( + scope.pageIsVisible(testData.conference.registrationPages[0]), + ).toBe(true); + }); + + it('returns false if no blocks are visible', () => { + initController({ + validateRegistrant: { + blockVisible: () => false, + validate: () => [], + }, + }); + + expect( + scope.pageIsVisible(testData.conference.registrationPages[0]), + ).toBe(false); + }); + }); + + describe('isBlockInvalid', () => { + const registrantId = testData.registrants[0].id; + const blockId = 'block-1'; + + it('returns false when there are no errors', () => { + initController({ + validateRegistrant: { + validate: () => [], + }, + }); + + expect(scope.isBlockInvalid(registrantId, blockId)).toBe(false); + }); + + it('returns true when there are errors', () => { + initController({ + validateRegistrant: { + validate: () => [blockId], + }, + }); + + expect(scope.isBlockInvalid(registrantId, blockId)).toBe(true); + }); + }); + + describe('allRegistrantsValid', () => { + it('returns true when there are no errors', () => { + initController({ + validateRegistrant: { + validate: () => [], + }, + }); + + expect(scope.allRegistrantsValid()).toBe(true); + }); + + it('returns false when there are errors', () => { + initController({ + validateRegistrant: { + validate: () => ['block-1'], + }, + }); + + expect(scope.allRegistrantsValid()).toBe(false); + }); + }); + + describe('blockVisibleForRegistrant', () => { + it('should return true for visible blocks', () => { + expect( + scope.blockVisibleForRegistrant( + scope.conference.registrationPages[1].blocks[0], + scope.currentRegistration.registrants[0], + ), + ).toBe(true); + }); + }); + + describe('acceptedPaymentMethods', () => { + it('calculates accepted payment methods for the registrant types', () => { + expect(scope.acceptedPaymentMethods()).toEqual({ + acceptCreditCards: true, + acceptChecks: true, + acceptTransfers: true, + acceptScholarships: false, + acceptPayOnSite: false, + }); + }); + + describe('acceptPayOnSite', () => { + it('is true on incomplete registrations', () => { + testData.conference.registrantTypes[1].acceptPayOnSite = true; + testData.registration.completed = false; + initController(); + + expect(scope.acceptedPaymentMethods().acceptPayOnSite).toBe(true); + }); + + it('is false on completed registrations', () => { + testData.conference.registrantTypes[1].acceptPayOnSite = true; + testData.registration.completed = true; + initController(); + + expect(scope.acceptedPaymentMethods().acceptPayOnSite).toBe(false); + }); + }); + + it('returns false when no payment methods are accepted', () => { + testData.conference.registrantTypes.forEach((registrantType) => { + Object.assign(registrantType, { + acceptCreditCards: false, + acceptTransfers: false, + acceptScholarships: false, + acceptChecks: false, + }); + }); + initController(); + + expect(scope.acceptedPaymentMethods()).toBe(false); + }); }); it('registrantDeletable should be possible when allowEditRegistrationAfterComplete set to true', function () { @@ -72,4 +265,47 @@ describe('Controller: ReviewRegistrationCtrl', function () { expect(mockWindow.location.href).toEqual('url2.com'); }); + + describe('hasPendingPayments ', () => { + it('returns true if there are requested payments', () => { + expect( + scope.hasPendingPayments([ + { status: 'REQUESTED' }, + { status: 'APPROVED' }, + ]), + ).toBe(true); + }); + + it('returns true if there are pending payments', () => { + expect( + scope.hasPendingPayments([ + { status: 'PENDING' }, + { status: 'APPROVED' }, + ]), + ).toBe(true); + }); + + it('returns false otherwise', () => { + expect(scope.hasPendingPayments([{ status: 'APPROVED' }])).toBe(false); + }); + }); + + describe('hasPendingCheckPayment', () => { + it('returns true if there are pending check payments', () => { + expect( + scope.hasPendingCheckPayment([ + { paymentType: 'CHECK', status: 'PENDING' }, + ]), + ).toBe(true); + }); + + it('returns false otherwise', () => { + expect( + scope.hasPendingCheckPayment([ + { paymentType: 'CASH', status: 'PENDING' }, + { paymentType: 'CHECK', status: 'RECEIVED' }, + ]), + ).toBe(false); + }); + }); }); From ab9467fc6c40f687e818fb36ae7a4064ee1c8f70 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Tue, 19 Dec 2023 11:47:19 -0500 Subject: [PATCH 2/5] Cleanup reviewRegistration controller * Simplify allowGroupRegistration calculation * Use an object for regValidate map * Document non-existent properties * Merge identical functions * Simplify pageIsVisible check --- app/scripts/controllers/reviewRegistration.js | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/app/scripts/controllers/reviewRegistration.js b/app/scripts/controllers/reviewRegistration.js index de4a09a74..23e519615 100644 --- a/app/scripts/controllers/reviewRegistration.js +++ b/app/scripts/controllers/reviewRegistration.js @@ -52,20 +52,21 @@ angular ); $scope.blocks = []; - $scope.regValidate = []; + $scope.regValidate = {}; + + $scope.getRegistrantType = function (id) { + return _.find(conference.registrantTypes, { id }); + }; //check if group registration is allowed based on registrants already in registration - if (!_.isEmpty(currentRegistration.registrants)) { - $scope.allowGroupRegistration = false; - angular.forEach(currentRegistration.registrants, function (r) { - if ($scope.allowGroupRegistration) { - return; - } - var regType = getRegistrantType(r.registrantTypeId); - $scope.allowGroupRegistration = regType.allowGroupRegistrations; - }); - } + $scope.allowGroupRegistration = currentRegistration.registrants.some( + (registrant) => + $scope.getRegistrantType(registrant.registrantTypeId) + .allowGroupRegistrations, + ); + // TODO: $scope.currentPayment is always undefined and conference.accept* is also undefined + // We need to need to use $scope.acceptedPaymentMethods() to calculate the initial payment type if (angular.isUndefined($scope.currentPayment)) { var paymentType; if (conference.acceptCreditCards) { @@ -128,17 +129,11 @@ angular }); } - function getRegistrantType(typeId) { - return _.find(conference.registrantTypes, { - id: typeId, - }); - } - function primaryRegType(currentRegistration) { let primaryRegistrant = _.find(currentRegistration.registrants, { id: currentRegistration.primaryRegistrantId, }); - return getRegistrantType(primaryRegistrant.registrantTypeId); + return $scope.getRegistrantType(primaryRegistrant.registrantTypeId); } // Navigate to the correct page after completing a registration @@ -204,12 +199,12 @@ angular }; $scope.pageIsVisible = (page, registrantId) => - page.blocks.filter((block) => + page.blocks.some((block) => validateRegistrant.blockVisible( block, currentRegistration.registrants.find((r) => r.id === registrantId), ), - ).length > 0; + ); $scope.removeRegistrant = function (id) { modalMessage @@ -236,10 +231,6 @@ angular }); }; - $scope.getRegistrantType = function (id) { - return _.find(conference.registrantTypes, { id: id }); - }; - $scope.isBlockInvalid = function (rId, bId) { return _.includes($scope.regValidate[rId], bId); }; @@ -301,7 +292,7 @@ angular var groupRegistrants = 0, noGroupRegistrants = 0; angular.forEach(currentRegistration.registrants, function (r) { - var regType = getRegistrantType(r.registrantTypeId); + var regType = $scope.getRegistrantType(r.registrantTypeId); if (regType.allowGroupRegistrations) { groupRegistrants++; } else { @@ -309,7 +300,7 @@ angular } }); - var regType = getRegistrantType(r.registrantTypeId); + var regType = $scope.getRegistrantType(r.registrantTypeId); if ( regType.allowGroupRegistrations && groupRegistrants === 1 && From beea6d6646d4b2338b79df0b15282924372f7e96 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Wed, 20 Dec 2023 15:33:46 -0500 Subject: [PATCH 3/5] Add eventRegistrations tests --- app/scripts/controllers/eventRegistrations.js | 8 +- .../controllers/eventRegistrations.spec.js | 491 +++++++++++++++++- 2 files changed, 475 insertions(+), 24 deletions(-) diff --git a/app/scripts/controllers/eventRegistrations.js b/app/scripts/controllers/eventRegistrations.js index dddc724c7..a43cdb363 100644 --- a/app/scripts/controllers/eventRegistrations.js +++ b/app/scripts/controllers/eventRegistrations.js @@ -103,7 +103,7 @@ angular }); //turn on visible blocks - var visibleBlocks = localStorage.getItem( + var visibleBlocks = $window.localStorage.getItem( 'visibleBlocks:' + conference.id, ); if (!_.isNull(visibleBlocks)) { @@ -121,7 +121,7 @@ angular $scope.toggleColumn = function (block) { $scope.blocks[block].visible = !$scope.blocks[block].visible; visibleBlocks = _.map(_.filter($scope.blocks, { visible: true }), 'id'); - localStorage.setItem( + $window.localStorage.setItem( 'visibleBlocks:' + conference.id, JSON.stringify(visibleBlocks), ); @@ -129,7 +129,7 @@ angular }; //turn on visible built in columns - var builtInColumnsVisibleInStorage = localStorage.getItem( + var builtInColumnsVisibleInStorage = $window.localStorage.getItem( 'builtInColumnsVisibleStorage', ); if ( @@ -154,7 +154,7 @@ angular $scope.toggleBuiltInColumn = function (columnName) { $scope.builtInColumnsVisible[columnName] = !$scope.builtInColumnsVisible[columnName]; - localStorage.setItem( + $window.localStorage.setItem( 'builtInColumnsVisibleStorage', JSON.stringify($scope.builtInColumnsVisible), ); diff --git a/test/spec/controllers/eventRegistrations.spec.js b/test/spec/controllers/eventRegistrations.spec.js index 1af57be80..c205a048a 100644 --- a/test/spec/controllers/eventRegistrations.spec.js +++ b/test/spec/controllers/eventRegistrations.spec.js @@ -1,8 +1,6 @@ import 'angular-mocks'; describe('Controller: eventRegistrations', function () { - var scope; - beforeEach(angular.mock.module('confRegistrationWebApp')); var fakeModal = { @@ -25,37 +23,259 @@ describe('Controller: eventRegistrations', function () { openModal = spyOn($uibModal, 'open').and.returnValue(fakeModal); })); - var testData, $httpBackend, $uibModal, $controller; + let $controller, + $httpBackend, + $q, + $uibModal, + $window, + RegistrationCache, + testData, + initController, + scope; beforeEach( angular.mock.inject(function ( $rootScope, _$controller_, + _$httpBackend_, + _$q_, _$uibModal_, + _$window_, + _RegistrationCache_, _testData_, - _$httpBackend_, ) { $controller = _$controller_; - testData = _testData_; $httpBackend = _$httpBackend_; + $q = _$q_; $uibModal = _$uibModal_; - scope = $rootScope.$new(); - scope.registrations = [testData.registration]; - - $controller('eventRegistrationsCtrl', { - $scope: scope, - conference: testData.conference, - $uibModal: $uibModal, - permissions: {}, - }); + $window = _$window_; + RegistrationCache = _RegistrationCache_; + testData = _testData_; + + spyOn(RegistrationCache, 'getAllForConference').and.returnValue( + $q.resolve({ + registrations: [testData.registration], + }), + ); + + initController = (injected) => { + scope = $rootScope.$new(); + + $controller('eventRegistrationsCtrl', { + $scope: scope, + conference: testData.conference, + $uibModal: $uibModal, + permissions: {}, + ...injected, + }); + + // Make RegistrationCache.getAllForConference() call the then handler in refreshRegistrations + // to populate $scope.registrations and $scope.registrants + scope.$digest(); + }; + + initController(); }), ); - it('builtInColumnsVisible initialization', function () { - expect(scope.builtInColumnsVisible['Email']).toBe(true); - expect(scope.builtInColumnsVisible['Group']).toBe(true); - expect(scope.builtInColumnsVisible['GroupId']).toBe(false); - expect(scope.builtInColumnsVisible['Started']).toBe(true); - expect(scope.builtInColumnsVisible['Completed']).toBe(true); + afterEach(() => { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + describe('queryParameters changes', () => { + it('goes to the first page when another filter changes', () => { + scope.$apply(() => { + scope.queryParameters.page = 2; + }); + scope.$apply(() => { + scope.queryParameters.orderBy = 50; + }); + + expect(scope.queryParameters.page).toBe(1); + expect(scope.queryParameters.orderBy).toBe(50); + }); + + it('scrolls to the top of window when the page changes', () => { + const scrollTo = spyOn($window, 'scrollTo'); + scope.$apply(() => { + scope.queryParameters.page = 2; + }); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('builtInColumnsVisible', () => { + it('uses saved column visibility when available', () => { + initController({ + $window: { + localStorage: { + getItem: () => '{"Email":false}', + }, + }, + }); + + expect(scope.builtInColumnsVisible).toEqual({ + Email: false, + }); + }); + + it('uses default column visibility when saved visibility is unavailable', () => { + expect(scope.builtInColumnsVisible).toEqual({ + Email: true, + Group: true, + GroupId: false, + Started: true, + Completed: true, + }); + }); + }); + + describe('toggleColumn', () => { + it('toggles the column visibility', () => { + const setItem = spyOn($window.localStorage, 'setItem'); + const block1Index = 0; + const block1 = scope.blocks[block1Index]; + scope.toggleColumn(block1Index); + + expect(block1.visible).toBe(true); + expect(setItem).toHaveBeenCalledWith( + `visibleBlocks:${testData.conference.id}`, + `["${block1.id}"]`, + ); + + expect(scope.queryParameters.block).toEqual([block1.id]); + + const block2Index = 1; + const block2 = scope.blocks[block2Index]; + scope.toggleColumn(block2Index); + + expect(block2.visible).toBe(true); + expect(setItem).toHaveBeenCalledWith( + `visibleBlocks:${testData.conference.id}`, + `["${block1.id}","${block2.id}"]`, + ); + + expect(scope.queryParameters.block).toEqual([block1.id, block2.id]); + + scope.toggleColumn(block1Index); + + expect(scope.blocks[0].visible).toBe(false); + expect(setItem).toHaveBeenCalledWith( + `visibleBlocks:${testData.conference.id}`, + `["${block2.id}"]`, + ); + + expect(scope.queryParameters.block).toEqual([block2.id]); + }); + }); + + describe('toggleBuiltInColumn', () => { + it('toggles the column visibility', () => { + const setItem = spyOn($window.localStorage, 'setItem'); + scope.toggleBuiltInColumn('Email'); + + expect(scope.builtInColumnsVisible.Email).toBe(false); + expect(setItem).toHaveBeenCalledWith( + `builtInColumnsVisibleStorage`, + '{"Email":false,"Group":true,"GroupId":false,"Started":true,"Completed":true}', + ); + + scope.toggleBuiltInColumn('Email'); + + expect(scope.builtInColumnsVisible.Email).toBe(true); + expect(setItem).toHaveBeenCalledWith( + `builtInColumnsVisibleStorage`, + '{"Email":true,"Group":true,"GroupId":false,"Started":true,"Completed":true}', + ); + }); + }); + + describe('resetStrFilter', () => { + it('clears the filter', () => { + scope.strFilter = 'Filter'; + scope.resetStrFilter(); + + expect(scope.strFilter).toBe(''); + }); + }); + + describe('isRegistrantReported', () => { + it('returns true if the registrant is reported', () => { + scope.registrations = [{ ...testData.registration, reported: true }]; + + expect( + scope.isRegistrantReported(testData.registration.registrants[0]), + ).toBe(true); + }); + + it('returns false if the registrant is not reported', () => { + scope.registrations = [{ ...testData.registration, reported: false }]; + + expect( + scope.isRegistrantReported(testData.registration.registrants[0]), + ).toBe(false); + }); + }); + + describe('answerSort', () => { + it('returns 0 if no orderBy is defined', () => { + scope.queryParameters.orderBy = undefined; + + expect(scope.answerSort(testData.registration.registrants[0])).toBe(0); + }); + + it('returns "" if the answer does not exist', () => { + scope.queryParameters.orderBy = 'last_name'; + + expect(scope.answerSort(testData.registration.registrants[0])).toBe(''); + }); + + it('returns the value of simple answers', () => { + const answer = testData.registration.registrants[0].answers[0]; + scope.queryParameters.orderBy = answer.blockId; + + expect(scope.answerSort(testData.registration.registrants[0])).toBe( + answer.value, + ); + }); + + it('returns the values of complex answers', () => { + const answer = testData.registration.registrants[0].answers[6]; + scope.queryParameters.orderBy = answer.blockId; + + expect(scope.answerSort(testData.registration.registrants[0])).toBe( + 'Test,Person', + ); + }); + + it('returns the keys of checkbox answers', () => { + const answer = testData.registration.registrants[0].answers[4]; + scope.queryParameters.orderBy = answer.blockId; + + expect(scope.answerSort(testData.registration.registrants[0])).toBe( + '651', + ); + }); + }); + + describe('setOrder', () => { + it('sorts by the specified field', () => { + scope.queryParameters.orderBy = 'last_name'; + scope.setOrder('first_name'); + + expect(scope.queryParameters.orderBy).toBe('first_name'); + expect(scope.queryParameters.order).toBe('ASC'); + }); + + it('reverse sorts when the order field is toggled again', () => { + scope.queryParameters.orderBy = 'last_name'; + scope.setOrder('first_name'); + scope.setOrder('first_name'); + + expect(scope.queryParameters.orderBy).toBe('first_name'); + expect(scope.queryParameters.order).toBe('DESC'); + }); }); it('editRegistrant should open modal window', function () { @@ -138,6 +358,7 @@ describe('Controller: eventRegistrations', function () { }); it('refreshRegistrations should assign reported attribute to registrants', () => { + RegistrationCache.getAllForConference.and.callThrough(); $httpBackend .whenGET(/^conferences\/.*\/registrations.*/) .respond(201, [testData.registration]); @@ -150,4 +371,234 @@ describe('Controller: eventRegistrations', function () { expect(registrant.reported).toBe(true); } }); + + describe('expandRegistration', () => { + beforeEach(() => { + $httpBackend.expectGET(/^registrants\/.+$/).respond(200, { + ...testData.registration.registrants[0], + firstName: 'Updated', + }); + }); + + it('opens unexpanded registrations', () => { + const registrantId = testData.registration.registrants[0].id; + + scope.expandRegistration(registrantId); + + expect(scope.expandedStatus(registrantId)).toBe('loading'); + + $httpBackend.flush(); + + expect(scope.expandedStatus(registrantId)).toBe('open'); + expect(scope.registrants[0].firstName).toBe('Updated'); + expect(scope.registrations[0].registrants[0].firstName).toBe('Updated'); + }); + + it('closes expanded registrations', () => { + const registrantId = testData.registration.registrants[0].id; + scope.expandRegistration(registrantId); + $httpBackend.flush(); + + expect(scope.expandedStatus(registrantId)).toBe('open'); + + scope.expandRegistration(registrantId); + + expect(scope.expandedStatus(registrantId)).toBeUndefined(); + }); + }); + + describe('editRegistrant', () => { + it('edits registrants', () => { + $httpBackend.expectGET(/^registrations\/.+$/).respond(200, {}); + + scope.editRegistrant(testData.registration.registrants[0]); + + const newRegistration = { + ...testData.registration, + registrants: [ + { ...testData.registration.registrants[0], firstName: 'Updated' }, + testData.registration.registrants[1], + ], + }; + + $httpBackend.flush(); + fakeModal.close(newRegistration); + + expect(scope.registrants[0].firstName).toBe('Updated'); + expect(scope.registrations[0]).toBe(newRegistration); + }); + }); + + describe('withdrawRegistrant', () => { + it('withdraws a registrant', () => { + $httpBackend + .expectPUT(/^registrants\/.+$/, (raw) => { + const data = JSON.parse(raw); + return data.withdrawn && angular.isString(data.withdrawnTimestamp); + }) + .respond(204, ''); + + const registrant = { + ...testData.registration.registrants[0], + withdrawn: false, + withdrawnTimestamp: null, + }; + scope.withdrawRegistrant(registrant, true); + + expect(registrant.withdrawn).toBe(true); + expect(registrant.withdrawnTimestamp).not.toBeNull(); + expect(scope.loadingMsg).toBe(`Withdrawing ${registrant.firstName}`); + + $httpBackend.flush(); + + expect(scope.loadingMsg).toBe(''); + }); + + it('reinstates a registrant', () => { + $httpBackend + .expectPUT(/^registrants\/.+$/, (data) => !JSON.parse(data).withdrawn) + .respond(204, ''); + + const registrant = { + ...testData.registration.registrants[0], + withdrawn: true, + withdrawnTimestamp: '2023-01-01T00:00:00.000Z', + }; + scope.withdrawRegistrant(registrant, false); + + expect(registrant.withdrawn).toBe(false); + expect(scope.loadingMsg).toBe(`Reinstating ${registrant.firstName}`); + + $httpBackend.flush(); + + expect(scope.loadingMsg).toBe(''); + }); + + it('reverts changes to withdrawn on failure', () => { + $httpBackend + .expectPUT(/^registrants\/.+$/, (data) => JSON.parse(data).withdrawn) + .respond(500, ''); + + const registrant = { + ...testData.registration.registrants[0], + withdrawn: false, + withdrawnTimestamp: null, + }; + scope.withdrawRegistrant(registrant, true); + $httpBackend.flush(); + + expect(registrant.withdrawn).toBe(false); + }); + }); + + describe('checkInRegistrant', () => { + it('checks in registrant', () => { + $httpBackend + .expectPUT(/^registrants\/.+$/, (data) => + angular.isString(JSON.parse(data).checkedInTimestamp), + ) + .respond(204, ''); + + const registrant = { + ...testData.registration.registrants[0], + checkedInTimestamp: null, + }; + scope.checkInRegistrant(registrant, true); + + expect(registrant.checkedInTimestamp).not.toBeNull(); + expect(scope.loadingMsg).toBe(`Checking in ${registrant.firstName}`); + + $httpBackend.flush(); + + expect(scope.loadingMsg).toBe(''); + }); + + it('removes check in from registrant', () => { + $httpBackend + .expectPUT( + /^registrants\/.+$/, + (data) => JSON.parse(data).checkedInTimestamp === null, + ) + .respond(204, ''); + + const registrant = { + ...testData.registration.registrants[0], + checkedInTimestamp: '2023-01-01T00:00:00.000Z', + }; + scope.checkInRegistrant(registrant, false); + + expect(registrant.checkedInTimestamp).toBeNull(); + expect(scope.loadingMsg).toBe( + `Removing check-in for ${registrant.firstName}`, + ); + + $httpBackend.flush(); + + expect(scope.loadingMsg).toBe(''); + }); + + it('reverts changes to checkedInTimestamp on failure', () => { + $httpBackend + .expectPUT(/^registrants\/.+$/, (data) => + angular.isString(JSON.parse(data).checkedInTimestamp), + ) + .respond(500, ''); + + const registrant = { + ...testData.registration.registrants[0], + checkedInTimestamp: null, + }; + scope.checkInRegistrant(registrant, true); + + expect(registrant.checkedInTimestamp).not.toBeNull(); + + $httpBackend.flush(); + + expect(registrant.checkedInTimestamp).toBeNull(); + }); + }); + + describe('deleteRegistrant', () => { + it('deletes a registrant in a group', () => { + $httpBackend + .expectGET(/^registrations\/.+$/) + .respond(200, testData.registration); + $httpBackend.expectDELETE(/^registrants\/.+$/).respond(204, ''); + + const registrant = scope.registrants[0]; + scope.deleteRegistrant(registrant); + fakeModal.close(); + $httpBackend.flush(); + $httpBackend.flush(); + + expect(scope.registrants.length).toBe(1); + expect(scope.registrations[0].registrants.map((r) => r.id)).not.toContain( + registrant.id, + ); + + expect(scope.registrations[0].groupRegistrants.length).toBe(0); + }); + + it('deletes the entire registration if there is one registrant', () => { + RegistrationCache.getAllForConference.and.returnValue( + $q.resolve({ + registrations: [testData.singleRegistration], + }), + ); + scope.refreshRegistrations(); + scope.$digest(); + + $httpBackend + .expectGET(/^registrations\/.+$/) + .respond(200, testData.singleRegistration); + $httpBackend.expectDELETE(/^registrations\/.+$/).respond(204, ''); + + scope.deleteRegistrant(scope.registrants[0]); + fakeModal.close(); + $httpBackend.flush(); + $httpBackend.flush(); + + expect(scope.registrants.length).toBe(0); + }); + }); }); From fcaed370a897e2172d09905b7120e19a77f7cf10 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 21 Dec 2023 09:09:49 -0500 Subject: [PATCH 4/5] Add eventForm tests --- app/scripts/controllers/eventForm.js | 1 + test/spec/controllers/eventForm.spec.js | 319 ++++++++++++++++++++++++ test/spec/testData.spec.js | 86 +++++++ 3 files changed, 406 insertions(+) create mode 100644 test/spec/controllers/eventForm.spec.js diff --git a/app/scripts/controllers/eventForm.js b/app/scripts/controllers/eventForm.js index b6b5c7c25..25d41623f 100644 --- a/app/scripts/controllers/eventForm.js +++ b/app/scripts/controllers/eventForm.js @@ -264,6 +264,7 @@ angular var profileType = null; if (angular.isDefined(defaultProfile)) { + // Check whether a block with this profile type exists already on any page var profileCount = 0; $scope.conference.registrationPages.forEach(function (page) { page.blocks.forEach(function (block) { diff --git a/test/spec/controllers/eventForm.spec.js b/test/spec/controllers/eventForm.spec.js new file mode 100644 index 000000000..672a2bf7c --- /dev/null +++ b/test/spec/controllers/eventForm.spec.js @@ -0,0 +1,319 @@ +import 'angular-mocks'; + +describe('Controller: eventForm', function () { + beforeEach(angular.mock.module('confRegistrationWebApp')); + + let $controller, + $httpBackend, + $location, + $q, + $timeout, + ConfCache, + GrowlService, + modalMessage, + testData, + initController, + scope; + beforeEach( + angular.mock.inject(function ( + $rootScope, + _$controller_, + _$httpBackend_, + _$location_, + _$q_, + _$timeout_, + _ConfCache_, + _GrowlService_, + _modalMessage_, + _testData_, + ) { + $controller = _$controller_; + $httpBackend = _$httpBackend_; + $location = _$location_; + $q = _$q_; + $timeout = _$timeout_; + ConfCache = _ConfCache_; + GrowlService = _GrowlService_; + modalMessage = _modalMessage_; + testData = _testData_; + + initController = (injected) => { + scope = $rootScope.$new(); + + $controller('eventFormCtrl', { + $scope: scope, + conference: { ...testData.conference }, + ...injected, + }); + }; + + initController(); + }), + ); + + afterEach(() => { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + describe('saveForm', () => { + beforeEach(() => { + // Trigger the conference $watch so that oldObject is set on future $watch triggers + scope.$digest(); + }); + + it('updates the conference cache and displays a notification', () => { + spyOn(ConfCache, 'update'); + $httpBackend.expectPUT(/^conferences\/.+$/).respond(204, ''); + + scope.$apply(() => { + scope.conference.name = 'Updated'; + }); + $httpBackend.flush(); + + expect(ConfCache.update).toHaveBeenCalledWith( + scope.conference.id, + scope.conference, + ); + + expect(scope.notify.class).toBe('alert-success'); + + $timeout.flush(); + + expect(scope.notify.class).toBeUndefined(); + }); + + it('debounces saves', () => { + spyOn(ConfCache, 'update'); + $httpBackend.expectPUT(/^conferences\/.+$/).respond(204, ''); + + // Saves the form + scope.$apply(() => { + scope.conference.name = 'Updated1'; + }); + // Starts a timer to save the form later + scope.$apply(() => { + scope.conference.name = 'Updated2'; + }); + // Updates the timer to save the form later + scope.$apply(() => { + scope.conference.name = 'Updated3'; + }); + + $httpBackend.flush(); + + expect(ConfCache.update).toHaveBeenCalledTimes(1); + + $timeout.flush(); + $httpBackend.flush(); + + expect(ConfCache.update).toHaveBeenCalledTimes(2); + }); + + it('displays errors', () => { + $httpBackend.expectPUT(/^conferences\/.+$/).respond(500, ''); + + scope.$apply(() => { + scope.conference.name = 'Updated'; + }); + $httpBackend.flush(); + + expect(scope.notify.class).toBe('alert-danger'); + }); + }); + + describe('previewForm', () => { + it('navigates to the preview page', () => { + spyOn($location, 'path'); + scope.previewForm(); + + expect($location.path).toHaveBeenCalledWith( + `/preview/${testData.conference.id}/page/`, + ); + }); + }); + + describe('deletePage', () => { + beforeEach(() => { + spyOn(modalMessage, 'error'); + scope.conference.registrationPages.push( + testData.waiverPage, + testData.rulesPage, + ); + }); + + it('refuses to delete pages with an email profile question', () => { + scope.deletePage(testData.conference.registrationPages[0].id); + + expect(modalMessage.error).toHaveBeenCalledTimes(1); + expect(modalMessage.error.calls.argsFor(0)[0].message).toBe( + 'This page contains required profile questions and cannot be deleted.', + ); + }); + + it('refuses to delete pages with a name profile question', () => { + scope.deletePage(testData.conference.registrationPages[1].id); + + expect(modalMessage.error).toHaveBeenCalledTimes(1); + expect(modalMessage.error.calls.argsFor(0)[0].message).toBe( + 'This page contains required profile questions and cannot be deleted.', + ); + }); + + it('refuses to delete pages with a waiver profile question', () => { + scope.deletePage(testData.waiverPage.id); + + expect(modalMessage.error).toHaveBeenCalledTimes(1); + expect(modalMessage.error.calls.argsFor(0)[0].message).toBe( + 'This page contains required liability questions and cannot be deleted.', + ); + }); + + it('deletes pages', () => { + spyOn(GrowlService, 'growl'); + spyOn(modalMessage, 'confirm').and.returnValue($q.resolve()); + const page = scope.conference.registrationPages[1]; + page.blocks = page.blocks.filter((block) => block.profileType === null); + + scope.deletePage(page.id, true); + scope.$digest(); + + expect(modalMessage.confirm).toHaveBeenCalledTimes(1); + const confirmationMessage = + modalMessage.confirm.calls.argsFor(0)[0].question; + + expect(confirmationMessage).toContain( + `Are you sure you want to delete ${page.title}?`, + ); + + expect(confirmationMessage).toContain( + 'The following rules will also be deleted:', + ); + + expect(confirmationMessage).toContain( + 'Multiple Choice Question = 12 on Question', + ); + + expect(GrowlService.growl).toHaveBeenCalledTimes(1); + expect(GrowlService.growl.calls.argsFor(0)[3]).toBe( + `Page "${page.title}" has been deleted.`, + ); + }); + }); + + describe('copyBlock', () => { + it('copies an existing block', () => { + const existingBlock = scope.conference.registrationPages[1].blocks[3]; + scope.copyBlock(existingBlock.id); + const newBlock = scope.conference.registrationPages[1].blocks[4]; + + expect(newBlock.id).not.toBe(existingBlock.id); + expect(newBlock.position).toBe(4); + expect(newBlock.title).toBe(`${existingBlock.title} (copy)`); + expect(newBlock.rules[0].id).not.toBe(existingBlock.rules[0].id); + expect(newBlock.rules[0].blockId).toBe(newBlock.id); + }); + }); + + describe('insertBlock', () => { + it('adds a new block without a default profile', () => { + const page = scope.conference.registrationPages[0]; + const previousFirstBlock = page.blocks[0]; + scope.insertBlock( + 'nameQuestion', + page.id, + 0, + 'Name', + undefined, + undefined, + ); + const newBlock = page.blocks[0]; + + expect(newBlock.pageId).toBe(page.id); + expect(newBlock.title).toBe('Name'); + expect(page.blocks[1]).toBe(previousFirstBlock); + }); + + it('adds a new block with an unused default profile', () => { + const page = scope.conference.registrationPages[0]; + scope.insertBlock( + 'phoneQuestion', + page.id, + 0, + 'Telephone', + 'PHONE', + undefined, + ); + + expect(page.blocks[0].profileType).toBe('PHONE'); + }); + + it('adds a new block with a used default profile', () => { + const page = scope.conference.registrationPages[0]; + scope.insertBlock( + 'addressQuestion', + page.id, + 0, + 'Address', + 'ADDRESS', + undefined, + ); + + expect(page.blocks[0].profileType).toBe(null); + }); + }); + + describe('deleteBlock', () => { + const block = testData.conference.registrationPages[1].blocks[4]; + + it('deletes a block', () => { + spyOn(GrowlService, 'growl'); + + scope.deleteBlock(block.id, true); + + expect(GrowlService.growl).toHaveBeenCalledTimes(1); + expect(GrowlService.growl.calls.argsFor(0)[3]).toBe( + `"${block.title}" has been deleted.`, + ); + }); + + it('refuses to delete a block with dependent rules', () => { + spyOn(modalMessage, 'error'); + scope.conference.registrationPages.push(testData.rulesPage); + + scope.deleteBlock(block.id, true); + + expect(modalMessage.error).toHaveBeenCalledTimes(1); + const errorMessage = modalMessage.error.calls.argsFor(0)[0].message; + + expect(errorMessage).toContain('
  • Question
  • '); + }); + }); + + describe('addNewPage', () => { + it('adds a new page', () => { + expect(scope.conference.registrationPages.length).toBe(2); + + scope.addNewPage(); + const newPage = scope.conference.registrationPages[2]; + + expect(scope.conference.registrationPages.length).toBe(3); + expect(newPage.title).toBe('Page 3'); + expect($location.hash()).toBe('page3'); + }); + }); + + describe('togglePage', () => { + it('toggles page visibility', () => { + const pageId = scope.conference.registrationPages[0].id; + + scope.togglePage(pageId); + + expect(scope.isPageHidden(pageId)).toBe(true); + + scope.togglePage(pageId); + + expect(scope.isPageHidden(pageId)).toBe(false); + }); + }); +}); diff --git a/test/spec/testData.spec.js b/test/spec/testData.spec.js index 4e63ceed9..5d8857539 100644 --- a/test/spec/testData.spec.js +++ b/test/spec/testData.spec.js @@ -545,6 +545,92 @@ angular.module('confRegistrationWebApp').service('testData', function () { }, }; + this.waiverPage = { + id: 'aee6734c-17c2-4e60-9506-b5670b95367e', + conferenceId: 'c63b8abf-52ff-4cc4-afbc-5923b01f1ab0', + title: 'Waiver', + position: 2, + blocks: [ + { + id: 'f9c33761-4cfc-47ae-99b4-04557b8ac375', + pageId: 'aee6734c-17c2-4e60-9506-b5670b95367e', + title: 'Are you under 18?', + exportFieldTitle: null, + type: 'selectQuestion', + required: true, + position: 0, + adminOnly: false, + content: { + forceSelectionRuleOperand: 'AND', + forceSelections: {}, + ruleoperand: 'AND', + choices: [ + { + value: 'Yes', + desc: '', + operand: 'OR', + }, + { + value: 'No', + desc: '', + operand: 'OR', + }, + ], + }, + profileType: null, + tag: 'EFORM', + registrantTypes: [], + rules: [], + expenseType: null, + startDateBlockId: null, + endDateBlockId: null, + }, + ], + }; + + this.rulesPage = { + id: '36f0fb55-b8af-4889-818f-a36a78b278a6', + conferenceId: 'c63b8abf-52ff-4cc4-afbc-5923b01f1ab0', + title: 'Rules', + position: 3, + blocks: [ + { + id: '98ba802c-50a6-43b0-b04c-5e32fe3c7867', + pageId: '36f0fb55-b8af-4889-818f-a36a78b278a6', + title: 'Question', + exportFieldTitle: null, + type: 'textQuestion', + required: false, + position: 0, + adminOnly: false, + content: { + default: '', + ruleoperand: 'OR', + forceSelections: {}, + forceSelectionRuleOperand: 'AND', + }, + profileType: null, + tag: null, + registrantTypes: [], + rules: [ + { + id: 'f0ee3bda-fe1f-425f-b22c-ef48f2c5b35e', + blockId: '98ba802c-50a6-43b0-b04c-5e32fe3c7867', + parentBlockId: '0b876382-5fd1-46af-b778-10fc9b1b530d', + operator: '=', + value: '12', + position: 0, + blockEntityOption: '', + ruleType: 'SHOW_QUESTION', + }, + ], + expenseType: null, + startDateBlockId: null, + endDateBlockId: null, + }, + ], + }; + this.registration = { id: '709738ff-da79-4eed-aacd-d9f005fc7f4e', userId: '0c3a1826-9a81-444f-9299-1f6f5288a0cc', From 6479121e3826d249980cd8e0eb9e6e5d50b79014 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Thu, 21 Dec 2023 08:17:02 -0500 Subject: [PATCH 5/5] Cleanup eventForm controller * Remove unused dependency * Remove redundant statement * Improve makePositionArray readability --- app/scripts/controllers/eventForm.js | 39 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/app/scripts/controllers/eventForm.js b/app/scripts/controllers/eventForm.js index 25d41623f..3738ac7c0 100644 --- a/app/scripts/controllers/eventForm.js +++ b/app/scripts/controllers/eventForm.js @@ -5,7 +5,6 @@ angular function ( $rootScope, $scope, - $uibModal, modalMessage, $location, $anchorScroll, @@ -46,14 +45,13 @@ angular var formSavingNotifyTimeout; function saveForm() { + $timeout.cancel(formSavingTimeout); if (formSaving) { - $timeout.cancel(formSavingTimeout); formSavingTimeout = $timeout(function () { saveForm(); }, 600); return; } - $timeout.cancel(formSavingTimeout); formSaving = true; let conferenceWithoutImage = angular.copy($scope.conference); @@ -210,28 +208,29 @@ angular }); }; - var makePositionArray = function () { - var tempPositionArray = []; + // Generate a map of blockIds to their page and index within the page + var makeBlockPositionMap = function () { + var positionMap = {}; $scope.conference.registrationPages.forEach(function (page, pageIndex) { page.blocks.forEach(function (block, blockIndex) { - tempPositionArray[block.id] = { - page: pageIndex, - block: blockIndex, + positionMap[block.id] = { + pageIndex, + blockIndex, }; }); }); - return tempPositionArray; + return positionMap; }; $scope.copyBlock = function (blockId) { - var tempPositionArray = makePositionArray(); - var origPageIndex = tempPositionArray[blockId].page; + var previousBlockPositions = makeBlockPositionMap(); + var origPageIndex = previousBlockPositions[blockId].pageIndex; var newBlock = angular.copy( $scope.conference.registrationPages[origPageIndex].blocks[ - tempPositionArray[blockId].block + previousBlockPositions[blockId].blockIndex ], ); - var newPosition = tempPositionArray[blockId].block + 1; + var newPosition = previousBlockPositions[blockId].blockIndex + 1; newBlock.id = uuid(); newBlock.profileType = null; newBlock.position = newPosition; @@ -330,20 +329,20 @@ angular return; } + var previousBlockPositions = makeBlockPositionMap(); if (growl) { - var t = makePositionArray(); var block = - $scope.conference.registrationPages[t[blockId].page].blocks[ - t[blockId].block - ]; + $scope.conference.registrationPages[ + previousBlockPositions[blockId].pageIndex + ].blocks[previousBlockPositions[blockId].blockIndex]; var message = '"' + block.title + '" has been deleted.'; GrowlService.growl($scope, 'conference', $scope.conference, message); } - var tempPositionArray = makePositionArray(); _.remove( - $scope.conference.registrationPages[tempPositionArray[blockId].page] - .blocks, + $scope.conference.registrationPages[ + previousBlockPositions[blockId].pageIndex + ].blocks, { id: blockId }, ); };