From 40dec0de79291fddc68288ac7d6bef99259abfee Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Tue, 16 Jun 2026 06:30:58 -0400 Subject: [PATCH 1/5] initial version of reviews use case --- docs/useCases.md | 24 ++ src/datasets/domain/models/DatasetReview.ts | 23 ++ .../repositories/IDatasetsRepository.ts | 2 + .../domain/useCases/GetDatasetReviews.ts | 21 + src/datasets/index.ts | 10 +- .../infra/repositories/DatasetsRepository.ts | 10 + .../transformers/DatasetReviewPayload.ts | 23 ++ .../transformers/datasetReviewTransformers.ts | 28 ++ test/environment/docker-compose.yml | 2 + .../datasets/DatasetsRepository.test.ts | 388 +++++++++++++++++- .../collections/collectionHelper.ts | 22 + test/testHelpers/datasets/datasetHelper.ts | 20 + .../datasets/datasetReviewHelper.ts | 62 +++ test/testHelpers/search/solrHelper.ts | 132 ++++++ test/unit/datasets/DatasetsRepository.test.ts | 100 +++++ test/unit/datasets/GetDatasetReviews.test.ts | 29 ++ 16 files changed, 892 insertions(+), 4 deletions(-) create mode 100644 src/datasets/domain/models/DatasetReview.ts create mode 100644 src/datasets/domain/useCases/GetDatasetReviews.ts create mode 100644 src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts create mode 100644 src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts create mode 100644 test/testHelpers/datasets/datasetReviewHelper.ts create mode 100644 test/testHelpers/search/solrHelper.ts create mode 100644 test/unit/datasets/GetDatasetReviews.test.ts diff --git a/docs/useCases.md b/docs/useCases.md index 5433e00a..dc81f6fb 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -1098,6 +1098,30 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetLocks.ts) implementati The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. +#### Get Dataset Reviews + +Returns a [DatasetReview](../src/datasets/domain/models/DatasetReview.ts) array with the local review datasets that review a dataset. Review datasets are matched by their `itemReviewedUrl` metadata field pointing at the URL form of the dataset persistent identifier. + +##### Example call: + +```typescript +import { getDatasetReviews } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.5072/FK2/ABCDEF' + +getDatasetReviews.execute(datasetId).then((datasetReviews: DatasetReview[]) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/GetDatasetReviews.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. An API token is optional when the review dataset has been published, but unpublished targets require permission to view the unpublished dataset. + #### Get Dataset Summary Field Names Returns the names of the dataset summary fields configured in the installation. diff --git a/src/datasets/domain/models/DatasetReview.ts b/src/datasets/domain/models/DatasetReview.ts new file mode 100644 index 00000000..15cf8adc --- /dev/null +++ b/src/datasets/domain/models/DatasetReview.ts @@ -0,0 +1,23 @@ +export interface DatasetReview { + title: string + authors: string[] + persistentId: string + persistentIdUrl: string + id: number + citation: string + citationHtml: string + datePublished: string + description: string + rubricMetadataBlocks: DatasetReviewRubricMetadataBlock[] +} + +export interface DatasetReviewRubricMetadataBlock { + name: string + displayName: string + fields: DatasetReviewRubricMetadataField[] +} + +export interface DatasetReviewRubricMetadataField { + typeName: string + value: unknown +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index e5aa4497..b3a20d73 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -18,6 +18,7 @@ import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest import { DatasetTypeDTO } from '../dtos/DatasetTypeDTO' import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../models/DatasetUploadLimits' +import { DatasetReview } from '../models/DatasetReview' export interface IDatasetsRepository { getDataset( @@ -104,4 +105,5 @@ export interface IDatasetsRepository { ): Promise getDatasetStorageDriver(datasetId: number | string): Promise getDatasetUploadLimits(datasetId: number | string): Promise + getDatasetReviews(datasetId: number | string): Promise } diff --git a/src/datasets/domain/useCases/GetDatasetReviews.ts b/src/datasets/domain/useCases/GetDatasetReviews.ts new file mode 100644 index 00000000..33ce43ae --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetReviews.ts @@ -0,0 +1,21 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetReview } from '../models/DatasetReview' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class GetDatasetReviews implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns the local review datasets that review the given dataset. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise} + */ + async execute(datasetId: number | string): Promise { + return this.datasetsRepository.getDatasetReviews(datasetId) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 99df9b56..37d09bc1 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -35,6 +35,7 @@ import { UpdateTermsOfAccess } from './domain/useCases/UpdateTermsOfAccess' import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense' import { GetDatasetStorageDriver } from './domain/useCases/GetDatasetStorageDriver' import { GetDatasetUploadLimits } from './domain/useCases/GetDatasetUploadLimits' +import { GetDatasetReviews } from './domain/useCases/GetDatasetReviews' const datasetsRepository = new DatasetsRepository() @@ -86,6 +87,7 @@ const updateTermsOfAccess = new UpdateTermsOfAccess(datasetsRepository) const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository) const getDatasetStorageDriver = new GetDatasetStorageDriver(datasetsRepository) const getDatasetUploadLimits = new GetDatasetUploadLimits(datasetsRepository) +const getDatasetReviews = new GetDatasetReviews(datasetsRepository) export { getDataset, @@ -118,7 +120,8 @@ export { deleteDatasetType, updateDatasetLicense, getDatasetStorageDriver, - getDatasetUploadLimits + getDatasetUploadLimits, + getDatasetReviews } export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' @@ -158,3 +161,8 @@ export { DatasetLinkedCollection } from './domain/models/DatasetLinkedCollection export { DatasetType } from './domain/models/DatasetType' export { DatasetTypeDTO } from './domain/dtos/DatasetTypeDTO' export { DatasetUploadLimits } from './domain/models/DatasetUploadLimits' +export { + DatasetReview, + DatasetReviewRubricMetadataBlock, + DatasetReviewRubricMetadataField +} from './domain/models/DatasetReview' diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index ee1bc1c9..e004aa8d 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -31,6 +31,8 @@ import { DatasetLicenseUpdateRequest } from '../../domain/dtos/DatasetLicenseUpd import { DatasetTypeDTO } from '../../domain/dtos/DatasetTypeDTO' import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../../domain/models/DatasetUploadLimits' +import { DatasetReview } from '../../domain/models/DatasetReview' +import { transformDatasetReviewsResponseToDatasetReviews } from './transformers/datasetReviewTransformers' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -523,4 +525,12 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi throw error }) } + + public async getDatasetReviews(datasetId: number | string): Promise { + return this.doGet(this.buildApiEndpoint(this.datasetsResourceName, 'reviews', datasetId), true) + .then((response) => transformDatasetReviewsResponseToDatasetReviews(response)) + .catch((error) => { + throw error + }) + } } diff --git a/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts new file mode 100644 index 00000000..3f0dc760 --- /dev/null +++ b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts @@ -0,0 +1,23 @@ +export interface DatasetReviewPayload { + title: string + authors?: string[] + persistentId: string + persistentIdUrl: string + id: number + citation: string + citationHtml: string + datePublished?: string + description?: string + rubricMetadataBlocks?: DatasetReviewRubricMetadataBlockPayload[] +} + +export interface DatasetReviewRubricMetadataBlockPayload { + name: string + displayName: string + fields?: DatasetReviewRubricMetadataFieldPayload[] +} + +export interface DatasetReviewRubricMetadataFieldPayload { + typeName: string + value: unknown +} diff --git a/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts b/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts new file mode 100644 index 00000000..e62fb3ac --- /dev/null +++ b/src/datasets/infra/repositories/transformers/datasetReviewTransformers.ts @@ -0,0 +1,28 @@ +import { AxiosResponse } from 'axios' + +import { DatasetReview } from '../../../domain/models/DatasetReview' +import { DatasetReviewPayload } from './DatasetReviewPayload' + +export const transformDatasetReviewsResponseToDatasetReviews = ( + response: AxiosResponse +): DatasetReview[] => { + const reviews = (response.data.data?.reviews ?? []) as DatasetReviewPayload[] + + return reviews.map((review) => ({ + title: review.title, + authors: review.authors ?? [], + persistentId: review.persistentId, + persistentIdUrl: review.persistentIdUrl, + id: review.id, + citation: review.citation, + citationHtml: review.citationHtml, + datePublished: review.datePublished ?? '', + description: review.description ?? '', + rubricMetadataBlocks: + review.rubricMetadataBlocks?.map((metadataBlock) => ({ + name: metadataBlock.name, + displayName: metadataBlock.displayName, + fields: metadataBlock.fields ?? [] + })) ?? [] + })) +} diff --git a/test/environment/docker-compose.yml b/test/environment/docker-compose.yml index ad98d16b..359fb07f 100644 --- a/test/environment/docker-compose.yml +++ b/test/environment/docker-compose.yml @@ -110,6 +110,8 @@ services: restart: on-failure expose: - '8983' + ports: + - '8983:8983' networks: - dataverse command: diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 50077a4f..aae4413e 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -11,7 +11,8 @@ import { deaccessionDatasetViaApi, createDatasetLicenseModel, setDatasetStorageSizeViaApi, - setUseStorageQuotasViaApi + setUseStorageQuotasViaApi, + loadMetadataBlockViaApi } from '../../testHelpers/datasets/datasetHelper' import { ReadError } from '../../../src/core/domain/repositories/ReadError' import { @@ -32,7 +33,9 @@ import { linkDatasetTypeWithMetadataBlocks, setAvailableLicensesForDatasetType, updateTermsOfAccess, - DatasetLicenseUpdateRequest + DatasetLicenseUpdateRequest, + getDatasetReviews, + DatasetReview } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -49,7 +52,8 @@ import { deleteCollectionViaApi, publishCollectionViaApi, ROOT_COLLECTION_ALIAS, - setStorageDriverViaApi + setStorageDriverViaApi, + setCollectionAllowedDatasetTypesViaApi } from '../../testHelpers/collections/collectionHelper' import { calculateBlobChecksum, @@ -61,6 +65,10 @@ import { DatasetVersionSummary, DatasetVersionSummaryStringValues } from '../../../src/datasets/domain/models/DatasetVersionSummaryInfo' +import { + replaceSolrSchemaWithDataverseGeneratedSchemaViaApi, + solrSchemaFieldExistsViaApi +} from '../../testHelpers/search/solrHelper' import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' import { createTestFileUploadDestination } from '../../testHelpers/files/fileUploadDestinationHelper' @@ -104,6 +112,153 @@ const TEST_DIFF_DATASET_DTO: DatasetDTO = { ] } +const createReviewDatasetDTO = (itemReviewedUrl: string): DatasetDTO => ({ + license: TestConstants.TEST_NEW_DATASET_DTO.license, + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: 'Review of Dataset with a review', + author: [ + { + authorName: 'Reviewer, Dataverse', + authorAffiliation: 'Dataverse.org' + } + ], + datasetContact: [ + { + datasetContactEmail: 'reviewer@mailinator.com', + datasetContactName: 'Reviewer, Dataverse' + } + ], + dsDescription: [ + { + dsDescriptionValue: 'This is a review of a dataset.' + } + ], + subject: ['Medicine, Health and Life Sciences'] + } + }, + { + name: 'review', + fields: { + itemReviewed: { + itemReviewedUrl, + itemReviewedType: 'Dataset', + itemReviewedCitation: 'Dataset with a review, Dataverse, 2026' + } + } + }, + { + name: RUBRIC_METADATA_BLOCK_NAME, + fields: { + rubricDatasetReviewSummary: 'The reviewed dataset is complete enough for reuse.' + } + } + ] +}) + +const getPersistentIdUrl = (persistentId: string): string => { + return persistentId.startsWith('doi:') + ? `https://doi.org/${persistentId.replace(/^doi:/, '')}` + : persistentId +} + +const REVIEW_METADATA_BLOCK_TSV = [ + '#metadataBlock\tname\tdataverseAlias\tdisplayName', + '\treview\t\tReview Metadata', + [ + '#datasetField', + 'name', + 'title', + 'description', + 'watermark', + 'fieldType', + 'displayOrder', + 'displayFormat', + 'advancedSearchField', + 'allowControlledVocabulary', + 'allowmultiples', + 'facetable', + 'displayoncreate', + 'required', + 'parent', + 'metadatablock_id', + 'termURI' + ].join('\t'), + '\titemReviewed\tItem Reviewed\tThe item being reviewed\t\tnone\t1\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\t\treview\t', + '\titemReviewedUrl\tURL\tThe URL of the item being reviewed\t\turl\t2\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '\titemReviewedType\tType\tThe type of the item being reviewed\t\ttext\t3\t\tFALSE\tTRUE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '\titemReviewedCitation\tCitation\tThe full bibliographic citation of the item being reviewed\t\ttextbox\t4\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tTRUE\titemReviewed\treview\t', + '#controlledVocabulary\tDatasetField\tValue\tidentifier\tdisplayOrder', + '\titemReviewedType\tAudiovisual\t\t0', + '\titemReviewedType\tAward\t\t1', + '\titemReviewedType\tBook\t\t2', + '\titemReviewedType\tBook Chapter\t\t3', + '\titemReviewedType\tCollection\t\t4', + '\titemReviewedType\tComputational Notebook\t\t5', + '\titemReviewedType\tConference Paper\t\t6', + '\titemReviewedType\tConference Proceeding\t\t7', + '\titemReviewedType\tDataPaper\t\t8', + '\titemReviewedType\tDataset\t\t9', + '\titemReviewedType\tDissertation\t\t10', + '\titemReviewedType\tEvent\t\t11', + '\titemReviewedType\tImage\t\t12', + '\titemReviewedType\tInteractive Resource\t\t13', + '\titemReviewedType\tInstrument\t\t14', + '\titemReviewedType\tJournal\t\t15', + '\titemReviewedType\tJournal Article\t\t16', + '\titemReviewedType\tModel\t\t17', + '\titemReviewedType\tOutput Management Plan\t\t18', + '\titemReviewedType\tPeer Review\t\t19', + '\titemReviewedType\tPhysical Object\t\t20', + '\titemReviewedType\tPreprint\t\t21', + '\titemReviewedType\tProject\t\t22', + '\titemReviewedType\tReport\t\t23', + '\titemReviewedType\tService\t\t24', + '\titemReviewedType\tSoftware\t\t25', + '\titemReviewedType\tSound\t\t26', + '\titemReviewedType\tStandard\t\t27', + '\titemReviewedType\tStudy Registration\t\t28', + '\titemReviewedType\tText\t\t29', + '\titemReviewedType\tWorkflow\t\t30', + '\titemReviewedType\tOther\t\t31' +].join('\n') + +const RUBRIC_METADATA_BLOCK_NAME = 'rubric_dataset_reviews' +const RUBRIC_METADATA_BLOCK_TSV = [ + '#metadataBlock\tname\tdataverseAlias\tdisplayName', + `\t${RUBRIC_METADATA_BLOCK_NAME}\t\tDataset Review Rubric`, + [ + '#datasetField', + 'name', + 'title', + 'description', + 'watermark', + 'fieldType', + 'displayOrder', + 'displayFormat', + 'advancedSearchField', + 'allowControlledVocabulary', + 'allowmultiples', + 'facetable', + 'displayoncreate', + 'required', + 'parent', + 'metadatablock_id', + 'termURI' + ].join('\t'), + `\trubricDatasetReviewSummary\tReview Summary\tA short summary of the dataset review.\t\ttextbox\t1\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tFALSE\t\t${RUBRIC_METADATA_BLOCK_NAME}\t` +].join('\n') + +const REVIEW_SOLR_SCHEMA_FIELD_NAMES = [ + 'itemReviewed', + 'itemReviewedCitation', + 'itemReviewedType', + 'itemReviewedUrl', + 'rubricDatasetReviewSummary' +] + describe('DatasetsRepository', () => { const testCollectionAlias = 'datasetsRepositoryTestCollection' @@ -1936,6 +2091,233 @@ describe('DatasetsRepository', () => { }) }) + describe('getDatasetReviews use case', () => { + const collectionAlias = `datasetReviews${randomUUID().replace(/-/g, '').slice(0, 8)}` + const reviewDatasetTypeName = 'review' + let targetDatasetIds: CreatedDatasetIdentifiers | undefined + let reviewDatasetIds: CreatedDatasetIdentifiers | undefined + let reviewDatasetTypeCreatedByTest = false + let reviewDatasetTypeIdCreatedByTest: number | undefined + let collectionCreated = false + let skipReason: string | undefined + + beforeAll(async () => { + skipReason = await getDatasetReviewsIntegrationSkipReason() + if (skipReason) { + return + } + + await ensureReviewMetadataBlocksExist() + await ensureReviewSolrSchemaFieldsExist() + await ensureReviewDatasetTypeExists() + await createCollectionViaApi(collectionAlias) + collectionCreated = true + await setCollectionAllowedDatasetTypesViaApi(collectionAlias, [ + defaultDatasetType, + reviewDatasetTypeName + ]) + await publishCollectionViaApi(collectionAlias) + + targetDatasetIds = await createDataset.execute( + { + ...TestConstants.TEST_NEW_DATASET_DTO, + metadataBlockValues: [ + { + ...TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0], + fields: { + ...TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields, + title: 'Dataset with a review' + } + } + ] + }, + collectionAlias + ) + await publishDatasetViaApi(targetDatasetIds.numericId) + await waitForNoLocks(targetDatasetIds.numericId, 10) + + reviewDatasetIds = await createDataset.execute( + createReviewDatasetDTO(getPersistentIdUrl(targetDatasetIds.persistentId)), + collectionAlias, + reviewDatasetTypeName + ) + await publishDatasetViaApi(reviewDatasetIds.numericId) + await waitForNoLocks(reviewDatasetIds.numericId, 10) + await waitForDatasetsIndexedInSolr(2, collectionAlias) + }) + + afterAll(async () => { + if (reviewDatasetIds) { + await deletePublishedDatasetViaApi(reviewDatasetIds.persistentId).catch(() => + deleteUnpublishedDatasetViaApi(reviewDatasetIds?.numericId as number).catch( + () => undefined + ) + ) + } + if (targetDatasetIds) { + await deletePublishedDatasetViaApi(targetDatasetIds.persistentId).catch(() => + deleteUnpublishedDatasetViaApi(targetDatasetIds?.numericId as number).catch( + () => undefined + ) + ) + } + if (collectionCreated) { + await deleteCollectionViaApi(collectionAlias).catch(() => undefined) + } + + if (reviewDatasetTypeCreatedByTest) { + await deleteDatasetType + .execute(reviewDatasetTypeIdCreatedByTest as number) + .catch(() => undefined) + } + }) + + test('should return reviews when providing a dataset persistent id', async () => { + if (skipWhenDatasetReviewsIntegrationIsUnavailable()) { + return + } + + const actual = await waitForDatasetReviews(targetDatasetIds?.persistentId as string) + + expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) + }) + + test('should return reviews when providing a numeric dataset id', async () => { + if (skipWhenDatasetReviewsIntegrationIsUnavailable()) { + return + } + + const actual = await waitForDatasetReviews(targetDatasetIds?.numericId as number) + + expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) + }) + + const getDatasetReviewsIntegrationSkipReason = async (): Promise => { + if (!(await isDatasetReviewsEndpointAvailable())) { + return 'this Dataverse test server does not expose /api/datasets/{id}/reviews' + } + + return undefined + } + + const isDatasetReviewsEndpointAvailable = async (): Promise => { + try { + await sut.getDatasetReviews(nonExistentTestDatasetId) + return true + } catch (error) { + return !(error as Error).message.includes('API endpoint does not exist') + } + } + + const isMetadataBlockAvailable = async (metadataBlockName: string): Promise => { + const metadataBlocksRepository = new MetadataBlocksRepository() + + try { + await metadataBlocksRepository.getMetadataBlockByName(metadataBlockName) + return true + } catch { + return false + } + } + + const ensureReviewMetadataBlocksExist = async (): Promise => { + await ensureMetadataBlockExists('review', REVIEW_METADATA_BLOCK_TSV) + await ensureMetadataBlockExists(RUBRIC_METADATA_BLOCK_NAME, RUBRIC_METADATA_BLOCK_TSV) + } + + const ensureMetadataBlockExists = async ( + metadataBlockName: string, + metadataBlockTsv: string + ): Promise => { + if (await isMetadataBlockAvailable(metadataBlockName)) { + return + } + + await loadMetadataBlockViaApi(metadataBlockTsv) + + if (!(await isMetadataBlockAvailable(metadataBlockName))) { + throw new Error(`${metadataBlockName} metadata block was loaded but is still unavailable.`) + } + } + + const ensureReviewSolrSchemaFieldsExist = async (): Promise => { + if (await reviewSolrSchemaFieldsExist()) { + return + } + + await replaceSolrSchemaWithDataverseGeneratedSchemaViaApi() + + if (!(await reviewSolrSchemaFieldsExist())) { + throw new Error('Solr schema was regenerated but review metadata fields are unavailable.') + } + } + + const reviewSolrSchemaFieldsExist = async (): Promise => { + const fieldExists = await Promise.all( + REVIEW_SOLR_SCHEMA_FIELD_NAMES.map((fieldName) => solrSchemaFieldExistsViaApi(fieldName)) + ) + + return fieldExists.every(Boolean) + } + + const skipWhenDatasetReviewsIntegrationIsUnavailable = (): boolean => { + if (!skipReason) { + return false + } + + console.warn(`Skipping getDatasetReviews integration assertion because ${skipReason}.`) + return true + } + + const ensureReviewDatasetTypeExists = async () => { + const reviewDatasetType = await getDatasetAvailableDatasetType + .execute(reviewDatasetTypeName) + .catch(async () => { + reviewDatasetTypeCreatedByTest = true + return await addDatasetType.execute({ + name: reviewDatasetTypeName, + displayName: 'Review', + description: 'A review of a dataset compiled by the expert community.', + linkedMetadataBlocks: [], + availableLicenses: [] + }) + }) + + reviewDatasetTypeIdCreatedByTest = reviewDatasetType.id + + await linkDatasetTypeWithMetadataBlocks.execute(reviewDatasetType.id as number, [ + 'review', + RUBRIC_METADATA_BLOCK_NAME + ]) + } + + const waitForDatasetReviews = async ( + datasetId: number | string, + maxRetries = 10 + ): Promise => { + for (let retry = 0; retry < maxRetries; retry++) { + const reviews = await getDatasetReviews.execute(datasetId) + + if (reviews.some((review) => review.id === reviewDatasetIds?.numericId)) { + return reviews + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + return await getDatasetReviews.execute(datasetId) + } + + const getExpectedReviewDataset = (): Partial => ({ + id: reviewDatasetIds?.numericId, + persistentId: reviewDatasetIds?.persistentId, + persistentIdUrl: getPersistentIdUrl(reviewDatasetIds?.persistentId as string), + title: 'Review of Dataset with a review', + authors: ['Reviewer, Dataverse'], + description: 'This is a review of a dataset.' + }) + }) + describe('updateTermsOfAccess', () => { let testDatasetIds: CreatedDatasetIdentifiers diff --git a/test/testHelpers/collections/collectionHelper.ts b/test/testHelpers/collections/collectionHelper.ts index 7e6ef5e8..404f5113 100644 --- a/test/testHelpers/collections/collectionHelper.ts +++ b/test/testHelpers/collections/collectionHelper.ts @@ -179,6 +179,28 @@ export async function publishCollectionViaApi(collectionAlias: string): Promise< } } +export async function setCollectionAllowedDatasetTypesViaApi( + collectionAlias: string, + allowedDatasetTypes: string[] +): Promise { + try { + return await axios.put( + `${TestConstants.TEST_API_URL}/dataverses/${collectionAlias}/attribute/allowedDatasetTypes`, + undefined, + { + params: { + value: allowedDatasetTypes.join(',') + }, + ...DATAVERSE_API_REQUEST_HEADERS + } + ) + } catch (error) { + throw new Error( + `Error while setting allowed dataset types for test collection ${collectionAlias}` + ) + } +} + export const createCollectionDTO = (alias = 'test-collection'): CollectionDTO => { return { alias: alias, diff --git a/test/testHelpers/datasets/datasetHelper.ts b/test/testHelpers/datasets/datasetHelper.ts index 097de6f5..b5057c3c 100644 --- a/test/testHelpers/datasets/datasetHelper.ts +++ b/test/testHelpers/datasets/datasetHelper.ts @@ -353,6 +353,26 @@ export const setDatasetStorageSizeViaApi = async ( } } +export const loadMetadataBlockViaApi = async (metadataBlockTsv: string): Promise => { + try { + return await axios.post( + `${TestConstants.TEST_API_URL}/admin/datasetfield/load`, + metadataBlockTsv, + { + headers: { + 'Content-Type': 'text/tab-separated-values', + 'X-Dataverse-Key': process.env.TEST_API_KEY + } + } + ) + } catch (error) { + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.message ?? error.message}` + : `${error}` + throw new Error(`Error while loading metadata block. Reason was: ${message}`) + } +} + export const deaccessionDatasetViaApi = async ( datasetId: number, versionId: string diff --git a/test/testHelpers/datasets/datasetReviewHelper.ts b/test/testHelpers/datasets/datasetReviewHelper.ts new file mode 100644 index 00000000..97837a22 --- /dev/null +++ b/test/testHelpers/datasets/datasetReviewHelper.ts @@ -0,0 +1,62 @@ +import { DatasetReview } from '../../../src/datasets/domain/models/DatasetReview' +import { DatasetReviewPayload } from '../../../src/datasets/infra/repositories/transformers/DatasetReviewPayload' + +export const createDatasetReviewModel = (): DatasetReview => ({ + title: 'Review of Pediatric Asthma', + authors: ['Wazowski, Mike'], + persistentId: 'doi:10.5072/FK2/1WD6BX', + persistentIdUrl: 'https://doi.org/10.5072/FK2/1WD6BX', + id: 13, + citation: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + citationHtml: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + datePublished: '', + description: 'This is a review of a dataset.', + rubricMetadataBlocks: [ + { + name: 'rubric_trusteddatadimensionsintensities', + displayName: 'Trusted Data Dimensions and Intensities', + fields: [ + { + typeName: 'licensingAndLegalClarity', + value: 'High' + }, + { + typeName: 'authorAndProvenance', + value: 'Medium' + } + ] + } + ] +}) + +export const createDatasetReviewPayload = (): DatasetReviewPayload => ({ + title: 'Review of Pediatric Asthma', + authors: ['Wazowski, Mike'], + persistentId: 'doi:10.5072/FK2/1WD6BX', + persistentIdUrl: 'https://doi.org/10.5072/FK2/1WD6BX', + id: 13, + citation: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + citationHtml: + 'Wazowski, Mike, 2026, "Review of Pediatric Asthma", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION', + datePublished: '', + description: 'This is a review of a dataset.', + rubricMetadataBlocks: [ + { + name: 'rubric_trusteddatadimensionsintensities', + displayName: 'Trusted Data Dimensions and Intensities', + fields: [ + { + typeName: 'licensingAndLegalClarity', + value: 'High' + }, + { + typeName: 'authorAndProvenance', + value: 'Medium' + } + ] + } + ] +}) diff --git a/test/testHelpers/search/solrHelper.ts b/test/testHelpers/search/solrHelper.ts new file mode 100644 index 00000000..30be5473 --- /dev/null +++ b/test/testHelpers/search/solrHelper.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs' +import path from 'node:path' +import axios from 'axios' +import { TestConstants } from '../TestConstants' + +const TEST_SOLR_COLLECTION_URL = 'http://localhost:8983/solr/collection1' +const TEST_SOLR_CORE_ADMIN_URL = 'http://localhost:8983/solr/admin/cores' +const TEST_SOLR_SCHEMA_PATHS = [ + path.resolve( + __dirname, + '../../environment/docker-dev-volumes/solr/data/data/collection1/conf/schema.xml' + ), + path.resolve(__dirname, '../../environment/docker-dev-volumes/solr/conf/conf/schema.xml') +] + +const DATAVERSE_API_REQUEST_HEADERS = { + headers: { 'X-Dataverse-Key': process.env.TEST_API_KEY } +} + +export const solrSchemaFieldExistsViaApi = async (fieldName: string): Promise => { + try { + await axios.get(`${TEST_SOLR_COLLECTION_URL}/schema/fields/${fieldName}`) + return true + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return false + } + + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.error?.msg ?? error.message}` + : `${error}` + throw new Error(`Error while checking Solr schema field ${fieldName}. Reason was: ${message}`) + } +} + +export const replaceSolrSchemaWithDataverseGeneratedSchemaViaApi = async (): Promise => { + const generatedSchemaFragment = await getDataverseGeneratedSolrSchemaFragment() + writeSolrSchemaFiles(generatedSchemaFragment) + await reloadSolrCoreViaApi() +} + +const getDataverseGeneratedSolrSchemaFragment = async (): Promise => { + try { + const response = await axios.get( + `${TestConstants.TEST_API_URL}/admin/index/solr/schema`, + DATAVERSE_API_REQUEST_HEADERS + ) + + if (typeof response.data !== 'string') { + throw new Error('Generated schema response was not text.') + } + + return response.data + } catch (error) { + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.message ?? error.message}` + : `${error}` + throw new Error(`Error while getting Dataverse-generated Solr schema. Reason was: ${message}`) + } +} + +const writeSolrSchemaFiles = (generatedSchemaFragment: string): void => { + const schemaPathsWritten = TEST_SOLR_SCHEMA_PATHS.filter((schemaPath) => + fs.existsSync(schemaPath) + ) + + if (schemaPathsWritten.length === 0) { + throw new Error('Could not find a mounted Solr schema.xml file to update.') + } + + schemaPathsWritten.forEach((schemaPath) => { + fs.writeFileSync( + schemaPath, + mergeGeneratedSchemaFragment(fs.readFileSync(schemaPath, 'utf8'), generatedSchemaFragment) + ) + }) +} + +const mergeGeneratedSchemaFragment = ( + currentSchema: string, + generatedSchemaFragment: string +): string => { + const generatedSchemaLines = generatedSchemaFragment.split('\n') + const fieldLines = generatedSchemaLines.filter((line) => line.includes(' line.includes('', + '', + fieldLines + ), + '', + '', + copyFieldLines + ) +} + +const replaceSchemaSection = ( + schema: string, + beginMarker: string, + endMarker: string, + replacementLines: string[] +): string => { + const beginIndex = schema.indexOf(beginMarker) + const endIndex = schema.indexOf(endMarker) + + if (beginIndex === -1 || endIndex === -1 || beginIndex > endIndex) { + throw new Error(`Could not find Solr schema section ${beginMarker}.`) + } + + return [ + schema.slice(0, beginIndex + beginMarker.length), + '', + replacementLines.join('\n'), + schema.slice(endIndex) + ].join('\n') +} + +const reloadSolrCoreViaApi = async (): Promise => { + try { + await axios.get(TEST_SOLR_CORE_ADMIN_URL, { + params: { action: 'RELOAD', core: 'collection1', wt: 'json' } + }) + } catch (error) { + const message = axios.isAxiosError(error) + ? `[${error.response?.status}] ${error.response?.data?.error?.msg ?? error.message}` + : `${error}` + throw new Error(`Error while reloading Solr core. Reason was: ${message}`) + } +} diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index b87e05de..67015187 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -26,6 +26,10 @@ import { createDatasetPreviewModel, createDatasetPreviewPayload } from '../../testHelpers/datasets/datasetPreviewHelper' +import { + createDatasetReviewModel, + createDatasetReviewPayload +} from '../../testHelpers/datasets/datasetReviewHelper' import { createDatasetDTO, createDatasetDeaccessionDTO, @@ -790,6 +794,102 @@ describe('DatasetsRepository', () => { }) }) + describe('getDatasetReviews', () => { + const testDatasetReviews = [createDatasetReviewModel()] + const testDatasetReviewsResponse = { + data: { + status: 'OK', + data: { + reviews: [createDatasetReviewPayload()] + } + } + } + + describe('by numeric id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/reviews` + + test('should return dataset reviews when providing id and response is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testDatasetReviewsResponse) + + // API Key auth + let actual = await sut.getDatasetReviews(testDatasetModel.id) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(testDatasetReviews) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + actual = await sut.getDatasetReviews(testDatasetModel.id) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + expect(actual).toStrictEqual(testDatasetReviews) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as ReadError + await sut.getDatasetReviews(testDatasetModel.id).catch((e) => (error = e)) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('by persistent id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/:persistentId/reviews?persistentId=${TestConstants.TEST_DUMMY_PERSISTENT_ID}` + + test('should return dataset reviews when providing persistent id and response is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testDatasetReviewsResponse) + + // API Key auth + let actual = await sut.getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(testDatasetReviews) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + actual = await sut.getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + expect(actual).toStrictEqual(testDatasetReviews) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as ReadError + await sut + .getDatasetReviews(TestConstants.TEST_DUMMY_PERSISTENT_ID) + .catch((e) => (error = e)) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(Error) + }) + }) + }) + describe('createDataset', () => { const testNewDataset = createDatasetDTO() const testMetadataBlocks = [createDatasetMetadataBlockModel()] diff --git a/test/unit/datasets/GetDatasetReviews.test.ts b/test/unit/datasets/GetDatasetReviews.test.ts new file mode 100644 index 00000000..f42d023d --- /dev/null +++ b/test/unit/datasets/GetDatasetReviews.test.ts @@ -0,0 +1,29 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { DatasetReview } from '../../../src/datasets/domain/models/DatasetReview' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { GetDatasetReviews } from '../../../src/datasets/domain/useCases/GetDatasetReviews' +import { createDatasetReviewModel } from '../../testHelpers/datasets/datasetReviewHelper' + +describe('GetDatasetReviews', () => { + const testDatasetId = 'doi:10.77777/FK2/AAAAAA' + + test('should return dataset reviews on repository success', async () => { + const testDatasetReviews: DatasetReview[] = [createDatasetReviewModel()] + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetReviews = jest.fn().mockResolvedValue(testDatasetReviews) + const sut = new GetDatasetReviews(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId) + + expect(actual).toEqual(testDatasetReviews) + expect(datasetsRepositoryStub.getDatasetReviews).toHaveBeenCalledWith(testDatasetId) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetReviews = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetDatasetReviews(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId)).rejects.toThrow(ReadError) + }) +}) From ff8a841ac57f4556265414f56ac7b540ab16a750 Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Tue, 16 Jun 2026 07:11:38 -0400 Subject: [PATCH 2/5] update solr schema in docker container --- test/environment/docker-compose.yml | 2 - .../datasets/DatasetsRepository.test.ts | 54 +++------ .../MetadataFieldsInfoRepository.test.ts | 2 +- test/testHelpers/search/solrHelper.ts | 112 +++++++++++------- 4 files changed, 89 insertions(+), 81 deletions(-) diff --git a/test/environment/docker-compose.yml b/test/environment/docker-compose.yml index 359fb07f..ad98d16b 100644 --- a/test/environment/docker-compose.yml +++ b/test/environment/docker-compose.yml @@ -110,8 +110,6 @@ services: restart: on-failure expose: - '8983' - ports: - - '8983:8983' networks: - dataverse command: diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index aae4413e..3edf2315 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -66,8 +66,8 @@ import { DatasetVersionSummaryStringValues } from '../../../src/datasets/domain/models/DatasetVersionSummaryInfo' import { - replaceSolrSchemaWithDataverseGeneratedSchemaViaApi, - solrSchemaFieldExistsViaApi + replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker, + solrSchemaFieldExistsViaDocker } from '../../testHelpers/search/solrHelper' import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' @@ -152,7 +152,7 @@ const createReviewDatasetDTO = (itemReviewedUrl: string): DatasetDTO => ({ { name: RUBRIC_METADATA_BLOCK_NAME, fields: { - rubricDatasetReviewSummary: 'The reviewed dataset is complete enough for reuse.' + authorAndProvenance: 'High' } } ] @@ -225,10 +225,10 @@ const REVIEW_METADATA_BLOCK_TSV = [ '\titemReviewedType\tOther\t\t31' ].join('\n') -const RUBRIC_METADATA_BLOCK_NAME = 'rubric_dataset_reviews' +const RUBRIC_METADATA_BLOCK_NAME = 'rubric_trusteddatadimensionsintensities' const RUBRIC_METADATA_BLOCK_TSV = [ - '#metadataBlock\tname\tdataverseAlias\tdisplayName', - `\t${RUBRIC_METADATA_BLOCK_NAME}\t\tDataset Review Rubric`, + '#metadataBlock\tname\tdataverseAlias\tdisplayName\tblockURI', + `\t${RUBRIC_METADATA_BLOCK_NAME}\t\tTrusted Data Dimensions and Intensities\t`, [ '#datasetField', 'name', @@ -248,7 +248,11 @@ const RUBRIC_METADATA_BLOCK_TSV = [ 'metadatablock_id', 'termURI' ].join('\t'), - `\trubricDatasetReviewSummary\tReview Summary\tA short summary of the dataset review.\t\ttextbox\t1\t\tFALSE\tFALSE\tFALSE\tFALSE\tTRUE\tFALSE\t\t${RUBRIC_METADATA_BLOCK_NAME}\t` + `\tauthorAndProvenance\tAuthor and Provenance\tThe level of trust in the data creators and in other provenance information\t\ttext\t1\t\tTRUE\tTRUE\tFALSE\tTRUE\tFALSE\tFALSE\t\t${RUBRIC_METADATA_BLOCK_NAME}\t`, + '#controlledVocabulary\tDatasetField\tValue\tidentifier\tdisplayOrder', + '\tauthorAndProvenance\tLow\t\t0', + '\tauthorAndProvenance\tMedium\t\t1', + '\tauthorAndProvenance\tHigh\t\t2' ].join('\n') const REVIEW_SOLR_SCHEMA_FIELD_NAMES = [ @@ -256,7 +260,7 @@ const REVIEW_SOLR_SCHEMA_FIELD_NAMES = [ 'itemReviewedCitation', 'itemReviewedType', 'itemReviewedUrl', - 'rubricDatasetReviewSummary' + 'authorAndProvenance' ] describe('DatasetsRepository', () => { @@ -2099,14 +2103,9 @@ describe('DatasetsRepository', () => { let reviewDatasetTypeCreatedByTest = false let reviewDatasetTypeIdCreatedByTest: number | undefined let collectionCreated = false - let skipReason: string | undefined beforeAll(async () => { - skipReason = await getDatasetReviewsIntegrationSkipReason() - if (skipReason) { - return - } - + await assertDatasetReviewsEndpointAvailable() await ensureReviewMetadataBlocksExist() await ensureReviewSolrSchemaFieldsExist() await ensureReviewDatasetTypeExists() @@ -2173,31 +2172,21 @@ describe('DatasetsRepository', () => { }) test('should return reviews when providing a dataset persistent id', async () => { - if (skipWhenDatasetReviewsIntegrationIsUnavailable()) { - return - } - const actual = await waitForDatasetReviews(targetDatasetIds?.persistentId as string) expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) }) test('should return reviews when providing a numeric dataset id', async () => { - if (skipWhenDatasetReviewsIntegrationIsUnavailable()) { - return - } - const actual = await waitForDatasetReviews(targetDatasetIds?.numericId as number) expect(actual).toContainEqual(expect.objectContaining(getExpectedReviewDataset())) }) - const getDatasetReviewsIntegrationSkipReason = async (): Promise => { + const assertDatasetReviewsEndpointAvailable = async (): Promise => { if (!(await isDatasetReviewsEndpointAvailable())) { - return 'this Dataverse test server does not expose /api/datasets/{id}/reviews' + throw new Error('Expected Dataverse test server to expose /api/datasets/{id}/reviews.') } - - return undefined } const isDatasetReviewsEndpointAvailable = async (): Promise => { @@ -2245,7 +2234,7 @@ describe('DatasetsRepository', () => { return } - await replaceSolrSchemaWithDataverseGeneratedSchemaViaApi() + await replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker() if (!(await reviewSolrSchemaFieldsExist())) { throw new Error('Solr schema was regenerated but review metadata fields are unavailable.') @@ -2254,21 +2243,12 @@ describe('DatasetsRepository', () => { const reviewSolrSchemaFieldsExist = async (): Promise => { const fieldExists = await Promise.all( - REVIEW_SOLR_SCHEMA_FIELD_NAMES.map((fieldName) => solrSchemaFieldExistsViaApi(fieldName)) + REVIEW_SOLR_SCHEMA_FIELD_NAMES.map((fieldName) => solrSchemaFieldExistsViaDocker(fieldName)) ) return fieldExists.every(Boolean) } - const skipWhenDatasetReviewsIntegrationIsUnavailable = (): boolean => { - if (!skipReason) { - return false - } - - console.warn(`Skipping getDatasetReviews integration assertion because ${skipReason}.`) - return true - } - const ensureReviewDatasetTypeExists = async () => { const reviewDatasetType = await getDatasetAvailableDatasetType .execute(reviewDatasetTypeName) diff --git a/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts b/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts index de94eb7c..edbf6820 100644 --- a/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts +++ b/test/integration/metadataBlocks/MetadataFieldsInfoRepository.test.ts @@ -19,7 +19,7 @@ describe('getAllFacetableMetadataFields', () => { test('should return all facetable metadata fields', async () => { const actual = await sut.getAllFacetableMetadataFields() - expect(actual.length).toBe(64) + expect(actual.length).toBeGreaterThanOrEqual(64) expect(actual[0].name).toBe('authorName') expect(actual[0].displayName).toBe('Author Name') }) diff --git a/test/testHelpers/search/solrHelper.ts b/test/testHelpers/search/solrHelper.ts index 30be5473..830797ae 100644 --- a/test/testHelpers/search/solrHelper.ts +++ b/test/testHelpers/search/solrHelper.ts @@ -1,42 +1,62 @@ import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import axios from 'axios' import { TestConstants } from '../TestConstants' +const execFileAsync = promisify(execFile) + +const TEST_SOLR_CONTAINER_NAME = 'test_solr' +const TEST_SOLR_SCHEMA_PATH = '/var/solr/data/collection1/conf/schema.xml' const TEST_SOLR_COLLECTION_URL = 'http://localhost:8983/solr/collection1' const TEST_SOLR_CORE_ADMIN_URL = 'http://localhost:8983/solr/admin/cores' -const TEST_SOLR_SCHEMA_PATHS = [ - path.resolve( - __dirname, - '../../environment/docker-dev-volumes/solr/data/data/collection1/conf/schema.xml' - ), - path.resolve(__dirname, '../../environment/docker-dev-volumes/solr/conf/conf/schema.xml') -] const DATAVERSE_API_REQUEST_HEADERS = { headers: { 'X-Dataverse-Key': process.env.TEST_API_KEY } } -export const solrSchemaFieldExistsViaApi = async (fieldName: string): Promise => { - try { - await axios.get(`${TEST_SOLR_COLLECTION_URL}/schema/fields/${fieldName}`) +type ExecFileError = Error & { + stderr?: string + stdout?: string +} + +export const solrSchemaFieldExistsViaDocker = async (fieldName: string): Promise => { + const statusCode = await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'curl', + '-sS', + '-o', + '/tmp/solr-schema-field-response.json', + '-w', + '%{http_code}', + `${TEST_SOLR_COLLECTION_URL}/schema/fields/${encodeURIComponent(fieldName)}` + ]) + + if (statusCode.trim() === '200') { return true - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 404) { - return false - } + } - const message = axios.isAxiosError(error) - ? `[${error.response?.status}] ${error.response?.data?.error?.msg ?? error.message}` - : `${error}` - throw new Error(`Error while checking Solr schema field ${fieldName}. Reason was: ${message}`) + if (statusCode.trim() === '404') { + return false } + + throw new Error(`Unexpected Solr schema field check status for ${fieldName}: ${statusCode}`) } -export const replaceSolrSchemaWithDataverseGeneratedSchemaViaApi = async (): Promise => { +export const replaceSolrSchemaWithDataverseGeneratedSchemaViaDocker = async (): Promise => { const generatedSchemaFragment = await getDataverseGeneratedSolrSchemaFragment() - writeSolrSchemaFiles(generatedSchemaFragment) - await reloadSolrCoreViaApi() + const currentSchema = await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'cat', + TEST_SOLR_SCHEMA_PATH + ]) + const mergedSchema = mergeGeneratedSchemaFragment(currentSchema, generatedSchemaFragment) + await copySchemaToSolrContainer(mergedSchema) + await reloadSolrCoreViaDocker() } const getDataverseGeneratedSolrSchemaFragment = async (): Promise => { @@ -59,21 +79,30 @@ const getDataverseGeneratedSolrSchemaFragment = async (): Promise => { } } -const writeSolrSchemaFiles = (generatedSchemaFragment: string): void => { - const schemaPathsWritten = TEST_SOLR_SCHEMA_PATHS.filter((schemaPath) => - fs.existsSync(schemaPath) - ) +const copySchemaToSolrContainer = async (schemaXml: string): Promise => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dataverse-solr-schema-')) + const tempSchemaPath = path.join(tempDir, 'schema.xml') - if (schemaPathsWritten.length === 0) { - throw new Error('Could not find a mounted Solr schema.xml file to update.') + try { + fs.writeFileSync(tempSchemaPath, schemaXml) + await runDockerCommand([ + 'cp', + tempSchemaPath, + `${TEST_SOLR_CONTAINER_NAME}:${TEST_SOLR_SCHEMA_PATH}` + ]) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) } +} - schemaPathsWritten.forEach((schemaPath) => { - fs.writeFileSync( - schemaPath, - mergeGeneratedSchemaFragment(fs.readFileSync(schemaPath, 'utf8'), generatedSchemaFragment) - ) - }) +const reloadSolrCoreViaDocker = async (): Promise => { + await runDockerCommand([ + 'exec', + TEST_SOLR_CONTAINER_NAME, + 'curl', + '-sS', + `${TEST_SOLR_CORE_ADMIN_URL}?action=RELOAD&core=collection1&wt=json` + ]) } const mergeGeneratedSchemaFragment = ( @@ -118,15 +147,16 @@ const replaceSchemaSection = ( ].join('\n') } -const reloadSolrCoreViaApi = async (): Promise => { +const runDockerCommand = async (args: string[]): Promise => { try { - await axios.get(TEST_SOLR_CORE_ADMIN_URL, { - params: { action: 'RELOAD', core: 'collection1', wt: 'json' } - }) + const { stdout } = await execFileAsync('docker', args, { maxBuffer: 10 * 1024 * 1024 }) + return stdout } catch (error) { - const message = axios.isAxiosError(error) - ? `[${error.response?.status}] ${error.response?.data?.error?.msg ?? error.message}` - : `${error}` - throw new Error(`Error while reloading Solr core. Reason was: ${message}`) + const execError = error as ExecFileError + throw new Error( + `Docker command failed: docker ${args.join(' ')}. Reason was: ${ + execError.stderr ?? execError.stdout ?? execError.message + }` + ) } } From d448e8dc981275c13b8d2fe579ff7573437313b9 Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Tue, 16 Jun 2026 07:23:46 -0400 Subject: [PATCH 3/5] fix functional test --- .../metadataBlocks/GetAllFacetableMetadataFields.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts b/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts index 44dc67f9..f4d0c76b 100644 --- a/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts +++ b/test/functional/metadataBlocks/GetAllFacetableMetadataFields.test.ts @@ -18,7 +18,7 @@ describe('execute', () => { } catch (error) { throw new Error('Should not raise an error') } finally { - expect(metadataFieldInfos?.length).toBe(64) + expect(metadataFieldInfos?.length).toBeGreaterThanOrEqual(64) expect(metadataFieldInfos?.[0].name).toBe('authorName') expect(metadataFieldInfos?.[0].displayName).toBe('Author Name') } From fcf81ace256e2a9e99c5fabe85cc5644a8184637 Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Tue, 16 Jun 2026 08:12:46 -0400 Subject: [PATCH 4/5] rubric metadata field value is type string --- src/datasets/domain/models/DatasetReview.ts | 2 +- .../infra/repositories/transformers/DatasetReviewPayload.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasets/domain/models/DatasetReview.ts b/src/datasets/domain/models/DatasetReview.ts index 15cf8adc..2ffbf7e5 100644 --- a/src/datasets/domain/models/DatasetReview.ts +++ b/src/datasets/domain/models/DatasetReview.ts @@ -19,5 +19,5 @@ export interface DatasetReviewRubricMetadataBlock { export interface DatasetReviewRubricMetadataField { typeName: string - value: unknown + value: string } diff --git a/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts index 3f0dc760..174a437b 100644 --- a/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts +++ b/src/datasets/infra/repositories/transformers/DatasetReviewPayload.ts @@ -19,5 +19,5 @@ export interface DatasetReviewRubricMetadataBlockPayload { export interface DatasetReviewRubricMetadataFieldPayload { typeName: string - value: unknown + value: string } From c6b21a4ea0642c970c0d1a87933a45f3d5c1eb75 Mon Sep 17 00:00:00 2001 From: Ellen Kraffmiller Date: Tue, 16 Jun 2026 08:40:41 -0400 Subject: [PATCH 5/5] update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b161347c..e1b427ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Guestbooks: Added `downloadGuestbookResponsesByCollectionId` and `downloadGuestbookResponsesOfAGuestbook` use cases and repository support for exporting guestbook responses as raw CSV content. - Guestbooks: Added optional `includeStats` support to `getGuestbooksByCollectionId`, returning `usageCount` and `responseCount` when requested. - Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats. +- Datasets: Added `getDatasetReviews` use case and repository method to support Dataverse endpoint `GET /datasets/{identifier}/reviews`, for retrieving review datasets associated with a dataset by persistent id or numeric id. - Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. - Collections: Added theme information when retrieving a collection using `getCollection`.