diff --git a/jest.config.js b/jest.config.js index 4c2cc568..133d852f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,6 +37,10 @@ module.exports = { moduleNameMapper: { '^node:crypto$': '/test/__mocks__/node_crypto.js', '^node:util$': '/test/__mocks__/node_util.js', + /** + * demoWorkspace is TypeScript; CommonJS resolvers use require() without extension + */ + '^.+/constants/demoWorkspace$': '/src/constants/demoWorkspace.ts', }, /** diff --git a/package.json b/package.json index 00cd7e34..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.11", + "version": "1.5.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -41,7 +41,7 @@ "@graphql-tools/merge": "^8.3.1", "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", - "@hawk.so/nodejs": "^3.3.1", + "@hawk.so/nodejs": "^3.3.2", "@hawk.so/types": "^0.5.9", "@n1ru4l/json-patch-plus": "^0.2.0", "@node-saml/node-saml": "^5.0.1", diff --git a/src/constants/demoWorkspace.ts b/src/constants/demoWorkspace.ts new file mode 100644 index 00000000..0aae0497 --- /dev/null +++ b/src/constants/demoWorkspace.ts @@ -0,0 +1,5 @@ +/** + * Mongo ObjectId string of the public "Join Demo Workspace" (Garage landing). + * Keep in sync with operations that seed demo data in Mongo. + */ +export const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; diff --git a/src/integrations/github/routes.ts b/src/integrations/github/routes.ts index 8144ec34..3acfc72f 100644 --- a/src/integrations/github/routes.ts +++ b/src/integrations/github/routes.ts @@ -11,6 +11,7 @@ import ProjectModel from '../../models/project'; import WorkspaceModel from '../../models/workspace'; import { sgr, Effect } from '../../utils/ansi'; import { databases } from '../../mongo'; +import { DEMO_WORKSPACE_ID } from '../../constants/demoWorkspace'; /** * Default task threshold for automatic task creation @@ -108,7 +109,7 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * Check if project is demo project (cannot be modified) */ - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { res.status(400).json({ error: 'Unable to update demo project' }); return null; diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 916fb9bb..1c620d7f 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -203,10 +203,11 @@ class EventsFactory extends Factory { * * @param {Number} limit - events count limitations * @param {DailyEventsCursor} paginationCursor - object that contains boundary values of the last event in the previous portion - * @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order - * @param {EventsFilters} filters - marks by which events should be filtered + * @param {'BY_DATE' | 'BY_COUNT' | 'BY_AFFECTED_USERS'} sort - events sort order + * @param {EventsFilters} filters - marks by which events should be filtered (resolved, starred, ignored only; assignee is separate) * @param {String} search - Search query - * @param {String} release - release name + * @param {String|undefined} release - release name + * @param {String|undefined} assignee - user id or __filter_unassigned__ / __filter_any_assignee__ * * @return {DaylyEventsPortionSchema} */ @@ -917,6 +918,106 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Max original event ids per bulkToggleEventMark request + */ + static get BULK_TOGGLE_EVENT_MARK_MAX() { + return 100; + } + + /** + * Bulk mark for resolved / ignored (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. + * + * @param {string[]} eventIds - original event ids + * @param {string} mark - 'resolved' | 'ignored' + * @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>} + */ + async bulkToggleEventMark(eventIds, mark) { + if (mark !== 'resolved' && mark !== 'ignored') { + throw new Error(`bulkToggleEventMark: mark must be resolved or ignored, got ${mark}`); + } + + const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; + const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; + + if (unique.length > max) { + throw new Error(`bulkToggleEventMark: at most ${max} event ids allowed`); + } + + const failedEventIds = []; + const validObjectIds = []; + + for (const id of unique) { + if (!ObjectId.isValid(id)) { + failedEventIds.push(id); + } else { + validObjectIds.push(new ObjectId(id)); + } + } + + if (validObjectIds.length === 0) { + return { + updatedCount: 0, + failedEventIds, + }; + } + + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); + const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); + + for (const oid of validObjectIds) { + const idStr = oid.toString(); + + if (!foundByIdStr.has(idStr)) { + failedEventIds.push(idStr); + } + } + + const nowSec = Math.floor(Date.now() / 1000); + const markKey = `marks.${mark}`; + const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]); + const ops = []; + + for (const doc of found) { + const hasMark = doc.marks && doc.marks[mark]; + let update; + + if (allHaveMark) { + update = { $unset: { [markKey]: '' } }; + } else if (!hasMark) { + update = { $set: { [markKey]: nowSec } }; + } else { + continue; + } + + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + } + + if (ops.length === 0) { + return { + updatedCount: 0, + failedEventIds, + }; + } + + const bulkResult = await collection.bulkWrite(ops, { ordered: false }); + + return { + updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, + failedEventIds, + }; + } + /** * Remove all project events * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c3c44971..49e30730 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,6 +1,8 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const sendPersonalNotification = require('../utils/personalNotifications').default; const { aiService } = require('../services/ai'); +const { DEMO_WORKSPACE_ID } = require('../constants/demoWorkspace'); +const { UserInputError } = require('apollo-server-express'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -48,7 +50,7 @@ module.exports = { */ const project = await factories.projectsFactory.findById(projectId); - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { return [ await factories.usersFactory.findById(user.id) ]; } @@ -153,6 +155,39 @@ 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<{ 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 (!eventIds || !eventIds.length) { + throw new UserInputError('eventIds must contain at least one id'); + } + + const factory = getEventsFactory(context, projectId); + + try { + return await factory.bulkToggleEventMark(eventIds, mark); + } catch (err) { + if (err.message && err.message.includes('bulkToggleEventMark: at most')) { + throw new UserInputError(err.message); + } + + throw err; + } + }, + /** * Mutations namespace * diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 63b00cff..716a9f78 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -1,5 +1,6 @@ import { ReceiveTypes } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; +import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const mongo = require('../mongo'); const { ObjectId } = require('mongodb'); const { ApolloError, UserInputError } = require('apollo-server-express'); @@ -20,6 +21,54 @@ const GROUPING_TIMESTAMP_INDEX_NAME = 'groupingTimestamp'; const GROUPING_TIMESTAMP_AND_LAST_REPETITION_TIME_AND_ID_INDEX_NAME = 'groupingTimestampAndLastRepetitionTimeAndId'; const GROUPING_TIMESTAMP_AND_GROUP_HASH_INDEX_NAME = 'groupingTimestampAndGroupHash'; const MAX_SEARCH_QUERY_LENGTH = 50; +const FALLBACK_EVENT_TITLE = 'Unknown'; + +/** + * Ensures each daily event has non-empty payload title + * and writes warning log with identifiers when fallback is used. + * + * @param {object} dailyEventsPortion - portion returned by events factory + * @param {string|ObjectId} projectId - project id for logs + * @returns {object} + */ +function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) { + if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) { + return dailyEventsPortion; + } + + dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => { + const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null; + const payload = event && event.payload ? event.payload : null; + const hasValidTitle = payload && + typeof payload.title === 'string' && + payload.title.trim().length > 0; + + if (hasValidTitle) { + return dailyEvent; + } + + console.warn('🔴🔴🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', { + projectId: projectId ? projectId.toString() : null, + dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null, + dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null, + eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null, + eventId: event && event._id ? event._id.toString() : null, + }); + + return { + ...dailyEvent, + event: { + ...(event || {}), + payload: { + ...(payload || {}), + title: FALLBACK_EVENT_TITLE, + }, + }, + }; + }); + + return dailyEventsPortion; +} /** * See all types and fields here {@see ../typeDefs/project.graphql} @@ -205,7 +254,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -243,7 +292,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -351,7 +400,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to remove demo project'); } @@ -410,7 +459,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -461,7 +510,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -571,17 +620,19 @@ module.exports = { }, /** - * Returns recent Events grouped by day - * - * @param {ProjectDBScheme} project - result of parent resolver - * @param {Number} limit - limit for events count - * @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion - * @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order - * @param {EventsFilters} filters - marks by which events should be filtered - * @param {String} release - release name - * @param {String} search - search query + * Returns a paginated portion of daily-grouped events * - * @return {Promise} + * @param {ProjectDBScheme} project - parent resolver result + * @param {object} args - GraphQL arguments + * @param {number} args.limit - max rows in portion + * @param {object|null} args.nextCursor - pagination cursor + * @param {string} args.sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (mapped in factory) + * @param {object} args.filters - mark filters only: resolved, starred, ignored (assignee uses args.assignee) + * @param {string} args.search - search query + * @param {string|undefined} args.release - optional release label filter + * @param {string|undefined} args.assignee - user id or __filter_unassigned__ / __filter_any_assignee__ + * @param {object} context - GraphQL context + * @returns {Promise} dailyEventsPortion payload from factory */ async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release, assignee }, context) { if (search) { @@ -602,6 +653,8 @@ module.exports = { assignee ); + normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id); + return dailyEventsPortion; }, diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 83ef92ae..7a0a9e5d 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -10,6 +10,7 @@ import Validator from '../utils/validator'; import { dateFromObjectId } from '../utils/dates'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import { publish } from '../rabbitmq'; +import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const { ApolloError, UserInputError, ForbiddenError } = require('apollo-server-express'); const crypto = require('crypto'); @@ -551,7 +552,7 @@ module.exports = { /** * Crutch for Demo Workspace */ - if (workspaceData._id.toString() === '6213b6a01e6281087467cc7a') { + if (workspaceData._id.toString() === DEMO_WORKSPACE_ID) { return [ { _id: user.id, diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c200de96..cab56838 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -452,6 +452,21 @@ type RemoveAssigneeResponse { success: Boolean! } +""" +Result of bulk toggling event marks (resolve / ignore) +""" +type BulkToggleEventMarksResult { + """ + Number of events updated in the database + """ + updatedCount: Int! + + """ + Event ids that were not updated (invalid id or not found) + """ + failedEventIds: [ID!]! +} + type EventsMutations { """ Set an assignee for the selected event @@ -504,6 +519,28 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Toggle the same mark on many original events at once (only resolved or ignored). + Same toggle semantics as toggleEventMark per event. + """ + bulkToggleEventMarks( + """ + Project id + """ + projectId: ID! + + """ + Original event ids (grouped event keys in Hawk) + """ + eventIds: [ID!]! + + """ + Mark (resolved or ignored only): 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! + ): BulkToggleEventMarksResult! @requireUserInWorkspace + """ Namespace that contains only mutations related to the events """ diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index 1e7135e9..85006168 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -123,10 +123,6 @@ input EventsFiltersInput { If True, includes events with ignored mark to the output """ ignored: Boolean - """ - Includes only events assigned to passed user id - """ - assignee: ID } """ diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 03eacc94..607c6cdb 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb'; import express from 'express'; import { createGitHubRouter } from '../../src/integrations/github/routes'; import { ContextFactories } from '../../src/types/graphql'; +import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; /** * Mock GitHubService @@ -32,8 +33,6 @@ jest.mock('../../src/integrations/github/store/install-state.redis.store', () => })), })); -const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; - function createMockProject(options: { projectId?: string; workspaceId?: string; diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts new file mode 100644 index 00000000..61305030 --- /dev/null +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -0,0 +1,206 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + find: jest.fn(), + bulkWrite: 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 -- CJS class +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkToggleEventMark', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 0, + upsertedCount: 0, + insertedCount: 0, + matchedCount: 0, + deletedCount: 0, + }); + }); + + it('should throw when mark is not resolved or ignored', async () => { + const factory = new EventsFactory(projectId); + + await expect(factory.bulkToggleEventMark([], 'starred' as any)).rejects.toThrow( + 'bulkToggleEventMark: mark must be resolved or ignored' + ); + }); + + it('should reject more than BULK_TOGGLE_EVENT_MARK_MAX unique ids', async () => { + const factory = new EventsFactory(projectId); + const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; + const ids = Array.from({ length: max + 1 }, (_, i) => `id-${i}`); + + await expect(factory.bulkToggleEventMark(ids, 'ignored')).rejects.toThrow( + `bulkToggleEventMark: at most ${max} event ids allowed` + ); + }); + + 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.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + await factory.bulkToggleEventMark([ id.toString(), id.toString(), id.toString() ], 'ignored'); + + expect(collectionMock.bulkWrite).toHaveBeenCalledTimes(1); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + }); + + it('should return failedEventIds for invalid ObjectIds and skip bulkWrite', async () => { + const factory = new EventsFactory(projectId); + + const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved'); + + expect(result.updatedCount).toBe(0); + expect(result.failedEventIds).toContain('not-a-valid-id'); + expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); + }); + + it('should list valid but missing document ids in failedEventIds', async () => { + const factory = new EventsFactory(projectId); + const missing = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([]), + }); + + const result = await factory.bulkToggleEventMark([ missing.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(0); + expect(result.failedEventIds).toContain(missing.toString()); + expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); + }); + + 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.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(1); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.filter._id).toEqual(b); + expect(ops[0].updateOne.update).toEqual( + 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.bulkWrite.mockResolvedValue({ + modifiedCount: 2, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'resolved'); + + expect(result.updatedCount).toBe(2); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(2); + expect(ops[0].updateOne.update).toEqual({ $unset: { 'marks.resolved': '' } }); + expect(ops[1].updateOne.update).toEqual({ $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.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(1); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.update).toEqual( + expect.objectContaining({ $set: { 'marks.ignored': expect.any(Number) } }) + ); + }); +}); 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..5b9dec1c --- /dev/null +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -0,0 +1,100 @@ +import '../../src/env-test'; + +import { UserInputError } from 'apollo-server-express'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +// 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<{ updatedCount: number; failedEventIds: string[] }>; + }; +}; + +const bulkToggleEventMark = jest.fn(); + +describe('Mutation.bulkToggleEventMarks', () => { + const ctx = {}; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkToggleEventMark }); + }); + + it('should throw when mark is not resolved or ignored', async () => { + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + ctx + ) + ).rejects.toThrow(UserInputError); + + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + ctx + ) + ).rejects.toThrow('bulkToggleEventMarks supports only resolved and ignored marks'); + + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + }); + + 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 = { updatedCount: 2, failedEventIds: [ 'x' ] }; + + 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(payload); + }); + + it('should map factory max-length error to UserInputError', async () => { + bulkToggleEventMark.mockRejectedValue( + new Error('bulkToggleEventMark: at most 100 event ids allowed') + ); + + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439011' ], mark: 'ignored' }, + ctx + ) + ).rejects.toThrow(UserInputError); + }); +}); diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index 23e2d071..ba9d61c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -87,7 +87,7 @@ describe('Project resolver dailyEventsPortion', () => { ); }); - it('should keep old call shape when assignee is not provided', async () => { + it('should call factory with undefined assignee when assignee argument is omitted', async () => { const findDailyEventsPortion = jest.fn().mockResolvedValue({ nextCursor: null, dailyEvents: [], @@ -118,4 +118,106 @@ describe('Project resolver dailyEventsPortion', () => { undefined ); }); + + it('should apply fallback title for null, empty and blank payload titles', async () => { + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: 'daily-1', + groupHash: 'group-1', + event: { + _id: 'repetition-1', + originalEventId: 'event-1', + payload: { + title: null, + }, + }, + }, + { + id: 'daily-2', + groupHash: 'group-2', + event: { + _id: 'repetition-2', + originalEventId: 'event-2', + payload: { + title: '', + }, + }, + }, + { + id: 'daily-3', + groupHash: 'group-3', + event: { + _id: 'repetition-3', + originalEventId: 'event-3', + payload: { + title: ' ', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const args = { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }; + + const result = await projectResolver.Project.dailyEventsPortion(project, args, {}) as { + dailyEvents: Array<{ event: { payload: { title: string } } }>; + }; + + expect(result.dailyEvents[0].event.payload.title).toBe('Unknown'); + expect(result.dailyEvents[1].event.payload.title).toBe('Unknown'); + expect(result.dailyEvents[2].event.payload.title).toBe('Unknown'); + }); + + it('should keep payload title when it is valid', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: 'daily-1', + groupHash: 'group-1', + event: { + _id: 'repetition-1', + originalEventId: 'event-1', + payload: { + title: 'TypeError', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const args = { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }; + + const result = await projectResolver.Project.dailyEventsPortion(project, args, {}) as { + dailyEvents: Array<{ event: { payload: { title: string } } }>; + }; + + expect(result.dailyEvents[0].event.payload.title).toBe('TypeError'); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); }); diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index 8f50acbc..ed73687c 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb'; import { ProjectDBScheme, ProjectTaskManagerConfig } from '@hawk.so/types'; import { ResolverContextWithUser } from '../../src/types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; +import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service')); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -29,11 +30,6 @@ process.env.JWT_SECRET_ACCESS_TOKEN = 'belarus'; process.env.JWT_SECRET_REFRESH_TOKEN = 'abacaba'; process.env.JWT_SECRET_PROJECT_TOKEN = 'qwerty'; -/** - * Demo workspace ID (projects in this workspace cannot be updated) - */ -const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; - /** * Creates mock project with optional taskManager configuration */