diff --git a/api/src/paths/taxonomy/taxon/tsn/hierarchy.test.ts b/api/src/paths/taxonomy/taxon/tsn/hierarchy.test.ts new file mode 100644 index 00000000..ad0c9c68 --- /dev/null +++ b/api/src/paths/taxonomy/taxon/tsn/hierarchy.test.ts @@ -0,0 +1,103 @@ +import Ajv from 'ajv'; +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { GET } from '.'; +import * as db from '../../../../database/db'; +import { HTTPError } from '../../../../errors/http-error'; +import { ItisService } from '../../../../services/itis-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; +import { getHierarchyForTSNs } from './hierarchy'; + +chai.use(sinonChai); + +describe('taxonomy/taxon/tsn/hierarchy', () => { + describe('openapi schema', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; + }); + }); + + describe('getHierarchyForTSNs', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns an empty array if no species are found', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getHierarchyForTSNsStub = sinon.stub(ItisService.prototype, 'getHierarchyForTSNs').resolves([]); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { + tsn: ['1', '2'] + }; + + const requestHandler = getHierarchyForTSNs(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getHierarchyForTSNsStub).to.have.been.calledWith([1, 2]); + + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql([]); + }); + + it('returns an array of species', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mock1 = { + tsn: 1, + commonNames: ['something'], + scientificName: 'string', + hierarchy: [3, 2, 1] + } as unknown as any; + const mock2 = { tsn: '2', commonNames: [], scientificName: 'string', hierarchy: [3, 2] } as unknown as any; + + const getHierarchyForTSNsStub = sinon.stub(ItisService.prototype, 'getHierarchyForTSNs').resolves([mock1, mock2]); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { + tsn: ['1', '2'] + }; + + const requestHandler = getHierarchyForTSNs(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getHierarchyForTSNsStub).to.have.been.calledWith([1, 2]); + + expect(mockRes.jsonValue).to.eql([mock1, mock2]); + expect(mockRes.statusValue).to.equal(200); + }); + + it('catches error, and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon.stub(ItisService.prototype, 'getHierarchyForTSNs').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { + tsn: ['1', '2'] + }; + + try { + const requestHandler = getHierarchyForTSNs(); + + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); diff --git a/api/src/paths/taxonomy/taxon/tsn/hierarchy.ts b/api/src/paths/taxonomy/taxon/tsn/hierarchy.ts index 33898d80..8f20c57c 100644 --- a/api/src/paths/taxonomy/taxon/tsn/hierarchy.ts +++ b/api/src/paths/taxonomy/taxon/tsn/hierarchy.ts @@ -6,7 +6,7 @@ import { getLogger } from '../../../../utils/logger'; const defaultLog = getLogger('paths/taxonomy/taxon/tsn/hierarchy'); -export const GET: Operation = [getTaxonHierarchyByTSN()]; +export const GET: Operation = [getHierarchyForTSNs()]; GET.apiDoc = { description: 'Get taxon hierarchy information by TSN ids.', @@ -79,9 +79,9 @@ GET.apiDoc = { * * @returns {RequestHandler} */ -export function getTaxonHierarchyByTSN(): RequestHandler { +export function getHierarchyForTSNs(): RequestHandler { return async (req, res) => { - defaultLog.debug({ label: 'getTaxonHierarchyByTSN', message: 'query params', query: req.query }); + defaultLog.debug({ label: 'getHierarchyForTSNs', message: 'query params', query: req.query }); const connection = getAPIUserDBConnection(); @@ -98,7 +98,7 @@ export function getTaxonHierarchyByTSN(): RequestHandler { res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'getTaxonHierarchyByTSN', message: 'error', error }); + defaultLog.error({ label: 'getHierarchyForTSNs', message: 'error', error }); connection.rollback(); throw error; } finally { diff --git a/api/src/services/itis-service.test.ts b/api/src/services/itis-service.test.ts index be8b8dd7..92c2c037 100644 --- a/api/src/services/itis-service.test.ts +++ b/api/src/services/itis-service.test.ts @@ -3,7 +3,7 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ApiGeneralError } from '../errors/api-error'; -import { ItisService } from './itis-service'; +import { ItisService, ItisSolrSearchResponseHierarchy } from './itis-service'; chai.use(sinonChai); @@ -182,6 +182,65 @@ describe('ItisService', () => { }); }); + describe('getHierarchyForTSNs', async () => { + it('returns array of hierarchy objects for tsns', async () => { + const mockTsns = [1, 2]; + const mockTsnHierarchies = [ + { tsn: mockTsns[0], hierarchyTSN: ['$3$2$1$'] }, + { tsn: mockTsns[1], hierarchyTSN: ['$3$2$'] } + ]; + const mockAxiosResponse = { + data: { + response: { + docs: mockTsnHierarchies + } + } + }; + + const getItisSolrTsnHierarchyUrlStub = sinon + .stub(ItisService.prototype, 'getItisSolrTsnHierarchyUrl') + .returns('url'); + + const axiosStub = sinon.stub(axios, 'get').resolves(mockAxiosResponse); + + const itisService = new ItisService(); + + const response = await itisService.getHierarchyForTSNs(mockTsns); + + expect(getItisSolrTsnHierarchyUrlStub).to.have.been.calledWith(mockTsns); + expect(axiosStub).to.have.been.calledWith('url'); + expect(response).to.eql([ + { + tsn: mockTsns[0], + hierarchy: [3, 2, 1] + }, + { + tsn: mockTsns[1], + hierarchy: [3, 2] + } + ]); + }); + + it('catches and re-throws an error', async () => { + const mockTsns = [1, 2]; + sinon.stub(axios, 'get').rejects(new Error('a test error')); + + const itisService = new ItisService(); + const getItisSolrTsnHierarchyUrlStub = sinon + .stub(ItisService.prototype, 'getItisSolrTsnHierarchyUrl') + .resolves('url'); + + try { + await itisService.getHierarchyForTSNs(mockTsns); + + expect.fail(); + } catch (error) { + expect((error as ApiGeneralError).message).to.equal('a test error'); + expect(getItisSolrTsnHierarchyUrlStub).to.have.been.calledWith(mockTsns); + } + }); + }); + describe('getItisSolrTermSearchUrl', () => { it('throws an error when itis solr url is not set', async () => { process.env.ITIS_SOLR_URL = ''; @@ -210,6 +269,35 @@ describe('ItisService', () => { }); }); + describe('getItisSolrTsnHierarchyUrl', () => { + const mockTsns = [1]; + it('throws an error when itis solr url is not set', async () => { + process.env.ITIS_SOLR_URL = ''; + + const itisService = new ItisService(); + + try { + await itisService.getItisSolrTsnHierarchyUrl(mockTsns); + + expect.fail(); + } catch (error) { + expect((error as ApiGeneralError).message).to.equal('Failed to build ITIS query.'); + } + }); + + it('returns a valid url', async () => { + process.env.ITIS_SOLR_URL = 'https://services.itis.gov/'; + + const itisService = new ItisService(); + + const response = await itisService.getItisSolrTsnHierarchyUrl(mockTsns); + + expect(response).to.equal( + 'https://services.itis.gov/??wt=json&sort=kingdom+asc&rows=150&omitHeader=true&fl=tsn+hierarchyTSN&&q=tsn:1' + ); + }); + }); + describe('getItisSolrTsnSearchUrl', () => { it('throws an error when itis solr url is not set', async () => { process.env.ITIS_SOLR_URL = ''; @@ -237,4 +325,45 @@ describe('ItisService', () => { ); }); }); + + describe('getItisSolrTsnSearchUrl', () => { + it('throws an error when itis solr url is not set', async () => { + process.env.ITIS_SOLR_URL = ''; + + const itisService = new ItisService(); + + try { + await itisService.getItisSolrTsnSearchUrl([123]); + + expect.fail(); + } catch (error) { + expect((error as ApiGeneralError).message).to.equal('Failed to build ITIS query.'); + } + }); + + it('returns a valid url', async () => { + process.env.ITIS_SOLR_URL = 'https://services.itis.gov/'; + + const itisService = new ItisService(); + + const response = await itisService.getItisSolrTsnSearchUrl([123]); + + expect(response).to.equal( + 'https://services.itis.gov/??wt=json&sort=kingdom+asc&rows=150&omitHeader=true&fl=tsn+scientificName:nameWOInd+kingdom+parentTSN+commonNames:vernacular+updateDate+usage+rank&&q=tsn:123' + ); + }); + }); + + describe('_sanitizeHierarchyData', () => { + it('turns an ITIS hierarchy string into an array'), + () => { + const mockData: ItisSolrSearchResponseHierarchy[] = [{ tsn: '1', hierarchyTSN: ['$3$2$1$'] }]; + + const itisService = new ItisService(); + + const result = itisService._sanitizeHierarchyData(mockData); + + expect(result).to.eql([3, 2, 1]); + }; + }); }); diff --git a/api/src/services/itis-service.ts b/api/src/services/itis-service.ts index b3f95117..290df3b8 100644 --- a/api/src/services/itis-service.ts +++ b/api/src/services/itis-service.ts @@ -102,7 +102,7 @@ export class ItisService { * @memberof ItisService */ async getHierarchyForTSNs(tsnIds: number[]): Promise { - const url = await this.getItisSolrTsnHierarchyUrl(tsnIds); + const url = this.getItisSolrTsnHierarchyUrl(tsnIds); defaultLog.debug({ label: 'getHierarchyForTSNs', message: 'url', url }); @@ -140,6 +140,7 @@ export class ItisService { * Cleans up the ITIS hierarchy response data * * @param {ItisSolrSearchResponse[]} data + * @return {TSNWithHierarchy[]} * @memberof ItisService */ _sanitizeHierarchyData = (data: ItisSolrSearchResponseHierarchy[]): TSNWithHierarchy[] => { @@ -200,10 +201,10 @@ export class ItisService { * Get the ITIS SOLR search-by-tsn URL for hierarchy information * * @param {number[]} tsnIds - * @return {*} {Promise} + * @return {*} {string} * @memberof ItisService */ - async getItisSolrTsnHierarchyUrl(tsnIds: number[]): Promise { + getItisSolrTsnHierarchyUrl(tsnIds: number[]): string { const itisUrl = this._getItisSolrUrl(); if (!itisUrl) {