diff --git a/src/common/rabbitmq/entities/notifier.event.identifier.ts b/src/common/rabbitmq/entities/notifier.event.identifier.ts index a944e0898..e3ebad9ed 100644 --- a/src/common/rabbitmq/entities/notifier.event.identifier.ts +++ b/src/common/rabbitmq/entities/notifier.event.identifier.ts @@ -1,5 +1,9 @@ export enum NotifierEventIdentifier { ESDTNFTCreate = 'ESDTNFTCreate', ESDTNFTUpdateAttributes = 'ESDTNFTUpdateAttributes', + ESDTNFTBurn = 'ESDTNFTBurn', + ESDTMetaDataUpdate = 'ESDTMetaDataUpdate', + ESDTMetaDataRecreate = 'ESDTMetaDataRecreate', + ESDTModifyCreator = 'ESDTModifyCreator', transferOwnership = 'transferOwnership', } diff --git a/src/common/rabbitmq/rabbitmq.consumer.ts b/src/common/rabbitmq/rabbitmq.consumer.ts index 44e578d2e..8534a1465 100644 --- a/src/common/rabbitmq/rabbitmq.consumer.ts +++ b/src/common/rabbitmq/rabbitmq.consumer.ts @@ -41,6 +41,16 @@ export class RabbitMqConsumer { case NotifierEventIdentifier.ESDTNFTUpdateAttributes: await this.nftHandlerService.handleNftUpdateAttributesEvent(event); break; + case NotifierEventIdentifier.ESDTNFTBurn: + await this.nftHandlerService.handleNftBurnEvent(event); + break; + case NotifierEventIdentifier.ESDTMetaDataUpdate: + case NotifierEventIdentifier.ESDTMetaDataRecreate: + await this.nftHandlerService.handleNftMetadataEvent(event); + break; + case NotifierEventIdentifier.ESDTModifyCreator: + await this.nftHandlerService.handleNftModifyCreatorEvent(event); + break; case NotifierEventIdentifier.transferOwnership: await this.tokenHandlerService.handleTransferOwnershipEvent(event); break; diff --git a/src/common/rabbitmq/rabbitmq.nft.handler.service.ts b/src/common/rabbitmq/rabbitmq.nft.handler.service.ts index 970204bfa..99b1f43c7 100644 --- a/src/common/rabbitmq/rabbitmq.nft.handler.service.ts +++ b/src/common/rabbitmq/rabbitmq.nft.handler.service.ts @@ -8,6 +8,9 @@ import { NotifierEvent } from './entities/notifier.event'; import { CacheService } from "@multiversx/sdk-nestjs-cache"; import { BinaryUtils, OriginLogger } from '@multiversx/sdk-nestjs-common'; import { IndexerService } from '../indexer/indexer.service'; +import { NftSubType } from 'src/endpoints/nfts/entities/nft.sub.type'; +import { Inject } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; @Injectable() export class RabbitMqNftHandlerService { @@ -18,6 +21,7 @@ export class RabbitMqNftHandlerService { private readonly nftService: NftService, private readonly indexerService: IndexerService, private readonly cachingService: CacheService, + @Inject('PUBSUB_SERVICE') private clientProxy: ClientProxy, ) { } private async getCollectionType(collectionIdentifier: string): Promise { @@ -62,7 +66,19 @@ export class RabbitMqNftHandlerService { nft.attributes = attributes; try { - await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ forceRefreshMetadata: true })); + const isDynamicNft = this.isDynamicNftType(nft.subType); + + if (isDynamicNft) { + this.logger.log(`Processing dynamic NFT with identifier '${identifier}', forcing refresh of metadata and media`); + + await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ + forceRefreshMetadata: true, + forceRefreshMedia: true, + forceRefreshThumbnail: true, + })); + } else { + await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ forceRefreshMetadata: true })); + } } catch (error) { this.logger.error(`An unhandled error occurred when processing NFT update attributes event for NFT with identifier '${identifier}'`); this.logger.error(error); @@ -71,6 +87,18 @@ export class RabbitMqNftHandlerService { } } + private isDynamicNftType(subType?: NftSubType): boolean { + if (subType) { + return [ + NftSubType.DynamicNonFungibleESDT, + NftSubType.DynamicSemiFungibleESDT, + NftSubType.DynamicMetaESDT, + ].includes(subType); + } + + return false; + } + public async handleNftCreateEvent(event: NotifierEvent): Promise { const identifier = this.getNftIdentifier(event.topics); @@ -92,6 +120,21 @@ export class RabbitMqNftHandlerService { } try { + const isDynamicNft = this.isDynamicNftType(nft.subType); + + if (isDynamicNft) { + this.logger.log(`Processing dynamic NFT creation with identifier '${identifier}', forcing full refresh`); + + await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ + uploadAsset: true, + forceRefreshMetadata: true, + forceRefreshMedia: true, + forceRefreshThumbnail: true, + })); + + return true; + } + const needsProcessing = await this.nftWorkerService.needsProcessing(nft, new ProcessNftSettings()); if (needsProcessing) { await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ uploadAsset: true })); @@ -105,6 +148,70 @@ export class RabbitMqNftHandlerService { } } + public async handleNftBurnEvent(event: NotifierEvent): Promise { + const identifier = this.getNftIdentifier(event.topics); + + this.logger.log(`Detected 'ESDTNFTBurn' event for NFT with identifier '${identifier}'`); + + try { + const cacheKey = `nft:${identifier}`; + await this.cachingService.delete(cacheKey); + + this.clientProxy.emit('deleteCacheKeys', [cacheKey]); + + this.logger.log(`Cache invalidated for NFT with identifier '${identifier}' across all instances`); + return true; + } catch (error) { + this.logger.error(`An unhandled error occurred when processing NFT Burn event for NFT with identifier '${identifier}'`); + this.logger.error(error); + return false; + } + } + + public async handleNftMetadataEvent(event: NotifierEvent): Promise { + const identifier = this.getNftIdentifier(event.topics); + + this.logger.log(`Detected '${event.identifier}' event for NFT with identifier '${identifier}'`); + + const nft = await this.nftService.getSingleNft(identifier); + if (!nft) { + this.logger.log(`Could not fetch NFT details for NFT with identifier '${identifier}'`); + return false; + } + + try { + await this.nftWorkerService.addProcessNftQueueJob(nft, new ProcessNftSettings({ + forceRefreshMetadata: true, + forceRefreshMedia: true, + })); + return true; + } catch (error) { + this.logger.error(`An unhandled error occurred when processing '${event.identifier}' event for NFT with identifier '${identifier}'`); + this.logger.error(error); + return false; + } + } + + public async handleNftModifyCreatorEvent(event: NotifierEvent): Promise { + const identifier = this.getNftIdentifier(event.topics); + + this.logger.log(`Detected 'ESDTModifyCreator' event for NFT with identifier '${identifier}'`); + + try { + const cacheKey = `nft:${identifier}`; + await this.cachingService.delete(cacheKey); + + this.clientProxy.emit('deleteCacheKeys', [cacheKey]); + + this.logger.log(`Cache invalidated for NFT with identifier '${identifier}' across all instances`); + return true; + } catch (error) { + this.logger.error(`An unhandled error occurred when processing NFT ModifyCreator event for NFT with identifier '${identifier}'`); + this.logger.error(error); + return false; + } + } + private getNftIdentifier(topics: string[]): string { const collection = BinaryUtils.base64Decode(topics[0]); const nonce = BinaryUtils.base64ToHex(topics[1]); diff --git a/src/test/unit/services/rabbitmq.consumer.spec.ts b/src/test/unit/services/rabbitmq.consumer.spec.ts index f9d3d9dda..31680407a 100644 --- a/src/test/unit/services/rabbitmq.consumer.spec.ts +++ b/src/test/unit/services/rabbitmq.consumer.spec.ts @@ -14,6 +14,9 @@ describe('RabbitMqConsumer', () => { const nftHandlerServiceMock = { handleNftCreateEvent: jest.fn(), handleNftUpdateAttributesEvent: jest.fn(), + handleNftBurnEvent: jest.fn(), + handleNftMetadataEvent: jest.fn(), + handleNftModifyCreatorEvent: jest.fn(), }; const tokenHandlerServiceMock = { @@ -54,10 +57,43 @@ describe('RabbitMqConsumer', () => { topics: [''], }; - await service.consumeEvents({ events: [event1, event2] }); + const event3: NotifierEvent = { + identifier: NotifierEventIdentifier.ESDTNFTBurn, + address: "erd1", + topics: [''], + }; + + const event4: NotifierEvent = { + identifier: NotifierEventIdentifier.ESDTMetaDataUpdate, + address: "erd1", + topics: [''], + }; + + const event5: NotifierEvent = { + identifier: NotifierEventIdentifier.ESDTModifyCreator, + address: "erd1", + topics: [''], + }; + + await service.consumeEvents({ events: [event1, event2, event3, event4, event5] }); expect(nftHandlerService.handleNftCreateEvent).toHaveBeenCalledWith(event1); expect(tokenHandlerService.handleTransferOwnershipEvent).toHaveBeenCalledWith(event2); + expect(nftHandlerService.handleNftBurnEvent).toHaveBeenCalledWith(event3); + expect(nftHandlerService.handleNftMetadataEvent).toHaveBeenCalledWith(event4); + expect(nftHandlerService.handleNftModifyCreatorEvent).toHaveBeenCalledWith(event5); + }); + + it('should handle ESDTMetaDataRecreate with the metadata event handler', async () => { + const event: NotifierEvent = { + identifier: NotifierEventIdentifier.ESDTMetaDataRecreate, + address: "erd1", + topics: [''], + }; + + await service.consumeEvents({ events: [event] }); + + expect(nftHandlerService.handleNftMetadataEvent).toHaveBeenCalledWith(event); }); it('should log the error when an unhandled error occurs', async () => { diff --git a/src/test/unit/services/rabbitmq.nft.handler.service.spec.ts b/src/test/unit/services/rabbitmq.nft.handler.service.spec.ts new file mode 100644 index 000000000..b20d3483c --- /dev/null +++ b/src/test/unit/services/rabbitmq.nft.handler.service.spec.ts @@ -0,0 +1,206 @@ +import { TestingModule, Test } from "@nestjs/testing"; +import { BinaryUtils } from "@multiversx/sdk-nestjs-common"; +import { CacheService } from "@multiversx/sdk-nestjs-cache"; +import { NotifierEvent } from "src/common/rabbitmq/entities/notifier.event"; +import { RabbitMqNftHandlerService } from "src/common/rabbitmq/rabbitmq.nft.handler.service"; +import { IndexerService } from "src/common/indexer/indexer.service"; +import { NftService } from "src/endpoints/nfts/nft.service"; +import { NftWorkerService } from "src/queue.worker/nft.worker/nft.worker.service"; + +describe('RabbitMqNftHandlerService', () => { + let service: RabbitMqNftHandlerService; + let nftService: NftService; + let nftWorkerService: NftWorkerService; + let cacheService: CacheService; + + beforeEach(async () => { + const nftServiceMock = { + getSingleNft: jest.fn(), + }; + + const nftWorkerServiceMock = { + addProcessNftQueueJob: jest.fn(), + needsProcessing: jest.fn(), + }; + + const indexerServiceMock = { + getCollection: jest.fn(), + }; + + const cacheServiceMock = { + getLocal: jest.fn(), + setLocal: jest.fn(), + delete: jest.fn(), + }; + + const clientProxyMock = { + emit: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RabbitMqNftHandlerService, + { provide: NftService, useValue: nftServiceMock }, + { provide: NftWorkerService, useValue: nftWorkerServiceMock }, + { provide: IndexerService, useValue: indexerServiceMock }, + { provide: CacheService, useValue: cacheServiceMock }, + { provide: 'PUBSUB_SERVICE', useValue: clientProxyMock }, + ], + }).compile(); + + service = module.get(RabbitMqNftHandlerService); + nftService = module.get(NftService); + nftWorkerService = module.get(NftWorkerService); + cacheService = module.get(CacheService); + + jest.spyOn(BinaryUtils, 'base64Decode').mockImplementation((value) => { + if (value === 'collection') return 'TEST-abcdef'; + if (value === 'nonce') return '0123456789abcdef'; + if (value === 'attributes') return 'metadata:test'; + return value; + }); + + jest.spyOn(BinaryUtils, 'base64ToHex').mockImplementation(() => '01'); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleNftBurnEvent', () => { + it('should invalidate cache for NFT', async () => { + const event: NotifierEvent = { + identifier: 'ESDTNFTBurn', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const result = await service.handleNftBurnEvent(event); + + expect(result).toBe(true); + expect(cacheService.delete).toHaveBeenCalledWith('nft:TEST-abcdef-01'); + }); + + it('should handle errors and return false', async () => { + const event: NotifierEvent = { + identifier: 'ESDTNFTBurn', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const error = new Error('Test error'); + jest.spyOn(cacheService, 'delete').mockImplementation(() => { + throw error; + }); + + const loggerSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + const result = await service.handleNftBurnEvent(event); + + expect(result).toBe(false); + expect(loggerSpy).toHaveBeenCalledWith(`An unhandled error occurred when processing NFT Burn event for NFT with identifier 'TEST-abcdef-01'`); + expect(loggerSpy).toHaveBeenCalledWith(error); + }); + }); + + describe('handleNftMetadataEvent', () => { + it('should process NFT with metadata refresh', async () => { + const event: NotifierEvent = { + identifier: 'ESDTMetaDataUpdate', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const nft = { identifier: 'TEST-abcdef-01' }; + jest.spyOn(nftService, 'getSingleNft').mockResolvedValue(nft as any); + + const result = await service.handleNftMetadataEvent(event); + + expect(result).toBe(true); + expect(nftService.getSingleNft).toHaveBeenCalledWith('TEST-abcdef-01'); + expect(nftWorkerService.addProcessNftQueueJob).toHaveBeenCalledWith( + nft, + expect.objectContaining({ + forceRefreshMetadata: true, + forceRefreshMedia: true, + }) + ); + }); + + it('should return false if NFT not found', async () => { + const event: NotifierEvent = { + identifier: 'ESDTMetaDataUpdate', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + jest.spyOn(nftService, 'getSingleNft').mockResolvedValue(undefined); + const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); + + const result = await service.handleNftMetadataEvent(event); + + expect(result).toBe(false); + expect(loggerSpy).toHaveBeenCalledWith(`Could not fetch NFT details for NFT with identifier 'TEST-abcdef-01'`); + }); + + it('should handle errors', async () => { + const event: NotifierEvent = { + identifier: 'ESDTMetaDataUpdate', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const nft = { identifier: 'TEST-abcdef-01' }; + jest.spyOn(nftService, 'getSingleNft').mockResolvedValue(nft as any); + + const error = new Error('Test error'); + jest.spyOn(nftWorkerService, 'addProcessNftQueueJob').mockImplementation(() => { + throw error; + }); + + const loggerSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + const result = await service.handleNftMetadataEvent(event); + + expect(result).toBe(false); + expect(loggerSpy).toHaveBeenCalledWith(`An unhandled error occurred when processing 'ESDTMetaDataUpdate' event for NFT with identifier 'TEST-abcdef-01'`); + expect(loggerSpy).toHaveBeenCalledWith(error); + }); + }); + + describe('handleNftModifyCreatorEvent', () => { + it('should invalidate cache for NFT', async () => { + const event: NotifierEvent = { + identifier: 'ESDTModifyCreator', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const result = await service.handleNftModifyCreatorEvent(event); + + expect(result).toBe(true); + expect(cacheService.delete).toHaveBeenCalledWith('nft:TEST-abcdef-01'); + }); + + it('should handle errors and return false', async () => { + const event: NotifierEvent = { + identifier: 'ESDTModifyCreator', + address: 'erd1', + topics: ['collection', 'nonce'], + }; + + const error = new Error('Test error'); + jest.spyOn(cacheService, 'delete').mockImplementation(() => { + throw error; + }); + + const loggerSpy = jest.spyOn(service['logger'], 'error').mockImplementation(); + + const result = await service.handleNftModifyCreatorEvent(event); + + expect(result).toBe(false); + expect(loggerSpy).toHaveBeenCalledWith(`An unhandled error occurred when processing NFT ModifyCreator event for NFT with identifier 'TEST-abcdef-01'`); + expect(loggerSpy).toHaveBeenCalledWith(error); + }); + }); +});