Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/api/orcid/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, test } from 'vitest';
import { isOrcidProfileEntry, isValidIOrcidUser, isValidOrcidId } from './models';

describe('isValidOrcidId', () => {
test('returns true for valid ORCID ids', () => {
expect(isValidOrcidId('0000-0002-1825-0097')).toBe(true);
expect(isValidOrcidId('1234-5678-9012-345X')).toBe(true);
});

test('returns false for malformed ORCID ids', () => {
expect(isValidOrcidId('0000-0002-1825-009')).toBe(false);
expect(isValidOrcidId('0000-0002-1825-00977')).toBe(false);
expect(isValidOrcidId('0000000218250097')).toBe(false);
expect(isValidOrcidId('0000-0002-1825-009x')).toBe(false);
expect(isValidOrcidId('abcd-0002-1825-0097')).toBe(false);
});

test('returns false for non-string values', () => {
expect(isValidOrcidId(null)).toBe(false);
expect(isValidOrcidId(undefined)).toBe(false);
expect(isValidOrcidId(1234)).toBe(false);
expect(isValidOrcidId({})).toBe(false);
});
});

describe('isValidIOrcidUser', () => {
const validUser = {
access_token: 'token',
expires_in: 3600,
name: 'Ada Lovelace',
orcid: '0000-0002-1825-0097',
refresh_token: 'refresh',
scope: '/read-limited',
token_type: 'bearer',
};

test('returns true for a valid ORCID user object', () => {
expect(isValidIOrcidUser(validUser)).toBe(true);
});

test('returns true for boundary numeric values that still produce a valid date', () => {
expect(isValidIOrcidUser({ ...validUser, expires_in: 0 })).toBe(true);
expect(isValidIOrcidUser({ ...validUser, expires_in: -1 })).toBe(true);
});

test('returns false when required keys are missing', () => {
const { refresh_token, ...missingRefreshToken } = validUser;

expect(isValidIOrcidUser(missingRefreshToken)).toBe(false);
});

test('returns false for non-object values', () => {
expect(isValidIOrcidUser(null)).toBe(false);
expect(isValidIOrcidUser(undefined)).toBe(false);
expect(isValidIOrcidUser('user')).toBe(false);
expect(isValidIOrcidUser([])).toBe(false);
});

test('returns false when any required property has the wrong type', () => {
expect(isValidIOrcidUser({ ...validUser, access_token: 123 })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, expires_in: '3600' })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, name: false })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, orcid: 12 })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, refresh_token: null })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, scope: {} })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, token_type: [] })).toBe(false);
});

test('returns false when expires_in produces an invalid date', () => {
expect(isValidIOrcidUser({ ...validUser, expires_in: NaN })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, expires_in: Infinity })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, expires_in: -Infinity })).toBe(false);
expect(isValidIOrcidUser({ ...validUser, expires_in: Number.MAX_SAFE_INTEGER })).toBe(false);
});
});

describe('isOrcidProfileEntry', () => {
const validEntry = {
identifier: '2024MNRAS.123..456A',
status: 'verified',
title: 'A valid ORCID profile entry',
pubyear: '2024',
pubmonth: '06',
updated: '2024-06-02',
putcode: '12345',
source: ['ADS', 'ORCID'],
};

test('returns true for a fully valid profile entry', () => {
expect(isOrcidProfileEntry(validEntry)).toBe(true);
});

test('returns true for allowed status values and nullable fields', () => {
expect(isOrcidProfileEntry({ ...validEntry, status: 'not in ADS', pubyear: null, pubmonth: null })).toBe(true);
expect(isOrcidProfileEntry({ ...validEntry, status: 'pending', pubmonth: undefined })).toBe(true);
expect(isOrcidProfileEntry({ ...validEntry, status: 'rejected', putcode: 12345 })).toBe(true);
});

test('returns false for nil or empty entries', () => {
expect(isOrcidProfileEntry(null)).toBe(false);
expect(isOrcidProfileEntry(undefined)).toBe(false);
expect(isOrcidProfileEntry('')).toBe(false);
expect(isOrcidProfileEntry({})).toBe(false);
expect(isOrcidProfileEntry([])).toBe(false);
});

test('returns false when required scalar fields have the wrong type', () => {
expect(isOrcidProfileEntry({ ...validEntry, identifier: 123 })).toBe(false);
expect(isOrcidProfileEntry({ ...validEntry, title: 123 })).toBe(false);
expect(isOrcidProfileEntry({ ...validEntry, updated: 123 })).toBe(false);
expect(isOrcidProfileEntry({ ...validEntry, putcode: true })).toBe(false);
});

test('returns false for invalid status values', () => {
expect(isOrcidProfileEntry({ ...validEntry, status: 'unknown' })).toBe(false);
expect(isOrcidProfileEntry({ ...validEntry, status: null })).toBe(false);
});

test('returns false when source is not an array of strings', () => {
expect(isOrcidProfileEntry({ ...validEntry, source: 'ADS' })).toBe(false);
expect(isOrcidProfileEntry({ ...validEntry, source: ['ADS', 1] })).toBe(false);
});

test('returns false when pubmonth has the wrong type', () => {
expect(isOrcidProfileEntry({ ...validEntry, pubmonth: 6 })).toBe(false);
});

test('accepts a non-string pubyear when pubmonth is undefined because of the current implementation', () => {
expect(isOrcidProfileEntry({ ...validEntry, pubyear: 2024, pubmonth: undefined })).toBe(true);
});
});
61 changes: 61 additions & 0 deletions src/api/orcid/orcid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'vitest';

