From 2a6b2fbd1b88b16f752a1d7607a4cc4fa593ebba Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:37:25 +0200 Subject: [PATCH] fix(server-nestjs/events): update project status after events --- .../modules/events/app-events.service.spec.ts | 44 +++++++++++++++++++ .../src/modules/events/app-events.service.ts | 35 ++++++++++++++- .../modules/events/app-events.utils.spec.ts | 27 +++++++++--- .../src/modules/events/app-events.utils.ts | 2 +- 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts index 1b1366f9a..c5e39f5f3 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -5,6 +5,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { projectSelect } from '../project/project-queries.utils' @@ -17,11 +18,14 @@ describe('appEventsService', () => { let prisma: DeepMockProxy let eventEmitter: DeepMockProxy let logs: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { prisma = mockDeep() eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) logs = mockDeep() + config = mockDeep() + config.appVersion = 'test-version' module = await Test.createTestingModule({ providers: [ @@ -29,6 +33,7 @@ describe('appEventsService', () => { { provide: PrismaService, useValue: prisma }, { provide: EventEmitter2, useValue: eventEmitter }, { provide: LogService, useValue: logs }, + { provide: ConfigurationService, useValue: config }, ], }).compile() @@ -88,6 +93,45 @@ describe('appEventsService', () => { }) }) + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + it('uses a provided project snapshot without fetching it again', async () => { const project = makeProject() diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index d08786fdf..e989391e1 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -2,9 +2,10 @@ import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' -import { mergePluginResults } from '../plugin/plugin.utils' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' import { getProject } from '../project/project-queries.utils' import { formatEventLogData, isPluginResults } from './app-events.utils' @@ -44,6 +45,7 @@ export class AppEventsService { @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, @Inject(LogService) private readonly logs: LogService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, ) {} /** @@ -68,7 +70,9 @@ export class AppEventsService { return {} } - return this.emitAndLog(event, project, project.id, context) + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results } async emitProjectMemberEvent( @@ -102,4 +106,31 @@ export class AppEventsService { return results } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.config.appVersion }, + }) + } + } } diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts index eca86bfd0..3c1ba22a7 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -1,4 +1,5 @@ import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' import { describe, expect, it } from 'vitest' import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' @@ -24,6 +25,22 @@ describe('serializeError', () => { expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) }) + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + it('should serialize non-Error values', () => { expect(serializeError({ code: 42 })).toBe('{"code":42}') expect(serializeError('boom')).toBe('"boom"') @@ -58,21 +75,19 @@ describe('formatEventLogData', () => { }) }) - it('should produce an empty failed list and a success resume when everything is OK', () => { + it('should omit the failed list and produce a success resume when everything is OK', () => { const results: PluginResults = { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, } - expect(formatEventLogData(args, results, 10)).toEqual(expect.objectContaining({ - failed: [], - messageResume: 'Success', - })) + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') }) it('should handle events without any listener', () => { expect(formatEventLogData(args, {}, 1)).toEqual({ args, - failed: [], results: {}, totalExecutionTime: 1, messageResume: 'Success', diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index 2178608b9..48684cb16 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -69,7 +69,7 @@ export function formatEventLogData(args: unknown, results: PluginResults, totalE return { args, - failed, + ...(failed.length ? { failed } : {}), results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), totalExecutionTime: Math.round(totalExecutionTime), messageResume: buildMessageResume(results, failed),