diff --git a/package.json b/package.json index 00cd7e34..6db19b0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.11", + "version": "1.4.12", "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/resolvers/project.js b/src/resolvers/project.js index 65e17130..65fc2cdc 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -20,6 +20,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} @@ -604,6 +652,8 @@ module.exports = { assignee ); + normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id); + return dailyEventsPortion; }, diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index e465242b..ba9d61c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -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(); + }); });