import { OrcidKeys, orcidKeys } from '@/api/orcid/orcid';

describe('orcidKeys', () => {
test('exchangeToken omits user from the query key params', () => {
const params = {
user: {
orcid: '0000-0000-0000-0001',
access_token: 'secret-token',
},
code: 'auth-code',
state: 'some-state',
};

expect(orcidKeys.exchangeToken(params)).toEqual([
OrcidKeys.EXCHANGE_TOKEN,
{ code: 'auth-code', state: 'some-state' },
]);
});

test('profile preserves formatting flags while omitting user', () => {
const params = {
user: {
orcid: '0000-0000-0000-0001',
access_token: 'secret-token',
},
full: false,
update: true,
};

expect(orcidKeys.profile(params)).toEqual([OrcidKeys.PROFILE, { full: false, update: true }]);
});

test('name and getWork keys keep non-user identifiers only', () => {
const user = {
orcid: '0000-0000-0000-0001',
access_token: 'secret-token',
};

expect(orcidKeys.name({ user })).toEqual([OrcidKeys.NAME, {}]);
expect(orcidKeys.getWork({ user, putcode: 12345 })).toEqual([OrcidKeys.GET_WORK, { putcode: 12345 }]);
});

test('preference keys follow the expected query and mutation shapes', () => {
const params = {
user: {
orcid: '0000-0000-0000-0001',
access_token: 'secret-token',
},
};

expect(orcidKeys.getPreferences(params)).toEqual([OrcidKeys.GET_PREFERENCES, {}]);
expect(orcidKeys.setPreferences()).toEqual([OrcidKeys.SET_PREFERENCES]);
});

test('addWorks and removeWorks return stable mutation keys', () => {
expect(orcidKeys.addWorks()).toEqual([OrcidKeys.ADD_WORKS]);
expect(orcidKeys.removeWorks()).toEqual([OrcidKeys.REMOVE_WORKS]);
});
});
33 changes: 33 additions & 0 deletions src/components/AbstractSources/__tests__/createUrlByType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { createUrlByType } from '@/components/AbstractSources/linkGenerator';

