diff --git a/package.json b/package.json index 6db19b0b..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.12", + "version": "1.5.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index d76c49bd..6f09055c 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -882,6 +882,27 @@ class EventsFactory extends Factory { return result; } + /** + * Mark many original events as visited for passed user + * + * @param {string[]} eventIds - original event ids + * @param {string|ObjectId} userId - id of the user who is visiting events + * @returns {Promise} + */ + async bulkVisitEvents(eventIds, userId) { + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const collection = this.getCollection(this.TYPES.EVENTS); + const userObjectId = new ObjectId(userId); + + return collection.updateMany( + { + _id: { $in: uniqueEventIds.map(id => new ObjectId(id)) }, + visitedBy: { $ne: userObjectId }, + }, + { $addToSet: { visitedBy: userObjectId } } + ); + } + /** * Mark or unmark event as Resolved, Ignored or Starred * @@ -918,6 +939,62 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Bulk toggle mark for original events. + * + * @param {string[]} eventIds - original event ids + * @param {string} mark - 'resolved' | 'ignored' | 'starred' + * @returns {Promise} + */ + async bulkToggleEventMark(eventIds, mark) { + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const objectIds = uniqueEventIds.map(id => new ObjectId(id)); + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: objectIds } }).toArray(); + const nowSec = Math.floor(Date.now() / 1000); + const markKey = `marks.${mark}`; + const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]); + + if (allHaveMark) { + return collection.updateMany( + { + _id: { $in: objectIds }, + [markKey]: { $exists: true }, + }, + { $unset: { [markKey]: '' } } + ); + } + + return collection.updateMany( + { + _id: { $in: objectIds }, + [markKey]: { $exists: false }, + }, + { $set: { [markKey]: nowSec } } + ); + } + + /** + * Bulk set/clear assignee for many original events. + * + * @param {string[]} eventIds - original event ids + * @param {string|null|undefined} assignee - target assignee id, null/undefined to clear + * @returns {Promise} + */ + async bulkUpdateAssignee(eventIds, assignee) { + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const collection = this.getCollection(this.TYPES.EVENTS); + const normalizedAssignee = assignee ? String(assignee) : ''; + + return collection.updateMany( + { + _id: { $in: uniqueEventIds.map(id => new ObjectId(id)) }, + assignee: { $ne: normalizedAssignee }, + }, + { $set: { assignee: normalizedAssignee } } + ); + } + /** * Remove all project events * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c3c44971..c3cd298b 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,6 +1,11 @@ const getEventsFactory = require('./helpers/eventsFactory').default; -const sendPersonalNotification = require('../utils/personalNotifications').default; +const { + parseBulkEventIds, + enqueueAssigneeNotification, +} = require('./helpers/bulkEventUtils'); const { aiService } = require('../services/ai'); +const { UserInputError } = require('apollo-server-express'); +const { ObjectId } = require('mongodb'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -135,6 +140,26 @@ module.exports = { return !!result.acknowledged; }, + /** + * Mark many original events as visited for current user + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string[]} eventIds - original event ids + * @param {UserInContext} user - user context + * @returns {Promise<{ success: boolean, modifiedCount: number }>} + */ + async bulkVisitEvents(_obj, { projectId, eventIds }, { user, ...context }) { + const validEventIds = parseBulkEventIds(eventIds); + + const factory = getEventsFactory(context, projectId); + const result = await factory.bulkVisitEvents(validEventIds, user.id); + + return { + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, + }; + }, /** * Mark event with one of the event marks @@ -153,6 +178,29 @@ module.exports = { return !!result.acknowledged; }, + /** + * Bulk set resolved/ignored: always set mark on events that lack it, unless all selected + * already have the mark — then remove from all. + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string[]} eventIds - original event ids + * @param {string} mark - EventMark enum value + * @param {object} context - gql context + * @return {Promise<{ success: boolean, modifiedCount: number }>} + */ + async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { + const validEventIds = parseBulkEventIds(eventIds); + + const factory = getEventsFactory(context, projectId); + const result = await factory.bulkToggleEventMark(validEventIds, mark); + + return { + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, + }; + }, + /** * Mutations namespace * @@ -196,14 +244,12 @@ module.exports = { const assigneeData = await factories.usersFactory.dataLoaders.userById.load(assignee); - await sendPersonalNotification(assigneeData, { - type: 'assignee', - payload: { - assigneeId: assignee, - projectId, - whoAssignedId: user.id, - eventId, - }, + enqueueAssigneeNotification({ + assigneeData, + assigneeId: assignee, + projectId, + whoAssignedId: user.id, + eventId, }); return { @@ -230,5 +276,62 @@ module.exports = { success: !!result.acknowledged, }; }, + + /** + * Bulk set/clear assignee for selected original events + * + * @param {ResolverObj} _obj - resolver context + * @param {BulkUpdateAssigneeInput} input - object of arguments + * @param factories - factories for working with models + * @return {Promise<{ success: boolean, modifiedCount: number }>} + */ + async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) { + const { projectId, eventIds, assignee } = input; + const validEventIds = parseBulkEventIds(eventIds); + let assigneeData = null; + + const factory = getEventsFactory(context, projectId); + + if (assignee) { + if (!ObjectId.isValid(String(assignee))) { + throw new UserInputError('assignee must be a valid id or null'); + } + + const userExists = await factories.usersFactory.findById(assignee); + + if (!userExists) { + throw new UserInputError('assignee not found'); + } + + assigneeData = userExists; + + const project = await factories.projectsFactory.findById(projectId); + const workspace = await factories.workspacesFactory.findById(project.workspaceId); + const assigneeExistsInWorkspace = await workspace.getMemberInfo(assignee); + + if (!assigneeExistsInWorkspace) { + throw new UserInputError('assignee is not a workspace member'); + } + } + + const result = await factory.bulkUpdateAssignee(validEventIds, assignee); + + if (assignee && result.modifiedCount > 0) { + validEventIds.forEach((eventId) => { + enqueueAssigneeNotification({ + assigneeData, + assigneeId: assignee, + projectId, + whoAssignedId: user.id, + eventId, + }); + }); + } + + return { + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, + }; + }, }, }; diff --git a/src/resolvers/helpers/bulkEventUtils.ts b/src/resolvers/helpers/bulkEventUtils.ts new file mode 100644 index 00000000..36104237 --- /dev/null +++ b/src/resolvers/helpers/bulkEventUtils.ts @@ -0,0 +1,60 @@ +import { UserInputError } from 'apollo-server-express'; +import { ObjectId } from 'mongodb'; +import sendPersonalNotification from '../../utils/personalNotifications'; +import type { UserDBScheme } from '@hawk.so/types'; +import { SenderWorkerTaskType } from '../../types/userNotifications/task-type'; + +/** + * Validate and normalize bulk event ids from resolver input. + * + * @param {string[]} eventIds - raw event ids from GraphQL input + * @returns {string[]} unique valid event ids + */ +export function parseBulkEventIds(eventIds: string[]): string[] { + if (!eventIds || !eventIds.length) { + throw new UserInputError('eventIds must contain at least one id'); + } + + const uniqueEventIds = [ ...new Set(eventIds.map(id => String(id))) ]; + + if (!uniqueEventIds.every((id) => ObjectId.isValid(id))) { + throw new UserInputError('eventIds must contain only valid ids'); + } + + return uniqueEventIds; +} + +type AssigneeNotificationParams = { + assigneeData: UserDBScheme | null; + assigneeId: string; + projectId: string; + whoAssignedId: string; + eventId: string; +}; + +/** + * Enqueue one assignee notification without blocking resolver response. + */ +export function enqueueAssigneeNotification({ + assigneeData, + assigneeId, + projectId, + whoAssignedId, + eventId, +}: AssigneeNotificationParams): void { + if (!assigneeData) { + return; + } + + sendPersonalNotification(assigneeData, { + type: SenderWorkerTaskType.Assignee, + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }).catch((error: unknown) => { + console.error('Failed to enqueue assignee notification', error); + }); +} diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c200de96..c94d71a7 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -445,6 +445,23 @@ input RemoveAssigneeInput { eventId: ID! } +input BulkUpdateAssigneeInput { + """ + ID of project event is related to + """ + projectId: ID! + + """ + Original event ids to update + """ + eventIds: [ID!]! + + """ + Assignee id to set. Pass null to clear assignee. + """ + assignee: ID +} + type RemoveAssigneeResponse { """ Response status @@ -452,6 +469,48 @@ type RemoveAssigneeResponse { success: Boolean! } +type BulkUpdateAssigneeResponse { + """ + True when database accepted mutation + """ + success: Boolean! + + """ + Number of original events actually modified + """ + modifiedCount: Int! +} + +""" +Response of bulk toggling event marks (resolve / ignore / starred) +""" +type BulkToggleEventMarksResponse { + """ + True when database accepted mutation + """ + success: Boolean! + + """ + Number of original events actually modified + """ + modifiedCount: Int! +} + +""" +Response of bulk marking events as viewed +""" +type BulkVisitEventsResponse { + """ + True when database accepted mutation + """ + success: Boolean! + + """ + Number of original events actually modified + """ + modifiedCount: Int! +} + type EventsMutations { """ Set an assignee for the selected event @@ -466,6 +525,13 @@ type EventsMutations { removeAssignee( input: RemoveAssigneeInput! ): RemoveAssigneeResponse! @requireUserInWorkspace + + """ + Bulk set/clear assignee on many original events + """ + bulkUpdateAssignee( + input: BulkUpdateAssigneeInput! + ): BulkUpdateAssigneeResponse! @requireUserInWorkspace } extend type Mutation { @@ -484,6 +550,21 @@ extend type Mutation { eventId: ID! ): Boolean! + """ + Mark many original events as visited for current user + """ + bulkVisitEvents( + """ + ID of project event is related to + """ + projectId: ID! + + """ + Original event ids + """ + eventIds: [ID!]! + ): BulkVisitEventsResponse! @requireUserInWorkspace + """ Mutation sets or unsets passed mark to event """ @@ -504,6 +585,29 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Toggle the same mark on many original events at once (resolved, ignored or starred). + Uses bulk semantics: if every selected event already has the mark, clear it for all; + otherwise set it on each selected event that does not have it yet. + """ + bulkToggleEventMarks( + """ + Project id + """ + projectId: ID! + + """ + Original event ids (grouped event keys in Hawk) + """ + eventIds: [ID!]! + + """ + 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! + ): BulkToggleEventMarksResponse! @requireUserInWorkspace + """ Namespace that contains only mutations related to the events """ diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts new file mode 100644 index 00000000..0b8c9eb3 --- /dev/null +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -0,0 +1,239 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + find: jest.fn(), + updateMany: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkToggleEventMark', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + }); + + 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.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await factory.bulkToggleEventMark([ id.toString() ], 'starred'); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [ id ] }, + 'marks.starred': { $exists: false }, + }, + expect.objectContaining({ + $set: { 'marks.starred': expect.any(Number) }, + }) + ); + }); + + it('should deduplicate duplicate event ids before applying', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: id, + marks: {}, + }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + await factory.bulkToggleEventMark([id.toString(), id.toString(), id.toString()], 'ignored'); + + const query = collectionMock.updateMany.mock.calls[0][0]; + + expect(query._id.$in).toHaveLength(1); + }); + + it('should return success shape even when nothing changed', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([ { + _id: id, + marks: { ignored: 1 }, + } ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ id.toString() ], 'ignored'); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 0, + }); + }); + + it('should set mark only on events that do not have it when selection is mixed', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: a, + marks: { ignored: 1 }, + }, + { + _id: b, + marks: {}, + }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + await factory.bulkToggleEventMark([a.toString(), b.toString()], 'ignored'); + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.ignored': { $exists: false }, + }, + expect.objectContaining({ + $set: { 'marks.ignored': expect.any(Number) }, + }) + ); + }); + + it('should remove mark from all when every selected event already has the mark', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: a, + marks: { resolved: 1 }, + }, + { + _id: b, + marks: { resolved: 2 }, + }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 2, + }); + + const result = await factory.bulkToggleEventMark([a.toString(), b.toString()], 'resolved'); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 2, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.resolved': { $exists: true }, + }, + { $unset: { 'marks.resolved': '' } } + ); + }); + + it('should not remove mark from a subset when only some of the found events have the mark', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: a, + marks: { ignored: 1 }, + }, + { + _id: b, + marks: {}, + }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + await factory.bulkToggleEventMark([a.toString(), b.toString()], 'ignored'); + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.ignored': { $exists: false }, + }, + expect.objectContaining({ $set: { 'marks.ignored': expect.any(Number) } }) + ); + }); +}); diff --git a/test/models/eventsFactory-bulk-update-assignee.test.ts b/test/models/eventsFactory-bulk-update-assignee.test.ts new file mode 100644 index 00000000..5c8b463a --- /dev/null +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -0,0 +1,96 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + updateMany: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkUpdateAssignee', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + }); + + it('should update assignee with updateMany', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await factory.bulkUpdateAssignee([a.toString(), b.toString()], 'user-1'); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + assignee: { $ne: 'user-1' }, + }, + { $set: { assignee: 'user-1' } } + ); + }); + + it('should clear assignee with null value', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await factory.bulkUpdateAssignee([ a.toString() ], null); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [ a ] }, + assignee: { $ne: '' }, + }, + { $set: { assignee: '' } } + ); + }); +}); diff --git a/test/models/eventsFactory-bulk-visit.test.ts b/test/models/eventsFactory-bulk-visit.test.ts new file mode 100644 index 00000000..991a7fb0 --- /dev/null +++ b/test/models/eventsFactory-bulk-visit.test.ts @@ -0,0 +1,84 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + updateMany: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkVisitEvents', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + }); + + it('should use updateMany with visitedBy guard', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + const userId = new ObjectId(); + + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await factory.bulkVisitEvents([a.toString(), b.toString()], userId.toString()); + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + visitedBy: { $ne: userId }, + }, + { $addToSet: { visitedBy: userId } } + ); + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + }); + + it('should deduplicate ids before updateMany', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + await factory.bulkVisitEvents([id.toString(), id.toString()], new ObjectId().toString()); + + const query = collectionMock.updateMany.mock.calls[0][0]; + + expect(query._id.$in).toHaveLength(1); + }); +}); diff --git a/test/resolvers/bulk-events-helper.test.ts b/test/resolvers/bulk-events-helper.test.ts new file mode 100644 index 00000000..2d27cf9a --- /dev/null +++ b/test/resolvers/bulk-events-helper.test.ts @@ -0,0 +1,25 @@ +import '../../src/env-test'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { parseBulkEventIds } = require('../../src/resolvers/helpers/bulkEventUtils') as { + parseBulkEventIds: (eventIds: string[]) => string[]; +}; + +describe('bulkEvents helper', () => { + it('should deduplicate valid ids', () => { + const validA = '507f1f77bcf86cd799439011'; + const validB = '507f1f77bcf86cd799439012'; + const result = parseBulkEventIds([validA, validA, validB]); + + expect(result).toEqual([validA, validB]); + }); + + it('should throw when eventIds is empty', () => { + expect(() => parseBulkEventIds([])).toThrow('eventIds must contain at least one id'); + }); + + it('should throw when at least one id is invalid', () => { + expect(() => parseBulkEventIds(['507f1f77bcf86cd799439011', 'bad-id'])).toThrow( + 'eventIds must contain only valid ids' + ); + }); +}); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts new file mode 100644 index 00000000..b2882f52 --- /dev/null +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -0,0 +1,117 @@ +import '../../src/env-test'; + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + Mutation: { + bulkToggleEventMarks: ( + o: unknown, + args: { projectId: string; eventIds: string[]; mark: string }, + ctx: unknown + ) => Promise<{ success: boolean; modifiedCount: number }>; + }; +}; + +const bulkToggleEventMark = jest.fn(); + +describe('Mutation.bulkToggleEventMarks', () => { + const ctx = {}; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkToggleEventMark, + }); + }); + + it('should throw when eventIds is empty', async () => { + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [], + mark: 'ignored', + }, + ctx + ) + ).rejects.toThrow('eventIds must contain at least one id'); + + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + }); + + it('should call factory with original event ids and return its result', async () => { + const payload = { + acknowledged: true, + modifiedCount: 2, + }; + + bulkToggleEventMark.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], + mark: 'resolved', + }, + ctx + ); + + expect(getEventsFactory).toHaveBeenCalledWith(ctx, 'p1'); + expect(bulkToggleEventMark).toHaveBeenCalledWith( + ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], + 'resolved' + ); + expect(result).toEqual({ + success: true, + modifiedCount: 2, + }); + }); + + it('should allow starred mark for bulk toggle', async () => { + const payload = { + acknowledged: true, + modifiedCount: 1, + }; + + bulkToggleEventMark.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + mark: 'starred', + }, + ctx + ); + + expect(bulkToggleEventMark).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'starred' + ); + expect(result).toEqual({ + success: true, + modifiedCount: 1, + }); + }); + + it('should throw for invalid ids', async () => { + await expect(eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: ['507f1f77bcf86cd799439011', 'invalid-id'], + mark: 'ignored', + }, + ctx + )).rejects.toThrow('eventIds must contain only valid ids'); + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + }); +}); diff --git a/test/resolvers/event-bulk-update-assignee.test.ts b/test/resolvers/event-bulk-update-assignee.test.ts new file mode 100644 index 00000000..c1cb7785 --- /dev/null +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -0,0 +1,190 @@ +import '../../src/env-test'; + +import { UserInputError } from 'apollo-server-express'; + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +import sendPersonalNotification from '../../src/utils/personalNotifications'; + +jest.mock('../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + EventsMutations: { + bulkUpdateAssignee: ( + o: unknown, + args: { input: { projectId: string; eventIds: string[]; assignee?: string | null } }, + ctx: any + ) => Promise<{ success: boolean; modifiedCount: number }>; + }; +}; + +const bulkUpdateAssignee = jest.fn(); +const ASSIGNEE_ID = '507f1f77bcf86cd799439012'; + +describe('EventsMutations.bulkUpdateAssignee', () => { + const ctx = { + user: { id: 'u1' }, + factories: { + usersFactory: { + findById: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID }), + dataLoaders: { + userById: { + load: jest.fn().mockResolvedValue({ + id: ASSIGNEE_ID, + email: 'a@a.a', + }), + }, + }, + }, + projectsFactory: { + findById: jest.fn().mockResolvedValue({ workspaceId: 'w1' }), + }, + workspacesFactory: { + findById: jest.fn().mockResolvedValue({ + getMemberInfo: jest.fn().mockResolvedValue({ userId: ASSIGNEE_ID }), + }), + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkUpdateAssignee, + }); + bulkUpdateAssignee.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + }); + + it('should throw when eventIds is empty', async () => { + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ) + ).rejects.toThrow(UserInputError); + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + + it('should throw when assignee id is invalid', async () => { + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: 'not-an-object-id', + }, + }, + ctx + ) + ).rejects.toThrow(UserInputError); + + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + + it('should call factory for bulk assign', async () => { + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ); + + expect(bulkUpdateAssignee).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + ASSIGNEE_ID + ); + expect(sendPersonalNotification).toHaveBeenCalledTimes(1); + expect(sendPersonalNotification).toHaveBeenCalledWith( + expect.objectContaining({ id: ASSIGNEE_ID }), + expect.objectContaining({ + type: expect.anything(), + payload: expect.objectContaining({ + assigneeId: ASSIGNEE_ID, + projectId: 'p1', + whoAssignedId: 'u1', + eventId: '507f1f77bcf86cd799439011', + }), + }) + ); + }); + + it('should throw for invalid event ids', async () => { + await expect(eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: ['bad-1', 'bad-2'], + assignee: ASSIGNEE_ID, + }, + }, + ctx + )).rejects.toThrow('eventIds must contain only valid ids'); + + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + + it('should call factory for bulk clear assignee', async () => { + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: null, + }, + }, + ctx + ); + + expect(bulkUpdateAssignee).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + null + ); + }); + + it('should not enqueue notifications when nothing changed', async () => { + bulkUpdateAssignee.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ); + + expect(sendPersonalNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/test/resolvers/event-bulk-visit.test.ts b/test/resolvers/event-bulk-visit.test.ts new file mode 100644 index 00000000..edddc2f5 --- /dev/null +++ b/test/resolvers/event-bulk-visit.test.ts @@ -0,0 +1,68 @@ +import '../../src/env-test'; + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + Mutation: { + bulkVisitEvents: ( + o: unknown, + args: { projectId: string; eventIds: string[] }, + ctx: any + ) => Promise<{ success: boolean; modifiedCount: number }>; + }; +}; + +const bulkVisitEvents = jest.fn(); + +describe('Mutation.bulkVisitEvents', () => { + const ctx = { + user: { id: '507f1f77bcf86cd799439011' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvents }); + }); + + it('should call factory and return normalized response', async () => { + bulkVisitEvents.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await eventResolvers.Mutation.bulkVisitEvents( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439012' ], + }, + ctx + ); + + expect(bulkVisitEvents).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439012' ], + '507f1f77bcf86cd799439011' + ); + expect(result).toEqual({ + success: true, + modifiedCount: 1, + }); + }); + + it('should throw when ids contain invalid values', async () => { + await expect(eventResolvers.Mutation.bulkVisitEvents( + {}, + { + projectId: 'p1', + eventIds: ['bad-1', 'bad-2'], + }, + ctx + )).rejects.toThrow('eventIds must contain only valid ids'); + expect(bulkVisitEvents).not.toHaveBeenCalled(); + }); +});