diff --git a/package.json b/package.json index 8a2c557b..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.13", + "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 3cf4d9ef..c552bc41 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,61 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Bulk set or clear mark for original events. + * + * @param {string[]} eventIds - original event ids + * @param {string} mark - 'resolved' | 'ignored' | 'starred' + * @param {boolean} enabled - true to set mark, false to clear + * @returns {Promise} + */ + async bulkSetEventMarks(eventIds, mark, enabled) { + 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 nowSec = Math.floor(Date.now() / 1000); + const markKey = `marks.${mark}`; + + if (!enabled) { + 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 a single event and all related data (repetitions, daily events) * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c90bf9a2..67225f6f 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 or clear mark for original events. + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string[]} eventIds - original event ids + * @param {string} mark - EventMark enum value + * @param {boolean} enabled - true to set mark, false to remove + * @param {object} context - gql context + * @return {Promise<{ success: boolean, modifiedCount: number }>} + */ + async bulkSetEventMarks(_obj, { projectId, eventIds, mark, enabled }, context) { + const validEventIds = parseBulkEventIds(eventIds); + + const factory = getEventsFactory(context, projectId); + const result = await factory.bulkSetEventMarks(validEventIds, mark, enabled); + + return { + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, + }; + }, + /** * Remove event and all related data (repetitions, daily events) * @@ -212,14 +260,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 { @@ -246,5 +292,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..812adfc8 --- /dev/null +++ b/src/resolvers/helpers/bulkEventUtils.ts @@ -0,0 +1,52 @@ +import { UserInputError } from 'apollo-server-express'; +import { ObjectId } from 'mongodb'; +import sendPersonalNotification from '../../utils/personalNotifications'; +import { SenderWorkerTaskType } from '../../types/userNotifications/task-type'; +import type { EnqueueAssigneeNotificationParams } from '../../types/userNotifications/assignee'; + +/** + * 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; +} + +/** + * Enqueue one assignee notification without blocking resolver response. + */ +export function enqueueAssigneeNotification({ + assigneeData, + assigneeId, + projectId, + whoAssignedId, + eventId, +}: EnqueueAssigneeNotificationParams): 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 fb510c1e..cf65e848 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 BulkSetEventMarksResponse { + """ + 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,31 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Set or clear one mark on many original events at once (resolved, ignored or starred). + """ + bulkSetEventMarks( + """ + Project id + """ + projectId: ID! + + """ + Original event ids (grouped event keys in Hawk) + """ + eventIds: [ID!]! + + """ + Mark (resolved, ignored or starred) to update + """ + mark: EventMark! + + """ + True - set mark, false - remove mark + """ + enabled: Boolean! + ): BulkSetEventMarksResponse! @requireUserInWorkspace + """ Remove event and all related data (repetitions, daily events) """ diff --git a/src/types/userNotifications/assignee.ts b/src/types/userNotifications/assignee.ts index 52a626c6..73021e1f 100644 --- a/src/types/userNotifications/assignee.ts +++ b/src/types/userNotifications/assignee.ts @@ -1,5 +1,6 @@ import { SenderWorkerTaskType } from './task-type'; import { SenderWorkerTask, SenderWorkerPayload } from './task'; +import type { UserDBScheme } from '@hawk.so/types'; /** * Payload of the task to notify the user about the assignment to the task @@ -31,4 +32,17 @@ export interface AssigneeNotificationPayload extends SenderWorkerPayload { */ export interface AssigneeNotificationTask extends SenderWorkerTask { type: SenderWorkerTaskType.Assignee; -} \ No newline at end of file +} + +/** + * Params for enqueueing assignee notification from resolvers/helpers. + */ +export type EnqueueAssigneeNotificationParams = Pick< + AssigneeNotificationPayload, + 'assigneeId' | 'projectId' | 'whoAssignedId' | 'eventId' +> & { + /** + * Full user record of assignee. + */ + assigneeData: UserDBScheme | null; +}; \ No newline at end of file diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts new file mode 100644 index 00000000..97148069 --- /dev/null +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -0,0 +1,125 @@ +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.bulkSetEventMarks', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + }); + + it.each([ + { + title: 'should set starred mark when enabled is true', + mark: 'starred', + enabled: true, + expectedQuery: { + markExists: false, + }, + expectedUpdate: expect.objectContaining({ + $set: { 'marks.starred': expect.any(Number) }, + }), + expectResult: true, + }, + { + title: 'should clear mark when enabled is false', + mark: 'ignored', + enabled: false, + expectedQuery: { + markExists: true, + }, + expectedUpdate: { $unset: { 'marks.ignored': '' } }, + expectResult: false, + }, + ])('$title', async ({ mark, enabled, expectedQuery, expectedUpdate, expectResult }) => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + + const result = await factory.bulkSetEventMarks([ id.toString() ], mark, enabled); + + if (expectResult) { + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + } + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [ id ] }, + [`marks.${mark}`]: { $exists: expectedQuery.markExists }, + }, + expectedUpdate + ); + }); + + it('should deduplicate duplicate event ids before applying', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + await factory.bulkSetEventMarks([ id.toString(), id.toString(), id.toString() ], 'ignored', true); + + 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.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + + const result = await factory.bulkSetEventMarks([ id.toString() ], 'ignored', true); + + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 0, + }); + }); +}); 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..010c2d75 --- /dev/null +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -0,0 +1,123 @@ +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.each([ + { + title: 'should clear assignee with null value', + assignee: null, + expectResult: true, + }, + { + title: 'should clear assignee when assignee is undefined', + assignee: undefined, + expectResult: false, + }, + ])('$title', async ({ assignee, expectResult }) => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + + if (expectResult) { + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); + } + + const result = await factory.bulkUpdateAssignee([ a.toString() ], assignee); + + if (expectResult) { + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + } + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [ a ] }, + assignee: { $ne: '' }, + }, + { $set: { assignee: '' } } + ); + }); + + it('should deduplicate duplicate event ids before updateMany', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + await factory.bulkUpdateAssignee([id.toString(), id.toString(), id.toString()], 'user-1'); + + const query = collectionMock.updateMany.mock.calls[0][0]; + + expect(query._id.$in).toHaveLength(1); + }); +}); 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..a2d0eb51 --- /dev/null +++ b/test/resolvers/bulk-events-helper.test.ts @@ -0,0 +1,30 @@ +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.each([ + { + title: 'should throw when eventIds is empty', + input: [], + message: 'eventIds must contain at least one id', + }, + { + title: 'should throw when at least one id is invalid', + input: ['507f1f77bcf86cd799439011', 'bad-id'], + message: 'eventIds must contain only valid ids', + }, + ])('$title', ({ input, message }) => { + expect(() => parseBulkEventIds(input)).toThrow(message); + }); +}); 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..04063096 --- /dev/null +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -0,0 +1,121 @@ +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: { + bulkSetEventMarks: ( + o: unknown, + args: { projectId: string; eventIds: string[]; mark: string; enabled: boolean }, + ctx: unknown + ) => Promise<{ success: boolean; modifiedCount: number }>; + }; +}; + +const bulkSetEventMarks = jest.fn(); + +describe('Mutation.bulkSetEventMarks', () => { + const ctx = {}; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkSetEventMarks, + }); + }); + + it.each([ + { + title: 'should throw when eventIds is empty', + eventIds: [], + message: 'eventIds must contain at least one id', + }, + { + title: 'should throw for invalid ids', + eventIds: ['507f1f77bcf86cd799439011', 'invalid-id'], + message: 'eventIds must contain only valid ids', + }, + ])('$title', async ({ eventIds, message }) => { + await expect( + eventResolvers.Mutation.bulkSetEventMarks( + {}, + { + projectId: 'p1', + eventIds, + mark: 'ignored', + enabled: true, + }, + ctx + ) + ).rejects.toThrow(message); + + expect(bulkSetEventMarks).not.toHaveBeenCalled(); + }); + + it('should call factory with original event ids and return its result', async () => { + const payload = { + acknowledged: true, + modifiedCount: 2, + }; + + bulkSetEventMarks.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkSetEventMarks( + {}, + { + projectId: 'p1', + eventIds: ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], + mark: 'resolved', + enabled: true, + }, + ctx + ); + + expect(getEventsFactory).toHaveBeenCalledWith(ctx, 'p1'); + expect(bulkSetEventMarks).toHaveBeenCalledWith( + ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], + 'resolved', + true + ); + expect(result).toEqual({ + success: true, + modifiedCount: 2, + }); + }); + + it('should clear starred mark when enabled is false', async () => { + const payload = { + acknowledged: true, + modifiedCount: 1, + }; + + bulkSetEventMarks.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkSetEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + mark: 'starred', + enabled: false, + }, + ctx + ); + + expect(bulkSetEventMarks).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'starred', + false + ); + expect(result).toEqual({ + success: true, + modifiedCount: 1, + }); + }); + +}); 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..36a64847 --- /dev/null +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -0,0 +1,253 @@ +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 throw when assignee does not exist', async () => { + ctx.factories.usersFactory.findById.mockResolvedValueOnce(null); + + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ) + ).rejects.toThrow('assignee not found'); + + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + + it('should throw when assignee is not workspace member', async () => { + ctx.factories.workspacesFactory.findById.mockResolvedValueOnce({ + getMemberInfo: jest.fn().mockResolvedValue(null), + }); + + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ) + ).rejects.toThrow('assignee is not a workspace member'); + + 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(); + }); + + it('should not enqueue notifications when clearing assignee', async () => { + bulkUpdateAssignee.mockResolvedValue({ + acknowledged: true, + modifiedCount: 2, + }); + + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439013' ], + assignee: null, + }, + }, + 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..d61f01ff --- /dev/null +++ b/test/resolvers/event-bulk-visit.test.ts @@ -0,0 +1,81 @@ +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.each([ + { + title: 'should throw when eventIds is empty', + eventIds: [], + message: 'eventIds must contain at least one id', + }, + { + title: 'should throw when ids contain invalid values', + eventIds: ['bad-1', 'bad-2'], + message: 'eventIds must contain only valid ids', + }, + ])('$title', async ({ eventIds, message }) => { + await expect(eventResolvers.Mutation.bulkVisitEvents( + {}, + { + projectId: 'p1', + eventIds, + }, + ctx + )).rejects.toThrow(message); + + expect(bulkVisitEvents).not.toHaveBeenCalled(); + }); + + 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, + }); + }); + +});