From 85a170073d798373243c8ab85c7bdef140117078 Mon Sep 17 00:00:00 2001 From: Stoyan Zhekov Date: Wed, 5 May 2021 12:08:34 +0900 Subject: [PATCH] NFT group and children GET calls + tests --- package.json | 1 + src/routes/v4/slp.js | 123 +++++++++++++++++- test/v4/integration/nft.js | 61 +++++++++ test/v4/mocks/slp-mocks.js | 67 +++++++++- test/v4/slp.js | 250 +++++++++++++++++++++++++++++++++++++ 5 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 test/v4/integration/nft.js diff --git a/package.json b/package.json index 595cd7b..309f8fb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test-v4": "export NETWORK=mainnet && nyc --reporter=text mocha --exit --timeout 60000 test/v4/", "test:integration": "mocha test/v4/integration", "test:integration:slpdb": "mocha --timeout 25000 -g '#validate2Single' test/v4/integration/slp*.js", + "test:integration:nft": "mocha --timeout 25000 -g '#nft' test/v4/integration/nft.js", "coverage": "nyc report --reporter=text-lcov | coveralls", "coverage:report": "export NETWORK=mainnet && nyc --reporter=html mocha --timeout 25000 test/v4/", "docs": "./node_modules/.bin/apidoc -i src/routes/v4 -o docs", diff --git a/src/routes/v4/slp.js b/src/routes/v4/slp.js index 1f1e54e..3a58201 100644 --- a/src/routes/v4/slp.js +++ b/src/routes/v4/slp.js @@ -100,6 +100,8 @@ class Slp { _this.router.post('/hydrateUtxos', _this.hydrateUtxos) _this.router.post('/hydrateUtxosWL', _this.hydrateUtxosWL) _this.router.get('/status', _this.getStatus) + _this.router.get('/nftChildren/:tokenId', _this.getNftChildren) + _this.router.get('/nftGroup/:tokenId', _this.getNftGroup) } // DRY error handler. @@ -133,6 +135,10 @@ class Slp { delete token.tokenDetails.batonVout delete token.tokenDetails.sendOutputs + if (token.tokenDetails.versionType === 65 && token.nftParentId) { + token.tokenDetails.nftParentId = token.nftParentId + } + token.tokenDetails.blockCreated = token.tokenStats.block_created token.tokenDetails.blockLastActiveSend = token.tokenStats.block_last_active_send @@ -296,7 +302,7 @@ class Slp { 'tokenDetails.tokenIdHex': tokenId } }, - project: { tokenDetails: 1, tokenStats: 1, _id: 0 }, + project: { tokenDetails: 1, tokenStats: 1, nftParentId: 1, _id: 0 }, limit: 1000 } } @@ -2222,6 +2228,121 @@ class Slp { return _this.errorHandler(err, res) } } + + /** + * @api {get} /slp/nftChildren/{tokenId} Get all NFT children for a given NFT group + * @apiName Get all NFT children for a given NFT group + * @apiGroup SLP + * @apiDescription Get all NFT children for a given NFT group + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/nftChildren/68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a" -H "accept:application/json" + * + */ + async getNftChildren (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const token = await _this.lookupToken(tokenId) + // console.log(`token: ${JSON.stringify(token, null, 2)}`) + + if (!token || token.id === 'not found' || token.versionType !== 129) { + res.status(400) + return res.json({ error: 'NFT group does not exists' }) + } + + const query = { + v: 3, + q: { + db: ['t'], + aggregate: [ + { $match: { nftParentId: tokenId } }, + { $skip: 0 }, // TODO: pass start point + { $limit: 100 } // TODO: pass count limit + ] + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // Request options + const opt = { + method: 'get', + baseURL: url + } + const childrenIds = [] + const childrenRes = await _this.axios.request(opt) + // console.log(`childrenRes.data: ${JSON.stringify(childrenRes.data, null, 2)}`) + if (!childrenRes || !childrenRes.data || !childrenRes.data.t) return { error: 'No children data in the group' } + + res.status(200) + + childrenRes.data.t.forEach(function (token) { + // console.log(`info: ${JSON.stringify(token, null, 2)}`) + if (token.tokenDetails.versionType === 65 && token.tokenDetails.transactionType === 'GENESIS') { + childrenIds.push(token.tokenDetails.tokenIdHex) + } + }) + return res.json({ nftChildren: childrenIds }) + } catch (err) { + // console.log(err) + wlogger.error('Error in slp.js/getNftChildren().', err) + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/nftGroup/{tokenId} Get the NFT group for a given NFT child token + * @apiName Get the NFT group for a given NFT child token + * @apiGroup SLP + * @apiDescription Get the NFT group for a given NFT child token + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/nftGroup/45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9" -H "accept:application/json" + * + */ + async getNftGroup (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const token = await _this.lookupToken(tokenId) + // console.log(`token: ${JSON.stringify(token, null, 2)}`) + + if (!token || token.id === 'not found' || token.versionType !== 65 || !token.nftParentId) { + res.status(400) + return res.json({ error: 'NFT child does not exists' }) + } + + const parentToken = await _this.lookupToken(token.nftParentId) + // console.log(`parentToken: ${JSON.stringify(token, null, 2)}`) + if (!parentToken || parentToken.id === 'not found' || parentToken.versionType !== 129) { + res.status(400) + return res.json({ error: 'NFT group does not exists' }) + } + + res.status(200) + return { nftGroup: parentToken } + } catch (err) { + // console.log(err) + wlogger.error('Error in slp.js/getNftGroup().', err) + return _this.errorHandler(err, res) + } + } } module.exports = Slp diff --git a/test/v4/integration/nft.js b/test/v4/integration/nft.js new file mode 100644 index 0000000..b8cfad5 --- /dev/null +++ b/test/v4/integration/nft.js @@ -0,0 +1,61 @@ +/* + These integration tests need to be run against a live SLPDB. They query + against a live SLPDB and test the results against known token stats. + */ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// Exit if SLPDB URL is not defined. +if (!process.env.SLPDB_URL) { + throw new Error('SLPDB_URL and SLPDB_PASS must be defined in order to run these tests.') +} + +const SLP = require('../../../src/routes/v4/slp') +const slp = new SLP() + +const { mockReq, mockRes } = require('../mocks/express-mocks') + +describe('#nft', () => { + let req, res + + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + }) + + describe('#getNftChildren', () => { + it('should get NFT children list', async () => { + req.params.tokenId = '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + const result = await slp.getNftChildren(req, res) + // console.log(`result: ${util.inspect(result)}`) + assert.isArray(result.nftChildren) + assert.equal(result.nftChildren.length, 2) + assert.equal(result.nftChildren[0], '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9') + assert.equal(result.nftChildren[1], '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55') + }) + }) + describe('#getNftGroup', () => { + it('should get NFT group token info', async () => { + req.params.tokenId = '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + // req.params.tokenId = '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + const result = await slp.getNftGroup(req, res) + // console.log(`result: ${util.inspect(result)}`) + assert.property(result, 'nftGroup') + assert.property(result.nftGroup, 'id') + assert.property(result.nftGroup, 'versionType') + assert.property(result.nftGroup, 'symbol') + assert.equal(result.nftGroup.id, '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a') + assert.equal(result.nftGroup.versionType, 129) + assert.equal(result.nftGroup.symbol, 'PSF.TEST.GROUP') + }) + }) +}) diff --git a/test/v4/mocks/slp-mocks.js b/test/v4/mocks/slp-mocks.js index bd06b95..1ab3327 100644 --- a/test/v4/mocks/slp-mocks.js +++ b/test/v4/mocks/slp-mocks.js @@ -66,6 +66,69 @@ const mockSingleToken = { ] } +const mockNftGroup = { + decimals: 0, + timestamp: '2021-05-03 10:36:01', + timestamp_unix: 1620038161, + versionType: 129, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.GROUP', + name: 'PSF Test NFT Group', + containsBaton: true, + id: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + documentHash: null, + initialTokenQty: 1000000, + blockCreated: 686117, + totalMinted: null, + totalBurned: null, + circulatingSupply: null +} + +const mockNftChildren = [ + { + decimals: 0, + timestamp: '2021-05-03 11:59:30', + timestamp_unix: 1620043170, + versionType: 65, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.CHILD.1', + name: 'PSF Test NFT Child #1', + containsBaton: false, + id: '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9', + documentHash: null, + initialTokenQty: 1, + nftParentId: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + blockCreated: 686130, + totalMinted: null, + totalBurned: null, + circulatingSupply: null, + // only in axios.data.t + transactionType: 'GENESIS', + tokenIdHex: '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + }, + { + decimals: 0, + timestamp: '2021-05-03 11:59:30', + timestamp_unix: 1620043170, + versionType: 65, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.CHILD.2', + name: 'PSF Test NFT Child #2', + containsBaton: false, + id: '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55', + documentHash: null, + initialTokenQty: 1, + nftParentId: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + blockCreated: 686130, + totalMinted: null, + totalBurned: null, + circulatingSupply: null, + // only in axios.data.t + transactionType: 'GENESIS', + tokenIdHex: '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + } +] + const mockSingleTokenError = { t: [] } @@ -1038,5 +1101,7 @@ module.exports = { mockPsfToken, mockValidateBulk, mockValidate3Bulk, - mockStatus + mockStatus, + mockNftGroup, + mockNftChildren } diff --git a/test/v4/slp.js b/test/v4/slp.js index c94a455..c4e1de7 100644 --- a/test/v4/slp.js +++ b/test/v4/slp.js @@ -1760,6 +1760,256 @@ describe('#SLP', () => { assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') }) }) + + describe('getNftChildren()', () => { + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should return error on non-existing NFT group token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + { id: 'not found' } + ) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + it('should return error on non-group NFT token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + mockData.mockNftChildren[0] + ) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + it('should return error on invalid NFT group data', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + mockData.mockNftGroup + ) + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { u: 'invalid' } + }) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'No children data in the group', + 'Error message expected' + ) + }) + it('should get NFT children IDs in given NFT group', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + mockData.mockNftGroup + ) + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { + t: [ + { + tokenDetails: mockData.mockNftChildren[0], + nftParentId: mockData.mockNftGroup.id + }, + { + tokenDetails: mockData.mockNftChildren[1], + nftParentId: mockData.mockNftGroup.id + } + ] + } + }) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isArray(result.nftChildren) + assert.equal(result.nftChildren.length, 2) + assert.equal(result.nftChildren[0], '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9') + assert.equal(result.nftChildren[1], '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55') + }) + }) + + describe('getNftGroup()', () => { + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should return error on non-existing NFT child token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + { id: 'not found' } + ) + } + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT child does not exists', + 'Error message expected' + ) + }) + it('should return error on invalid NFT child token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves( + mockData.mockNftGroup + ) + } + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT child does not exists', + 'Error message expected' + ) + }) + it('should return error on invalid parent', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + if (process.env.TEST === 'unit') { + const callback = sandbox.stub(slpRoute, 'lookupToken') + callback.withArgs(req.params.tokenId).resolves(mockData.mockNftChildren[0]) + // parent is non-valid NFT group (type != 129) + callback.withArgs(mockData.mockNftGroup.id).resolves(mockData.mockNftChildren[0]) + } + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + it('should get NFT group information for tokenId', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + if (process.env.TEST === 'unit') { + const callback = sandbox.stub(slpRoute, 'lookupToken') + callback.withArgs(req.params.tokenId).resolves(mockData.mockNftChildren[0]) + callback.withArgs(mockData.mockNftGroup.id).resolves(mockData.mockNftGroup) + } + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.property(result, 'nftGroup') + assert.property(result.nftGroup, 'id') + assert.equal(result.nftGroup.id, mockData.mockNftGroup.id) + assert.property(result.nftGroup, 'versionType') + assert.equal(result.nftGroup.versionType, 129) + assert.property(result.nftGroup, 'symbol') + assert.property(result.nftGroup, 'initialTokenQty') + }) + }) }) /*