Merge pull request #114 from Permissionless-Software-Foundation/dh-update-endpoint

feat(update): Added Update button REST endpoint
This commit is contained in:
Chris Troutner
2025-10-25 11:25:02 -07:00
committed by GitHub
6 changed files with 243 additions and 0 deletions
@@ -34,6 +34,7 @@ class OfferRESTControllerLib {
this.listFungibleOffers = this.listFungibleOffers.bind(this) this.listFungibleOffers = this.listFungibleOffers.bind(this)
this.takeOffer = this.takeOffer.bind(this) this.takeOffer = this.takeOffer.bind(this)
this.listOffersByAddress = this.listOffersByAddress.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this)
this.syncOfferMutableData = this.syncOfferMutableData.bind(this)
this.handleError = this.handleError.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 // DRY error handler
handleError (ctx, err) { handleError (ctx, err) {
console.log('err', err.message) console.log('err', err.message)
+1
View File
@@ -55,6 +55,7 @@ class OfferRouter {
// Define the routes and attach the controller. // Define the routes and attach the controller.
// this.router.post('/', _this.offerRESTController.createOffer) // Deprecated. // this.router.post('/', _this.offerRESTController.createOffer) // Deprecated.
this.router.post('/take', this.offerRESTController.takeOffer) 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/all/:page', this.offerRESTController.listOffers)
this.router.get('/list/nft/:page', this.offerRESTController.listNftOffers) this.router.get('/list/nft/:page', this.offerRESTController.listNftOffers)
this.router.get('/list/fungible/:page', this.offerRESTController.listFungibleOffers) this.router.get('/list/fungible/:page', this.offerRESTController.listFungibleOffers)
+85
View File
@@ -65,6 +65,7 @@ class OfferUseCases {
this.flagOffer = this.flagOffer.bind(this) this.flagOffer = this.flagOffer.bind(this)
this.loadOffers = this.loadOffers.bind(this) this.loadOffers = this.loadOffers.bind(this)
this.listOffersByAddress = this.listOffersByAddress.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this)
this.syncOfferMutableData = this.syncOfferMutableData.bind(this)
// State // State
this.seenOffers = [] this.seenOffers = []
@@ -935,6 +936,90 @@ class OfferUseCases {
throw err 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 export default OfferUseCases
@@ -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', () => { describe('#handleError', () => {
it('should still throw error if there is no message', () => { it('should still throw error if there is no message', () => {
+3
View File
@@ -71,6 +71,9 @@ class Offer {
async acceptCounterOffer() { async acceptCounterOffer() {
return {} return {}
} }
async syncOfferMutableData(){
return {}
}
} }
class Order { class Order {
+120
View File
@@ -852,6 +852,18 @@ describe('#offer-use-case', () => {
assert.equal(result, 'N/A') 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 () => { it('should return if utxo can not be validated', async () => {
// Mock dependencies // Mock dependencies
const mock = Object.assign({}, mockData.offerMockData.data) const mock = Object.assign({}, mockData.offerMockData.data)
@@ -1010,4 +1022,112 @@ describe('#offer-use-case', () => {
assert.notEqual('N/A') 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)
})
})
}) })