From c09f6fd2516b77b8b605882d00d0b6d22c311b9e Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Fri, 31 Jul 2026 13:52:23 -0400 Subject: [PATCH] add external vocab use cases --- docs/useCases.md | 99 ++++++++++ .../domain/models/ExternalVocabularyConfig.ts | 74 +++++++ .../domain/models/ExternalVocabularyTerm.ts | 8 + .../IExternalVocabulariesRepository.ts | 19 ++ .../GetConfiguredExternalVocabularies.ts | 15 ++ .../useCases/GetExternalVocabularyConfig.ts | 15 ++ .../useCases/ResolveExternalVocabularyTerm.ts | 23 +++ .../useCases/SearchExternalVocabularyTerms.ts | 25 +++ .../ValidateExternalVocabularyValue.ts | 17 ++ src/externalVocabularies/index.ts | 33 ++++ .../ExternalVocabulariesRepository.ts | 82 ++++++++ src/index.ts | 1 + test/environment/.env | 4 +- .../ExternalVocabulariesRepository.test.ts | 154 +++++++++++++++ .../externalVocabularyHelper.ts | 51 +++++ .../ExternalVocabulariesRepository.test.ts | 182 ++++++++++++++++++ .../ExternalVocabularyUseCases.test.ts | 99 ++++++++++ 17 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 src/externalVocabularies/domain/models/ExternalVocabularyConfig.ts create mode 100644 src/externalVocabularies/domain/models/ExternalVocabularyTerm.ts create mode 100644 src/externalVocabularies/domain/repositories/IExternalVocabulariesRepository.ts create mode 100644 src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies.ts create mode 100644 src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig.ts create mode 100644 src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm.ts create mode 100644 src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms.ts create mode 100644 src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue.ts create mode 100644 src/externalVocabularies/index.ts create mode 100644 src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository.ts create mode 100644 test/integration/externalVocabularies/ExternalVocabulariesRepository.test.ts create mode 100644 test/testHelpers/externalVocabularies/externalVocabularyHelper.ts create mode 100644 test/unit/externalVocabularies/ExternalVocabulariesRepository.test.ts create mode 100644 test/unit/externalVocabularies/ExternalVocabularyUseCases.test.ts diff --git a/docs/useCases.md b/docs/useCases.md index 2df0b987..4f55c9c7 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -131,6 +131,13 @@ The different use cases currently available in the package are classified below, - [Mark As Read](#mark-as-read) - [Search](#Search) - [Get Search Services](#get-search-services) +- [External Vocabularies](#external-vocabularies) + - [External Vocabularies read use cases](#external-vocabularies-read-use-cases) + - [Get Configured External Vocabularies](#get-configured-external-vocabularies) + - [Get External Vocabulary Config](#get-external-vocabulary-config) + - [Search External Vocabulary Terms](#search-external-vocabulary-terms) + - [Resolve External Vocabulary Term](#resolve-external-vocabulary-term) + - [Validate External Vocabulary Value](#validate-external-vocabulary-value) - [External Tools](#external-tools) - [External Tools read use cases](#external-tools-read-use-cases) - [Get External Tools](#get-external-tools) @@ -3126,6 +3133,98 @@ getSearchServices.execute().then((searchServices: SearchService[]) => { _See [use case](../src/search/domain/useCases/GetSearchServices.ts) implementation_. +## External Vocabularies + +### External Vocabularies Read Use Cases + +#### Get Configured External Vocabularies + +Returns sanitized external vocabulary configurations derived from the Dataverse `:CVocConf` setting. + +##### Example call: + +```typescript +import { getConfiguredExternalVocabularies } from '@iqss/dataverse-client-javascript' + +getConfiguredExternalVocabularies.execute().then((configs: ExternalVocabularyConfig[]) => { + /* ... */ +}) +``` + +_See [use case](../src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies.ts) implementation_. + +#### Get External Vocabulary Config + +Returns the sanitized external vocabulary configuration for a field. + +##### Example call: + +```typescript +import { getExternalVocabularyConfig } from '@iqss/dataverse-client-javascript' + +getExternalVocabularyConfig + .execute('authorAffiliation') + .then((config: ExternalVocabularyConfig) => { + /* ... */ + }) +``` + +_See [use case](../src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig.ts) implementation_. + +#### Search External Vocabulary Terms + +Searches terms for a configured external vocabulary field. + +##### Example call: + +```typescript +import { searchExternalVocabularyTerms } from '@iqss/dataverse-client-javascript' + +searchExternalVocabularyTerms + .execute('authorAffiliation', 'harvard', 'ror', 'en') + .then((terms: ExternalVocabularyTerm[]) => { + /* ... */ + }) +``` + +_See [use case](../src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms.ts) implementation_. + +#### Resolve External Vocabulary Term + +Resolves a stored external vocabulary URI to its label and mapped metadata. + +##### Example call: + +```typescript +import { resolveExternalVocabularyTerm } from '@iqss/dataverse-client-javascript' + +resolveExternalVocabularyTerm + .execute('authorAffiliation', 'https://ror.org/03vek6s52', 'en') + .then((term: ExternalVocabularyTerm | null) => { + /* ... */ + }) +``` + +_See [use case](../src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm.ts) implementation_. + +#### Validate External Vocabulary Value + +Validates a value against the configured vocabulary URI spaces and free-text rules for a field. + +##### Example call: + +```typescript +import { validateExternalVocabularyValue } from '@iqss/dataverse-client-javascript' + +validateExternalVocabularyValue + .execute('authorAffiliation', 'https://ror.org/03vek6s52') + .then((valid: boolean) => { + /* ... */ + }) +``` + +_See [use case](../src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue.ts) implementation_. + ## External Tools ### External Tools Read Use Cases diff --git a/src/externalVocabularies/domain/models/ExternalVocabularyConfig.ts b/src/externalVocabularies/domain/models/ExternalVocabularyConfig.ts new file mode 100644 index 00000000..11f4437d --- /dev/null +++ b/src/externalVocabularies/domain/models/ExternalVocabularyConfig.ts @@ -0,0 +1,74 @@ +export type ExternalVocabularyJsonValue = + | string + | number + | boolean + | null + | ExternalVocabularyJsonValue[] + | { [key: string]: ExternalVocabularyJsonValue } + +export type ExternalVocabularyPathConfig = string | string[] + +export interface ExternalVocabularyConfig { + fieldName: string + termUriField: string + protocol: string + allowFreeText: boolean + languages: string + termParentUri?: string + vocabs: Record + managedFields: Record + provider?: ExternalVocabularyProviderConfig + headers?: Record + prefix?: string + 'retrieval-uri'?: string + 'retrieval-filtering'?: ExternalVocabularyRetrievalFilteringConfig + 'search-uri'?: string + 'search-url'?: string + 'resolve-uri'?: string + 'resolve-url'?: string + 'results-path'?: string + 'resolve-result-path'?: string + 'uri-path'?: ExternalVocabularyPathConfig + 'label-path'?: ExternalVocabularyPathConfig + 'vocabulary-name'?: string + 'vocabulary-name-path'?: ExternalVocabularyPathConfig + 'vocabulary-uri'?: string + 'vocabulary-uri-path'?: ExternalVocabularyPathConfig + limit?: number +} + +export interface ExternalVocabularyVocabConfig { + uriSpace: string + vocabularyUri?: string +} + +export interface ExternalVocabularyProviderConfig { + type?: 'http-json' | string + 'search-uri'?: string + 'search-url'?: string + 'resolve-uri'?: string + 'resolve-url'?: string + 'results-path'?: string + 'resolve-result-path'?: string + 'uri-path'?: ExternalVocabularyPathConfig + 'label-path'?: ExternalVocabularyPathConfig + 'vocabulary-name'?: string + 'vocabulary-name-path'?: ExternalVocabularyPathConfig + 'vocabulary-uri'?: string + 'vocabulary-uri-path'?: ExternalVocabularyPathConfig + limit?: number + [key: string]: ExternalVocabularyJsonValue | undefined +} + +export interface ExternalVocabularyRetrievalFilteringConfig { + '@context'?: Record + [fieldName: string]: + | ExternalVocabularyRetrievalFilterConfig + | Record + | undefined +} + +export interface ExternalVocabularyRetrievalFilterConfig { + pattern: string + params?: string[] +} diff --git a/src/externalVocabularies/domain/models/ExternalVocabularyTerm.ts b/src/externalVocabularies/domain/models/ExternalVocabularyTerm.ts new file mode 100644 index 00000000..2c73a1f4 --- /dev/null +++ b/src/externalVocabularies/domain/models/ExternalVocabularyTerm.ts @@ -0,0 +1,8 @@ +export interface ExternalVocabularyTerm { + uri: string + label: string + vocabularyName?: string + vocabularyUri?: string + source?: string + mappedFields?: Record +} diff --git a/src/externalVocabularies/domain/repositories/IExternalVocabulariesRepository.ts b/src/externalVocabularies/domain/repositories/IExternalVocabulariesRepository.ts new file mode 100644 index 00000000..14f6d2c3 --- /dev/null +++ b/src/externalVocabularies/domain/repositories/IExternalVocabulariesRepository.ts @@ -0,0 +1,19 @@ +import { ExternalVocabularyConfig } from '../models/ExternalVocabularyConfig' +import { ExternalVocabularyTerm } from '../models/ExternalVocabularyTerm' + +export interface IExternalVocabulariesRepository { + getConfiguredExternalVocabularies(): Promise + getExternalVocabularyConfig(fieldName: string): Promise + searchExternalVocabularyTerms( + fieldName: string, + query: string, + vocabulary?: string, + language?: string + ): Promise + resolveExternalVocabularyTerm( + fieldName: string, + uri: string, + language?: string + ): Promise + validateExternalVocabularyValue(fieldName: string, value: string): Promise +} diff --git a/src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies.ts b/src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies.ts new file mode 100644 index 00000000..033eedae --- /dev/null +++ b/src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ExternalVocabularyConfig } from '../models/ExternalVocabularyConfig' +import { IExternalVocabulariesRepository } from '../repositories/IExternalVocabulariesRepository' + +export class GetConfiguredExternalVocabularies implements UseCase { + private externalVocabulariesRepository: IExternalVocabulariesRepository + + constructor(externalVocabulariesRepository: IExternalVocabulariesRepository) { + this.externalVocabulariesRepository = externalVocabulariesRepository + } + + async execute(): Promise { + return await this.externalVocabulariesRepository.getConfiguredExternalVocabularies() + } +} diff --git a/src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig.ts b/src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig.ts new file mode 100644 index 00000000..6b0965d5 --- /dev/null +++ b/src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ExternalVocabularyConfig } from '../models/ExternalVocabularyConfig' +import { IExternalVocabulariesRepository } from '../repositories/IExternalVocabulariesRepository' + +export class GetExternalVocabularyConfig implements UseCase { + private externalVocabulariesRepository: IExternalVocabulariesRepository + + constructor(externalVocabulariesRepository: IExternalVocabulariesRepository) { + this.externalVocabulariesRepository = externalVocabulariesRepository + } + + async execute(fieldName: string): Promise { + return await this.externalVocabulariesRepository.getExternalVocabularyConfig(fieldName) + } +} diff --git a/src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm.ts b/src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm.ts new file mode 100644 index 00000000..9bd99e2c --- /dev/null +++ b/src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm.ts @@ -0,0 +1,23 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ExternalVocabularyTerm } from '../models/ExternalVocabularyTerm' +import { IExternalVocabulariesRepository } from '../repositories/IExternalVocabulariesRepository' + +export class ResolveExternalVocabularyTerm implements UseCase { + private externalVocabulariesRepository: IExternalVocabulariesRepository + + constructor(externalVocabulariesRepository: IExternalVocabulariesRepository) { + this.externalVocabulariesRepository = externalVocabulariesRepository + } + + async execute( + fieldName: string, + uri: string, + language?: string + ): Promise { + return await this.externalVocabulariesRepository.resolveExternalVocabularyTerm( + fieldName, + uri, + language + ) + } +} diff --git a/src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms.ts b/src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms.ts new file mode 100644 index 00000000..df6504ad --- /dev/null +++ b/src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms.ts @@ -0,0 +1,25 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ExternalVocabularyTerm } from '../models/ExternalVocabularyTerm' +import { IExternalVocabulariesRepository } from '../repositories/IExternalVocabulariesRepository' + +export class SearchExternalVocabularyTerms implements UseCase { + private externalVocabulariesRepository: IExternalVocabulariesRepository + + constructor(externalVocabulariesRepository: IExternalVocabulariesRepository) { + this.externalVocabulariesRepository = externalVocabulariesRepository + } + + async execute( + fieldName: string, + query: string, + vocabulary?: string, + language?: string + ): Promise { + return await this.externalVocabulariesRepository.searchExternalVocabularyTerms( + fieldName, + query, + vocabulary, + language + ) + } +} diff --git a/src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue.ts b/src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue.ts new file mode 100644 index 00000000..234ec0dd --- /dev/null +++ b/src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue.ts @@ -0,0 +1,17 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IExternalVocabulariesRepository } from '../repositories/IExternalVocabulariesRepository' + +export class ValidateExternalVocabularyValue implements UseCase { + private externalVocabulariesRepository: IExternalVocabulariesRepository + + constructor(externalVocabulariesRepository: IExternalVocabulariesRepository) { + this.externalVocabulariesRepository = externalVocabulariesRepository + } + + async execute(fieldName: string, value: string): Promise { + return await this.externalVocabulariesRepository.validateExternalVocabularyValue( + fieldName, + value + ) + } +} diff --git a/src/externalVocabularies/index.ts b/src/externalVocabularies/index.ts new file mode 100644 index 00000000..7cb68427 --- /dev/null +++ b/src/externalVocabularies/index.ts @@ -0,0 +1,33 @@ +import { GetConfiguredExternalVocabularies } from './domain/useCases/GetConfiguredExternalVocabularies' +import { GetExternalVocabularyConfig } from './domain/useCases/GetExternalVocabularyConfig' +import { ResolveExternalVocabularyTerm } from './domain/useCases/ResolveExternalVocabularyTerm' +import { SearchExternalVocabularyTerms } from './domain/useCases/SearchExternalVocabularyTerms' +import { ValidateExternalVocabularyValue } from './domain/useCases/ValidateExternalVocabularyValue' +import { ExternalVocabulariesRepository } from './infra/repositories/ExternalVocabulariesRepository' + +const externalVocabulariesRepository = new ExternalVocabulariesRepository() + +const getConfiguredExternalVocabularies = new GetConfiguredExternalVocabularies( + externalVocabulariesRepository +) +const getExternalVocabularyConfig = new GetExternalVocabularyConfig(externalVocabulariesRepository) +const searchExternalVocabularyTerms = new SearchExternalVocabularyTerms( + externalVocabulariesRepository +) +const resolveExternalVocabularyTerm = new ResolveExternalVocabularyTerm( + externalVocabulariesRepository +) +const validateExternalVocabularyValue = new ValidateExternalVocabularyValue( + externalVocabulariesRepository +) + +export { + getConfiguredExternalVocabularies, + getExternalVocabularyConfig, + searchExternalVocabularyTerms, + resolveExternalVocabularyTerm, + validateExternalVocabularyValue +} + +export { ExternalVocabularyConfig } from './domain/models/ExternalVocabularyConfig' +export { ExternalVocabularyTerm } from './domain/models/ExternalVocabularyTerm' diff --git a/src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository.ts b/src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository.ts new file mode 100644 index 00000000..6c26da57 --- /dev/null +++ b/src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository.ts @@ -0,0 +1,82 @@ +import { AxiosResponse } from 'axios' +import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { ExternalVocabularyConfig } from '../../domain/models/ExternalVocabularyConfig' +import { ExternalVocabularyTerm } from '../../domain/models/ExternalVocabularyTerm' +import { IExternalVocabulariesRepository } from '../../domain/repositories/IExternalVocabulariesRepository' + +export class ExternalVocabulariesRepository + extends ApiRepository + implements IExternalVocabulariesRepository +{ + private readonly externalVocabulariesResourceName: string = 'external-vocabularies' + + public async getConfiguredExternalVocabularies(): Promise { + return this.doGet(this.buildApiEndpoint(this.externalVocabulariesResourceName)) + .then((response: AxiosResponse<{ data: ExternalVocabularyConfig[] }>) => response.data.data) + .catch((error) => { + throw error + }) + } + + public async getExternalVocabularyConfig(fieldName: string): Promise { + return this.doGet(this.buildApiEndpoint(this.externalVocabulariesResourceName, fieldName)) + .then((response: AxiosResponse<{ data: ExternalVocabularyConfig }>) => response.data.data) + .catch((error) => { + throw error + }) + } + + public async searchExternalVocabularyTerms( + fieldName: string, + query: string, + vocabulary?: string, + language?: string + ): Promise { + return this.doGet( + this.buildApiEndpoint(this.externalVocabulariesResourceName, `${fieldName}/search`), + false, + { + q: query, + ...(vocabulary ? { vocabulary } : {}), + ...(language ? { language } : {}) + } + ) + .then((response: AxiosResponse<{ data: ExternalVocabularyTerm[] }>) => response.data.data) + .catch((error) => { + throw error + }) + } + + public async resolveExternalVocabularyTerm( + fieldName: string, + uri: string, + language?: string + ): Promise { + return this.doGet( + this.buildApiEndpoint(this.externalVocabulariesResourceName, `${fieldName}/resolve`), + false, + { + uri, + ...(language ? { language } : {}) + } + ) + .then((response: AxiosResponse<{ data: ExternalVocabularyTerm }>) => response.data.data) + .catch((error) => { + throw error + }) + } + + public async validateExternalVocabularyValue(fieldName: string, value: string): Promise { + return this.doGet( + this.buildApiEndpoint(this.externalVocabulariesResourceName, `${fieldName}/validate`), + false, + { + value + } + ) + .then((response: AxiosResponse<{ data: { valid: boolean } }>) => response.data.data.valid) + .catch((error) => { + throw error + }) + } +} diff --git a/src/index.ts b/src/index.ts index efe54b0d..2f584023 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from './notifications' export * from './search' export * from './licenses' export * from './externalTools' +export * from './externalVocabularies' export * from './templates' export * from './guestbooks' export * from './access' diff --git a/test/environment/.env b/test/environment/.env index e7b54bde..ada7c751 100644 --- a/test/environment/.env +++ b/test/environment/.env @@ -1,6 +1,6 @@ POSTGRES_VERSION=17 DATAVERSE_DB_USER=dataverse SOLR_VERSION=9.8.0 -DATAVERSE_IMAGE_REGISTRY=docker.io -DATAVERSE_IMAGE_TAG=unstable +DATAVERSE_IMAGE_REGISTRY=ghcr.io +DATAVERSE_IMAGE_TAG=external-vocabulary-api DATAVERSE_BOOTSTRAP_TIMEOUT=5m diff --git a/test/integration/externalVocabularies/ExternalVocabulariesRepository.test.ts b/test/integration/externalVocabularies/ExternalVocabulariesRepository.test.ts new file mode 100644 index 00000000..2ec9aff3 --- /dev/null +++ b/test/integration/externalVocabularies/ExternalVocabulariesRepository.test.ts @@ -0,0 +1,154 @@ +import axios, { AxiosResponse } from 'axios' +import { ApiConfig } from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { ExternalVocabulariesRepository } from '../../../src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('ExternalVocabulariesRepository', () => { + const sut: ExternalVocabulariesRepository = new ExternalVocabulariesRepository() + const fieldName = 'authorAffiliation' + let previousCVocConf: string | undefined + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + + previousCVocConf = await getCVocConfViaApi() + await setCVocConfViaApi(JSON.stringify(createIntegrationCVocConf())) + }) + + afterAll(async () => { + if (previousCVocConf === undefined) { + await deleteCVocConfViaApi() + return + } + + await setCVocConfViaApi(previousCVocConf) + }) + + test('should return configured external vocabularies', async () => { + const actual = await sut.getConfiguredExternalVocabularies() + + expect(actual).toHaveLength(1) + expect(actual[0]).toMatchObject({ + fieldName, + termUriField: fieldName, + protocol: 'integration-http-json', + allowFreeText: true, + vocabs: { + dataverseVersion: { + uriSpace: '' + } + }, + managedFields: {} + }) + }) + + test('should return external vocabulary config for a field', async () => { + const actual = await sut.getExternalVocabularyConfig(fieldName) + + expect(actual.fieldName).toBe(fieldName) + expect(actual.termUriField).toBe(fieldName) + expect(actual.protocol).toBe('integration-http-json') + }) + + test('should search external vocabulary terms through the backend provider proxy', async () => { + const actual = await sut.searchExternalVocabularyTerms(fieldName, 'version') + + expect(actual).toHaveLength(1) + expect(actual[0].uri).toBeTruthy() + expect(actual[0].label).toBe(actual[0].uri) + expect(actual[0].source).toBe('integration-http-json') + expect(actual[0].mappedFields).toMatchObject({ + termName: actual[0].label, + scheme: 'DataverseVersion', + '@type': 'https://schema.org/SoftwareApplication' + }) + }) + + test('should resolve an external vocabulary term through the backend provider proxy', async () => { + const actual = await sut.resolveExternalVocabularyTerm(fieldName, 'ignored') + + expect(actual).not.toBeNull() + expect(actual?.uri).toBeTruthy() + expect(actual?.label).toBe(actual?.uri) + expect(actual?.vocabularyName).toBe('Dataverse Version') + expect(actual?.vocabularyUri).toBe('https://dataverse.org/') + expect(actual?.mappedFields).toMatchObject({ + termName: actual?.label, + scheme: 'DataverseVersion', + '@type': 'https://schema.org/SoftwareApplication' + }) + }) + + test('should validate external vocabulary values', async () => { + const actual = await sut.validateExternalVocabularyValue(fieldName, 'free text value') + + expect(actual).toBe(true) + }) +}) + +async function getCVocConfViaApi(): Promise { + return axios + .get(`${TestConstants.TEST_API_URL}/admin/settings/:CVocConf`) + .then((response: AxiosResponse<{ data: { message: string } }>) => response.data.data.message) + .catch(() => undefined) +} + +async function setCVocConfViaApi(cvocConf: string): Promise { + return axios.put(`${TestConstants.TEST_API_URL}/admin/settings/:CVocConf`, cvocConf, { + headers: { 'Content-Type': 'text/plain' } + }) +} + +async function deleteCVocConfViaApi(): Promise { + return axios + .delete(`${TestConstants.TEST_API_URL}/admin/settings/:CVocConf`) + .catch(() => undefined) +} + +function createIntegrationCVocConf(): object[] { + return [ + { + 'field-name': 'authorAffiliation', + 'term-uri-field': 'authorAffiliation', + protocol: 'integration-http-json', + 'retrieval-uri': 'http://localhost:8080/api/info/version', + 'allow-free-text': true, + 'managed-fields': {}, + languages: '', + vocabs: { + dataverseVersion: { + uriSpace: '' + } + }, + provider: { + type: 'http-json', + 'search-uri': 'http://localhost:8080/api/info/version', + 'resolve-uri': 'http://localhost:8080/api/info/version', + 'results-path': '/data', + 'resolve-result-path': '/data', + 'uri-path': '/version', + 'label-path': '/version', + 'vocabulary-name': 'Dataverse Version', + 'vocabulary-uri': 'https://dataverse.org/', + limit: 1 + }, + 'retrieval-filtering': { + termName: { + pattern: '{0}', + params: ['/version'] + }, + scheme: { + pattern: 'DataverseVersion' + }, + '@type': { + pattern: 'https://schema.org/SoftwareApplication' + } + } + } + ] +} diff --git a/test/testHelpers/externalVocabularies/externalVocabularyHelper.ts b/test/testHelpers/externalVocabularies/externalVocabularyHelper.ts new file mode 100644 index 00000000..ec417472 --- /dev/null +++ b/test/testHelpers/externalVocabularies/externalVocabularyHelper.ts @@ -0,0 +1,51 @@ +import { ExternalVocabularyConfig } from '../../../src/externalVocabularies/domain/models/ExternalVocabularyConfig' +import { ExternalVocabularyTerm } from '../../../src/externalVocabularies/domain/models/ExternalVocabularyTerm' + +export const createExternalVocabularyConfig = ( + fieldName = 'authorAffiliation' +): ExternalVocabularyConfig => ({ + fieldName, + termUriField: fieldName, + protocol: 'ror', + allowFreeText: true, + languages: '', + vocabs: { + ror: { + uriSpace: 'https://ror.org/', + vocabularyUri: 'https://ror.org/' + } + }, + managedFields: {}, + provider: { + type: 'http-json', + 'search-uri': 'https://api.ror.org/organizations?query={encodeUrl:query}', + 'resolve-uri': 'https://api.ror.org/organizations/{encodeUrl:termId}', + 'results-path': '/items/*', + 'uri-path': '/id', + 'label-path': '/names/types=ror_display/value', + 'vocabulary-name': 'ROR', + 'vocabulary-uri': 'https://ror.org/', + limit: 10 + }, + 'retrieval-filtering': { + termName: { + pattern: '{0}', + params: ['/names/types=ror_display/value'] + }, + '@type': { + pattern: 'https://schema.org/Organization' + } + } +}) + +export const createExternalVocabularyTerm = (): ExternalVocabularyTerm => ({ + uri: 'https://ror.org/03vek6s52', + label: 'Harvard University', + vocabularyName: 'ROR', + vocabularyUri: 'https://ror.org/', + source: 'ror', + mappedFields: { + termName: 'Harvard University', + '@type': 'https://schema.org/Organization' + } +}) diff --git a/test/unit/externalVocabularies/ExternalVocabulariesRepository.test.ts b/test/unit/externalVocabularies/ExternalVocabulariesRepository.test.ts new file mode 100644 index 00000000..d9f6d9ac --- /dev/null +++ b/test/unit/externalVocabularies/ExternalVocabulariesRepository.test.ts @@ -0,0 +1,182 @@ +import axios from 'axios' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { ExternalVocabulariesRepository } from '../../../src/externalVocabularies/infra/repositories/ExternalVocabulariesRepository' +import { TestConstants } from '../../testHelpers/TestConstants' +import { + createExternalVocabularyConfig, + createExternalVocabularyTerm +} from '../../testHelpers/externalVocabularies/externalVocabularyHelper' + +describe('ExternalVocabulariesRepository', () => { + const sut: ExternalVocabulariesRepository = new ExternalVocabulariesRepository() + const fieldName = 'authorAffiliation' + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + + describe('getConfiguredExternalVocabularies', () => { + test('should return configured external vocabularies on successful response', async () => { + const configs = [createExternalVocabularyConfig()] + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: configs + } + }) + + const actual = await sut.getConfiguredExternalVocabularies() + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies`, + TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG + ) + expect(actual).toEqual(configs) + }) + }) + + describe('getExternalVocabularyConfig', () => { + test('should return configured external vocabulary for field on successful response', async () => { + const config = createExternalVocabularyConfig() + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: config + } + }) + + const actual = await sut.getExternalVocabularyConfig(fieldName) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies/${fieldName}`, + TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG + ) + expect(actual).toEqual(config) + }) + }) + + describe('searchExternalVocabularyTerms', () => { + test('should return external vocabulary terms on successful response', async () => { + const terms = [createExternalVocabularyTerm()] + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: terms + } + }) + + const actual = await sut.searchExternalVocabularyTerms(fieldName, 'harvard', 'ror', 'en') + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies/${fieldName}/search`, + { + params: { + q: 'harvard', + vocabulary: 'ror', + language: 'en' + }, + headers: { + 'Content-Type': 'application/json' + } + } + ) + expect(actual).toEqual(terms) + }) + + test('should omit optional vocabulary and language query params', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: [] + } + }) + + await sut.searchExternalVocabularyTerms(fieldName, 'harvard') + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies/${fieldName}/search`, + { + params: { + q: 'harvard' + }, + headers: { + 'Content-Type': 'application/json' + } + } + ) + }) + }) + + describe('resolveExternalVocabularyTerm', () => { + test('should return external vocabulary term on successful response', async () => { + const term = createExternalVocabularyTerm() + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: term + } + }) + + const actual = await sut.resolveExternalVocabularyTerm(fieldName, term.uri, 'en') + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies/${fieldName}/resolve`, + { + params: { + uri: term.uri, + language: 'en' + }, + headers: { + 'Content-Type': 'application/json' + } + } + ) + expect(actual).toEqual(term) + }) + }) + + describe('validateExternalVocabularyValue', () => { + test('should return validation result on successful response', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + status: 'OK', + data: { + valid: true + } + } + }) + + const actual = await sut.validateExternalVocabularyValue( + fieldName, + 'https://ror.org/03vek6s52' + ) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/external-vocabularies/${fieldName}/validate`, + { + params: { + value: 'https://ror.org/03vek6s52' + }, + headers: { + 'Content-Type': 'application/json' + } + } + ) + expect(actual).toBe(true) + }) + }) + + test('should throw ReadError on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.getConfiguredExternalVocabularies()).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/externalVocabularies/ExternalVocabularyUseCases.test.ts b/test/unit/externalVocabularies/ExternalVocabularyUseCases.test.ts new file mode 100644 index 00000000..44388f0e --- /dev/null +++ b/test/unit/externalVocabularies/ExternalVocabularyUseCases.test.ts @@ -0,0 +1,99 @@ +import { ReadError } from '../../../src' +import { IExternalVocabulariesRepository } from '../../../src/externalVocabularies/domain/repositories/IExternalVocabulariesRepository' +import { GetConfiguredExternalVocabularies } from '../../../src/externalVocabularies/domain/useCases/GetConfiguredExternalVocabularies' +import { GetExternalVocabularyConfig } from '../../../src/externalVocabularies/domain/useCases/GetExternalVocabularyConfig' +import { ResolveExternalVocabularyTerm } from '../../../src/externalVocabularies/domain/useCases/ResolveExternalVocabularyTerm' +import { SearchExternalVocabularyTerms } from '../../../src/externalVocabularies/domain/useCases/SearchExternalVocabularyTerms' +import { ValidateExternalVocabularyValue } from '../../../src/externalVocabularies/domain/useCases/ValidateExternalVocabularyValue' +import { + createExternalVocabularyConfig, + createExternalVocabularyTerm +} from '../../testHelpers/externalVocabularies/externalVocabularyHelper' + +describe('external vocabulary use cases', () => { + test('GetConfiguredExternalVocabularies returns repository result', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + const configs = [createExternalVocabularyConfig()] + repository.getConfiguredExternalVocabularies = jest.fn().mockResolvedValue(configs) + + const actual = await new GetConfiguredExternalVocabularies(repository).execute() + + expect(actual).toEqual(configs) + }) + + test('GetExternalVocabularyConfig returns repository result', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + const config = createExternalVocabularyConfig() + repository.getExternalVocabularyConfig = jest.fn().mockResolvedValue(config) + + const actual = await new GetExternalVocabularyConfig(repository).execute('authorAffiliation') + + expect(repository.getExternalVocabularyConfig).toHaveBeenCalledWith('authorAffiliation') + expect(actual).toEqual(config) + }) + + test('SearchExternalVocabularyTerms returns repository result', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + const terms = [createExternalVocabularyTerm()] + repository.searchExternalVocabularyTerms = jest.fn().mockResolvedValue(terms) + + const actual = await new SearchExternalVocabularyTerms(repository).execute( + 'authorAffiliation', + 'harvard', + 'ror', + 'en' + ) + + expect(repository.searchExternalVocabularyTerms).toHaveBeenCalledWith( + 'authorAffiliation', + 'harvard', + 'ror', + 'en' + ) + expect(actual).toEqual(terms) + }) + + test('ResolveExternalVocabularyTerm returns repository result', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + const term = createExternalVocabularyTerm() + repository.resolveExternalVocabularyTerm = jest.fn().mockResolvedValue(term) + + const actual = await new ResolveExternalVocabularyTerm(repository).execute( + 'authorAffiliation', + term.uri, + 'en' + ) + + expect(repository.resolveExternalVocabularyTerm).toHaveBeenCalledWith( + 'authorAffiliation', + term.uri, + 'en' + ) + expect(actual).toEqual(term) + }) + + test('ValidateExternalVocabularyValue returns repository result', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + repository.validateExternalVocabularyValue = jest.fn().mockResolvedValue(true) + + const actual = await new ValidateExternalVocabularyValue(repository).execute( + 'authorAffiliation', + 'https://ror.org/03vek6s52' + ) + + expect(repository.validateExternalVocabularyValue).toHaveBeenCalledWith( + 'authorAffiliation', + 'https://ror.org/03vek6s52' + ) + expect(actual).toBe(true) + }) + + test('use cases propagate repository errors', async () => { + const repository: IExternalVocabulariesRepository = {} as IExternalVocabulariesRepository + repository.getConfiguredExternalVocabularies = jest.fn().mockRejectedValue(new ReadError()) + + await expect(new GetConfiguredExternalVocabularies(repository).execute()).rejects.toThrow( + ReadError + ) + }) +})