Skip to content

Commit

Permalink
feat: bulk update status
Browse files Browse the repository at this point in the history
  • Loading branch information
solaris007 committed Nov 21, 2024
1 parent 00d117f commit 95f919d
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 3 additions & 2 deletions packages/spacecat-shared-data-access/src/v2/models/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface BaseModel {
*/
export interface Opportunity extends BaseModel {
// eslint-disable-next-line no-use-before-define
addSuggestions(suggestions: Array<object>): Promise<Array<Suggestion>>;
addSuggestions(suggestions: object[]): Promise<Suggestion[]>;
// eslint-disable-next-line no-use-before-define
getSuggestions(): Promise<Suggestion[]>;
getSiteId(): string;
Expand Down Expand Up @@ -76,7 +76,7 @@ export interface Suggestion extends BaseModel {
export interface BaseCollection<T extends BaseModel> {
findById(id: string): Promise<T>;
create(item: object): Promise<T>;
createMany(items: object[]): Promise<Array<T>>;
createMany(items: object[]): Promise<T[]>;
}

/**
Expand All @@ -93,6 +93,7 @@ export interface OpportunityCollection extends BaseCollection<Opportunity> {
export interface SuggestionCollection extends BaseCollection<Suggestion> {
allByOpportunityId(opportunityId: string): Promise<Suggestion[]>;
allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<Suggestion[]>;
bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise<Suggestion[]>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
13 changes: 13 additions & 0 deletions packages/spacecat-shared-data-access/src/v2/util/patcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -104,6 +105,7 @@ class Patcher {
[propertyName]: value,
});
this.record[propertyName] = value;
this.updates[propertyName] = value;
}

/**
Expand Down Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/spacecat-shared-data-access/test/it/util/db.js
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/spacecat-shared-data-access/test/it/v2/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
},
},
};
Expand Down Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down

0 comments on commit 95f919d

Please sign in to comment.