diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4a912c..c416ed85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - 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. - Datasets: Added `exportDatasetMetadata` use case, repository method, and `ExportedDatasetMetadata` response type to support exporting dataset metadata by numeric id or persistent id through Dataverse endpoint `GET /datasets/export`. +- Datasets: Added `getDatasetVersions` use case. - 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`. diff --git a/docs/useCases.md b/docs/useCases.md index 2df0b987..a9767917 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -54,6 +54,7 @@ The different use cases currently available in the package are classified below, - [Get User Permissions on a Dataset](#get-user-permissions-on-a-dataset) - [Get Differences between Two Dataset Versions](#get-differences-between-two-dataset-versions) - [List All Datasets](#list-all-datasets) + - [Get Dataset Versions](#get-dataset-versions) - [Get Dataset Versions Summaries](#get-dataset-versions-summaries) - [Get Dataset Linked Collections](#get-dataset-linked-collections) - [Get Dataset Available Categories](#get-dataset-available-categories) @@ -1262,6 +1263,36 @@ Note that `collectionId` is an optional parameter to filter datasets by collecti The `DatasetPreviewSubset`returned instance contains a property called `totalDatasetCount` which is necessary for pagination. +#### Get Dataset Versions + +Returns an array of [Dataset](../src/datasets/domain/models/Dataset.ts) that contains information about every specific version. + +##### Example call: + +```typescript +import { getDatasetVersions } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' + +getDatasetVersions + .execute(datasetId) + .then((datasetVersions: DatasetVersionSubset) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/GetDatasetVersions.ts) implementation_. + +- The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. +- **limit**: (number) Limit for pagination. +- **offset**: (number) Offset for pagination. +- **excludeMetadataBlocks**: (boolean) Exclude metadata blocks (default: false). +- Note that the **excludeFiles** parameter, which is available on Dataverse's "List Versions of a Dataset" API, is not available here. The list of files can be retrieved using the separate use case [List Files in a Dataset](#list-files-in-a-dataset). + #### Get Dataset Versions Summaries Returns the total count of versions and an array of [DatasetVersionSummaryInfo](../src/datasets/domain/models/DatasetVersionSummaryInfo.ts) that contains information about what changed in every specific version. diff --git a/src/datasets/domain/models/DatasetVersionSubset.ts b/src/datasets/domain/models/DatasetVersionSubset.ts new file mode 100644 index 00000000..07801577 --- /dev/null +++ b/src/datasets/domain/models/DatasetVersionSubset.ts @@ -0,0 +1,5 @@ +import { Dataset, DatasetMetadataBlocks } from './Dataset' + +export interface DatasetVersionSubset { + versions: Array & { metadataBlocks?: DatasetMetadataBlocks }> +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index a50ad2ff..0a7119af 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -9,6 +9,7 @@ import { MetadataBlock } from '../../../metadataBlocks' import { DatasetVersionDiff } from '../models/DatasetVersionDiff' import { DatasetDownloadCount } from '../models/DatasetDownloadCount' import { DatasetVersionSummarySubset } from '../models/DatasetVersionSummaryInfo' +import { DatasetVersionSubset } from '../models/DatasetVersionSubset' import { DatasetLinkedCollection } from '../models/DatasetLinkedCollection' import { CitationFormat } from '../models/CitationFormat' import { FormattedCitation } from '../models/FormattedCitation' @@ -77,6 +78,13 @@ export interface IDatasetsRepository { limit?: number, offset?: number ): Promise + getDatasetVersions( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise deleteDatasetDraft(datasetId: number | string): Promise linkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise unlinkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise diff --git a/src/datasets/domain/useCases/GetDatasetVersions.ts b/src/datasets/domain/useCases/GetDatasetVersions.ts new file mode 100644 index 00000000..03ae71fd --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetVersions.ts @@ -0,0 +1,38 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetVersionSubset } from '../models/DatasetVersionSubset' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class GetDatasetVersions implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns a list of versions for a given dataset including (optionally) metadata blocks and files. + * Draft versions will only be available to users who have permission to view unpublished drafts. + * + * @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {number} [limit] - Limit for pagination (optional). + * @param {number} [offset] - Offset for pagination (optional). + * @param {boolean} [excludeMetadataBlocks] - Exclude metadata blocks (optional, default: false). + * @param {boolean} [keepRawFields=false] - Indicates whether or not the use case should keep the metadata fields as they are and avoid the transformation to markdown. The default value is false. + * @returns {Promise} - A DatasetVersionSubset containing the versions and total count. + */ + async execute( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise { + return await this.datasetsRepository.getDatasetVersions( + datasetId, + limit, + offset, + excludeMetadataBlocks, + keepRawFields + ) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index de7a32b4..afa2252e 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -19,6 +19,7 @@ import { GetDatasetVersionDiff } from './domain/useCases/GetDatasetVersionDiff' import { DeaccessionDataset } from './domain/useCases/DeaccessionDataset' import { GetDatasetDownloadCount } from './domain/useCases/GetDatasetDownloadCount' import { GetDatasetVersionsSummaries } from './domain/useCases/GetDatasetVersionsSummaries' +import { GetDatasetVersions } from './domain/useCases/GetDatasetVersions' import { DeleteDatasetDraft } from './domain/useCases/DeleteDatasetDraft' import { LinkDataset } from './domain/useCases/LinkDataset' import { UnlinkDataset } from './domain/useCases/UnlinkDataset' @@ -70,6 +71,7 @@ const updateDataset = new UpdateDataset( const deaccessionDataset = new DeaccessionDataset(datasetsRepository) const getDatasetDownloadCount = new GetDatasetDownloadCount(datasetsRepository) const getDatasetVersionsSummaries = new GetDatasetVersionsSummaries(datasetsRepository) +const getDatasetVersions = new GetDatasetVersions(datasetsRepository) const deleteDatasetDraft = new DeleteDatasetDraft(datasetsRepository) const linkDataset = new LinkDataset(datasetsRepository) const unlinkDataset = new UnlinkDataset(datasetsRepository) @@ -107,6 +109,7 @@ export { deaccessionDataset, getDatasetDownloadCount, getDatasetVersionsSummaries, + getDatasetVersions, deleteDatasetDraft, linkDataset, unlinkDataset, @@ -143,6 +146,7 @@ export { TermsOfUse } from './domain/models/Dataset' export { DatasetPreview } from './domain/models/DatasetPreview' +export { DatasetVersionSubset } from './domain/models/DatasetVersionSubset' export { DatasetVersionDiff } from './domain/models/DatasetVersionDiff' export { DatasetPreviewSubset } from './domain/models/DatasetPreviewSubset' export { diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 1eba3fa4..a0e80a29 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -3,7 +3,8 @@ import { IDatasetsRepository } from '../../domain/repositories/IDatasetsReposito import { Dataset, VersionUpdateType } from '../../domain/models/Dataset' import { transformVersionResponseToDataset, - transformDatasetModelToUpdateDatasetRequestPayload + transformDatasetModelToUpdateDatasetRequestPayload, + transformVersionPayloadToDataset } from './transformers/datasetTransformers' import { DatasetUserPermissions } from '../../domain/models/DatasetUserPermissions' import { transformDatasetUserPermissionsResponseToDatasetUserPermissions } from './transformers/datasetUserPermissionsTransformers' @@ -20,6 +21,7 @@ import { DatasetVersionDiff } from '../../domain/models/DatasetVersionDiff' import { transformDatasetVersionDiffResponseToDatasetVersionDiff } from './transformers/datasetVersionDiffTransformers' import { DatasetDownloadCount } from '../../domain/models/DatasetDownloadCount' import { DatasetVersionSummarySubset } from '../../domain/models/DatasetVersionSummaryInfo' +import { DatasetVersionSubset } from '../../domain/models/DatasetVersionSubset' import { DatasetLinkedCollection } from '../../domain/models/DatasetLinkedCollection' import { CitationFormat } from '../../domain/models/CitationFormat' import { transformDatasetLinkedCollectionsResponseToDatasetLinkedCollection } from './transformers/datasetLinkedCollectionsTransformers' @@ -35,6 +37,7 @@ import { DatasetUploadLimits } from '../../domain/models/DatasetUploadLimits' import { DatasetReview } from '../../domain/models/DatasetReview' import { transformDatasetReviewsResponseToDatasetReviews } from './transformers/datasetReviewTransformers' import { ExportedDatasetMetadata } from '../../domain/models/ExportedDatasetMetadata' +import { DatasetPayload } from './transformers/DatasetPayload' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -372,6 +375,42 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async getDatasetVersions( + datasetId: string | number, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + keepRawFields?: boolean + ): Promise { + const queryParams = new URLSearchParams() + + if (limit) { + queryParams.set('limit', limit.toString()) + } + + if (offset) { + queryParams.set('offset', offset.toString()) + } + + if (excludeMetadataBlocks !== undefined) { + queryParams.set('excludeMetadataBlocks', excludeMetadataBlocks.toString()) + } + + return this.doGet( + this.buildApiEndpoint(this.datasetsResourceName, 'versions', datasetId), + true, + queryParams + ) + .then((response) => ({ + versions: response.data.data.map((x: DatasetPayload) => + transformVersionPayloadToDataset(x, keepRawFields ?? false) + ) + })) + .catch((error) => { + throw error + }) + } + public async deleteDatasetDraft(datasetId: string | number): Promise { return this.doDelete( this.buildApiEndpoint(this.datasetsResourceName, 'versions/:draft', datasetId) diff --git a/src/datasets/infra/repositories/transformers/datasetTransformers.ts b/src/datasets/infra/repositories/transformers/datasetTransformers.ts index 1c1e31d8..49ac6a42 100644 --- a/src/datasets/infra/repositories/transformers/datasetTransformers.ts +++ b/src/datasets/infra/repositories/transformers/datasetTransformers.ts @@ -254,10 +254,12 @@ export const transformVersionPayloadToDataset = ( studyCompletion: transformPayloadText(keepRawFields, versionPayload.studyCompletion) } }, - metadataBlocks: transformPayloadToDatasetMetadataBlocks( - versionPayload.metadataBlocks, - keepRawFields - ), + ...(versionPayload.metadataBlocks && { + metadataBlocks: transformPayloadToDatasetMetadataBlocks( + versionPayload.metadataBlocks, + keepRawFields + ) + }), ...(versionPayload.isPartOf && { isPartOf: transformPayloadToOwnerNode(versionPayload.isPartOf) }) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 6a67299e..cb3cec95 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -1750,6 +1750,241 @@ describe('DatasetsRepository', () => { }, 180000) }) + describe('getDatasetVersions', () => { + const testDatasetVersionsCollectionAlias = 'testDatasetVersionsCollection' + + beforeAll(async () => { + await createCollectionViaApi(testDatasetVersionsCollectionAlias) + await publishCollectionViaApi(testDatasetVersionsCollectionAlias) + await setStorageDriverViaApi(testDatasetVersionsCollectionAlias, 'LocalStack') + }) + + afterAll(async () => { + await deleteCollectionViaApi(testDatasetVersionsCollectionAlias) + }) + + test('should return version fields and metadata blocks, and exclude metadata blocks when requested', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + const testLicense = TestConstants.TEST_NEW_DATASET_DTO.license + if (testLicense === undefined) { + throw new Error('The test dataset must define a license.') + } + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + const actual = await sut.getDatasetVersions(testDatasetIds.persistentId) + const version = actual.versions[0] + + expect(actual.versions).toHaveLength(1) + expect(version).toEqual( + expect.objectContaining({ + id: testDatasetIds.numericId, + persistentId: testDatasetIds.persistentId, + versionId: expect.any(Number), + versionInfo: expect.objectContaining({ + majorNumber: 1, + minorNumber: 0, + state: 'RELEASED', + lastUpdateTime: expect.any(String), + releaseTime: expect.any(Date), + createTime: expect.any(Date) + }), + internalVersionNumber: expect.any(Number), + license: expect.objectContaining({ + name: testLicense.name, + uri: testLicense.uri, + iconUri: testLicense.iconUri + }), + publicationDate: expect.any(String), + citationDate: expect.any(String), + metadataBlocks: expect.arrayContaining([ + { + name: 'citation', + fields: expect.objectContaining({ + title: TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.title, + subject: TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.subject + }) + } + ]), + datasetType: 'dataset' + }) + ) + + const excludedMetadataBlocks = await sut.getDatasetVersions( + testDatasetIds.persistentId, + undefined, + undefined, + true + ) + + expect(excludedMetadataBlocks.versions).toHaveLength(1) + expect(excludedMetadataBlocks.versions[0].metadataBlocks).toBeUndefined() + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }, 180000) + + test('should return draft dataset versions correctly', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toBeGreaterThan(0) + expect(actual.versions[0].versionInfo.state).toBe('DRAFT') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should return deaccessioned dataset versions correctly', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + await deaccessionDatasetViaApi(testDatasetIds.numericId, '1.0') + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toBeGreaterThan(0) + expect(actual.versions[0].versionInfo.majorNumber).toBe(1) + expect(actual.versions[0].versionInfo.minorNumber).toBe(0) + expect(actual.versions[0].versionInfo.state).toBe('DEACCESSIONED') + expect(actual.versions[0].versionInfo.deaccessionNote).toBe('Test reason.') + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + test('should return dataset versions correctly after 1st publish and metadata fields update', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + + await waitForNoLocks(testDatasetIds.numericId, 10) + + const metadataBlocksRepository = new MetadataBlocksRepository() + const citationMetadataBlock = await metadataBlocksRepository.getMetadataBlockByName( + 'citation' + ) + + await sut.updateDataset( + testDatasetIds.numericId, + { + license: createDatasetLicenseModel(true), + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: 'Updated Dataset Title' + } + } + ] + }, + [citationMetadataBlock] + ) + + const actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toEqual(2) + expect(actual.versions[0].versionInfo.state).toBe('DRAFT') + expect(actual.versions[0].metadataBlocks?.[0].fields.title).toBe('Updated Dataset Title') + + expect(actual.versions[1].versionInfo.majorNumber).toBe(1) + expect(actual.versions[1].versionInfo.minorNumber).toBe(0) + expect(actual.versions[1].versionInfo.state).toBe('RELEASED') + expect(actual.versions[1].metadataBlocks?.[0].fields.title).toBe( + TestConstants.TEST_NEW_DATASET_DTO.metadataBlockValues[0].fields.title + ) + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + test('should return dataset versions with pagination', async () => { + const testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testDatasetVersionsCollectionAlias + ) + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const metadataBlocksRepository = new MetadataBlocksRepository() + const citationMetadataBlock = await metadataBlocksRepository.getMetadataBlockByName( + 'citation' + ) + + for (let i = 1; i <= 21; i++) { + await sut.updateDataset( + testDatasetIds.numericId, + { + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: `Updated Dataset Title - Version ${i}` + } + } + ] + }, + [citationMetadataBlock] + ) + + await publishDataset.execute(testDatasetIds.numericId, VersionUpdateType.MINOR) + await waitForNoLocks(testDatasetIds.numericId, 10) + } + + const firstPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 0) + + expect(firstPage.versions.length).toBe(5) + expect(firstPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(firstPage.versions[0].versionInfo.minorNumber).toBe(21) + expect(firstPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(firstPage.versions[4].versionInfo.minorNumber).toBe(17) + + // Test pagination with limit=5, offset=5 (second page) + const secondPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 5) + expect(secondPage.versions.length).toBe(5) + expect(secondPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(secondPage.versions[0].versionInfo.minorNumber).toBe(16) + expect(secondPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(secondPage.versions[4].versionInfo.minorNumber).toBe(12) + + // Test pagination with limit=5, offset=10 (third page) + const thirdPage = await sut.getDatasetVersions(testDatasetIds.numericId, 5, 10) + expect(thirdPage.versions.length).toBe(5) + expect(thirdPage.versions[0].versionInfo.majorNumber).toBe(1) + expect(thirdPage.versions[0].versionInfo.minorNumber).toBe(11) + expect(thirdPage.versions[4].versionInfo.majorNumber).toBe(1) + expect(thirdPage.versions[4].versionInfo.minorNumber).toBe(7) + + // Test that all versions are returned without pagination + const allVersions = await sut.getDatasetVersions(testDatasetIds.numericId) + expect(allVersions.versions.length).toBe(22) // 1 initial + 21 updates + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }, 180000) + + test('should return error when dataset does not exist', async () => { + const expectedError = new ReadError( + `[404] Dataset with ID ${nonExistentTestDatasetId} not found.` + ) + + await expect(sut.getDatasetVersions(nonExistentTestDatasetId)).rejects.toThrow(expectedError) + }) + }) + describe('getDatasetDownloadCount', () => { const testGetDatasetDownloadCountCollectionAlias = 'testGetDatasetDownloadCountCollection' let testDatasetIds: CreatedDatasetIdentifiers diff --git a/test/unit/datasets/GetDatasetVersions.test.ts b/test/unit/datasets/GetDatasetVersions.test.ts new file mode 100644 index 00000000..63388882 --- /dev/null +++ b/test/unit/datasets/GetDatasetVersions.test.ts @@ -0,0 +1,39 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { createDatasetModel } from '../../testHelpers/datasets/datasetHelper' +import { GetDatasetVersions } from '../../../src/datasets/domain/useCases/GetDatasetVersions' +import { DatasetVersionSubset } from '../../../src/datasets/domain/models/DatasetVersionSubset' + +const testDatasetId = 1 + +describe('execute', () => { + test('should return dataset versions on repository success', async () => { + const testDatasetVersionsSubset: DatasetVersionSubset = { + versions: [createDatasetModel()] + } + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersions = jest + .fn() + .mockResolvedValue(testDatasetVersionsSubset) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId) + + expect(actual).toEqual(testDatasetVersionsSubset) + expect(datasetsRepositoryStub.getDatasetVersions).toHaveBeenCalledWith( + testDatasetId, + undefined, + undefined, + undefined, + undefined + ) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersions = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId)).rejects.toThrow(ReadError) + }) +})