diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d3e1fb..3b170263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,12 @@ ## v1.0 ### Updated +- Updated License resolver to remove pagination from `licenses` query. It now returns all licenses. - Updated tests and isValid functions on `Question`, `VersionedQuestion` and `Answer` to work with new version of `@dmptool/types` v2.0 - Related works stored procedures so that they can insert existing related works and ground truth data. ### Added +- Added unit tests for the license resolver - Added endpoint for returning summary stats for related works associated with a plan. - Added ability to manually add a related work via a DOI. - Added `findByURIs` methods to both `Repository` and `MetadataStandards` models [#572] diff --git a/package-lock.json b/package-lock.json index f451bcc6..2f8d8111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@aws-sdk/client-ssm": "3.972.0", "@aws-sdk/credential-providers": "3.972.0", "@aws-sdk/util-dynamodb": "3.972.0", - "@dmptool/types": "2.3.2", + "@dmptool/types": "3.1.0", "@elastic/ecs-pino-format": "1.5.0", "@graphql-tools/merge": "9.1.7", "@graphql-tools/mock": "9.1.5", @@ -1906,9 +1906,9 @@ } }, "node_modules/@dmptool/types": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@dmptool/types/-/types-2.3.2.tgz", - "integrity": "sha512-ZzgNqFjsOwTndbaPX5KJSNl3X7+dN9Hp0jYQ+cm1GzwkPYD5GpU/IZ/QP8yXXNoFA+/QItiWFl5bBKPPtFW3ww==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dmptool/types/-/types-3.1.0.tgz", + "integrity": "sha512-0KbtZazYeGwwNpgCVJK1BDMWHRjOduY5fqxOOzO+oOjz+UoUcC1OVzNFXXsTUeL2ckFhIpVuTOY8AhO4q/8aiw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7c2782de..ed8a290f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@aws-sdk/client-ssm": "3.972.0", "@aws-sdk/credential-providers": "3.972.0", "@aws-sdk/util-dynamodb": "3.972.0", - "@dmptool/types": "2.3.2", + "@dmptool/types": "3.1.0", "@elastic/ecs-pino-format": "1.5.0", "@graphql-tools/merge": "9.1.7", "@graphql-tools/mock": "9.1.5", diff --git a/src/__mocks__/context.ts b/src/__mocks__/context.ts index 2d90ff60..f9ba7d5d 100644 --- a/src/__mocks__/context.ts +++ b/src/__mocks__/context.ts @@ -112,6 +112,19 @@ export const mockToken = async ( } } +export const mockResearcherToken = async (): Promise => { + const token = await mockToken(); + return { ...token, role: UserRole.RESEARCHER }; +} +export const mockAdminToken = async (): Promise => { + const token = await mockToken(); + return { ...token, role: UserRole.ADMIN }; +} +export const mockSuperAdminToken = async (): Promise => { + const token = await mockToken(); + return { ...token, role: UserRole.SUPERADMIN }; +} + export const mockDataSources = { dmphubAPIDataSource: new DMPHubAPI({ cache: null, token: null}), sqlDataSource: mockedMysqlInstance, diff --git a/src/models/License.ts b/src/models/License.ts index c75a87f0..c92e55ae 100644 --- a/src/models/License.ts +++ b/src/models/License.ts @@ -1,10 +1,8 @@ import { MyContext } from "../context"; -import { prepareObjectForLogs } from "../logger"; -import { PaginatedQueryResults, PaginationOptions, PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from "../types/general"; -import { isNullOrUndefined, randomHex, validateURL } from "../utils/helpers"; +import { randomHex, validateURL } from "../utils/helpers"; import { MySqlModel } from "./MySqlModel"; -export const DEFAULT_DMPTOOL_LICENSE_URL = 'https://dmptool.org/licenses/';; +export const DEFAULT_DMPTOOL_LICENSE_URL = 'https://dmptool.org/licenses/'; export class License extends MySqlModel { public name: string; @@ -12,7 +10,7 @@ export class License extends MySqlModel { public description?: string; public recommended: boolean; - private tableName = 'licenses'; + private static tableName = 'licenses'; constructor(options) { super(options.id, options.created, options.createdById, options.modified, options.modifiedById, options.errors); @@ -63,7 +61,7 @@ export class License extends MySqlModel { this.addError('general', 'License already exists'); } else { // Save the record and then fetch it - const newId = await License.insert(context, this.tableName, this, reference); + const newId = await License.insert(context, License.tableName, this, reference); const response = await License.findById(reference, context, newId); return response; } @@ -79,7 +77,7 @@ export class License extends MySqlModel { this.prepForSave(); if (await this.isValid()) { if (id) { - await License.update(context, this.tableName, this, 'License.update', [], noTouch); + await License.update(context, License.tableName, this, 'License.update', [], noTouch); return await License.findById('License.update', context, id); } // This template has never been saved before so we cannot update it! @@ -95,7 +93,7 @@ export class License extends MySqlModel { const successfullyDeleted = await License.delete( context, - this.tableName, + License.tableName, this.id, 'License.delete' ); @@ -110,77 +108,36 @@ export class License extends MySqlModel { // Fetch a License by it's id static async findById(reference: string, context: MyContext, licenseId: number): Promise { - const sql = `SELECT * FROM licenses WHERE id = ?`; + const sql = `SELECT * FROM ${License.tableName} WHERE id = ?`; const results = await License.query(context, sql, [licenseId?.toString()], reference); return Array.isArray(results) && results.length > 0 ? new License(results[0]) : null; } + // Find a License by its URI. The URI is case insensitive. static async findByURI(reference: string, context: MyContext, uri: string): Promise { - const sql = `SELECT * FROM licenses WHERE uri = ?`; + const sql = `SELECT * FROM ${License.tableName} WHERE uri = ?`; const results = await License.query(context, sql, [uri], reference); return Array.isArray(results) && results.length > 0 ? new License(results[0]) : null; } + // Find a License by its name. The name is case insensitive. static async findByName(reference: string, context: MyContext, name: string): Promise { - const sql = `SELECT * FROM licenses WHERE LOWER(name) = ?`; + const sql = `SELECT * FROM ${License.tableName} WHERE LOWER(name) = ?`; const results = await License.query(context, sql, [name?.toLowerCase()?.trim()], reference); return Array.isArray(results) && results.length > 0 ? new License(results[0]) : null; } - // Find licenses that match the search term - static async search( - reference: string, - context: MyContext, - name: string, - options: PaginationOptions = License.getDefaultPaginationOptions(), - ): Promise> { - const whereFilters = []; - const values = []; - - // Handle the incoming search term - const searchTerm = (name ?? '').toLowerCase().trim(); - if (!isNullOrUndefined(searchTerm)) { - whereFilters.push('(LOWER(l.name) LIKE ? OR LOWER(l.description) LIKE ?)'); - values.push(`%${searchTerm}%`, `%${searchTerm}%`); - } - - // Set the default sort field and order if none was provided - if (isNullOrUndefined(options.sortField)) options.sortField = 'l.name'; - if (isNullOrUndefined(options.sortDir)) options.sortDir = 'ASC'; - - // Specify the fields available for sorting - options.availableSortFields = ['l.name', 'l.created', 'l.recommended']; - // Specify the field we want to use for the count - options.countField = 'l.id'; - - // Determine the type of pagination we are using and then set any additional options we need - let opts; - if (options.type === PaginationType.OFFSET) { - opts = options as PaginationOptionsForOffsets; - } else { - opts = options as PaginationOptionsForCursors; - opts.cursorField = 'l.id'; - } - - const sqlStatement = 'SELECT l.* FROM licenses l'; - - const response: PaginatedQueryResults = await License.queryWithPagination( - context, - sqlStatement, - whereFilters, - '', - values, - opts, - reference, - ) - - context.logger.debug(prepareObjectForLogs({ options, response }), reference); - return response; + // Return all licenses + static async all(reference: string, context: MyContext): Promise { + const sql = `SELECT * FROM ${License.tableName} ORDER BY name ASC`; + const results = await License.query(context, sql, [], reference); + // No need to initialize new License objects here as they are just search results + return Array.isArray(results) ? results : []; } - // Find licenses that match the search term + // Find licenses that are either recommended or not recommended static async recommended(reference: string, context: MyContext, recommended = true): Promise { - const sql = `SELECT * FROM licenses WHERE recommended = ?`; + const sql = `SELECT * FROM ${License.tableName} WHERE recommended = ?`; const vals = recommended ? ['1'] : ['0']; const results = await License.query(context, sql, vals, reference); // No need to initialize new License objects here as they are just search results diff --git a/src/models/__tests__/License.spec.ts b/src/models/__tests__/License.spec.ts index 3f760dbb..76f40550 100644 --- a/src/models/__tests__/License.spec.ts +++ b/src/models/__tests__/License.spec.ts @@ -1,7 +1,6 @@ import casual from "casual"; import { buildMockContextWithToken } from "../../__mocks__/context"; import { License } from "../License"; -import { generalConfig } from "../../config/generalConfig"; import { logger } from "../../logger"; jest.mock('../../context.ts'); @@ -145,32 +144,18 @@ describe('findBy Queries', () => { expect(result).toEqual(null); }); - it('search should call query with correct params and return the objects', async () => { - localPaginationQuery.mockResolvedValueOnce([license]); - const term = casual.company_name; - const result = await License.search('testing', context, term); - const sql = 'SELECT l.* FROM licenses l'; - const whereFilters = ['(LOWER(l.name) LIKE ? OR LOWER(l.description) LIKE ?)']; - const vals = [`%${term.toLowerCase().trim()}%`, `%${term.toLowerCase().trim()}%`]; - const sortFields = ["l.name", "l.created", "l.recommended"]; - const opts = { - cursor: null, - limit: generalConfig.defaultSearchLimit, - sortField: 'l.name', - sortDir: 'ASC', - countField: 'l.id', - cursorField: 'l.id', - availableSortFields: sortFields, - }; - expect(localPaginationQuery).toHaveBeenCalledTimes(1); - expect(localPaginationQuery).toHaveBeenLastCalledWith(context, sql, whereFilters, '', vals, opts, 'testing') + it('all should call query with correct params and return the objects', async () => { + localQuery.mockResolvedValueOnce([license]); + const result = await License.all('testing', context); + const expectedSql = 'SELECT * FROM licenses ORDER BY name ASC'; + expect(localQuery).toHaveBeenCalledTimes(1); + expect(localQuery).toHaveBeenLastCalledWith(context, expectedSql, [], 'testing') expect(result).toEqual([license]); }); - it('search should return an empty array if it finds no records', async () => { - localPaginationQuery.mockResolvedValueOnce([]); - const term = casual.company_name; - const result = await License.search('testing', context, term); + it('all should return an empty array if it finds no records', async () => { + localQuery.mockResolvedValueOnce([]); + const result = await License.all('testing', context); expect(result).toEqual([]); }); diff --git a/src/resolvers/__tests__/license.spec.ts b/src/resolvers/__tests__/license.spec.ts new file mode 100644 index 00000000..79a859d2 --- /dev/null +++ b/src/resolvers/__tests__/license.spec.ts @@ -0,0 +1,629 @@ +import { ApolloServer } from "@apollo/server"; +import { typeDefs } from "../../schema"; +import { resolvers } from "../../resolver"; +import casual from "casual"; +import { MyContext } from "../../context"; +import { + buildContext, + mockResearcherToken, + mockAdminToken, + mockSuperAdminToken, +} from "../../__mocks__/context"; +import { logger } from "../../logger"; +import { JWTAccessToken } from "../../services/tokenService"; +import { getCurrentDate } from '../../utils/helpers' + +import { License, DEFAULT_DMPTOOL_LICENSE_URL } from '../../models/License'; + +jest.mock('../../context.ts'); +jest.mock('../../datasources/cache'); + +let testServer: ApolloServer; +let token: JWTAccessToken; +let context: MyContext; + +// Proxy call to the Apollo server test server +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function executeQuery (query: string, variables: any): Promise { + return await testServer.executeOperation( + { query, variables }, + { contextValue: context }, + ); +} + +beforeEach(async () => { + jest.resetAllMocks(); + + // Initialize the Apollo server + testServer = new ApolloServer({ + typeDefs, resolvers + }); + + context = buildContext(logger, token, null); + + token = await mockResearcherToken(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('License Resolvers', () => { + let mockLicenses: License[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLicenses = [ + { + id: casual.integer(1, 99), + uri: `${DEFAULT_DMPTOOL_LICENSE_URL}/test`, + name: `Last`, + description: casual.description, + recommended: false, + created: getCurrentDate(), + modified: getCurrentDate(), + errors: {}, + addError: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as License, + { + id: casual.integer(100, 999), + uri: `${DEFAULT_DMPTOOL_LICENSE_URL}/test-recommended`, + name: 'First', + description: casual.description, + recommended: true, + created: getCurrentDate(), + modified: getCurrentDate(), + errors: {}, + addError: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as License, + ]; + }); + + describe('Query', () => { + describe('licenses', () => { + const query = ` + query { + licenses { + id + uri + name + description + recommended + created + modified + } + }`; + + it('should return all licenses successfully', async () => { + const querySpy = jest.spyOn(License, 'all').mockResolvedValue(mockLicenses); + + const result = await executeQuery(query, undefined); + expect(querySpy).toHaveBeenCalledWith('licenses resolver', context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.licenses).toBeTruthy(); + expect(result.body.singleResult.data.licenses.length).toEqual(2); + }); + + it('should throw InternalServerError on failure', async () => { + const error = new Error('Database error'); + // spy and throw an error + const querySpy = jest.spyOn(License, 'all').mockRejectedValue(error); + + const result = await executeQuery(query, undefined); + expect(querySpy).toHaveBeenCalledWith('licenses resolver', context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.licenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + + describe('recommendedLicenses', () => { + const query = ` + query RecommendedLicenses($recommended: Boolean!) { + recommendedLicenses(recommended: $recommended) { + name + id + uri + } + }`; + + it('should return all recommended licenses successfully', async () => { + const querySpy = jest.spyOn(License, 'recommended').mockResolvedValue([mockLicenses[1]]); + + const result = await executeQuery(query, { recommended: true }); + expect(querySpy).toHaveBeenCalledWith('recommendedLicenses resolver', context, true); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.recommendedLicenses).toBeTruthy(); + expect(result.body.singleResult.data.recommendedLicenses.length).toEqual(1); + }); + + it('should throw InternalServerError on failure', async () => { + const error = new Error('Database error'); + // spy and throw an error + const querySpy = jest.spyOn(License, 'recommended').mockRejectedValue(error); + + const result = await executeQuery(query, { recommended: true }); + expect(querySpy).toHaveBeenCalledWith('recommendedLicenses resolver', context, true); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.recommendedLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + + describe('license', () => { + const query = ` + query License($uri: String!) { + license(uri: $uri) { + name + id + uri + } + }`; + + it('should return the specified license', async () => { + const querySpy = jest.spyOn(License, 'findByURI').mockResolvedValue(mockLicenses[1]); + + const uri = mockLicenses[1].uri; + const result = await executeQuery(query, {uri: uri}); + expect(querySpy).toHaveBeenCalledWith('license resolver', context, uri); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.license).toBeTruthy(); + }); + + it('should throw InternalServerError on failure', async () => { + const error = new Error('Database error'); + // spy and throw an error + const querySpy = jest.spyOn(License, 'findByURI').mockRejectedValue(error); + + const uri = mockLicenses[1].uri; + const result = await executeQuery(query, {uri: uri}); + expect(querySpy).toHaveBeenCalledWith('license resolver', context, uri); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.license).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + }); + + describe('Mutation', () => { + let querySpy: jest.SpyInstance; + let mockInput; + + describe('addLicense', () => { + const query = ` + mutation AddLicense($name: String!, $uri: String!, $description: String!, $recommended: Boolean!) { + addLicense(name: $name, uri: $uri, description: $description, recommended: $recommended) { + id + uri + name + description + recommended + errors { + uri + name + } + } + }`; + + beforeEach(() => { + querySpy = jest.spyOn(License.prototype, 'create').mockResolvedValue(mockLicenses[0]); + + mockInput = { + uri: mockLicenses[0].uri, + name: mockLicenses[0].name, + description: mockLicenses[0].description, + recommended: mockLicenses[0].recommended + } + }) + + it('should return a 403 for a Researcher', async () => { + context.token = await mockResearcherToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.addLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should add a new license for an Admin', async () => { + context.token = await mockAdminToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.addLicense).toBeTruthy(); + }); + + it('should add a new license for a SuperAdmin', async () => { + context.token = await mockSuperAdminToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.addLicense).toBeTruthy(); + }); + + it('should return a 500 when an error occurs', async () => { + const error = new Error('Database error'); + // spy and throw an error + querySpy = jest.spyOn(License.prototype, 'create').mockRejectedValue(error); + + context.token = await mockAdminToken(); + + const result = await executeQuery(query, mockInput); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.addLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + + describe('updateLicense', () => { + const query = ` + mutation UpdateLicense($uri: String!, $name: String!, $description: String!, $recommended: Boolean!) { + updateLicense(uri: $uri, name: $name, description: $description, recommended: $recommended) { + id + uri + name + description + recommended + errors { + uri + name + } + } + }`; + + beforeEach(() => { + querySpy = jest.spyOn(License.prototype, 'update').mockResolvedValue(mockLicenses[0]); + + mockInput = { + uri: `${DEFAULT_DMPTOOL_LICENSE_URL}test/123`, + name: mockLicenses[0].name, + description: mockLicenses[0].description, + recommended: mockLicenses[0].recommended + } + }) + + it('should return a 403 for a Researcher', async () => { + context.token = await mockResearcherToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.updateLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 403 if the License belongs to an external repository', async () => { + context.token = await mockResearcherToken(); + + mockInput.uri = 'someone-elses-license'; + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.updateLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 404 if the License doesn\'t exist', async () => { + context.token = await mockAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(null); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.updateLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + + it('should update the license for an Admin', async () => { + context.token = await mockAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(mockInput); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.updateLicense).toBeTruthy(); + }); + + it('should update the license for a SuperAdmin', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(mockInput); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.updateLicense).toBeTruthy(); + }); + + it('should return a 500 when an error occurs', async () => { + const error = new Error('Database error'); + jest.spyOn(License, 'findByURI').mockResolvedValue(mockInput); + + // spy and throw an error + querySpy = jest.spyOn(License.prototype, 'update').mockRejectedValue(error); + context.token = await mockAdminToken(); + + const result = await executeQuery(query, mockInput); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.updateLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + + describe('removeLicense', () => { + const query = ` + mutation RemoveLicense($uri: String!) { + removeLicense(uri: $uri) { + id + } + }`; + + beforeEach(() => { + querySpy = jest.spyOn(License.prototype, 'delete').mockResolvedValue(mockLicenses[0]); + + mockInput = { + uri: `${DEFAULT_DMPTOOL_LICENSE_URL}test/123` + } + }) + + it('should return a 403 for a Researcher', async () => { + context.token = await mockResearcherToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.removeLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 403 if the License belongs to an external repository', async () => { + context.token = await mockResearcherToken(); + + mockInput.uri = 'someone-elses-license'; + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.removeLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 404 if the License doesn\'t exist', async () => { + context.token = await mockAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(null); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.removeLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + + it('should remove the license for an Admin', async () => { + context.token = await mockAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(new License(mockLicenses[0])); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.removeLicense).toBeTruthy(); + }); + + it('should remove the license for a SuperAdmin', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findByURI').mockResolvedValue(new License(mockLicenses[0])); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.removeLicense).toBeTruthy(); + }); + + it('should return a 500 when an error occurs', async () => { + const error = new Error('Database error'); + jest.spyOn(License, 'findByURI').mockResolvedValue(mockInput); + + // spy and throw an error + querySpy = jest.spyOn(License.prototype, 'delete').mockRejectedValue(error); + context.token = await mockAdminToken(); + + const result = await executeQuery(query, mockInput); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.removeLicense).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + + describe('mergeLicenses', () => { + const query = ` + mutation MergeLicenses($licenseToKeepId: Int!, $licenseToRemoveId: Int!) { + mergeLicenses(licenseToKeepId: $licenseToKeepId, licenseToRemoveId: $licenseToRemoveId) { + id + } + }`; + + let deleteSpy: jest.SpyInstance; + + beforeEach(() => { + querySpy = jest.spyOn(License.prototype, 'update').mockResolvedValue(mockLicenses[1]); + deleteSpy = jest.spyOn(License.prototype, 'delete').mockResolvedValue(null) + + mockInput = { + licenseToKeepId: mockLicenses[0].id, + licenseToRemoveId: mockLicenses[1].id + } + }) + + it('should return a 403 for a Researcher', async () => { + context.token = await mockResearcherToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 403 for an Admin', async () => { + context.token = await mockAdminToken(); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('should return a 403 if the License to be removed belongs to an external repository', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findById') + .mockResolvedValueOnce(new License(mockLicenses[0])) + .mockResolvedValueOnce(new License({ uri: 'external-license'})); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Forbidden'); + }); + + it('doesn\'t update the license to keep if it belongs to an external repository', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findById') + .mockResolvedValueOnce(new License({ uri: 'external-license'})) + .mockResolvedValueOnce(new License(mockLicenses[0])); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined() + expect(result.body.singleResult.data.mergeLicenses).toBeTruthy(); + }); + + it('should return a 404 if the License to remove doesn\'t exist', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findById') + .mockResolvedValueOnce(new License(mockLicenses[0])) + .mockResolvedValueOnce(null); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + + it('should return a 404 if the License to keep doesn\'t exist', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findById') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(new License(mockLicenses[0])); + + const result = await executeQuery(query, mockInput); + expect(querySpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Not Found'); + }); + + it('should merge the licenses for a SuperAdmin', async () => { + context.token = await mockSuperAdminToken(); + jest.spyOn(License, 'findById') + .mockResolvedValue(new License(mockLicenses[0])) + .mockResolvedValue(new License(mockLicenses[1])); + + const result = await executeQuery(query, mockInput); + expect(querySpy).toHaveBeenCalledWith(context); + expect(deleteSpy).toHaveBeenCalledWith(context); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeUndefined(); + expect(result.body.singleResult.data.mergeLicenses).toBeTruthy(); + }); + + it('should return a 500 when an error occurs', async () => { + const error = new Error('Database error'); + // spy and throw an error + jest.spyOn(License, 'findById').mockRejectedValue(error); + context.token = await mockSuperAdminToken(); + + const result = await executeQuery(query, mockInput); + + expect(result.body.kind).toEqual('single'); + expect(result.body.singleResult.errors).toBeTruthy(); + expect(result.body.singleResult.data.mergeLicenses).toBeNull(); + expect(result.body.singleResult.errors[0].message).toEqual('Something went wrong'); + }); + }); + }); +}); diff --git a/src/resolvers/license.ts b/src/resolvers/license.ts index 127b2c0a..7b84e1ed 100644 --- a/src/resolvers/license.ts +++ b/src/resolvers/license.ts @@ -1,25 +1,20 @@ import { prepareObjectForLogs } from '../logger'; -import { LicenseSearchResults, Resolvers } from "../types"; +import { Resolvers } from "../types"; import { DEFAULT_DMPTOOL_LICENSE_URL, License } from "../models/License"; import { MyContext } from '../context'; import { isAdmin, isSuperAdmin } from '../services/authService'; import { AuthenticationError, ForbiddenError, InternalServerError, NotFoundError } from '../utils/graphQLErrors'; import { GraphQLError } from 'graphql'; -import { PaginationOptionsForCursors, PaginationOptionsForOffsets, PaginationType } from '../types/general'; import { isNullOrUndefined, normaliseDateTime } from '../utils/helpers'; export const resolvers: Resolvers = { Query: { - // searches the licenses table or returns all licenses if no critieria is specified - licenses: async (_, { term, paginationOptions }, context: MyContext): Promise => { + // returns all licenses + licenses: async (_, __, context: MyContext): Promise => { const reference = 'licenses resolver'; try { - const opts = !isNullOrUndefined(paginationOptions) && paginationOptions.type === PaginationType.OFFSET - ? paginationOptions as PaginationOptionsForOffsets - : { ...paginationOptions, type: PaginationType.CURSOR } as PaginationOptionsForCursors; - - return await License.search(reference, context, term, opts); + return await License.all(reference, context); } catch (err) { context.logger.error(prepareObjectForLogs(err), `Failure in ${reference}`); throw InternalServerError(); @@ -90,7 +85,7 @@ export const resolvers: Resolvers = { // If the user is a an admin and its a DMPTool managed license (no updates to licenses managed elsewhere!) if (isAdmin(context.token) && uri.startsWith(DEFAULT_DMPTOOL_LICENSE_URL)) { const license = await License.findByURI(reference, context, uri); - if (!license) { + if (isNullOrUndefined(license)) { throw NotFoundError(); } @@ -116,10 +111,10 @@ export const resolvers: Resolvers = { removeLicense: async (_, { uri }, context) => { const reference = 'removeLicense resolver'; try { - // If the user is a an admin and its a DMPTool managed license (no removals of licenses managed elsewhere!) + // If the user is an admin and its a DMPTool managed license (no removals of licenses managed elsewhere!) if (isAdmin(context.token) && uri.startsWith(DEFAULT_DMPTOOL_LICENSE_URL)) { const license = await License.findByURI(reference, context, uri); - if (!license) { + if (isNullOrUndefined(license)) { throw NotFoundError(); } @@ -152,8 +147,8 @@ export const resolvers: Resolvers = { throw ForbiddenError(); } - // Only modify the one we want to keep if it is a DMP Tool managed licenses! - if (!toKeep.uri.startsWith(DEFAULT_DMPTOOL_LICENSE_URL)) { + // Only modify the one we want to keep if it is a DMP Tool managed license! + if (toKeep.uri.startsWith(DEFAULT_DMPTOOL_LICENSE_URL)) { // Merge the description in if the one we want to keep does not have one if (!toKeep.description) { toKeep.description = toRemove.description diff --git a/src/schemas/license.ts b/src/schemas/license.ts index 9d57ce33..ec755ddf 100644 --- a/src/schemas/license.ts +++ b/src/schemas/license.ts @@ -2,8 +2,8 @@ import gql from 'graphql-tag'; export const typeDefs = gql` extend type Query { - "Search for a license" - licenses(term: String, paginationOptions: PaginationOptions): LicenseSearchResults + "Return all licenses" + licenses: [License] "Return the recommended Licenses" recommendedLicenses(recommended: Boolean!): [License] "Fetch a specific license" @@ -47,25 +47,6 @@ export const typeDefs = gql` recommended: Boolean! } - type LicenseSearchResults implements PaginatedQueryResults { - "The TemplateSearchResults that match the search criteria" - items: [License] - "The total number of possible items" - totalCount: Int - "The number of items returned" - limit: Int - "The cursor to use for the next page of results (for infinite scroll/load more)" - nextCursor: String - "The current offset of the results (for standard offset pagination)" - currentOffset: Int - "Whether or not there is a next page" - hasNextPage: Boolean - "Whether or not there is a previous page" - hasPreviousPage: Boolean - "The sortFields that are available for this query (for standard offset pagination only!)" - availableSortFields: [String] - } - "A collection of errors related to the License" type LicenseErrors { "General error messages such as the object already exists" diff --git a/src/types.ts b/src/types.ts index 1219ec5c..25188912 100644 --- a/src/types.ts +++ b/src/types.ts @@ -885,26 +885,6 @@ export type LicenseErrors = { uri?: Maybe; }; -export type LicenseSearchResults = PaginatedQueryResults & { - __typename?: 'LicenseSearchResults'; - /** The sortFields that are available for this query (for standard offset pagination only!) */ - availableSortFields?: Maybe>>; - /** The current offset of the results (for standard offset pagination) */ - currentOffset?: Maybe; - /** Whether or not there is a next page */ - hasNextPage?: Maybe; - /** Whether or not there is a previous page */ - hasPreviousPage?: Maybe; - /** The TemplateSearchResults that match the search criteria */ - items?: Maybe>>; - /** The number of items returned */ - limit?: Maybe; - /** The cursor to use for the next page of results (for infinite scroll/load more) */ - nextCursor?: Maybe; - /** The total number of possible items */ - totalCount?: Maybe; -}; - export type MemberRole = { __typename?: 'MemberRole'; /** The timestamp when the Object was created */ @@ -2530,8 +2510,8 @@ export type Query = { languages?: Maybe>>; /** Fetch a specific license */ license?: Maybe; - /** Search for a license */ - licenses?: Maybe; + /** Return all licenses */ + licenses?: Maybe>>; /** Returns the currently logged in user's information */ me?: Maybe; /** Get the member role by it's id */ @@ -2608,8 +2588,6 @@ export type Query = { relatedWorksByPlanStats?: Maybe; /** Get all of the related works for a project */ relatedWorksByProject?: Maybe; - /** Get summary statistics for related works by project */ - relatedWorksByProjectStats?: Maybe; /** Search for a repository */ repositories?: Maybe; /** return all repositories whose unique uri values are provided */ @@ -2744,12 +2722,6 @@ export type QueryLicenseArgs = { }; -export type QueryLicensesArgs = { - paginationOptions?: InputMaybe; - term?: InputMaybe; -}; - - export type QueryMemberRoleByIdArgs = { memberRoleId: Scalars['Int']['input']; }; @@ -2934,11 +2906,6 @@ export type QueryRelatedWorksByProjectArgs = { }; -export type QueryRelatedWorksByProjectStatsArgs = { - projectId: Scalars['Int']['input']; -}; - - export type QueryRepositoriesArgs = { input: RepositorySearchInput; }; @@ -4681,7 +4648,6 @@ export type ResolversInterfaceTypes<_RefType extends Record> = PaginatedQueryResults: | ( AffiliationSearchResults ) | ( CollaboratorSearchResults ) - | ( LicenseSearchResults ) | ( MetadataStandardSearchResults ) | ( ProjectSearchResults ) | ( PublishedTemplateSearchResults ) @@ -4754,7 +4720,6 @@ export type ResolversTypes = { Language: ResolverTypeWrapper; License: ResolverTypeWrapper; LicenseErrors: ResolverTypeWrapper; - LicenseSearchResults: ResolverTypeWrapper; MD5: ResolverTypeWrapper; MemberRole: ResolverTypeWrapper; MemberRoleErrors: ResolverTypeWrapper; @@ -4949,7 +4914,6 @@ export type ResolversParentTypes = { Language: Language; License: License; LicenseErrors: LicenseErrors; - LicenseSearchResults: LicenseSearchResults; MD5: Scalars['MD5']['output']; MemberRole: MemberRole; MemberRoleErrors: MemberRoleErrors; @@ -5389,18 +5353,6 @@ export type LicenseErrorsResolvers, ParentType, ContextType>; }; -export type LicenseSearchResultsResolvers = { - availableSortFields?: Resolver>>, ParentType, ContextType>; - currentOffset?: Resolver, ParentType, ContextType>; - hasNextPage?: Resolver, ParentType, ContextType>; - hasPreviousPage?: Resolver, ParentType, ContextType>; - items?: Resolver>>, ParentType, ContextType>; - limit?: Resolver, ParentType, ContextType>; - nextCursor?: Resolver, ParentType, ContextType>; - totalCount?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - export interface Md5ScalarConfig extends GraphQLScalarTypeConfig { name: 'MD5'; } @@ -5591,7 +5543,7 @@ export interface OrcidScalarConfig extends GraphQLScalarTypeConfig = { - __resolveType: TypeResolveFn<'AffiliationSearchResults' | 'CollaboratorSearchResults' | 'LicenseSearchResults' | 'MetadataStandardSearchResults' | 'ProjectSearchResults' | 'PublishedTemplateSearchResults' | 'RelatedWorkSearchResults' | 'RepositorySearchResults' | 'ResearchDomainSearchResults' | 'TemplateSearchResults' | 'UserSearchResults' | 'VersionedSectionSearchResults', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AffiliationSearchResults' | 'CollaboratorSearchResults' | 'MetadataStandardSearchResults' | 'ProjectSearchResults' | 'PublishedTemplateSearchResults' | 'RelatedWorkSearchResults' | 'RepositorySearchResults' | 'ResearchDomainSearchResults' | 'TemplateSearchResults' | 'UserSearchResults' | 'VersionedSectionSearchResults', ParentType, ContextType>; }; export type PlanResolvers = { @@ -5950,7 +5902,7 @@ export type QueryResolvers, ParentType, ContextType, Partial>; languages?: Resolver>>, ParentType, ContextType>; license?: Resolver, ParentType, ContextType, RequireFields>; - licenses?: Resolver, ParentType, ContextType, Partial>; + licenses?: Resolver>>, ParentType, ContextType>; me?: Resolver, ParentType, ContextType>; memberRoleById?: Resolver, ParentType, ContextType, RequireFields>; memberRoleByURL?: Resolver, ParentType, ContextType, RequireFields>; @@ -5989,7 +5941,6 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; relatedWorksByPlanStats?: Resolver, ParentType, ContextType, RequireFields>; relatedWorksByProject?: Resolver, ParentType, ContextType, RequireFields>; - relatedWorksByProjectStats?: Resolver, ParentType, ContextType, RequireFields>; repositories?: Resolver, ParentType, ContextType, RequireFields>; repositoriesByURIs?: Resolver>, ParentType, ContextType, RequireFields>; repository?: Resolver, ParentType, ContextType, RequireFields>; @@ -6113,7 +6064,6 @@ export type RelatedWorkStatsResultsResolvers, ParentType, ContextType>; rejectedCount?: Resolver, ParentType, ContextType>; totalCount?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; }; export type ReorderQuestionsResultResolvers = { @@ -6748,7 +6698,6 @@ export type Resolvers = { Language?: LanguageResolvers; License?: LicenseResolvers; LicenseErrors?: LicenseErrorsResolvers; - LicenseSearchResults?: LicenseSearchResultsResolvers; MD5?: GraphQLScalarType; MemberRole?: MemberRoleResolvers; MemberRoleErrors?: MemberRoleErrorsResolvers;