diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts new file mode 100644 index 0000000000..1e17f4a44c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts @@ -0,0 +1,154 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ApplicationRegistrationAssetUrlService } from 'src/engine/core-modules/application/application-registration/application-registration-asset-url.service'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; +import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +describe('ApplicationRegistrationService - upsertFromCatalog', () => { + let service: ApplicationRegistrationService; + let applicationRegistrationRepository: { + findOne: jest.Mock; + save: jest.Mock; + create: jest.Mock; + }; + + const catalogParams = { + universalIdentifier: '97141c95-2870-5662-8992-44fb6536be9a', + name: 'My App', + sourceType: ApplicationRegistrationSourceType.NPM, + sourcePackage: 'twenty-app-my-app', + latestAvailableVersion: '0.2.0', + manifest: null, + }; + + const buildExistingRegistration = ( + overrides: Partial, + ) => + ({ + id: 'registration-id', + universalIdentifier: catalogParams.universalIdentifier, + name: 'My App', + galleryImages: [], + ...overrides, + }) as ApplicationRegistrationEntity; + + beforeEach(async () => { + applicationRegistrationRepository = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn((entity) => entity), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationRegistrationService, + { + provide: getRepositoryToken(ApplicationRegistrationEntity), + useValue: applicationRegistrationRepository, + }, + { + provide: getRepositoryToken(ApplicationEntity), + useValue: { find: jest.fn(), findOne: jest.fn() }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { find: jest.fn(), findOne: jest.fn() }, + }, + { + provide: ApplicationRegistrationVariableService, + useValue: { syncVariableSchemas: jest.fn() }, + }, + { + provide: ApplicationRegistrationAssetUrlService, + useValue: { resolveAssetUrls: jest.fn() }, + }, + { + provide: ServerFileStorageService, + useValue: { write: jest.fn(), delete: jest.fn() }, + }, + { + provide: CacheLockService, + useValue: { withLock: jest.fn((_key, fn) => fn()) }, + }, + { + provide: CoreEntityCacheService, + useValue: { invalidate: jest.fn() }, + }, + ], + }).compile(); + + service = module.get( + ApplicationRegistrationService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should re-list a registration first created by a local install when the catalog serves it', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.TARBALL, + isListed: false, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + isListed: true, + sourceType: ApplicationRegistrationSourceType.NPM, + }), + ); + }); + + it('should preserve an operator delisting of a registry-sourced registration', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.NPM, + isListed: false, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: false }), + ); + }); + + it('should keep an already listed registration listed', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.NPM, + isListed: true, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: true }), + ); + }); + + it('should create new catalog registrations as listed', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue(null); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: true }), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 4e25f43719..cc9fdcd494 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -629,12 +629,21 @@ export class ApplicationRegistrationService { params.latestAvailableVersion ?? null, ); + // A registration first created by a local install (CLI dev / tarball + // upload) starts unlisted on purpose. Once the catalog source serves the + // same universalIdentifier, surface it in the marketplace — while + // preserving an operator's decision to delist a registry-sourced app. + const isRelistedFromLocalSource = + existing.sourceType === ApplicationRegistrationSourceType.TARBALL || + existing.sourceType === ApplicationRegistrationSourceType.LOCAL; + await this.applicationRegistrationRepository.save({ ...existing, name: params.name, sourceType: params.sourceType, sourcePackage: params.sourcePackage, latestAvailableVersion: params.latestAvailableVersion, + isListed: existing.isListed || isRelistedFromLocalSource, isVetted, manifest: params.manifest, ...fromManifestApplicationToDisplayFields(params.manifest?.application),