diff --git a/package.json b/package.json index 088d9a29..d5b59fc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.1", + "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..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,23 +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, - 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 a22ccfc1..471788fd 100644 --- a/src/models/releasesFactory.ts +++ b/src/models/releasesFactory.ts @@ -1,6 +1,9 @@ -// 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'; /** * Interface representing how release files are stored in the DB @@ -57,6 +60,43 @@ export default class ReleasesFactory { this.filesCollection = dbConnection.collection('releases.files'); } + /** + * Compare against the newest valid registered release, not the newest observed/deployed release. + * @param projectId - event's project + * @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, + latestReleaseCache?: Map> + ): Promise { + try { + const name = normalizeReleaseName(firstSeenRelease); + + 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); + } + } + + return name === await latest; + } catch (error) { + console.error('Failed to compare event release with latest registered release', error); + + return false; + } + } + /** * Find one release document by projectId and release label. * Tries both exact string match and numeric fallback (if release can be cast to number). @@ -67,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); @@ -96,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 0e57bf4b..7232ecc8 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} @@ -23,6 +24,58 @@ module.exports = { }, }, Event: { + /** + * Returns the original release name, preserving null for repetitions of legacy events. + * + * @param {Object} event - original event or composed repetition + * @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(event) { + const { firstSeenInRelease, payload } = event; + + return normalizeReleaseName(firstSeenInRelease !== undefined ? firstSeenInRelease : payload && payload.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 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(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, + context.latestRegisteredReleaseByProject + ); + }, + + /** + * Return the stored resolution release name. + * @param {Object} event - event with stored lifecycle metadata + * @returns {string|null} normalized resolution release + */ + 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(event) { + return normalizeReleaseName(event.regressionInRelease); + }, /** * Returns repetitions portion of the event diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index cf65e848..74b99b47 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -301,6 +301,26 @@ type Event { """ release: Release + """ + Release of the original event, regardless of the selected repetition + """ + firstSeenInRelease: String + + """ + Whether the original event first appeared in the newest valid registered release for its project + """ + isNew: Boolean! + + """ + Release in which the event was resolved + """ + resolvedInRelease: String + + """ + Release in which the event regressed + """ + regressionInRelease: String + """ Event repetitions portion """ 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/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 new file mode 100644 index 00000000..f96db55b --- /dev/null +++ b/test/models/eventsFactory-release-metadata.test.ts @@ -0,0 +1,331 @@ +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(Object.freeze({ + ...original, + payload: Object.freeze({ ...original.payload }), + })); + }); + + 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); + 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); + 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, + firstSeenInRelease: original.payload.release, + 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).toBeNull(); + expect(event.payload.release).toBe('later release'); + 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-is-new.test.ts b/test/models/releasesFactory-is-new.test.ts new file mode 100644 index 00000000..2299cb8f --- /dev/null +++ b/test/models/releasesFactory-is-new.test.ts @@ -0,0 +1,408 @@ +/** + * @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 find: jest.Mock; + let cursor: { + next: jest.Mock; + close: jest.Mock; + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }>; + }; + let factory: ReleasesFactory; + let cache: Map>; + + beforeEach(() => { + 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, {}, [] ])( + 'returns false without reading releases for invalid first-seen name %p', async (name) => { + 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, cache)).toBe(false); + expect(find).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) => { + 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, {}, [] ])( + '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.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 cursor across 50 rows and ObjectId/string project keys', async () => { + let resolveLatest!: (doc: IteratorResult<{ release: string }>) => void; + + 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, + cache + )); + + 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', cache)).toBe(false); + expect(find).toHaveBeenCalledTimes(1); + expect(cursor.close).toHaveBeenCalledTimes(1); + }); + + 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' }, + ]); + + 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', () => { + 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 find = jest.spyOn(Collection.prototype, 'find'); + const factory = new ReleasesFactory(db); + const cache = new Map>(); + + 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] = find.mock.calls[0]; + const explain = await releases.find(filter!, options).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}\\"]"]`); + /* + * 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. + */ + 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 ', 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: 0, + }, + { + _id: ObjectId.createFromTime(3), + projectId, + ...(release === undefined ? {} : { release }), + }, + ]); + const factory = new ReleasesFactory(db); + const cache = new Map>(); + + 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 new file mode 100644 index 00000000..20748709 --- /dev/null +++ b/test/resolvers/event-release-metadata.test.ts @@ -0,0 +1,423 @@ +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', () => ({})); +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, events: [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, + 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 find: jest.Mock; + let cursor: { + next: jest.Mock; + close: jest.Mock; + [Symbol.asyncIterator](): AsyncIterableIterator<{ release: unknown }>; + }; + let context: Pick & { + factories: { releasesFactory: ReleasesFactory }; + }; + + beforeEach(() => { + 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; + + 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: latestName } }, + expected: latestName, + }, + { + title: 'selected repetition', + event: { + firstSeenInRelease: latestName, + payload: { release: 'repeated / build+8' }, + }, + 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', + event: { payload: {} }, + expected: null, + }, + { + title: 'repetition whose original has no release', + event: { + firstSeenInRelease: null, + payload: { release: latestName }, + }, + expected: null, + }, + { + title: 'empty original release', + event: { + firstSeenInRelease: '', + payload: { release: latestName }, + }, + expected: null, + }, + ])('resolves first seen and isNew from the $title', async ({ event, expected }) => { + const { data, errors } = await graphql({ + schema, + source, + rootValue: { + event: { + ...event, + projectId, + }, + }, + contextValue: context, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + firstSeenInRelease: expected, + resolvedInRelease: null, + regressionInRelease: null, + isNew: expected === latestName, + payload: { release: event.payload.release ?? null }, + }); + expect(find).toHaveBeenCalledTimes(expected === null ? 0 : 1); + }); + + it.each([ + [0, '0'], + [42, '42'], + [-1.5, '-1.5'], + [' opaque / Build+7 ', ' opaque / Build+7 '], + [undefined, null], + [null, null], + ['', null], + [' ', null], + [' NULL ', null], + ['undefined', null], + ['NaN', null], + [NaN, null], + [Infinity, null], + [-Infinity, null], + [true, null], + [ {}, null], + [ [], null], + ])('normalizes raw legacy release %p without field errors', async (release, expected) => { + cursor.next.mockReset().mockResolvedValueOnce({ + value: { release }, + done: false, + }) + .mockResolvedValue({ done: true }); + const { data, errors } = await graphql({ + schema, + source: metadataSource, + rootValue: { + event: { + projectId, + payload: { release }, + resolvedInRelease: release, + regressionInRelease: release, + }, + }, + contextValue: context, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + firstSeenInRelease: expected, + resolvedInRelease: expected, + regressionInRelease: expected, + isNew: expected !== null, + }); + expect(find).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(find).not.toHaveBeenCalled(); + } + ); + + it('returns stored lifecycle release names unchanged', async () => { + const event = { + projectId, + payload: { release: latestName }, + resolvedInRelease: 'hotfix / opaque+name', + regressionInRelease: 'canary build:abc', + }; + const { data, errors } = await graphql({ + schema, + source, + rootValue: { event }, + contextValue: context, + }); + + expect(errors).toBeUndefined(); + expect(data?.event).toEqual({ + 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 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, + }); + + expect(first.errors).toBeUndefined(); + expect(first.data?.events).toEqual(events.map((_, index) => ({ isNew: Boolean(index % 2) }))); + expect(find).toHaveBeenCalledTimes(1); + expect(context.latestRegisteredReleaseByProject).toBeInstanceOf(Map); + + cursor.next.mockResolvedValueOnce({ + value: { release: 'registered with no occurrences' }, + done: false, + }); + const nextContext = { factories: context.factories }; + const second = await graphql({ + ...query, + contextValue: nextContext, + }); + + expect(second.errors).toBeUndefined(); + expect(second.data?.events).toEqual(events.map(() => ({ isNew: false }))); + 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 () => { + 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', + }, + }); + + expect(errors).toBeUndefined(); + expect(data?.events).toEqual([ { isNew: true }, { isNew: false } ]); + expect(find.mock.calls.map(([ filter ]) => filter)).toEqual([ { projectId }, { projectId: 'another-project' } ]); + }); +}); 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); + }); +});