From c660acd55a373003b3a9d9e4aef75617797ae4af Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 28 Oct 2026 16:37:23 +0300 Subject: [PATCH 1/6] Expose event release lifecycle metadata --- package.json | 2 +- src/models/eventsFactory.js | 1 + src/resolvers/event.js | 13 ++ src/typeDefs/event.ts | 15 ++ .../eventsFactory-release-metadata.test.ts | 155 ++++++++++++++++++ test/resolvers/event-release-metadata.test.ts | 119 ++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 test/models/eventsFactory-release-metadata.test.ts create mode 100644 test/resolvers/event-release-metadata.test.ts diff --git a/package.json b/package.json index 85f560d3..d5b59fc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.0", + "version": "1.7.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 18f0b2e1..8048e0dd 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -1119,6 +1119,7 @@ class EventsFactory extends Factory { originalTimestamp: event.timestamp, originalEventId: event._id, timestamp: repetition.timestamp, + firstSeenInRelease: event.payload.release === undefined ? null : event.payload.release, payload: composeEventPayloadByRepetition(event.payload, repetition), projectId: this.projectId, }; diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 0e57bf4b..33fe50de 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -23,6 +23,19 @@ module.exports = { }, }, Event: { + /** + * Returns the original release, preserving null for repetitions of legacy events. + * + * @param {Object} event - original event or composed repetition + * @returns {string|null} + */ + firstSeenInRelease({ firstSeenInRelease, payload }) { + if (firstSeenInRelease !== undefined) { + return firstSeenInRelease; + } + + return payload && payload.release !== undefined ? payload.release : null; + }, /** * Returns repetitions portion of the event diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index cf65e848..ec6fbc42 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -301,6 +301,21 @@ type Event { """ release: Release + """ + Release of the original event, regardless of the selected repetition + """ + firstSeenInRelease: String + + """ + Release in which the event was resolved + """ + resolvedInRelease: String + + """ + Release in which the event regressed + """ + regressionInRelease: String + """ Event repetitions portion """ diff --git a/test/models/eventsFactory-release-metadata.test.ts b/test/models/eventsFactory-release-metadata.test.ts new file mode 100644 index 00000000..0238333a --- /dev/null +++ b/test/models/eventsFactory-release-metadata.test.ts @@ -0,0 +1,155 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + aggregate: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), +}; +const loadEvent = jest.fn(); + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { getInstance: (): object => ({}) }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: (): { load: jest.Mock } => ({ load: loadEvent }), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { collection: jest.fn(() => collectionMock) }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const EventsFactory = require('../../src/models/eventsFactory'); + +const lifecycle = { + resolvedInRelease: 'hotfix / opaque+name', + regressionInRelease: 'canary build:abc', +}; + +describe('EventsFactory release metadata', () => { + const projectId = '507f1f77bcf86cd799439011'; + const original = { + _id: new ObjectId(), + groupHash: 'group-1', + timestamp: 100, + payload: { + title: 'Error', + release: 'original / build+7', + }, + ...lifecycle, + }; + const repetition = { + _id: new ObjectId(), + groupHash: original.groupHash, + timestamp: 200, + delta: JSON.stringify({ release: [original.payload.release, 'repeated / build+8'] }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + loadEvent.mockResolvedValue({ ...original }); + }); + + it.each([true, false])('preserves grouped metadata in daily events (repetition: %s)', async (hasRepetition) => { + const factory = new EventsFactory(projectId); + + collectionMock.aggregate.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ { + _id: new ObjectId(), + event: { ...original }, + repetition: hasRepetition ? repetition : undefined, + } ]), + }); + + const { dailyEvents } = await factory.findDailyEventsPortion(); + const event = dailyEvents[0].event; + + expect(event).toMatchObject(lifecycle); + expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release); + if (hasRepetition) { + expect(event.firstSeenInRelease).toBe(original.payload.release); + } + expect(loadEvent).not.toHaveBeenCalled(); + expect(collectionMock.aggregate).toHaveBeenCalledTimes(1); + }); + + it.each([true, false])('preserves grouped metadata in event detail (repetition: %s)', async (hasRepetition) => { + const factory = new EventsFactory(projectId); + + collectionMock.findOne.mockResolvedValue(repetition); + + const event = await factory.getEventRepetition( + hasRepetition ? repetition._id : original._id, + original._id + ); + + expect(event).toMatchObject(lifecycle); + expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release); + if (hasRepetition) { + expect(event.firstSeenInRelease).toBe(original.payload.release); + } + expect(loadEvent).toHaveBeenCalledTimes(1); + expect(collectionMock.findOne).toHaveBeenCalledTimes(hasRepetition ? 1 : 0); + }); + + it('preserves grouped metadata for repetitions and the appended original', async () => { + const factory = new EventsFactory(projectId); + const cursor = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([ repetition ]), + }; + + collectionMock.find.mockReturnValue(cursor); + + const { repetitions, nextCursor } = await factory.getEventRepetitions(original._id); + + expect(nextCursor).toBeNull(); + expect(repetitions).toHaveLength(2); + expect(repetitions[0]).toMatchObject({ + ...lifecycle, + firstSeenInRelease: original.payload.release, + payload: { release: 'repeated / build+8' }, + }); + expect(repetitions[1]).toMatchObject({ + ...lifecycle, + payload: { release: original.payload.release }, + }); + expect(loadEvent).toHaveBeenCalledTimes(1); + expect(collectionMock.find).toHaveBeenCalledTimes(1); + expect(original.payload.release).toBe('original / build+7'); + }); + + it.each([undefined, null, ''])('retains original release %s instead of a repetition release', (release) => { + const factory = new EventsFactory(projectId); + const legacy = { + _id: original._id, + timestamp: original.timestamp, + payload: { + title: 'Legacy error', + release, + }, + }; + const event = factory._composeEventWithRepetition(legacy, { + ...repetition, + delta: undefined, + payload: { release: 'later release' }, + }); + + expect(event.firstSeenInRelease).toBe(release ?? null); + expect(event.payload.release).toBe('later release'); + expect(event.resolvedInRelease).toBeUndefined(); + expect(event.regressionInRelease).toBeUndefined(); + expect(loadEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/test/resolvers/event-release-metadata.test.ts b/test/resolvers/event-release-metadata.test.ts new file mode 100644 index 00000000..5f093e73 --- /dev/null +++ b/test/resolvers/event-release-metadata.test.ts @@ -0,0 +1,119 @@ +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { graphql, GraphQLObjectType, GraphQLString } from 'graphql'; +import eventTypeDefs from '../../src/typeDefs/event'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({})); +jest.mock('../../src/resolvers/helpers/bulkEventUtils', () => ({})); +jest.mock('../../src/services/askAi', () => ({})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event'); + +const schema = makeExecutableSchema({ + typeDefs: [eventTypeDefs, ` + type Query { event: Event } + type Mutation { _: Boolean } + type User { id: ID } + type ChartLine { label: String } + type TaskManagerItem { id: ID } + scalar DateTime + scalar JSONObject + scalar EncodedJSON + directive @renameFrom(name: String!) on FIELD_DEFINITION + directive @default(value: String!) on FIELD_DEFINITION + directive @requireUserInWorkspace on FIELD_DEFINITION + directive @requireAdmin on FIELD_DEFINITION + `], + resolvers: { + Event: { firstSeenInRelease: eventResolvers.Event.firstSeenInRelease }, + }, +}); + +const source = `{ + event { + firstSeenInRelease + resolvedInRelease + regressionInRelease + payload { release } + } +}`; + +describe('Event release metadata GraphQL contract', () => { + it.each(['firstSeenInRelease', 'resolvedInRelease', 'regressionInRelease'])('exposes %s as a nullable String', (field) => { + const eventType = schema.getType('Event') as GraphQLObjectType; + + expect(eventType.getFields()[field].type).toBe(GraphQLString); + }); + + it.each([ + { + title: 'original event', + event: { payload: { release: 'original / build+7' } }, + expected: 'original / build+7', + }, + { + title: 'selected repetition', + event: { + firstSeenInRelease: 'original / build+7', + payload: { release: 'repeated / build+8' }, + }, + expected: 'original / build+7', + }, + { + title: 'legacy original event', + event: { payload: {} }, + expected: null, + }, + { + title: 'repetition whose original has no release', + event: { + firstSeenInRelease: null, + payload: { release: 'later release' }, + }, + expected: null, + }, + { + title: 'empty original release', + event: { + firstSeenInRelease: '', + payload: { release: 'later release' }, + }, + expected: '', + }, + ])('resolves first seen from the $title', async ({ event, expected }) => { + const { data, errors } = await graphql({ + schema, + source, + rootValue: { event }, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + firstSeenInRelease: expected, + resolvedInRelease: null, + regressionInRelease: null, + payload: { release: event.payload.release ?? null }, + }); + }); + + it('returns stored lifecycle release names unchanged', async () => { + const event = { + payload: { release: 'original release' }, + resolvedInRelease: 'hotfix / opaque+name', + regressionInRelease: 'canary build:abc', + }; + const { data, errors } = await graphql({ + schema, + source, + rootValue: { event }, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + firstSeenInRelease: event.payload.release, + resolvedInRelease: event.resolvedInRelease, + regressionInRelease: event.regressionInRelease, + payload: event.payload, + }); + }); +}); From e37933383b87ce88a0a2b3db4477b440b2f0d540 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 28 Oct 2026 17:50:08 +0300 Subject: [PATCH 2/6] Expose the latest meaningful project release Include unchecked releases without events and ignore empty or placeholder release names. --- src/models/releasesFactory.ts | 22 +++++++ src/resolvers/project.js | 12 ++++ src/typeDefs/project.ts | 5 ++ test/models/releasesFactory-latest.test.ts | 76 ++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 test/models/releasesFactory-latest.test.ts diff --git a/src/models/releasesFactory.ts b/src/models/releasesFactory.ts index a22ccfc1..506089d3 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -57,6 +57,28 @@ export default class ReleasesFactory { this.filesCollection = dbConnection.collection('releases.files'); } + /** + * Return the newest meaningful release name, regardless of validation status. + * + * @param projectId - project identifier + * @returns latest release name or null when the project has no releases + */ + public async findLatestName(projectId: string | ObjectId): Promise { + const release = await this.collection.findOne({ + projectId: projectId.toString(), + release: { + $type: 'string', + $regex: /\S/, + $not: /^\s*(null|undefined|nan)\s*$/i, + }, + }, { + projection: { release: 1 }, + sort: { _id: -1 }, + }); + + return release?.release ?? null; + } + /** * Find one release document by projectId and release label. * Tries both exact string match and numeric fallback (if release can be cast to number). diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 2e82f6c0..874009f5 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -781,6 +781,18 @@ module.exports = { return dateFromObjectId(project._id); }, + /** + * Return the newest project release, including releases without events. + * + * @param {ProjectDBScheme} project - parent project + * @param {Object} _args - field arguments + * @param {ContextFactories} factories - model factories + * @returns {Promise} + */ + latestRelease(project, _args, { factories }) { + return factories.releasesFactory.findLatestName(project._id); + }, + /** * Find project's event * diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index 85006168..e9d6df78 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -279,6 +279,11 @@ type Project { """ uidAdded: User! + """ + Most recently created meaningful release, even if no events occurred in it + """ + latestRelease: String + """ Project's Event """ diff --git a/test/models/releasesFactory-latest.test.ts b/test/models/releasesFactory-latest.test.ts new file mode 100644 index 00000000..82536503 --- /dev/null +++ b/test/models/releasesFactory-latest.test.ts @@ -0,0 +1,76 @@ +import { Db, MongoClient, ObjectId } from 'mongodb'; +import ReleasesFactory from '../../src/models/releasesFactory'; + +const projectId = new ObjectId(); +let client: MongoClient; +let db: Db; +let factory: ReleasesFactory; + +beforeAll(async () => { + const uri = process.env.MONGO_URL || (global as unknown as { __MONGO_URI__: string }).__MONGO_URI__; + + client = new MongoClient(uri); + await client.connect(); + db = client.db('latest-release-tests-' + new ObjectId().toHexString()); + factory = new ReleasesFactory(db); +}); + +beforeEach(async () => { + await db.collection('releases').deleteMany({}); +}); + +afterAll(async () => { + try { + await db?.dropDatabase(); + } finally { + await client?.close(); + } +}); + +describe('ReleasesFactory.findLatestName', () => { + it('returns null for a project without releases', async () => { + expect(await factory.findLatestName(projectId)).toBeNull(); + }); + + it('uses creation order and includes unchecked releases without events, isolated by project', async () => { + await db.collection('releases').insertMany([ + { + _id: ObjectId.createFromTime(100), + projectId: projectId.toString(), + release: 'z-9.9.9', + fixChecked: true, + }, + { + _id: ObjectId.createFromTime(200), + projectId: projectId.toString(), + release: 'a-build/abc#1', + fixChecked: false, + }, + { + _id: ObjectId.createFromTime(500), + projectId: 'another-project', + release: 'another-latest', + }, + ...['', ' ', 'null', ' Undefined ', 'NaN'].map((release, index) => ({ + _id: ObjectId.createFromTime(300 + index), + projectId: projectId.toString(), + release, + })), + ]); + + expect(await factory.findLatestName(projectId)).toBe('a-build/abc#1'); + expect(await factory.findLatestName('another-project')).toBe('another-latest'); + }); + + it('returns null when only missing or placeholder releases exist', async () => { + await db.collection('releases').insertMany([ + ...[null, '', ' ', 'null', 'undefined', ' NAN '].map(release => ({ + projectId: projectId.toString(), + release, + })), + { projectId: projectId.toString() }, + ]); + + expect(await factory.findLatestName(projectId.toString())).toBeNull(); + }); +}); From 917cc8c691388a7e6d37df8ca81de3697cca7278 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Fri, 30 Oct 2026 02:03:08 +0300 Subject: [PATCH 3/6] Normalize release metadata and remove latestRelease field --- src/models/eventsFactory.js | 47 ++--- src/models/releasesFactory.ts | 22 -- src/resolvers/event.js | 21 +- src/resolvers/project.js | 12 -- src/typeDefs/project.ts | 5 - src/utils/releaseName.ts | 19 ++ .../eventsFactory-release-metadata.test.ts | 196 +++++++++++++++++- test/models/releasesFactory-latest.test.ts | 76 ------- test/resolvers/event-release-metadata.test.ts | 38 +++- test/utils/releaseName.test.ts | 25 +++ 10 files changed, 300 insertions(+), 161 deletions(-) create mode 100644 src/utils/releaseName.ts delete mode 100644 test/models/releasesFactory-latest.test.ts create mode 100644 test/utils/releaseName.test.ts diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 8048e0dd..cab3f2e7 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -3,6 +3,7 @@ import { createProjectEventsByIdLoader } from '../dataLoaders'; import RedisHelper from '../redisHelper'; import ChartDataService from '../services/chartDataService'; import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates'; +import { normalizeReleaseName } from '../utils/releaseName'; const Factory = require('./modelFactory'); const mongo = require('../mongo'); @@ -770,10 +771,7 @@ class EventsFactory extends Factory { for (const repetition of repetitions) { const event = this._composeEventWithRepetition(eventOriginal, repetition); - result.repetitions.push({ - ...event, - projectId: this.projectId, - }); + result.repetitions.push(event); } const isLastPortion = result.nextCursor === null; @@ -787,12 +785,7 @@ class EventsFactory extends Factory { * Get only 'repetitions' fields from event to fit Repetition scheme * @type {EventRepetitionSchema} */ - const firstRepetition = { - ...eventOriginal, - originalTimestamp: eventOriginal.timestamp, - originalEventId: eventOriginal._id, - projectId: this.projectId, - }; + const firstRepetition = this._composeEventWithRepetition(eventOriginal); result.repetitions.push(firstRepetition); } @@ -814,14 +807,7 @@ class EventsFactory extends Factory { if (repetitionId.toString() === originalEventId.toString()) { const originalEvent = await this.eventsDataLoader.load(originalEventId); - /** - * All events have same type with originalEvent id - */ - originalEvent.originalEventId = originalEventId; - originalEvent.originalTimestamp = originalEvent.timestamp; - originalEvent.projectId = this.projectId; - - return originalEvent || null; + return originalEvent ? this._composeEventWithRepetition(originalEvent) : null; } /** @@ -1104,24 +1090,25 @@ class EventsFactory extends Factory { * @returns {Event} event merged with repetition */ _composeEventWithRepetition(event, repetition) { + const original = { + ...event, + originalTimestamp: event.timestamp, + originalEventId: event._id, + firstSeenInRelease: normalizeReleaseName(event.payload && event.payload.release), + resolvedInRelease: normalizeReleaseName(event.resolvedInRelease), + regressionInRelease: normalizeReleaseName(event.regressionInRelease), + projectId: this.projectId, + }; + if (!repetition) { - return { - ...event, - originalTimestamp: event.timestamp, - originalEventId: event._id, - projectId: this.projectId, - }; + return original; } return { - ...event, + ...original, _id: repetition._id, - originalTimestamp: event.timestamp, - originalEventId: event._id, timestamp: repetition.timestamp, - firstSeenInRelease: event.payload.release === undefined ? null : event.payload.release, - payload: composeEventPayloadByRepetition(event.payload, repetition), - projectId: this.projectId, + payload: composeEventPayloadByRepetition(event.payload == null ? {} : event.payload, repetition), }; } } diff --git a/src/models/releasesFactory.ts b/src/models/releasesFactory.ts index 506089d3..a22ccfc1 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -57,28 +57,6 @@ export default class ReleasesFactory { this.filesCollection = dbConnection.collection('releases.files'); } - /** - * Return the newest meaningful release name, regardless of validation status. - * - * @param projectId - project identifier - * @returns latest release name or null when the project has no releases - */ - public async findLatestName(projectId: string | ObjectId): Promise { - const release = await this.collection.findOne({ - projectId: projectId.toString(), - release: { - $type: 'string', - $regex: /\S/, - $not: /^\s*(null|undefined|nan)\s*$/i, - }, - }, { - projection: { release: 1 }, - sort: { _id: -1 }, - }); - - return release?.release ?? null; - } - /** * Find one release document by projectId and release label. * Tries both exact string match and numeric fallback (if release can be cast to number). diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 33fe50de..7e99ceb1 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -6,6 +6,7 @@ const { const { askAiService } = require('../services/askAi'); const { UserInputError } = require('apollo-server-express'); const { ObjectId } = require('mongodb'); +const { normalizeReleaseName } = require('../utils/releaseName'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -30,11 +31,23 @@ module.exports = { * @returns {string|null} */ firstSeenInRelease({ firstSeenInRelease, payload }) { - if (firstSeenInRelease !== undefined) { - return firstSeenInRelease; - } + return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release); + }, + + /** + * @param {Object} event - event with stored lifecycle metadata + * @returns {string|null} normalized resolution release + */ + resolvedInRelease({ resolvedInRelease }) { + return normalizeReleaseName(resolvedInRelease); + }, - return payload && payload.release !== undefined ? payload.release : null; + /** + * @param {Object} event - event with stored lifecycle metadata + * @returns {string|null} normalized regression release + */ + regressionInRelease({ regressionInRelease }) { + return normalizeReleaseName(regressionInRelease); }, /** diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 874009f5..2e82f6c0 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -781,18 +781,6 @@ module.exports = { return dateFromObjectId(project._id); }, - /** - * Return the newest project release, including releases without events. - * - * @param {ProjectDBScheme} project - parent project - * @param {Object} _args - field arguments - * @param {ContextFactories} factories - model factories - * @returns {Promise} - */ - latestRelease(project, _args, { factories }) { - return factories.releasesFactory.findLatestName(project._id); - }, - /** * Find project's event * diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index e9d6df78..85006168 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -279,11 +279,6 @@ type Project { """ uidAdded: User! - """ - Most recently created meaningful release, even if no events occurred in it - """ - latestRelease: String - """ Project's Event """ diff --git a/src/utils/releaseName.ts b/src/utils/releaseName.ts new file mode 100644 index 00000000..ddfbdd87 --- /dev/null +++ b/src/utils/releaseName.ts @@ -0,0 +1,19 @@ +/** + * Normalize absent or invalid release names without changing opaque identifiers. + * + * @param value - raw release name + * @returns the original identifier, a finite number as a string, or null + */ +export function normalizeReleaseName(value: unknown): string | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : null; + } + + if (typeof value !== 'string') { + return null; + } + + const name = value.trim().toLowerCase(); + + return name === '' || name === 'null' || name === 'undefined' || name === 'nan' ? null : value; +} diff --git a/test/models/eventsFactory-release-metadata.test.ts b/test/models/eventsFactory-release-metadata.test.ts index 0238333a..f96db55b 100644 --- a/test/models/eventsFactory-release-metadata.test.ts +++ b/test/models/eventsFactory-release-metadata.test.ts @@ -57,7 +57,10 @@ describe('EventsFactory release metadata', () => { beforeEach(() => { jest.clearAllMocks(); - loadEvent.mockResolvedValue({ ...original }); + loadEvent.mockResolvedValue(Object.freeze({ + ...original, + payload: Object.freeze({ ...original.payload }), + })); }); it.each([true, false])('preserves grouped metadata in daily events (repetition: %s)', async (hasRepetition) => { @@ -76,9 +79,7 @@ describe('EventsFactory release metadata', () => { expect(event).toMatchObject(lifecycle); expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release); - if (hasRepetition) { - expect(event.firstSeenInRelease).toBe(original.payload.release); - } + expect(event.firstSeenInRelease).toBe(original.payload.release); expect(loadEvent).not.toHaveBeenCalled(); expect(collectionMock.aggregate).toHaveBeenCalledTimes(1); }); @@ -95,9 +96,7 @@ describe('EventsFactory release metadata', () => { expect(event).toMatchObject(lifecycle); expect(event.payload.release).toBe(hasRepetition ? 'repeated / build+8' : original.payload.release); - if (hasRepetition) { - expect(event.firstSeenInRelease).toBe(original.payload.release); - } + expect(event.firstSeenInRelease).toBe(original.payload.release); expect(loadEvent).toHaveBeenCalledTimes(1); expect(collectionMock.findOne).toHaveBeenCalledTimes(hasRepetition ? 1 : 0); }); @@ -123,6 +122,7 @@ describe('EventsFactory release metadata', () => { }); expect(repetitions[1]).toMatchObject({ ...lifecycle, + firstSeenInRelease: original.payload.release, payload: { release: original.payload.release }, }); expect(loadEvent).toHaveBeenCalledTimes(1); @@ -146,10 +146,186 @@ describe('EventsFactory release metadata', () => { payload: { release: 'later release' }, }); - expect(event.firstSeenInRelease).toBe(release ?? null); + expect(event.firstSeenInRelease).toBeNull(); expect(event.payload.release).toBe('later release'); - expect(event.resolvedInRelease).toBeUndefined(); - expect(event.regressionInRelease).toBeUndefined(); + expect(event.resolvedInRelease).toBeNull(); + expect(event.regressionInRelease).toBeNull(); expect(loadEvent).not.toHaveBeenCalled(); }); + + it.each([ + [undefined, null], + [null, null], + ['', null], + [' \t', null], + [' NULL ', null], + [' Undefined\n', null], + [' nAn ', null], + [0, '0'], + [42, '42'], + [-1.5, '-1.5'], + [' opaque / Build+7 ', ' opaque / Build+7 '], + [true, null], + [ {}, null], + [ [], null], + [NaN, null], + [Infinity, null], + [-Infinity, null], + ])('normalizes release metadata %p in every composition path', async (release, expected) => { + const factory = new EventsFactory(projectId); + const cachedOriginal = Object.freeze({ + ...original, + payload: Object.freeze({ + title: 'Error', + release, + }), + resolvedInRelease: release, + regressionInRelease: release, + marks: Object.freeze({ resolved: { timestamp: 150 } }), + assignee: 'owner', + }); + const selectedRepetition = { + ...repetition, + delta: undefined, + payload: { release: 'later release' }, + resolvedInRelease: 'not original lifecycle', + regressionInRelease: 'not original lifecycle', + }; + + loadEvent.mockResolvedValue(cachedOriginal); + collectionMock.findOne.mockResolvedValue(selectedRepetition); + collectionMock.aggregate.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([undefined, selectedRepetition].map(rep => ({ + _id: new ObjectId(), + event: cachedOriginal, + repetition: rep, + }))), + }); + collectionMock.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([ selectedRepetition ]), + }); + + const { dailyEvents } = await factory.findDailyEventsPortion(); + const originalDetail = await factory.getEventRepetition(original._id, original._id); + const repetitionDetail = await factory.getEventRepetition(repetition._id, original._id); + const { repetitions } = await factory.getEventRepetitions(original._id); + const events = [ + factory._composeEventWithRepetition(cachedOriginal), + factory._composeEventWithRepetition(cachedOriginal, selectedRepetition), + ...dailyEvents.map(({ event }: { event: object }) => event), + originalDetail, + repetitionDetail, + ...repetitions, + ]; + + for (const event of events) { + expect(event).toMatchObject({ + firstSeenInRelease: expected, + resolvedInRelease: expected, + regressionInRelease: expected, + originalEventId: original._id, + originalTimestamp: original.timestamp, + projectId, + groupHash: original.groupHash, + marks: cachedOriginal.marks, + assignee: cachedOriginal.assignee, + }); + expect(event).not.toBe(cachedOriginal); + } + expect(originalDetail.payload.release).toBe(release); + expect(repetitionDetail.payload.release).toBe('later release'); + expect(cachedOriginal.payload.release).toBe(release); + expect(cachedOriginal.resolvedInRelease).toBe(release); + expect(cachedOriginal.regressionInRelease).toBe(release); + expect(cachedOriginal).not.toHaveProperty('originalEventId'); + expect(cachedOriginal).not.toHaveProperty('firstSeenInRelease'); + expect(cachedOriginal).not.toHaveProperty('projectId'); + }); + + describe.each([undefined, null])('original payload is %p', (payload) => { + it.each([ + { + format: 'new delta', + fields: { delta: JSON.stringify({ release: [ 'later release' ] }) }, + }, + { + format: 'legacy', + fields: { payload: { release: 'later release' } }, + }, + { + format: 'unchanged', + fields: { delta: null }, + }, + ])('safely composes $format repetitions in every path', async ({ format, fields }) => { + const factory = new EventsFactory(projectId); + const cachedOriginal = Object.freeze({ + _id: original._id, + groupHash: original.groupHash, + timestamp: original.timestamp, + ...lifecycle, + ...(payload === null ? { payload: null } : {}), + }); + const selectedRepetition = { + _id: repetition._id, + timestamp: repetition.timestamp, + ...fields, + }; + + loadEvent.mockResolvedValue(cachedOriginal); + collectionMock.findOne.mockResolvedValue(selectedRepetition); + collectionMock.aggregate.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([undefined, selectedRepetition].map(rep => ({ + _id: new ObjectId(), + event: cachedOriginal, + repetition: rep, + }))), + }); + collectionMock.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([ selectedRepetition ]), + }); + + const { dailyEvents } = await factory.findDailyEventsPortion(); + const originalDetail = await factory.getEventRepetition(original._id, original._id); + const repetitionDetail = await factory.getEventRepetition(repetition._id, original._id); + const { repetitions } = await factory.getEventRepetitions(original._id); + const composed = factory._composeEventWithRepetition(cachedOriginal, selectedRepetition); + + for (const event of [composed, dailyEvents[1].event, repetitionDetail, repetitions[0]]) { + expect(event).toMatchObject({ + ...lifecycle, + firstSeenInRelease: null, + originalEventId: original._id, + originalTimestamp: original.timestamp, + _id: repetition._id, + timestamp: repetition.timestamp, + projectId, + }); + expect(event.payload).toEqual(format === 'unchanged' ? {} : { release: 'later release' }); + expect(event.payload.title).toBeUndefined(); + } + for (const event of [dailyEvents[0].event, originalDetail, repetitions[1]]) { + expect(event).toMatchObject({ + ...lifecycle, + firstSeenInRelease: null, + }); + expect(event.payload).toBe(payload); + } + expect(cachedOriginal.payload).toBe(payload); + expect(cachedOriginal).not.toHaveProperty('originalEventId'); + expect(cachedOriginal).not.toHaveProperty('firstSeenInRelease'); + }); + }); + + it('returns null for a missing original on the direct original detail path', async () => { + const factory = new EventsFactory(projectId); + + loadEvent.mockResolvedValue(null); + + await expect(factory.getEventRepetition(original._id, original._id)).resolves.toBeNull(); + expect(collectionMock.findOne).not.toHaveBeenCalled(); + }); }); diff --git a/test/models/releasesFactory-latest.test.ts b/test/models/releasesFactory-latest.test.ts deleted file mode 100644 index 82536503..00000000 --- a/test/models/releasesFactory-latest.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Db, MongoClient, ObjectId } from 'mongodb'; -import ReleasesFactory from '../../src/models/releasesFactory'; - -const projectId = new ObjectId(); -let client: MongoClient; -let db: Db; -let factory: ReleasesFactory; - -beforeAll(async () => { - const uri = process.env.MONGO_URL || (global as unknown as { __MONGO_URI__: string }).__MONGO_URI__; - - client = new MongoClient(uri); - await client.connect(); - db = client.db('latest-release-tests-' + new ObjectId().toHexString()); - factory = new ReleasesFactory(db); -}); - -beforeEach(async () => { - await db.collection('releases').deleteMany({}); -}); - -afterAll(async () => { - try { - await db?.dropDatabase(); - } finally { - await client?.close(); - } -}); - -describe('ReleasesFactory.findLatestName', () => { - it('returns null for a project without releases', async () => { - expect(await factory.findLatestName(projectId)).toBeNull(); - }); - - it('uses creation order and includes unchecked releases without events, isolated by project', async () => { - await db.collection('releases').insertMany([ - { - _id: ObjectId.createFromTime(100), - projectId: projectId.toString(), - release: 'z-9.9.9', - fixChecked: true, - }, - { - _id: ObjectId.createFromTime(200), - projectId: projectId.toString(), - release: 'a-build/abc#1', - fixChecked: false, - }, - { - _id: ObjectId.createFromTime(500), - projectId: 'another-project', - release: 'another-latest', - }, - ...['', ' ', 'null', ' Undefined ', 'NaN'].map((release, index) => ({ - _id: ObjectId.createFromTime(300 + index), - projectId: projectId.toString(), - release, - })), - ]); - - expect(await factory.findLatestName(projectId)).toBe('a-build/abc#1'); - expect(await factory.findLatestName('another-project')).toBe('another-latest'); - }); - - it('returns null when only missing or placeholder releases exist', async () => { - await db.collection('releases').insertMany([ - ...[null, '', ' ', 'null', 'undefined', ' NAN '].map(release => ({ - projectId: projectId.toString(), - release, - })), - { projectId: projectId.toString() }, - ]); - - expect(await factory.findLatestName(projectId.toString())).toBeNull(); - }); -}); diff --git a/test/resolvers/event-release-metadata.test.ts b/test/resolvers/event-release-metadata.test.ts index 5f093e73..7fa57727 100644 --- a/test/resolvers/event-release-metadata.test.ts +++ b/test/resolvers/event-release-metadata.test.ts @@ -25,7 +25,11 @@ const schema = makeExecutableSchema({ directive @requireAdmin on FIELD_DEFINITION `], resolvers: { - Event: { firstSeenInRelease: eventResolvers.Event.firstSeenInRelease }, + Event: { + firstSeenInRelease: eventResolvers.Event.firstSeenInRelease, + resolvedInRelease: eventResolvers.Event.resolvedInRelease, + regressionInRelease: eventResolvers.Event.regressionInRelease, + }, }, }); @@ -78,7 +82,7 @@ describe('Event release metadata GraphQL contract', () => { firstSeenInRelease: '', payload: { release: 'later release' }, }, - expected: '', + expected: null, }, ])('resolves first seen from the $title', async ({ event, expected }) => { const { data, errors } = await graphql({ @@ -96,6 +100,36 @@ describe('Event release metadata GraphQL contract', () => { }); }); + it.each([ + [0, '0'], + [42, '42'], + [' opaque / build+7 ', ' opaque / build+7 '], + ['', null], + [' ', null], + [' NULL ', null], + ['undefined', null], + ['NaN', null], + ])('normalizes all release metadata fields for %p', async (release, expected) => { + const { data, errors } = await graphql({ + schema, + source: '{ event { firstSeenInRelease resolvedInRelease regressionInRelease } }', + rootValue: { + event: { + payload: { release }, + resolvedInRelease: release, + regressionInRelease: release, + }, + }, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + firstSeenInRelease: expected, + resolvedInRelease: expected, + regressionInRelease: expected, + }); + }); + it('returns stored lifecycle release names unchanged', async () => { const event = { payload: { release: 'original release' }, diff --git a/test/utils/releaseName.test.ts b/test/utils/releaseName.test.ts new file mode 100644 index 00000000..e7f1ebd7 --- /dev/null +++ b/test/utils/releaseName.test.ts @@ -0,0 +1,25 @@ +import { normalizeReleaseName } from '../../src/utils/releaseName'; + +describe('normalizeReleaseName', () => { + it.each([ + undefined, null, '', ' ', '\t\n', 'null', ' NULL ', 'undefined', '\tUnDeFiNeD\n', 'nan', ' NaN ', + true, false, {}, [], [ 'release' ], NaN, Infinity, -Infinity, + ])('normalizes absent or invalid value %p to null', (value) => { + expect(normalizeReleaseName(value)).toBeNull(); + }); + + it.each([ + [0, '0'], + [-12, '-12'], + [1.5, '1.5'], + [Number.MAX_VALUE, String(Number.MAX_VALUE)], + ['0', '0'], + [' original / build+7 ', ' original / build+7 '], + ['\tcanary build:AbC\n', '\tcanary build:AbC\n'], + ['null-build', 'null-build'], + ['NaN+1', 'NaN+1'], + ['Infinity', 'Infinity'], + ])('preserves meaningful identifier %p as %p', (value, expected) => { + expect(normalizeReleaseName(value)).toBe(expected); + }); +}); From a8991f2ad55a338a1163d38f34b8f07727e6b707 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Sun, 1 Nov 2026 14:54:41 +0300 Subject: [PATCH 4/6] Expose whether an event is new in the latest release --- src/models/releasesFactory.ts | 38 ++++ src/resolvers/event.js | 14 ++ src/typeDefs/event.ts | 5 + test/models/releasesFactory-is-new.test.ts | 185 ++++++++++++++++++ test/resolvers/event-release-metadata.test.ts | 130 ++++++++++-- 5 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 test/models/releasesFactory-is-new.test.ts diff --git a/src/models/releasesFactory.ts b/src/models/releasesFactory.ts index a22ccfc1..1c91cc60 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -1,6 +1,7 @@ // TODO: it would be great to move release logic from another factories/resolvers to this class import type { Collection, Db, ObjectId } from 'mongodb'; import type { ReleaseDBScheme } from '@hawk.so/types'; +import { normalizeReleaseName } from '../utils/releaseName'; /** * Interface representing how release files are stored in the DB @@ -47,6 +48,7 @@ export default class ReleasesFactory { */ private readonly collection: Collection; private readonly filesCollection: Collection; + private readonly latestRegisteredReleaseByProject = new Map>(); /** * Creates releases factory instance @@ -57,6 +59,42 @@ export default class ReleasesFactory { this.filesCollection = dbConnection.collection('releases.files'); } + /** + * Compare against the newest registered release, not the newest observed/deployed release. + * @param projectId - event's project + * @param firstSeenRelease - original event's release + */ + public async isNewInLatestRelease( + projectId: string | ObjectId | null | undefined, + firstSeenRelease: unknown + ): Promise { + const name = normalizeReleaseName(firstSeenRelease); + + if (!projectId || name === null) { + return false; + } + const key = projectId.toString(); + let latest = this.latestRegisteredReleaseByProject.get(key); + + if (!latest) { + // The existing index bounds the project, but does not cover the _id sort. + latest = this.collection.findOne({ projectId: key }, { + sort: { _id: -1 }, + projection: { + release: 1, + _id: 0, + }, + hint: { + projectId: 1, + release: 1, + }, + }).then(doc => normalizeReleaseName(doc?.release)); + this.latestRegisteredReleaseByProject.set(key, latest); + } + + return name === await latest; + } + /** * Find one release document by projectId and release label. * Tries both exact string match and numeric fallback (if release can be cast to number). diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 7e99ceb1..afacaa4a 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -34,6 +34,20 @@ module.exports = { return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release); }, + /** + * Compare the original release with the newest registered release. + * @param {Object} event - original event or composed repetition + * @param {Object} _args - query arguments + * @param {Object} context - request-scoped factories + * @returns {Promise} whether the original first appeared in the newest registered release + */ + isNew({ projectId, firstSeenInRelease, payload }, _args, { factories }) { + return factories.releasesFactory.isNewInLatestRelease( + projectId, + firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release + ); + }, + /** * @param {Object} event - event with stored lifecycle metadata * @returns {string|null} normalized resolution release diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index ec6fbc42..384cd402 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -306,6 +306,11 @@ type Event { """ firstSeenInRelease: String + """ + Whether the original event first appeared in the newest registered release for its project + """ + isNew: Boolean! + """ Release in which the event was resolved """ diff --git a/test/models/releasesFactory-is-new.test.ts b/test/models/releasesFactory-is-new.test.ts new file mode 100644 index 00000000..6a7cf9e1 --- /dev/null +++ b/test/models/releasesFactory-is-new.test.ts @@ -0,0 +1,185 @@ +/** @jest-environment @shelf/jest-mongodb/lib/environment */ +import { Collection, Db, MongoClient, ObjectId } from 'mongodb'; +import ReleasesFactory from '../../src/models/releasesFactory'; + +declare const __MONGO_URI__: string; + +const projectId = '507f1f77bcf86cd799439011'; +const latestName = ' opaque / Build+7 '; +const queryOptions = { + sort: { _id: -1 }, + projection: { release: 1, _id: 0 }, + hint: { projectId: 1, release: 1 }, +}; + +describe('ReleasesFactory.isNewInLatestRelease', () => { + let findOne: jest.Mock; + let db: Db; + let factory: ReleasesFactory; + + beforeEach(() => { + findOne = jest.fn().mockResolvedValue({ release: latestName }); + db = { collection: () => ({ findOne }) } as unknown as Db; + factory = new ReleasesFactory(db); + }); + + it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, []])( + 'returns false without reading releases for invalid first-seen name %p', async (name) => { + expect(await factory.isNewInLatestRelease(projectId, name)).toBe(false); + expect(findOne).not.toHaveBeenCalled(); + } + ); + + it.each([undefined, null, ''])('returns false without reading releases for missing project %p', async (project) => { + expect(await factory.isNewInLatestRelease(project, latestName)).toBe(false); + expect(findOne).not.toHaveBeenCalled(); + }); + + it.each([ + [0, '0', true], + ['42', 42, true], + [-1.5, '-1.5', true], + [latestName, latestName, true], + [latestName.trim(), latestName, false], + [latestName.toLowerCase(), latestName, false], + ['042', 42, false], + ['older', latestName, false], + ])('compares normalized first-seen %p with registered %p', async (firstSeen, latest, expected) => { + findOne.mockResolvedValue({ release: latest }); + expect(await factory.isNewInLatestRelease(projectId, firstSeen)).toBe(expected); + expect(findOne).toHaveBeenCalledWith({ projectId }, queryOptions); + }); + + it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, []])( + 'caches invalid newest release %p without falling back to an older valid document', async (release) => { + findOne.mockResolvedValueOnce({ release }).mockResolvedValue({ release: latestName }); + expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); + expect(findOne).toHaveBeenCalledTimes(1); + } + ); + + it('caches a missing release document', async () => { + findOne.mockResolvedValue(null); + expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); + expect(findOne).toHaveBeenCalledTimes(1); + }); + + it('shares one in-flight query across 50 rows and ObjectId/string project keys', async () => { + let resolveLatest!: (doc: { release: string }) => void; + + findOne.mockReturnValue(new Promise(resolve => { resolveLatest = resolve; })); + const rows = Array.from({ length: 50 }, (_, index) => factory.isNewInLatestRelease( + index % 2 ? new ObjectId(projectId) : projectId, + latestName + )); + + expect(findOne).toHaveBeenCalledTimes(1); + resolveLatest({ release: latestName }); + expect(await Promise.all(rows)).toEqual(Array(50).fill(true)); + expect(await factory.isNewInLatestRelease(projectId, 'older')).toBe(false); + expect(findOne).toHaveBeenCalledTimes(1); + }); + + it('isolates projects and refreshes the cache for a new request factory', async () => { + findOne.mockResolvedValueOnce({ release: 'a' }).mockResolvedValueOnce({ release: 'b' }); + expect(await factory.isNewInLatestRelease('project-a', 'a')).toBe(true); + expect(await factory.isNewInLatestRelease('project-b', 'a')).toBe(false); + expect(await factory.isNewInLatestRelease('project-b', 'b')).toBe(true); + expect(await factory.isNewInLatestRelease('project-a', 'b')).toBe(false); + expect(findOne.mock.calls.map(([filter]) => filter)).toEqual([ + { projectId: 'project-a' }, { projectId: 'project-b' }, + ]); + + findOne.mockResolvedValue({ release: 'next' }); + expect(await new ReleasesFactory(db).isNewInLatestRelease('project-a', 'next')).toBe(true); + expect(findOne).toHaveBeenCalledTimes(3); + }); +}); + +describe('ReleasesFactory latest registered release Mongo query', () => { + let client: MongoClient; + let db: Db; + + beforeAll(async () => { + client = await new MongoClient(__MONGO_URI__).connect(); + db = client.db(`test_releases_is_new_${new ObjectId().toHexString()}`); + // Reproduce the existing production index only in this isolated test database. + await db.collection('releases').createIndex( + { projectId: 1, release: 1 }, + { name: 'projectId_release_unique_idx', unique: true } + ); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + if (db) { + await db.collection('releases').deleteMany({}); + } + }); + + afterAll(async () => { + if (db) { + await db.dropDatabase(); + } + if (client) { + await client.close(); + } + }); + + it('uses ObjectId order, includes a release with no occurrences, and bounds the scan to the project', async () => { + const releases = db.collection('releases'); + + await releases.insertMany([ + { _id: ObjectId.createFromTime(3), projectId, release: 'unseen / Build+9' }, + { _id: ObjectId.createFromTime(1), projectId, release: 'z-older' }, + { _id: ObjectId.createFromTime(2), projectId, release: 'a-observed' }, + ...Array.from({ length: 200 }, (_, index) => ({ + _id: ObjectId.createFromTime(100 + index), + projectId: 'unrelated-project', + release: `foreign-${index}`, + })), + ]); + const findOne = jest.spyOn(Collection.prototype, 'findOne'); + const factory = new ReleasesFactory(db); + + expect(await factory.isNewInLatestRelease(projectId, 'a-observed')).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, 'unseen / Build+9')).toBe(true); + expect(findOne).toHaveBeenCalledTimes(1); + expect(findOne).toHaveBeenCalledWith({ projectId }, queryOptions); + expect((await db.listCollections().toArray()).map(({ name }) => name)).toEqual(['releases']); + + const [filter, options] = findOne.mock.calls[0]; + const explain = await releases.find(filter!, options).limit(1).explain('executionStats'); + const plan = explain.queryPlanner.winningPlan; + const serializedPlan = JSON.stringify(plan); + + expect(serializedPlan).toContain('"stage":"IXSCAN"'); + expect(serializedPlan).toContain('"indexName":"projectId_release_unique_idx"'); + expect(serializedPlan).toContain('"stage":"SORT"'); + expect(serializedPlan).not.toContain('"stage":"COLLSCAN"'); + expect(serializedPlan).toContain(`"projectId":["[\\"${projectId}\\", \\"${projectId}\\"]"]`); + expect(explain.executionStats.nReturned).toBe(1); + expect(explain.executionStats.totalDocsExamined).toBe(3); + // A compound-index prefix scan may examine one additional boundary key. + expect(explain.executionStats.totalKeysExamined).toBeLessThanOrEqual(4); + console.info('Latest registered release explain:', JSON.stringify({ + plan, + nReturned: explain.executionStats.nReturned, + totalDocsExamined: explain.executionStats.totalDocsExamined, + totalKeysExamined: explain.executionStats.totalKeysExamined, + totalReleaseDocuments: 203, + })); + }); + + it.each([undefined, null, ' NaN '])('does not skip an invalid newest document (%p)', async (release) => { + await db.collection('releases').insertMany([ + { _id: ObjectId.createFromTime(1), projectId, release: 'valid-older' }, + { _id: ObjectId.createFromTime(2), projectId, ...(release === undefined ? {} : { release }) }, + ]); + const factory = new ReleasesFactory(db); + + expect(await factory.isNewInLatestRelease(projectId, 'valid-older')).toBe(false); + }); +}); diff --git a/test/resolvers/event-release-metadata.test.ts b/test/resolvers/event-release-metadata.test.ts index 7fa57727..001de04c 100644 --- a/test/resolvers/event-release-metadata.test.ts +++ b/test/resolvers/event-release-metadata.test.ts @@ -1,5 +1,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import { graphql, GraphQLObjectType, GraphQLString } from 'graphql'; +import type { Db } from 'mongodb'; +import ReleasesFactory from '../../src/models/releasesFactory'; import eventTypeDefs from '../../src/typeDefs/event'; jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({})); @@ -11,7 +13,7 @@ const eventResolvers = require('../../src/resolvers/event'); const schema = makeExecutableSchema({ typeDefs: [eventTypeDefs, ` - type Query { event: Event } + type Query { event: Event, events: [Event!]! } type Mutation { _: Boolean } type User { id: ID } type ChartLine { label: String } @@ -29,39 +31,68 @@ const schema = makeExecutableSchema({ firstSeenInRelease: eventResolvers.Event.firstSeenInRelease, resolvedInRelease: eventResolvers.Event.resolvedInRelease, regressionInRelease: eventResolvers.Event.regressionInRelease, + isNew: eventResolvers.Event.isNew, }, }, }); +const projectId = '507f1f77bcf86cd799439011'; +const latestName = 'original / build+7'; const source = `{ event { firstSeenInRelease resolvedInRelease regressionInRelease + isNew payload { release } } }`; +const metadataSource = '{ event { firstSeenInRelease resolvedInRelease regressionInRelease isNew } }'; describe('Event release metadata GraphQL contract', () => { + let findOne: jest.Mock; + let db: Db; + let context: { factories: { releasesFactory: ReleasesFactory } }; + + beforeEach(() => { + findOne = jest.fn().mockResolvedValue({ release: latestName }); + db = { collection: () => ({ findOne }) } as unknown as Db; + context = { factories: { releasesFactory: new ReleasesFactory(db) } }; + }); + it.each(['firstSeenInRelease', 'resolvedInRelease', 'regressionInRelease'])('exposes %s as a nullable String', (field) => { const eventType = schema.getType('Event') as GraphQLObjectType; expect(eventType.getFields()[field].type).toBe(GraphQLString); }); + it('exposes isNew as Boolean!', () => { + const eventType = schema.getType('Event') as GraphQLObjectType; + + expect(eventType.getFields().isNew.type.toString()).toBe('Boolean!'); + }); + it.each([ { title: 'original event', - event: { payload: { release: 'original / build+7' } }, - expected: 'original / build+7', + event: { payload: { release: latestName } }, + expected: latestName, }, { title: 'selected repetition', event: { - firstSeenInRelease: 'original / build+7', + firstSeenInRelease: latestName, payload: { release: 'repeated / build+8' }, }, - expected: 'original / build+7', + expected: latestName, + }, + { + title: 'old original with a repetition in the latest registered release', + event: { + firstSeenInRelease: 'older release', + payload: { release: latestName }, + }, + expected: 'older release', }, { title: 'legacy original event', @@ -72,7 +103,7 @@ describe('Event release metadata GraphQL contract', () => { title: 'repetition whose original has no release', event: { firstSeenInRelease: null, - payload: { release: 'later release' }, + payload: { release: latestName }, }, expected: null, }, @@ -80,15 +111,16 @@ describe('Event release metadata GraphQL contract', () => { title: 'empty original release', event: { firstSeenInRelease: '', - payload: { release: 'later release' }, + payload: { release: latestName }, }, expected: null, }, - ])('resolves first seen from the $title', async ({ event, expected }) => { + ])('resolves first seen and isNew from the $title', async ({ event, expected }) => { const { data, errors } = await graphql({ schema, source, - rootValue: { event }, + rootValue: { event: { ...event, projectId } }, + contextValue: context, }); expect(errors).toBeUndefined(); @@ -96,30 +128,44 @@ describe('Event release metadata GraphQL contract', () => { firstSeenInRelease: expected, resolvedInRelease: null, regressionInRelease: null, + isNew: expected === latestName, payload: { release: event.payload.release ?? null }, }); + expect(findOne).toHaveBeenCalledTimes(expected === null ? 0 : 1); }); it.each([ [0, '0'], [42, '42'], - [' opaque / build+7 ', ' opaque / build+7 '], + [-1.5, '-1.5'], + [' opaque / Build+7 ', ' opaque / Build+7 '], + [undefined, null], + [null, null], ['', null], [' ', null], [' NULL ', null], ['undefined', null], ['NaN', null], - ])('normalizes all release metadata fields for %p', async (release, expected) => { + [NaN, null], + [Infinity, null], + [-Infinity, null], + [true, null], + [{}, null], + [[], null], + ])('normalizes raw legacy release %p without field errors', async (release, expected) => { + findOne.mockResolvedValue({ release }); const { data, errors } = await graphql({ schema, - source: '{ event { firstSeenInRelease resolvedInRelease regressionInRelease } }', + source: metadataSource, rootValue: { event: { + projectId, payload: { release }, resolvedInRelease: release, regressionInRelease: release, }, }, + contextValue: context, }); expect(errors).toBeUndefined(); @@ -127,12 +173,30 @@ describe('Event release metadata GraphQL contract', () => { firstSeenInRelease: expected, resolvedInRelease: expected, regressionInRelease: expected, + isNew: expected !== null, }); + expect(findOne).toHaveBeenCalledTimes(expected === null ? 0 : 1); }); + it.each([{}, { projectId }, { projectId, payload: null }, { payload: { release: latestName } }])( + 'returns false without querying for missing project or payload: %p', async (event) => { + const { data, errors } = await graphql({ + schema, + source: metadataSource, + rootValue: { event }, + contextValue: context, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toMatchObject({ isNew: false }); + expect(findOne).not.toHaveBeenCalled(); + } + ); + it('returns stored lifecycle release names unchanged', async () => { const event = { - payload: { release: 'original release' }, + projectId, + payload: { release: latestName }, resolvedInRelease: 'hotfix / opaque+name', regressionInRelease: 'canary build:abc', }; @@ -140,6 +204,7 @@ describe('Event release metadata GraphQL contract', () => { schema, source, rootValue: { event }, + contextValue: context, }); expect(errors).toBeUndefined(); @@ -147,7 +212,46 @@ describe('Event release metadata GraphQL contract', () => { firstSeenInRelease: event.payload.release, resolvedInRelease: event.resolvedInRelease, regressionInRelease: event.regressionInRelease, + isNew: true, payload: event.payload, }); }); + + it('queries once for 50 GraphQL rows and reads afresh in the next request', async () => { + const events = Array.from({ length: 50 }, (_, index) => ({ + projectId, + firstSeenInRelease: index % 2 ? latestName : 'older', + payload: { release: 'selected repetition' }, + })); + const query = { schema, source: '{ events { isNew } }', rootValue: { events } }; + const first = await graphql({ ...query, contextValue: context }); + + expect(first.errors).toBeUndefined(); + expect(first.data?.events).toEqual(events.map((_, index) => ({ isNew: Boolean(index % 2) }))); + expect(findOne).toHaveBeenCalledTimes(1); + + findOne.mockResolvedValue({ release: 'registered with no occurrences' }); + const second = await graphql({ + ...query, + contextValue: { factories: { releasesFactory: new ReleasesFactory(db) } }, + }); + + expect(second.errors).toBeUndefined(); + expect(second.data?.events).toEqual(events.map(() => ({ isNew: false }))); + expect(findOne).toHaveBeenCalledTimes(2); + }); + + it('uses each event project, not the parent context project', async () => { + findOne.mockImplementation(({ projectId: id }) => Promise.resolve({ release: id === projectId ? latestName : 'other' })); + const { data, errors } = await graphql({ + schema, + source: '{ events { isNew } }', + rootValue: { events: [projectId, 'another-project'].map(id => ({ projectId: id, payload: { release: latestName } })) }, + contextValue: { ...context, projectId: 'wrong-parent-project' }, + }); + + expect(errors).toBeUndefined(); + expect(data?.events).toEqual([{ isNew: true }, { isNew: false }]); + expect(findOne.mock.calls.map(([filter]) => filter)).toEqual([{ projectId }, { projectId: 'another-project' }]); + }); }); From 4cdc42ceac53c8d0d7c4a8c04e7fe21a8cbd6a2f Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Sun, 1 Nov 2026 15:00:06 +0300 Subject: [PATCH 5/6] Format multiline comments consistently in release query tests and model --- src/models/releasesFactory.ts | 4 +++- test/models/releasesFactory-is-new.test.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/models/releasesFactory.ts b/src/models/releasesFactory.ts index 1c91cc60..a864c2cb 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -77,7 +77,9 @@ export default class ReleasesFactory { let latest = this.latestRegisteredReleaseByProject.get(key); if (!latest) { - // The existing index bounds the project, but does not cover the _id sort. + /* + * The existing index bounds the project, but does not cover the _id sort. + */ latest = this.collection.findOne({ projectId: key }, { sort: { _id: -1 }, projection: { diff --git a/test/models/releasesFactory-is-new.test.ts b/test/models/releasesFactory-is-new.test.ts index 6a7cf9e1..950bdceb 100644 --- a/test/models/releasesFactory-is-new.test.ts +++ b/test/models/releasesFactory-is-new.test.ts @@ -1,4 +1,6 @@ -/** @jest-environment @shelf/jest-mongodb/lib/environment */ +/** + * @jest-environment @shelf/jest-mongodb/lib/environment + */ import { Collection, Db, MongoClient, ObjectId } from 'mongodb'; import ReleasesFactory from '../../src/models/releasesFactory'; @@ -105,7 +107,9 @@ describe('ReleasesFactory latest registered release Mongo query', () => { beforeAll(async () => { client = await new MongoClient(__MONGO_URI__).connect(); db = client.db(`test_releases_is_new_${new ObjectId().toHexString()}`); - // Reproduce the existing production index only in this isolated test database. + /* + * Reproduce the existing production index only in this isolated test database. + */ await db.collection('releases').createIndex( { projectId: 1, release: 1 }, { name: 'projectId_release_unique_idx', unique: true } @@ -162,7 +166,9 @@ describe('ReleasesFactory latest registered release Mongo query', () => { expect(serializedPlan).toContain(`"projectId":["[\\"${projectId}\\", \\"${projectId}\\"]"]`); expect(explain.executionStats.nReturned).toBe(1); expect(explain.executionStats.totalDocsExamined).toBe(3); - // A compound-index prefix scan may examine one additional boundary key. + /* + * A compound-index prefix scan may examine one additional boundary key. + */ expect(explain.executionStats.totalKeysExamined).toBeLessThanOrEqual(4); console.info('Latest registered release explain:', JSON.stringify({ plan, From c96568d5d47465a4925175f201098f371ff9fcf6 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Mon, 2 Nov 2026 20:28:28 +0300 Subject: [PATCH 6/6] Cache latest release lookups per request and skip invalid releases Use a cursor to find the newest valid registered release, sharing in-flight lookups through request context rather than factory state. Handle lookup failures safely and update resolver documentation and tests. --- src/models/releasesFactory.ts | 107 ++++-- src/resolvers/event.js | 39 +- src/typeDefs/event.ts | 2 +- src/types/graphql.ts | 15 +- test/models/releasesFactory-is-new.test.ts | 343 ++++++++++++++---- test/resolvers/event-release-metadata.test.ts | 216 +++++++++-- 6 files changed, 585 insertions(+), 137 deletions(-) diff --git a/src/models/releasesFactory.ts b/src/models/releasesFactory.ts index a864c2cb..471788fd 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -1,4 +1,6 @@ -// TODO: it would be great to move release logic from another factories/resolvers to this class +/** + * TODO: it would be great to move release logic from another factories/resolvers to this class + */ import type { Collection, Db, ObjectId } from 'mongodb'; import type { ReleaseDBScheme } from '@hawk.so/types'; import { normalizeReleaseName } from '../utils/releaseName'; @@ -48,7 +50,6 @@ export default class ReleasesFactory { */ private readonly collection: Collection; private readonly filesCollection: Collection; - private readonly latestRegisteredReleaseByProject = new Map>(); /** * Creates releases factory instance @@ -60,41 +61,40 @@ export default class ReleasesFactory { } /** - * Compare against the newest registered release, not the newest observed/deployed release. + * Compare against the newest valid registered release, not the newest observed/deployed release. * @param projectId - event's project - * @param firstSeenRelease - original event's release + * @param firstSeenRelease - original event's release name + * @param latestReleaseCache - caller-owned request cache keyed by project ID; shares in-flight lookups + * and null results (including failures) only within that request. Without it, every call reads afresh. + * @returns whether the original first appeared in the newest valid registered release, or false on failure */ public async isNewInLatestRelease( projectId: string | ObjectId | null | undefined, - firstSeenRelease: unknown + firstSeenRelease: unknown, + latestReleaseCache?: Map> ): Promise { - const name = normalizeReleaseName(firstSeenRelease); + try { + const name = normalizeReleaseName(firstSeenRelease); - if (!projectId || name === null) { - return false; - } - const key = projectId.toString(); - let latest = this.latestRegisteredReleaseByProject.get(key); + if (!projectId || name === null) { + return false; + } + const key = projectId.toString(); + let latest = latestReleaseCache?.get(key); + + if (!latest) { + latest = this.findLatestValidRelease(key); + if (latestReleaseCache) { + latestReleaseCache.set(key, latest); + } + } - if (!latest) { - /* - * The existing index bounds the project, but does not cover the _id sort. - */ - latest = this.collection.findOne({ projectId: key }, { - sort: { _id: -1 }, - projection: { - release: 1, - _id: 0, - }, - hint: { - projectId: 1, - release: 1, - }, - }).then(doc => normalizeReleaseName(doc?.release)); - this.latestRegisteredReleaseByProject.set(key, latest); - } + return name === await latest; + } catch (error) { + console.error('Failed to compare event release with latest registered release', error); - return name === await latest; + return false; + } } /** @@ -107,13 +107,17 @@ export default class ReleasesFactory { ): Promise { const projectIdStr = projectId.toString(); - // Try exact match as stored + /* + * Try exact match as stored + */ let doc = await this.collection.findOne({ projectId: projectIdStr, release: release as ReleaseDBScheme['release'], }); - // Fallback if release stored as number + /* + * Fallback if release stored as number + */ if (!doc) { const asNumber = Number(release); @@ -136,4 +140,45 @@ export default class ReleasesFactory { public async findFilesByFileIds(fileIds: ObjectId[]): Promise { return this.filesCollection.find({ _id: { $in: fileIds } }).toArray(); } + + /** + * Read newest first, skipping legacy invalid names without materializing the release history. + * Failures resolve to null so concurrent rows share one logged failure, not rejected promises. + * @param projectId - normalized project ID + * @returns newest valid release name, or null if none exists or the lookup fails + */ + private async findLatestValidRelease(projectId: string): Promise { + try { + /* + * The existing index bounds the project, but does not cover the _id sort. + */ + const cursor = this.collection.find({ projectId }, { + sort: { _id: -1 }, + projection: { + release: 1, + _id: 0, + }, + hint: { + projectId: 1, + release: 1, + }, + }); + + try { + for await (const doc of cursor) { + const name = normalizeReleaseName(doc.release); + + if (name !== null) { + return name; + } + } + } finally { + await cursor.close(); + } + } catch (error) { + console.error(`Failed to look up latest registered release for project ${projectId}`, error); + } + + return null; + } } diff --git a/src/resolvers/event.js b/src/resolvers/event.js index afacaa4a..7232ecc8 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -25,43 +25,56 @@ module.exports = { }, Event: { /** - * Returns the original release, preserving null for repetitions of legacy events. + * Returns the original release name, preserving null for repetitions of legacy events. * * @param {Object} event - original event or composed repetition - * @returns {string|null} + * @param {*} event.firstSeenInRelease - original release name; explicit null preserves legacy absence + * @param {Object} event.payload - selected occurrence payload, used only when original metadata is undefined + * @returns {string|null} normalized original release name */ - firstSeenInRelease({ firstSeenInRelease, payload }) { + firstSeenInRelease(event) { + const { firstSeenInRelease, payload } = event; + return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release); }, /** - * Compare the original release with the newest registered release. + * Compare the original release name with the newest valid registered release. * @param {Object} event - original event or composed repetition * @param {Object} _args - query arguments - * @param {Object} context - request-scoped factories - * @returns {Promise} whether the original first appeared in the newest registered release + * @param {Object} context - request context owning the lazy per-project release cache, independent of factory lifetime + * @returns {Promise} whether the original first appeared in the newest valid registered release */ - isNew({ projectId, firstSeenInRelease, payload }, _args, { factories }) { - return factories.releasesFactory.isNewInLatestRelease( + isNew(event, _args, context) { + const { projectId, firstSeenInRelease, payload } = event; + + if (!context.latestRegisteredReleaseByProject) { + context.latestRegisteredReleaseByProject = new Map(); + } + + return context.factories.releasesFactory.isNewInLatestRelease( projectId, - firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release + firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.release, + context.latestRegisteredReleaseByProject ); }, /** + * Return the stored resolution release name. * @param {Object} event - event with stored lifecycle metadata * @returns {string|null} normalized resolution release */ - resolvedInRelease({ resolvedInRelease }) { - return normalizeReleaseName(resolvedInRelease); + resolvedInRelease(event) { + return normalizeReleaseName(event.resolvedInRelease); }, /** + * Return the stored regression release name. * @param {Object} event - event with stored lifecycle metadata * @returns {string|null} normalized regression release */ - regressionInRelease({ regressionInRelease }) { - return normalizeReleaseName(regressionInRelease); + regressionInRelease(event) { + return normalizeReleaseName(event.regressionInRelease); }, /** diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 384cd402..74b99b47 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -307,7 +307,7 @@ type Event { firstSeenInRelease: String """ - Whether the original event first appeared in the newest registered release for its project + Whether the original event first appeared in the newest valid registered release for its project """ isNew: Boolean! diff --git a/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36..d5f7a615 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -28,10 +28,17 @@ export interface ResolverContextBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any eventsFactoryCache?: Map; - // /** - // * SDK for working with CodeX Accounting API - // */ - // accounting: Accounting; + /** + * Request-owned latest valid registered release lookups keyed by string project ID. + * Lazily initialized by Event.isNew to deduplicate in-flight lookups across rows, even with shared factories. + * Null results, including logged lookup failures, last only for this request; a new context reads afresh. + */ + latestRegisteredReleaseByProject?: Map>; + + /* + * SDK for working with CodeX Accounting API + * accounting: Accounting; + */ } /** diff --git a/test/models/releasesFactory-is-new.test.ts b/test/models/releasesFactory-is-new.test.ts index 950bdceb..2299cb8f 100644 --- a/test/models/releasesFactory-is-new.test.ts +++ b/test/models/releasesFactory-is-new.test.ts @@ -10,31 +10,57 @@ const projectId = '507f1f77bcf86cd799439011'; const latestName = ' opaque / Build+7 '; const queryOptions = { sort: { _id: -1 }, - projection: { release: 1, _id: 0 }, - hint: { projectId: 1, release: 1 }, + projection: { + release: 1, + _id: 0, + }, + hint: { + projectId: 1, + release: 1, + }, }; describe('ReleasesFactory.isNewInLatestRelease', () => { - let findOne: jest.Mock; - let db: Db; + let find: jest.Mock; + let cursor: { + next: jest.Mock; + close: jest.Mock; + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }>; + }; let factory: ReleasesFactory; + let cache: Map>; beforeEach(() => { - findOne = jest.fn().mockResolvedValue({ release: latestName }); - db = { collection: () => ({ findOne }) } as unknown as Db; - factory = new ReleasesFactory(db); + cursor = { + next: jest.fn().mockResolvedValueOnce({ + value: { release: latestName }, + done: false, + }) + .mockResolvedValue({ done: true }), + close: jest.fn().mockResolvedValue(undefined), + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }> { + return this; + }, + }; + find = jest.fn().mockReturnValue(cursor); + factory = new ReleasesFactory({ collection: () => ({ find }) } as unknown as Db); + cache = new Map(); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); - it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, []])( + it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, [] ])( 'returns false without reading releases for invalid first-seen name %p', async (name) => { - expect(await factory.isNewInLatestRelease(projectId, name)).toBe(false); - expect(findOne).not.toHaveBeenCalled(); + expect(await factory.isNewInLatestRelease(projectId, name, cache)).toBe(false); + expect(find).not.toHaveBeenCalled(); } ); it.each([undefined, null, ''])('returns false without reading releases for missing project %p', async (project) => { - expect(await factory.isNewInLatestRelease(project, latestName)).toBe(false); - expect(findOne).not.toHaveBeenCalled(); + expect(await factory.isNewInLatestRelease(project, latestName, cache)).toBe(false); + expect(find).not.toHaveBeenCalled(); }); it.each([ @@ -47,57 +73,199 @@ describe('ReleasesFactory.isNewInLatestRelease', () => { ['042', 42, false], ['older', latestName, false], ])('compares normalized first-seen %p with registered %p', async (firstSeen, latest, expected) => { - findOne.mockResolvedValue({ release: latest }); - expect(await factory.isNewInLatestRelease(projectId, firstSeen)).toBe(expected); - expect(findOne).toHaveBeenCalledWith({ projectId }, queryOptions); + cursor.next.mockReset().mockResolvedValueOnce({ + value: { release: latest }, + done: false, + }) + .mockResolvedValue({ done: true }); + expect(await factory.isNewInLatestRelease(projectId, firstSeen, cache)).toBe(expected); + expect(find).toHaveBeenCalledWith({ projectId }, queryOptions); + expect(cursor.close).toHaveBeenCalledTimes(1); }); - it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, []])( - 'caches invalid newest release %p without falling back to an older valid document', async (release) => { - findOne.mockResolvedValueOnce({ release }).mockResolvedValue({ release: latestName }); - expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); - expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); - expect(findOne).toHaveBeenCalledTimes(1); + it.each([undefined, null, '', ' ', ' NULL ', 'undefined', 'NaN', NaN, Infinity, -Infinity, true, {}, [] ])( + 'skips invalid newest release %p and stops at the newest valid name, including numeric zero', async (release) => { + cursor.next.mockReset() + .mockResolvedValueOnce({ + value: { release }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 0 }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 'older' }, + done: false, + }); + expect(await factory.isNewInLatestRelease(projectId, '0', cache)).toBe(true); + expect(await factory.isNewInLatestRelease(projectId, 'older', cache)).toBe(false); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.next).toHaveBeenCalledTimes(2); + expect(cursor.close).toHaveBeenCalledTimes(1); } ); - it('caches a missing release document', async () => { - findOne.mockResolvedValue(null); - expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); - expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(false); - expect(findOne).toHaveBeenCalledTimes(1); - }); + it.each([ { releases: [] }, { releases: [undefined, null, ' NaN ', {}, true] } ])( + 'caches null and closes an exhausted history $releases', async ({ releases }) => { + cursor.next.mockReset().mockResolvedValue({ done: true }); + for (const release of releases) { + cursor.next.mockResolvedValueOnce({ + value: { release }, + done: false, + }); + } + expect(await factory.isNewInLatestRelease(projectId, latestName, cache)).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, latestName, cache)).toBe(false); + expect(await cache.get(projectId)).toBeNull(); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.next).toHaveBeenCalledTimes(releases.length + 1); + expect(cursor.close).toHaveBeenCalledTimes(1); + } + ); - it('shares one in-flight query across 50 rows and ObjectId/string project keys', async () => { - let resolveLatest!: (doc: { release: string }) => void; + it('shares one in-flight cursor across 50 rows and ObjectId/string project keys', async () => { + let resolveLatest!: (doc: IteratorResult<{ release: string }>) => void; - findOne.mockReturnValue(new Promise(resolve => { resolveLatest = resolve; })); + cursor.next.mockReset().mockReturnValueOnce(new Promise(resolve => { + resolveLatest = resolve; + })); const rows = Array.from({ length: 50 }, (_, index) => factory.isNewInLatestRelease( index % 2 ? new ObjectId(projectId) : projectId, - latestName + latestName, + cache )); - expect(findOne).toHaveBeenCalledTimes(1); - resolveLatest({ release: latestName }); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.close).not.toHaveBeenCalled(); + resolveLatest({ + value: { release: latestName }, + done: false, + }); expect(await Promise.all(rows)).toEqual(Array(50).fill(true)); - expect(await factory.isNewInLatestRelease(projectId, 'older')).toBe(false); - expect(findOne).toHaveBeenCalledTimes(1); + expect(await factory.isNewInLatestRelease(projectId, 'older', cache)).toBe(false); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.close).toHaveBeenCalledTimes(1); }); - it('isolates projects and refreshes the cache for a new request factory', async () => { - findOne.mockResolvedValueOnce({ release: 'a' }).mockResolvedValueOnce({ release: 'b' }); - expect(await factory.isNewInLatestRelease('project-a', 'a')).toBe(true); - expect(await factory.isNewInLatestRelease('project-b', 'a')).toBe(false); - expect(await factory.isNewInLatestRelease('project-b', 'b')).toBe(true); - expect(await factory.isNewInLatestRelease('project-a', 'b')).toBe(false); - expect(findOne.mock.calls.map(([filter]) => filter)).toEqual([ + it('isolates projects and refreshes the request cache with the same factory', async () => { + cursor.next.mockReset() + .mockResolvedValueOnce({ + value: { release: 'a' }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 'b' }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 'next' }, + done: false, + }); + expect(await factory.isNewInLatestRelease('project-a', 'a', cache)).toBe(true); + expect(await factory.isNewInLatestRelease('project-b', 'a', cache)).toBe(false); + expect(await factory.isNewInLatestRelease('project-b', 'b', cache)).toBe(true); + expect(await factory.isNewInLatestRelease('project-a', 'b', cache)).toBe(false); + expect(find.mock.calls.map(([ filter ]) => filter)).toEqual([ { projectId: 'project-a' }, { projectId: 'project-b' }, ]); - findOne.mockResolvedValue({ release: 'next' }); - expect(await new ReleasesFactory(db).isNewInLatestRelease('project-a', 'next')).toBe(true); - expect(findOne).toHaveBeenCalledTimes(3); + expect(await factory.isNewInLatestRelease('project-a', 'next', new Map())).toBe(true); + expect(await factory.isNewInLatestRelease('project-a', 'a', cache)).toBe(true); + expect(find).toHaveBeenCalledTimes(3); }); + + it('reads afresh on every call without a caller-owned cache', async () => { + cursor.next.mockReset() + .mockResolvedValueOnce({ + value: { release: latestName }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 'next' }, + done: false, + }); + expect(await factory.isNewInLatestRelease(projectId, latestName)).toBe(true); + expect(await factory.isNewInLatestRelease(projectId, 'next')).toBe(true); + expect(find).toHaveBeenCalledTimes(2); + expect(cursor.close).toHaveBeenCalledTimes(2); + }); + + it.each(['find throws', 'iteration throws', 'iteration rejects', 'close throws', 'close rejects'])( + 'returns false and logs once for 50 rows when %s, then retries with a fresh request cache', async (failure) => { + const error = new Error(failure); + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + if (failure === 'find throws') { + find.mockImplementationOnce(() => { + throw error; + }); + } else if (failure === 'iteration throws') { + cursor.next.mockReset().mockImplementationOnce(() => { + throw error; + }); + } else if (failure === 'iteration rejects') { + cursor.next.mockReset().mockRejectedValueOnce(error); + } else if (failure === 'close throws') { + cursor.close.mockImplementationOnce(() => { + throw error; + }); + } else { + cursor.close.mockRejectedValueOnce(error); + } + + const rows = Array.from({ length: 50 }, () => factory.isNewInLatestRelease(projectId, latestName, cache)); + + expect(await Promise.all(rows)).toEqual(Array(50).fill(false)); + expect(await cache.get(projectId)).toBeNull(); + expect(find).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining(projectId), error); + expect(cursor.close).toHaveBeenCalledTimes(failure === 'find throws' ? 0 : 1); + + cursor.next.mockReset().mockResolvedValueOnce({ + value: { release: latestName }, + done: false, + }); + expect(await factory.isNewInLatestRelease(projectId, latestName, new Map())).toBe(true); + expect(find).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledTimes(1); + } + ); + + it('closes and logs once even if iteration and closing both fail', async () => { + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + cursor.next.mockReset().mockRejectedValueOnce(new Error('iteration')); + cursor.close.mockRejectedValueOnce(new Error('close')); + expect(await factory.isNewInLatestRelease(projectId, latestName, cache)).toBe(false); + expect(cursor.close).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + }); + + it.each(['get', 'set', 'rejected promise', 'project key'])( + 'catches unexpected %s failures at the public boundary', async (failure) => { + const error = new Error(failure); + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + let project: string | ObjectId = projectId; + + if (failure === 'get' || failure === 'set') { + jest.spyOn(cache, failure).mockImplementation(() => { + throw error; + }); + } else if (failure === 'rejected promise') { + cache.set(projectId, Promise.reject(error)); + } else { + project = new ObjectId(projectId); + jest.spyOn(project, 'toString').mockImplementation(() => { + throw error; + }); + } + expect(await factory.isNewInLatestRelease(project, latestName, cache)).toBe(false); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.any(String), error); + } + ); }); describe('ReleasesFactory latest registered release Mongo query', () => { @@ -111,8 +279,14 @@ describe('ReleasesFactory latest registered release Mongo query', () => { * Reproduce the existing production index only in this isolated test database. */ await db.collection('releases').createIndex( - { projectId: 1, release: 1 }, - { name: 'projectId_release_unique_idx', unique: true } + { + projectId: 1, + release: 1, + }, + { + name: 'projectId_release_unique_idx', + unique: true, + } ); }); @@ -136,26 +310,39 @@ describe('ReleasesFactory latest registered release Mongo query', () => { const releases = db.collection('releases'); await releases.insertMany([ - { _id: ObjectId.createFromTime(3), projectId, release: 'unseen / Build+9' }, - { _id: ObjectId.createFromTime(1), projectId, release: 'z-older' }, - { _id: ObjectId.createFromTime(2), projectId, release: 'a-observed' }, + { + _id: ObjectId.createFromTime(3), + projectId, + release: 'unseen / Build+9', + }, + { + _id: ObjectId.createFromTime(1), + projectId, + release: 'z-older', + }, + { + _id: ObjectId.createFromTime(2), + projectId, + release: 'a-observed', + }, ...Array.from({ length: 200 }, (_, index) => ({ _id: ObjectId.createFromTime(100 + index), projectId: 'unrelated-project', release: `foreign-${index}`, })), ]); - const findOne = jest.spyOn(Collection.prototype, 'findOne'); + const find = jest.spyOn(Collection.prototype, 'find'); const factory = new ReleasesFactory(db); + const cache = new Map>(); - expect(await factory.isNewInLatestRelease(projectId, 'a-observed')).toBe(false); - expect(await factory.isNewInLatestRelease(projectId, 'unseen / Build+9')).toBe(true); - expect(findOne).toHaveBeenCalledTimes(1); - expect(findOne).toHaveBeenCalledWith({ projectId }, queryOptions); - expect((await db.listCollections().toArray()).map(({ name }) => name)).toEqual(['releases']); + expect(await factory.isNewInLatestRelease(projectId, 'a-observed', cache)).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, 'unseen / Build+9', cache)).toBe(true); + expect(find).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledWith({ projectId }, queryOptions); + expect((await db.listCollections().toArray()).map(({ name }) => name)).toEqual([ 'releases' ]); - const [filter, options] = findOne.mock.calls[0]; - const explain = await releases.find(filter!, options).limit(1).explain('executionStats'); + const [filter, options] = find.mock.calls[0]; + const explain = await releases.find(filter!, options).explain('executionStats'); const plan = explain.queryPlanner.winningPlan; const serializedPlan = JSON.stringify(plan); @@ -164,7 +351,10 @@ describe('ReleasesFactory latest registered release Mongo query', () => { expect(serializedPlan).toContain('"stage":"SORT"'); expect(serializedPlan).not.toContain('"stage":"COLLSCAN"'); expect(serializedPlan).toContain(`"projectId":["[\\"${projectId}\\", \\"${projectId}\\"]"]`); - expect(explain.executionStats.nReturned).toBe(1); + /* + * The application stops at the first valid name; the database still sorts the project's full history. + */ + expect(explain.executionStats.nReturned).toBe(3); expect(explain.executionStats.totalDocsExamined).toBe(3); /* * A compound-index prefix scan may examine one additional boundary key. @@ -179,13 +369,40 @@ describe('ReleasesFactory latest registered release Mongo query', () => { })); }); - it.each([undefined, null, ' NaN '])('does not skip an invalid newest document (%p)', async (release) => { + it.each([undefined, null, ' NaN ', true, {} ])('skips an invalid newest document (%p)', async (release) => { await db.collection('releases').insertMany([ - { _id: ObjectId.createFromTime(1), projectId, release: 'valid-older' }, - { _id: ObjectId.createFromTime(2), projectId, ...(release === undefined ? {} : { release }) }, + { + _id: ObjectId.createFromTime(1), + projectId, + release: 'valid-older', + }, + { + _id: ObjectId.createFromTime(2), + projectId, + release: 0, + }, + { + _id: ObjectId.createFromTime(3), + projectId, + ...(release === undefined ? {} : { release }), + }, ]); const factory = new ReleasesFactory(db); + const cache = new Map>(); - expect(await factory.isNewInLatestRelease(projectId, 'valid-older')).toBe(false); + expect(await factory.isNewInLatestRelease(projectId, '0', cache)).toBe(true); + expect(await factory.isNewInLatestRelease(projectId, 'valid-older', cache)).toBe(false); }); + + it.each([ { releases: [] }, { releases: [null, ' NaN ', true] } ])( + 'returns false for absent or all-invalid history $releases', async ({ releases }) => { + if (releases.length) { + await db.collection('releases').insertMany(releases.map(release => ({ + projectId, + release, + }))); + } + expect(await new ReleasesFactory(db).isNewInLatestRelease(projectId, 'valid')).toBe(false); + } + ); }); diff --git a/test/resolvers/event-release-metadata.test.ts b/test/resolvers/event-release-metadata.test.ts index 001de04c..20748709 100644 --- a/test/resolvers/event-release-metadata.test.ts +++ b/test/resolvers/event-release-metadata.test.ts @@ -2,6 +2,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import { graphql, GraphQLObjectType, GraphQLString } from 'graphql'; import type { Db } from 'mongodb'; import ReleasesFactory from '../../src/models/releasesFactory'; +import type { ResolverContextBase } from '../../src/types/graphql'; import eventTypeDefs from '../../src/typeDefs/event'; jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({})); @@ -50,16 +51,38 @@ const source = `{ const metadataSource = '{ event { firstSeenInRelease resolvedInRelease regressionInRelease isNew } }'; describe('Event release metadata GraphQL contract', () => { - let findOne: jest.Mock; - let db: Db; - let context: { factories: { releasesFactory: ReleasesFactory } }; + let find: jest.Mock; + let cursor: { + next: jest.Mock; + close: jest.Mock; + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }>; + }; + let context: Pick & { + factories: { releasesFactory: ReleasesFactory }; + }; beforeEach(() => { - findOne = jest.fn().mockResolvedValue({ release: latestName }); - db = { collection: () => ({ findOne }) } as unknown as Db; + cursor = { + next: jest.fn().mockResolvedValueOnce({ + value: { release: latestName }, + done: false, + }) + .mockResolvedValue({ done: true }), + close: jest.fn().mockResolvedValue(undefined), + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }> { + return this; + }, + }; + find = jest.fn().mockReturnValue(cursor); + const db = { collection: () => ({ find }) } as unknown as Db; + context = { factories: { releasesFactory: new ReleasesFactory(db) } }; }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it.each(['firstSeenInRelease', 'resolvedInRelease', 'regressionInRelease'])('exposes %s as a nullable String', (field) => { const eventType = schema.getType('Event') as GraphQLObjectType; @@ -119,7 +142,12 @@ describe('Event release metadata GraphQL contract', () => { const { data, errors } = await graphql({ schema, source, - rootValue: { event: { ...event, projectId } }, + rootValue: { + event: { + ...event, + projectId, + }, + }, contextValue: context, }); @@ -131,7 +159,7 @@ describe('Event release metadata GraphQL contract', () => { isNew: expected === latestName, payload: { release: event.payload.release ?? null }, }); - expect(findOne).toHaveBeenCalledTimes(expected === null ? 0 : 1); + expect(find).toHaveBeenCalledTimes(expected === null ? 0 : 1); }); it.each([ @@ -150,10 +178,14 @@ describe('Event release metadata GraphQL contract', () => { [Infinity, null], [-Infinity, null], [true, null], - [{}, null], - [[], null], + [ {}, null], + [ [], null], ])('normalizes raw legacy release %p without field errors', async (release, expected) => { - findOne.mockResolvedValue({ release }); + cursor.next.mockReset().mockResolvedValueOnce({ + value: { release }, + done: false, + }) + .mockResolvedValue({ done: true }); const { data, errors } = await graphql({ schema, source: metadataSource, @@ -175,10 +207,13 @@ describe('Event release metadata GraphQL contract', () => { regressionInRelease: expected, isNew: expected !== null, }); - expect(findOne).toHaveBeenCalledTimes(expected === null ? 0 : 1); + expect(find).toHaveBeenCalledTimes(expected === null ? 0 : 1); }); - it.each([{}, { projectId }, { projectId, payload: null }, { payload: { release: latestName } }])( + it.each([ {}, { projectId }, { + projectId, + payload: null, + }, { payload: { release: latestName } } ])( 'returns false without querying for missing project or payload: %p', async (event) => { const { data, errors } = await graphql({ schema, @@ -189,7 +224,7 @@ describe('Event release metadata GraphQL contract', () => { expect(errors).toBeUndefined(); expect(data?.event).toMatchObject({ isNew: false }); - expect(findOne).not.toHaveBeenCalled(); + expect(find).not.toHaveBeenCalled(); } ); @@ -217,41 +252,172 @@ describe('Event release metadata GraphQL contract', () => { }); }); - it('queries once for 50 GraphQL rows and reads afresh in the next request', async () => { + it('queries once for 50 GraphQL rows and reads afresh in a new context with the same factory', async () => { const events = Array.from({ length: 50 }, (_, index) => ({ projectId, firstSeenInRelease: index % 2 ? latestName : 'older', payload: { release: 'selected repetition' }, })); - const query = { schema, source: '{ events { isNew } }', rootValue: { events } }; - const first = await graphql({ ...query, contextValue: context }); + const query = { + schema, + source: '{ events { isNew } }', + rootValue: { events }, + }; + const first = await graphql({ + ...query, + contextValue: context, + }); expect(first.errors).toBeUndefined(); expect(first.data?.events).toEqual(events.map((_, index) => ({ isNew: Boolean(index % 2) }))); - expect(findOne).toHaveBeenCalledTimes(1); + expect(find).toHaveBeenCalledTimes(1); + expect(context.latestRegisteredReleaseByProject).toBeInstanceOf(Map); - findOne.mockResolvedValue({ release: 'registered with no occurrences' }); + cursor.next.mockResolvedValueOnce({ + value: { release: 'registered with no occurrences' }, + done: false, + }); + const nextContext = { factories: context.factories }; const second = await graphql({ ...query, - contextValue: { factories: { releasesFactory: new ReleasesFactory(db) } }, + contextValue: nextContext, }); expect(second.errors).toBeUndefined(); expect(second.data?.events).toEqual(events.map(() => ({ isNew: false }))); - expect(findOne).toHaveBeenCalledTimes(2); + expect(find).toHaveBeenCalledTimes(2); + expect(nextContext.factories.releasesFactory).toBe(context.factories.releasesFactory); + expect(nextContext).toHaveProperty('latestRegisteredReleaseByProject', expect.any(Map)); + await expect(context.latestRegisteredReleaseByProject?.get(projectId)).resolves.toBe(latestName); + }); + + it.each(['find throws', 'iteration throws', 'iteration rejects', 'close throws', 'close rejects'])( + 'keeps all 50 Boolean! list rows false without GraphQL errors when %s, then retries in a new context', async (failure) => { + const error = new Error(failure); + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + if (failure === 'find throws') { + find.mockImplementationOnce(() => { + throw error; + }); + } else if (failure === 'iteration throws') { + cursor.next.mockReset().mockImplementationOnce(() => { + throw error; + }); + } else if (failure === 'iteration rejects') { + cursor.next.mockReset().mockRejectedValueOnce(error); + } else if (failure === 'close throws') { + cursor.close.mockImplementationOnce(() => { + throw error; + }); + } else { + cursor.close.mockRejectedValueOnce(error); + } + + const events = Array.from({ length: 50 }, () => ({ + projectId, + payload: { release: latestName }, + })); + const query = { + schema, + source: '{ events { isNew } }', + rootValue: { events }, + }; + const failed = await graphql({ + ...query, + contextValue: context, + }); + + expect(failed.errors).toBeUndefined(); + expect(failed.data?.events).toEqual(events.map(() => ({ isNew: false }))); + expect(find).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining(projectId), error); + expect(await context.latestRegisteredReleaseByProject?.get(projectId)).toBeNull(); + expect(cursor.close).toHaveBeenCalledTimes(failure === 'find throws' ? 0 : 1); + + cursor.next.mockReset().mockResolvedValueOnce({ + value: { release: latestName }, + done: false, + }); + const retried = await graphql({ + ...query, + contextValue: { factories: context.factories }, + }); + + expect(retried.errors).toBeUndefined(); + expect(retried.data?.events).toEqual(events.map(() => ({ isNew: true }))); + expect(find).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledTimes(1); + } + ); + + it('skips invalid newest names and exposes numeric zero as the newest valid original release', async () => { + cursor.next.mockReset() + .mockResolvedValueOnce({ + value: { release: ' NULL ' }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 0 }, + done: false, + }) + .mockResolvedValueOnce({ + value: { release: 'older' }, + done: false, + }); + const { data, errors } = await graphql({ + schema, + source: '{ events { firstSeenInRelease isNew } }', + rootValue: { + events: [0, 'older'].map(release => ({ + projectId, + payload: { release }, + })), + }, + contextValue: context, + }); + + expect(errors).toBeUndefined(); + expect(data?.events).toEqual([ + { + firstSeenInRelease: '0', + isNew: true, + }, + { + firstSeenInRelease: 'older', + isNew: false, + }, + ]); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.next).toHaveBeenCalledTimes(2); + expect(cursor.close).toHaveBeenCalledTimes(1); }); it('uses each event project, not the parent context project', async () => { - findOne.mockImplementation(({ projectId: id }) => Promise.resolve({ release: id === projectId ? latestName : 'other' })); + find.mockImplementation(({ projectId: id }) => ({ + async * [Symbol.asyncIterator](): AsyncIterableIterator<{ release: string }> { + yield { release: id === projectId ? latestName : 'other' }; + }, + close: jest.fn().mockResolvedValue(undefined), + })); const { data, errors } = await graphql({ schema, source: '{ events { isNew } }', - rootValue: { events: [projectId, 'another-project'].map(id => ({ projectId: id, payload: { release: latestName } })) }, - contextValue: { ...context, projectId: 'wrong-parent-project' }, + rootValue: { + events: [projectId, 'another-project'].map(id => ({ + projectId: id, + payload: { release: latestName }, + })), + }, + contextValue: { + ...context, + projectId: 'wrong-parent-project', + }, }); expect(errors).toBeUndefined(); - expect(data?.events).toEqual([{ isNew: true }, { isNew: false }]); - expect(findOne.mock.calls.map(([filter]) => filter)).toEqual([{ projectId }, { projectId: 'another-project' }]); + expect(data?.events).toEqual([ { isNew: true }, { isNew: false } ]); + expect(find.mock.calls.map(([ filter ]) => filter)).toEqual([ { projectId }, { projectId: 'another-project' } ]); }); });