diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index e349ef6..9810115 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -34,6 +34,7 @@ class OfferRESTControllerLib { this.listFungibleOffers = this.listFungibleOffers.bind(this) this.takeOffer = this.takeOffer.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this) + this.syncOfferMutableData = this.syncOfferMutableData.bind(this) this.handleError = this.handleError.bind(this) } @@ -395,6 +396,18 @@ class OfferRESTControllerLib { } } + async syncOfferMutableData (ctx) { + try { + const tokenId = ctx.request.body.tokenId + const offer = await this.useCases.offer.syncOfferMutableData(tokenId) + + ctx.body = offer + } catch (err) { + console.log('Error in syncOfferMutableData REST API handler: ', err) + this.handleError(ctx, err) + } + } + // DRY error handler handleError (ctx, err) { console.log('err', err.message) diff --git a/src/controllers/rest-api/offer/index.js b/src/controllers/rest-api/offer/index.js index d62231a..10fd4f3 100644 --- a/src/controllers/rest-api/offer/index.js +++ b/src/controllers/rest-api/offer/index.js @@ -55,6 +55,7 @@ class OfferRouter { // Define the routes and attach the controller. // this.router.post('/', _this.offerRESTController.createOffer) // Deprecated. this.router.post('/take', this.offerRESTController.takeOffer) + this.router.post('/mutable/sync/', this.offerRESTController.syncOfferMutableData) this.router.get('/list/all/:page', this.offerRESTController.listOffers) this.router.get('/list/nft/:page', this.offerRESTController.listNftOffers) this.router.get('/list/fungible/:page', this.offerRESTController.listFungibleOffers) diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index 606a1bc..e46a383 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -65,6 +65,7 @@ class OfferUseCases { this.flagOffer = this.flagOffer.bind(this) this.loadOffers = this.loadOffers.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this) + this.syncOfferMutableData = this.syncOfferMutableData.bind(this) // State this.seenOffers = [] @@ -935,6 +936,90 @@ class OfferUseCases { throw err } } + + // update offer model mutable data + async syncOfferMutableData (tokenId) { + try { + // validate input + if (!tokenId || typeof tokenId !== 'string') { + throw new Error('tokenId must be a string!') + } + + // validate existing offer with the associated tokenId + const offer = await this.OfferModel.findOne({ tokenId }) + if (!offer) throw new Error('Associated offer not found!') + + // Verify last update timestamp. This prevents users from spamming the API with requests. + // It only updates the mutable data if it has been more than 5 minutes since the last update. + const lastUpdateTs = Number(offer.lastUpdatedTokenData) + const now = new Date().getTime() + const period = 5 + if (lastUpdateTs) { + // add 5 minutes to the last update + const lastUpdate = new Date(lastUpdateTs) + console.log('lastUpdate', lastUpdate) + lastUpdate.setMinutes(lastUpdate.getMinutes() + period) + + console.log(new Date().getMinutes() + ' ' + lastUpdate.getMinutes()) + // if now is less than the lastUpdate + 5 minutos , them skip. + if (now < lastUpdate.getTime()) { + console.log('Skipping , token lastUpdate is less than 5 minutes.') + return offer + } + } + + let tokenData = null + try { + tokenData = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.getTokenData, tokenId) + } catch (err) { + // Dev Note: If getTokenData() fails, the code below will + console.error('Error in OfferUseCases/createOffer() getting token data: ', err.message) + } + + // Do additional data analysis if the token data was successfully retrieved. + if (tokenData) { + // Store the mutable and immutable data cids. + const mutableDataCid = tokenData.mutableData + const immutableDataCid = tokenData.immutableData + offer.mutableDataCid = mutableDataCid + offer.immutableDataCid = immutableDataCid + + // Get the mutable data from the cid if it exists. + if (mutableDataCid && typeof mutableDataCid === 'string') { + let mutableData = null + try { + mutableData = await this.retryQueue.addToQueue(this.adapters.wallet.cid2json, mutableDataCid) + } catch (err) { + console.error('Error in OfferUseCases/createOffer() getting mutable data: ', err.message) + } + console.log('mutableData: ', mutableData) + + if (mutableData) { + try { + offer.tokenIconUrl = mutableData.tokenIcon + offer.tokenCategories = mutableData.category + offer.tokenTags = mutableData.tags + + offer.userDataStr = JSON.stringify(mutableData.userData) + } catch (error) { + // skip error + } + } + } + // Save update time stamp + offer.lastUpdatedTokenData = new Date().getTime() + } + + // Save the updated offer data to the database. + await offer.save() + + // Return the updated offer data. + return offer + } catch (err) { + console.error('Error in syncOfferMutableData(): ', err) + throw err + } + } } export default OfferUseCases diff --git a/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js b/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js index f470843..96dcded 100644 --- a/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js +++ b/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js @@ -229,6 +229,27 @@ describe('#Offer-REST-Router', () => { } }) }) + describe('#syncOfferMutableData', () => { + it('should sync mutable data', async () => { + ctx.request.body = { tokenId: 'tokenId' } + sandbox.stub(uut.useCases.offer, 'syncOfferMutableData').resolves({}) + await uut.syncOfferMutableData(ctx) + assert.isObject(ctx.body) + }) + it('should catch and throw an error', async () => { + try { + ctx.request.body = { tokenId: 'tokenId' } + sandbox + .stub(uut.useCases.offer, 'syncOfferMutableData') + .throws(new Error('test error')) + + await uut.syncOfferMutableData(ctx) + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + }) describe('#handleError', () => { it('should still throw error if there is no message', () => { diff --git a/test/unit/mocks/use-cases/index.js b/test/unit/mocks/use-cases/index.js index fac7dd6..a30d32e 100644 --- a/test/unit/mocks/use-cases/index.js +++ b/test/unit/mocks/use-cases/index.js @@ -71,6 +71,9 @@ class Offer { async acceptCounterOffer() { return {} } + async syncOfferMutableData(){ + return {} + } } class Order { diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index 716f132..ce9aaf2 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -852,6 +852,18 @@ describe('#offer-use-case', () => { assert.equal(result, 'N/A') }) + it('should return N/A on axios error', async () => { + // Mock dependencies + const mock = Object.assign({}, mockData.offerMockData.data) + sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) + // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').throws(new Error('test error')) + const axiosErr = new Error('axios err') + axiosErr.isAxiosError = true + sandbox.stub(uut.retryQueue, 'addToQueue').throws(axiosErr) + + const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.equal(result, 'N/A') + }) it('should return if utxo can not be validated', async () => { // Mock dependencies const mock = Object.assign({}, mockData.offerMockData.data) @@ -1010,4 +1022,112 @@ describe('#offer-use-case', () => { assert.notEqual('N/A') }) }) + + describe('#syncOfferMutableData', () => { + it('should throw error if tokenId is not provided!', async () => { + try { + await uut.syncOfferMutableData() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'tokenId must be a string!') + } + }) + + it('should throw error if tokenId is not found!', async () => { + try { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'findOne').resolves(null) + + await uut.syncOfferMutableData('tokenId') + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'Associated offer not found!') + } + }) + + it('should return current data if lastUpdatedTokenData is less than 5 minutes', async () => { + // Mock dependencies + const lastUpdatedTokenData = new Date().getTime() + const offerMock = Object.assign({}, mockData.offerMockData) + offerMock.lastUpdatedTokenData = lastUpdatedTokenData + // stub + sandbox.stub(uut.OfferModel, 'findOne').returns(offerMock) + const spy = sandbox.stub(uut.retryQueue, 'addToQueue').resolves(true) + + const offer = await uut.syncOfferMutableData('tokenId') + + assert.isObject(offer) + assert.isTrue(spy.notCalled, 'it should not call retryQueue functions.') + }) + + it('should sync offer', async () => { + // create a timestamp 6 minutes in the past + + // Create offer mock + const offerMock = Object.assign({}, mockData.offerMockData) + offerMock.save = () => { } + + // Stub functions + sandbox.stub(uut.OfferModel, 'findOne').returns(offerMock) + sandbox.stub(uut.retryQueue, 'addToQueue') + .onCall(0).resolves(mockData.simpleNftTokenData01) // Token Data call + .onCall(1).resolves(mockData.mutableDataMock) + + const offer = await uut.syncOfferMutableData('tokenId') + assert.isObject(offer) + assert.isNumber(offer.lastUpdatedTokenData) + }) + + it('should skip userData stringify error', async () => { + // create mutable data mock + const mutableDataMock = mockData.mutableDataMock + mutableDataMock.userData = { n: 10n } + + // create offer mock + const offerMock = Object.assign({}, mockData.offerMockData) + offerMock.save = () => { } + + // Stub functions + sandbox.stub(uut.OfferModel, 'findOne').returns(offerMock) + sandbox.stub(uut.retryQueue, 'addToQueue') + .onCall(0).resolves(mockData.simpleNftTokenData01) // Token Data call + .onCall(1).resolves(mutableDataMock) + + const offer = await uut.syncOfferMutableData('tokenId') + + assert.isObject(offer) + assert.isNumber(offer.lastUpdatedTokenData) + }) + + it('should handle error getting token data', async () => { + // create offer mock + const offerMock = Object.assign({}, mockData.offerMockData) + offerMock.save = () => { } + + // Stub functions + sandbox.stub(uut.OfferModel, 'findOne').returns(offerMock) + sandbox.stub(uut.retryQueue, 'addToQueue') + .onCall(0).throws(new Error('tokendata error')) // Token Data call + .onCall(1).resolves(mockData.mutableDataMock) + + const offer = await uut.syncOfferMutableData('tokenId') + assert.isObject(offer) + }) + + it('should handle error getting mutable data', async () => { + // create offer mock + const offerMock = Object.assign({}, mockData.offerMockData) + offerMock.save = () => { } + + // Stub functions + sandbox.stub(uut.OfferModel, 'findOne').returns(offerMock) + sandbox.stub(uut.retryQueue, 'addToQueue') + .onCall(0).resolves(mockData.simpleNftTokenData01) // Token Data call + .onCall(1).throws(new Error('mutable data error')) + + const offer = await uut.syncOfferMutableData('tokenId') + assert.isObject(offer) + assert.isNumber(offer.lastUpdatedTokenData) + }) + }) })