From 95f919de4d50f5ac780d36deee0441ecc9acc3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominique=20J=C3=A4ggi?= Date: Thu, 21 Nov 2024 08:46:23 +0100 Subject: [PATCH] feat: bulk update status --- .../src/v2/models/base.collection.js | 20 ++++++++ .../src/v2/models/index.d.ts | 5 +- .../src/v2/models/suggestion.collection.js | 24 +++++++++ .../src/v2/util/patcher.js | 13 +++++ .../test/it/util/db.js | 1 + .../test/it/v2/index.test.js | 16 ++++++ .../unit/v2/models/base.collection.test.js | 43 ++++++++++++++++ .../v2/models/suggestion.collection.test.js | 50 ++++++++++++++++++- .../test/unit/v2/util/patcher.test.js | 15 ++++++ 9 files changed, 184 insertions(+), 3 deletions(-) mode change 100644 => 100755 packages/spacecat-shared-data-access/test/it/util/db.js diff --git a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js b/packages/spacecat-shared-data-access/src/v2/models/base.collection.js index 9ac5be0e..c8b808c3 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/base.collection.js +++ b/packages/spacecat-shared-data-access/src/v2/models/base.collection.js @@ -175,6 +175,26 @@ class BaseCollection { throw error; } } + + async _saveMany(items) { + if (!Array.isArray(items) || items.length === 0) { + const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`; + this.log.error(message); + throw new Error(message); + } + + try { + const updates = items.map((item) => item.record); + const response = await this.entity.put(updates).go(); + + if (response.unprocessed) { + this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`); + } + } catch (error) { + this.log.error(`Failed to save many [${this.entityName}]`, error); + throw error; + } + } } export default BaseCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts index 5617a88b..381b48b5 100755 --- a/packages/spacecat-shared-data-access/src/v2/models/index.d.ts +++ b/packages/spacecat-shared-data-access/src/v2/models/index.d.ts @@ -26,7 +26,7 @@ export interface BaseModel { */ export interface Opportunity extends BaseModel { // eslint-disable-next-line no-use-before-define - addSuggestions(suggestions: Array): Promise>; + addSuggestions(suggestions: object[]): Promise; // eslint-disable-next-line no-use-before-define getSuggestions(): Promise; getSiteId(): string; @@ -76,7 +76,7 @@ export interface Suggestion extends BaseModel { export interface BaseCollection { findById(id: string): Promise; create(item: object): Promise; - createMany(items: object[]): Promise>; + createMany(items: object[]): Promise; } /** @@ -93,6 +93,7 @@ export interface OpportunityCollection extends BaseCollection { export interface SuggestionCollection extends BaseCollection { allByOpportunityId(opportunityId: string): Promise; allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise; + bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise; } /** diff --git a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js b/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js index 486054dd..5618b411 100644 --- a/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js +++ b/packages/spacecat-shared-data-access/src/v2/models/suggestion.collection.js @@ -75,6 +75,30 @@ class SuggestionCollection extends BaseCollection { return this._createInstances(records); } + + /** + * Updates the status of multiple given suggestions. + * @param {Suggestion[]} suggestions - An array of Suggestion instances to update. + * @param {string} status - The new status to set for the suggestions. + * @return {Promise<*>} - A promise that resolves to the updated suggestions. + */ + async bulkUpdateStatus(suggestions, status) { + if (!Array.isArray(suggestions)) { + throw new Error('Suggestions must be an array'); + } + + if (!hasText(status)) { + throw new Error('Status is required'); + } + + suggestions.forEach((suggestion) => { + suggestion.setStatus(status); + }); + + await this._saveMany(suggestions); + + return suggestions; + } } export default SuggestionCollection; diff --git a/packages/spacecat-shared-data-access/src/v2/util/patcher.js b/packages/spacecat-shared-data-access/src/v2/util/patcher.js index 0508f88f..84411bcd 100644 --- a/packages/spacecat-shared-data-access/src/v2/util/patcher.js +++ b/packages/spacecat-shared-data-access/src/v2/util/patcher.js @@ -45,6 +45,7 @@ class Patcher { this.model = entity.model; this.idName = `${this.model.name.toLowerCase()}Id`; this.record = record; + this.updates = {}; this.patchRecord = null; } @@ -104,6 +105,7 @@ class Patcher { [propertyName]: value, }); this.record[propertyName] = value; + this.updates[propertyName] = value; } /** @@ -175,9 +177,20 @@ class Patcher { * @throws {Error} - Throws an error if the save operation fails. */ async save() { + if (!this.hasUpdates()) { + return; + } await this.#getPatchRecord().go(); this.record.updatedAt = new Date().getTime(); } + + getUpdates() { + return this.updates; + } + + hasUpdates() { + return Object.keys(this.updates).length > 0; + } } export default Patcher; diff --git a/packages/spacecat-shared-data-access/test/it/util/db.js b/packages/spacecat-shared-data-access/test/it/util/db.js old mode 100644 new mode 100755 index c019306a..0def934c --- a/packages/spacecat-shared-data-access/test/it/util/db.js +++ b/packages/spacecat-shared-data-access/test/it/util/db.js @@ -28,6 +28,7 @@ async function waitForDynamoDBStartup(url, timeout = 20000, interval = 500) { return; } } catch (error) { + // eslint-disable-next-line no-console console.log('DynamoDB Local not yet started', error.message); } // eslint-disable-next-line no-await-in-loop diff --git a/packages/spacecat-shared-data-access/test/it/v2/index.test.js b/packages/spacecat-shared-data-access/test/it/v2/index.test.js index 16c3fa86..aae0de34 100755 --- a/packages/spacecat-shared-data-access/test/it/v2/index.test.js +++ b/packages/spacecat-shared-data-access/test/it/v2/index.test.js @@ -424,5 +424,21 @@ describe('Opportunity & Suggestion IT', function () { expect(record).to.eql(data[index]); }); }); + + it('updates the status of multiple suggestions', async () => { + const { Suggestion } = dataAccess; + + const suggestions = sampleData.suggestions.slice(0, 3); + + await Suggestion.bulkUpdateStatus(suggestions, 'APPROVED'); + + const updatedSuggestions = await Promise.all( + suggestions.map((suggestion) => Suggestion.findById(suggestion.getId())), + ); + + updatedSuggestions.forEach((suggestion) => { + expect(suggestion.getStatus()).to.equal('APPROVED'); + }); + }); }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js index acfb27ca..d21f569a 100644 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/base.collection.test.js @@ -20,6 +20,7 @@ import chaiAsPromised from 'chai-as-promised'; import BaseCollection from '../../../../src/v2/models/base.collection.js'; chaiUse(chaiAsPromised); + describe('BaseCollection', () => { let baseCollectionInstance; let mockElectroService; @@ -244,4 +245,46 @@ describe('BaseCollection', () => { expect(instances[1]).to.deep.include(mockEntityModel); }); }); + + describe('_saveMany', () => { + it('throws an error if the records are empty', async () => { + await expect(baseCollectionInstance._saveMany(null)) + .to.be.rejectedWith('Failed to save many [mockentitymodel]: items must be a non-empty array'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + + it('saves multiple entities successfully', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockentitymodel.put.returns({ go: () => [] }); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; + }); + + it('saves some entities successfully with unprocessed items', async () => { + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockentitymodel.put.returns( + { + go: () => Promise.resolve({ unprocessed: [mockRecord] }), + }, + ); + + const result = await baseCollectionInstance._saveMany(mockRecords); + expect(result).to.be.undefined; + expect(mockElectroService.entities.mockentitymodel.put.calledOnce).to.be.true; + expect(mockLogger.error.calledOnceWith(`Failed to process all items in batch write for [mockentitymodel]: ${JSON.stringify([mockRecord])}`)).to.be.true; + }); + + it('throws error and logs when save fails', async () => { + const error = new Error('Save failed'); + const mockRecords = [mockRecord, mockRecord]; + mockElectroService.entities.mockentitymodel.put.returns( + { go: () => Promise.reject(error) }, + ); + + await expect(baseCollectionInstance._saveMany(mockRecords)).to.be.rejectedWith('Save failed'); + expect(mockLogger.error.calledOnce).to.be.true; + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js index 414ba1e4..f520825c 100644 --- a/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/models/suggestion.collection.test.js @@ -13,25 +13,43 @@ /* eslint-env mocha */ import { expect, use as chaiUse } from 'chai'; +import { Entity } from 'electrodb'; import { spy, stub } from 'sinon'; import chaiAsPromised from 'chai-as-promised'; import SuggestionCollection from '../../../../src/v2/models/suggestion.collection.js'; import Suggestion from '../../../../src/v2/models/suggestion.model.js'; +import SuggestionSchema from '../../../../src/v2/schema/suggestion.schema.js'; chaiUse(chaiAsPromised); +const { attributes } = new Entity(SuggestionSchema).model.schema; + const mockElectroService = { entities: { suggestion: { model: { name: 'suggestion', + schema: { attributes }, + indexes: { + primary: { + pk: { + field: 'pk', + composite: ['suggestionId'], + }, + }, + }, }, query: { byOpportunityId: stub(), byOpportunityIdAndStatus: stub(), }, - put: stub(), + put: stub().returns({ + go: stub().resolves({}), + }), + patch: stub().returns({ + set: stub(), + }), }, }, }; @@ -143,4 +161,34 @@ describe('SuggestionCollection', () => { .to.be.rejectedWith('Status is required'); }); }); + + describe('bulkUpdateStatus', () => { + it('updates the status of multiple suggestions', async () => { + const mockSuggestions = [mockSuggestionModel]; + const mockStatus = 'NEW'; + + await suggestionCollectionInstance.bulkUpdateStatus(mockSuggestions, mockStatus); + + expect(mockElectroService.entities.suggestion.put.calledOnce).to.be.true; + expect(mockElectroService.entities.suggestion.put.firstCall.args[0]).to.deep.equal([{ + suggestionId: 's12345', + opportunityId: 'op67890', + data: { + title: 'Test Suggestion', + description: 'This is a test suggestion.', + }, + status: 'NEW', + }]); + }); + + it('throws an error if suggestions is not an array', async () => { + await expect(suggestionCollectionInstance.bulkUpdateStatus({}, 'NEW')) + .to.be.rejectedWith('Suggestions must be an array'); + }); + + it('throws an error if status is not provided', async () => { + await expect(suggestionCollectionInstance.bulkUpdateStatus([mockSuggestionModel], '')) + .to.be.rejectedWith('Status is required'); + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js index 7af0e819..31673b14 100755 --- a/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js +++ b/packages/spacecat-shared-data-access/test/unit/v2/util/patcher.test.js @@ -103,12 +103,27 @@ describe('Patcher', () => { .to.throw('Property nonExistent does not exist on entity testentity.'); }); + it('tracks updates', () => { + patcher.patchValue('name', 'UpdatedName'); + + expect(patcher.hasUpdates()).to.be.true; + expect(patcher.getUpdates()).to.deep.equal({ name: 'UpdatedName' }); + }); + it('saves the record', async () => { + patcher.patchValue('name', 'UpdatedName'); + await patcher.save(); + expect(mockEntity.patch().go.calledOnce).to.be.true; expect(mockRecord.updatedAt).to.be.a('number'); }); + it('does not save if there are no updates', async () => { + await patcher.save(); + expect(mockEntity.patch().go.notCalled).to.be.true; + }); + it('throws error if attribute type is unsupported', () => { mockEntity.model.schema.attributes.invalidType = { type: 'unsupported' }; expect(() => patcher.patchValue('invalidType', 'value'))