diff --git a/package.json b/package.json index b9aaa8e8..d484a908 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.0", + "version": "1.5.1", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 1c620d7f..0dd0e16b 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -926,19 +926,19 @@ class EventsFactory extends Factory { } /** - * Bulk mark for resolved / ignored (not the same as per-event toggleEventMark). + * Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark). * - If every found event already has the mark: remove it from all (bulk "undo"). * - Otherwise: set the mark on every found event that does not have it yet (never remove * from a subset when the selection is mixed). - * Only 'resolved' and 'ignored' are allowed for bulk. + * Only 'resolved', 'ignored' and 'starred' are allowed for bulk. * * @param {string[]} eventIds - original event ids - * @param {string} mark - 'resolved' | 'ignored' - * @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>} + * @param {string} mark - 'resolved' | 'ignored' | 'starred' + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { - if (mark !== 'resolved' && mark !== 'ignored') { - throw new Error(`bulkToggleEventMark: mark must be resolved or ignored, got ${mark}`); + if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { + throw new Error(`bulkToggleEventMark: mark must be resolved, ignored or starred, got ${mark}`); } const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; @@ -962,6 +962,7 @@ class EventsFactory extends Factory { if (validObjectIds.length === 0) { return { updatedCount: 0, + updatedEventIds: [], failedEventIds, }; } @@ -982,6 +983,7 @@ class EventsFactory extends Factory { const markKey = `marks.${mark}`; const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]); const ops = []; + const updatedEventIds = []; for (const doc of found) { const hasMark = doc.marks && doc.marks[mark]; @@ -1001,11 +1003,13 @@ class EventsFactory extends Factory { update, }, }); + updatedEventIds.push(doc._id.toString()); } if (ops.length === 0) { return { updatedCount: 0, + updatedEventIds: [], failedEventIds, }; } @@ -1014,6 +1018,7 @@ class EventsFactory extends Factory { return { updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, + updatedEventIds, failedEventIds, }; } diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 49e30730..b28c56ad 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -167,8 +167,8 @@ module.exports = { * @return {Promise<{ updatedCount: number, failedEventIds: string[] }>} */ async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { - if (mark !== 'resolved' && mark !== 'ignored') { - throw new UserInputError('bulkToggleEventMarks supports only resolved and ignored marks'); + if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { + throw new UserInputError('bulkToggleEventMarks supports only resolved, ignored and starred marks'); } if (!eventIds || !eventIds.length) { diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index cab56838..e47e82b0 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -461,6 +461,11 @@ type BulkToggleEventMarksResult { """ updatedCount: Int! + """ + Original event ids actually toggled in this operation + """ + updatedEventIds: [ID!]! + """ Event ids that were not updated (invalid id or not found) """ @@ -520,7 +525,7 @@ extend type Mutation { ): Boolean! """ - Toggle the same mark on many original events at once (only resolved or ignored). + Toggle the same mark on many original events at once (resolved, ignored or starred). Same toggle semantics as toggleEventMark per event. """ bulkToggleEventMarks( @@ -535,7 +540,7 @@ extend type Mutation { eventIds: [ID!]! """ - Mark (resolved or ignored only): if every selected event already has it, clear it for all; + Mark (resolved, ignored or starred): if every selected event already has it, clear it for all; otherwise set it on every selected event that does not have it yet. """ mark: EventMark! diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 61305030..35657678 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -49,11 +49,43 @@ describe('EventsFactory.bulkToggleEventMark', () => { }); }); - it('should throw when mark is not resolved or ignored', async () => { + it('should throw when mark is unsupported', async () => { const factory = new EventsFactory(projectId); - await expect(factory.bulkToggleEventMark([], 'starred' as any)).rejects.toThrow( - 'bulkToggleEventMark: mark must be resolved or ignored' + await expect(factory.bulkToggleEventMark([], 'some-unknown-mark' as any)).rejects.toThrow( + 'bulkToggleEventMark: mark must be resolved, ignored or starred' + ); + }); + + it('should support starred mark', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: id, + marks: {}, + }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ id.toString() ], 'starred'); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ id.toString() ]); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.update).toEqual( + expect.objectContaining({ + $set: { 'marks.starred': expect.any(Number) }, + }) ); }); @@ -99,6 +131,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved'); expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); expect(result.failedEventIds).toContain('not-a-valid-id'); expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); }); @@ -114,6 +147,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ missing.toString() ], 'ignored'); expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); expect(result.failedEventIds).toContain(missing.toString()); expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); }); @@ -138,6 +172,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); const ops = collectionMock.bulkWrite.mock.calls[0][0]; expect(ops).toHaveLength(1); @@ -169,6 +204,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'resolved'); expect(result.updatedCount).toBe(2); + expect(result.updatedEventIds).toEqual([ a.toString(), b.toString() ]); const ops = collectionMock.bulkWrite.mock.calls[0][0]; expect(ops).toHaveLength(2); @@ -196,6 +232,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); const ops = collectionMock.bulkWrite.mock.calls[0][0]; expect(ops).toHaveLength(1); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 5b9dec1c..eeb22819 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -15,7 +15,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[]; mark: string }, ctx: unknown - ) => Promise<{ updatedCount: number; failedEventIds: string[] }>; + ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; }; }; @@ -29,11 +29,11 @@ describe('Mutation.bulkToggleEventMarks', () => { (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkToggleEventMark }); }); - it('should throw when mark is not resolved or ignored', async () => { + it('should throw when mark is not supported', async () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, ctx ) ).rejects.toThrow(UserInputError); @@ -41,10 +41,10 @@ describe('Mutation.bulkToggleEventMarks', () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, ctx ) - ).rejects.toThrow('bulkToggleEventMarks supports only resolved and ignored marks'); + ).rejects.toThrow('bulkToggleEventMarks supports only resolved, ignored and starred marks'); expect(bulkToggleEventMark).not.toHaveBeenCalled(); }); @@ -62,7 +62,7 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should call factory with original event ids and return its result', async () => { - const payload = { updatedCount: 2, failedEventIds: [ 'x' ] }; + const payload = { updatedCount: 2, updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; bulkToggleEventMark.mockResolvedValue(payload); @@ -84,6 +84,28 @@ describe('Mutation.bulkToggleEventMarks', () => { expect(result).toEqual(payload); }); + it('should allow starred mark for bulk toggle', async () => { + const payload = { updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; + + bulkToggleEventMark.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + mark: 'starred', + }, + ctx + ); + + expect(bulkToggleEventMark).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'starred' + ); + expect(result).toEqual(payload); + }); + it('should map factory max-length error to UserInputError', async () => { bulkToggleEventMark.mockRejectedValue( new Error('bulkToggleEventMark: at most 100 event ids allowed')