From c660acd55a373003b3a9d9e4aef75617797ae4af Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 28 Oct 2026 16:37:23 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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,