describe('createUrlByType', () => {
it('encodes # in a DOI identifier', () => {
const url = createUrlByType(
'1999AN....320..163M',
'doi',
'10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#',
);
expect(url).not.toContain('#');
expect(url).toContain('%23');
});

it('encodes < and > in a DOI identifier', () => {
const url = createUrlByType('test', 'doi', '10.1000/foo<bar>');
expect(url).toContain('%3C');
expect(url).toContain('%3E');
expect(url).not.toContain('<');
expect(url).not.toContain('>');
});

it('preserves / in DOI identifiers', () => {
const url = createUrlByType('test', 'doi', '10.48550/arXiv.2507.19320');
expect(url).toContain('10.48550/arXiv.2507.19320');
});

it('returns empty string for non-string arguments', () => {
expect(createUrlByType(null as unknown as string, 'doi', '10.1000/x')).toBe('');
expect(createUrlByType('bib', null as unknown as string, '10.1000/x')).toBe('');
expect(createUrlByType('bib', 'doi', null as unknown as string)).toBe('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('processLinkData', () => {
rawType: 'INSTITUTION',
shortName: 'My Institution',
type: 'INSTITUTION',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft.degree=false&rft.genre=article',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft.degree=false&rft.genre=article',
},
],
dataProducts: [],
Expand Down Expand Up @@ -163,7 +163,7 @@ describe('processLinkData', () => {
rawType: 'INSTITUTION',
shortName: 'My Institution',
type: 'INSTITUTION',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft_id=info:bibcode/test&rft.genre=article',
url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft_id=info%3Abibcode%2Ftest&rft.genre=article',
},
{
description: 'Publisher PDF',
Expand Down
13 changes: 13 additions & 0 deletions src/components/AbstractSources/__tests__/openUrlGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from 'vitest';
import { processLinkData } from '@/components/AbstractSources/linkGenerator';
import { getOpenUrl } from '@/components/AbstractSources/openUrlGenerator';

test('processLinkData produces correct output', () => {
expect(
Expand Down Expand Up @@ -151,3 +152,15 @@ test('processLinkData can handle empty input', () => {
),
).toEqual(defaultReturn);
});

test('encodes # in DOI when building OpenURL', () => {
const url = getOpenUrl({
metadata: {
doi: ['10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#'],
bibcode: '1999AN....320..163M',
},
linkServer: 'https://example.com/openurl',
});
expect(url).not.toContain('#');
expect(url).toContain('%23');
});
3 changes: 2 additions & 1 deletion src/components/AbstractSources/linkGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getOpenUrl } from './openUrlGenerator';
import { isNilOrEmpty, isNonEmptyString } from 'ramda-adjunct';
import { IDataProductSource, IFullTextSource, ProcessLinkDataReturns } from '@/components/AbstractSources/types';
import { Esources, IDocsEntity } from '@/api/search/types';
import { encodeDOIPath } from '@/utils/common/encodeDOI';

/**
* Create the resolver url
Expand Down Expand Up @@ -139,7 +140,7 @@ export const processLinkData = (doc: IDocsEntity, linkServer?: string): ProcessL
*/
export const createUrlByType = function (bibcode: string, type: string, identifier: string): string {
if (typeof bibcode === 'string' && typeof type === 'string' && typeof identifier === 'string') {
return `${GATEWAY_BASE_URL + bibcode}/${type}:${identifier}`;
return `${GATEWAY_BASE_URL + bibcode}/${type}:${encodeDOIPath(identifier)}`;
}
Comment on lines 141 to 144
return '';
};
Expand Down
24 changes: 12 additions & 12 deletions src/components/AbstractSources/openUrlGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => {
};

interface IContext extends Partial<typeof parsed> {
spage: typeof parsed['rft.spage'];
volume: typeof parsed['rft.volume'];
title: typeof parsed['rft.jtitle'];
atitle: typeof parsed['rft.atitle'];
aulast: typeof parsed['rft.aulast'];
aufirst: typeof parsed['rft.aufirst'];
date: typeof parsed['rft.date'];
isbn: typeof parsed['rft.isbn'];
issn: typeof parsed['rft.issn'];
spage: (typeof parsed)['rft.spage'];
volume: (typeof parsed)['rft.volume'];
title: (typeof parsed)['rft.jtitle'];
atitle: (typeof parsed)['rft.atitle'];
aulast: (typeof parsed)['rft.aulast'];
aufirst: (typeof parsed)['rft.aufirst'];
date: (typeof parsed)['rft.date'];
isbn: (typeof parsed)['rft.isbn'];
issn: (typeof parsed)['rft.issn'];
}

// add extra fields to context object
Expand Down Expand Up @@ -110,11 +110,11 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => {
if (isArray(val)) {
return val
.filter((v) => v)
.map((v) => `${k}=${v}`)
.map((v) => `${k}=${encodeURIComponent(String(v))}`)
.join('&');
}
return `${k}=${val}`;
return `${k}=${encodeURIComponent(String(val))}`;
});

return encodeURI(openUrl + fields.join('&'));
return openUrl + fields.join('&');
};
Loading
Loading