diff --git a/docs/useCases.md b/docs/useCases.md index 3254e4a5..3fd43643 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -37,6 +37,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) @@ -871,6 +872,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 the total count of versions and an array of [DatasetVersion](../src/datasets/domain/models/DatasetVersion.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). +- **excludesFiles**: (boolean) Exclude files (default: true). + #### 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/DatasetVersion.ts b/src/datasets/domain/models/DatasetVersion.ts new file mode 100644 index 00000000..b502faa4 --- /dev/null +++ b/src/datasets/domain/models/DatasetVersion.ts @@ -0,0 +1,31 @@ +import { DatasetLicense, DatasetMetadataBlocks, DatasetVersionState } from "./Dataset"; +import { FilePayload } from "../../../files/infra/repositories/transformers/FilePayload"; + +export interface DatasetVersion { + id: number + datasetId: number + datasetPersistentId: string + alternativePersistentId?: string + datasetType: string + storageIdentifier: string + versionNumber?: number + versionMinorNumber?: number + internalVersionNumber: number + versionState: DatasetVersionState + isInReviewState: boolean + latestVersionPublishingState: DatasetVersionState + lastUpdateTime: string + releaseTime?: string + createTime: string + publicationDate: string + citationDate: string + license: DatasetLicense + fileAccessRequest: boolean + files?: Array + metadataBlocks?: DatasetMetadataBlocks +} + +export interface DatasetVersionSubset { + versions: DatasetVersion[] + totalCount: number +} \ No newline at end of file diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 8a52f8f9..73c07408 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/DatasetVersion' import { DatasetLinkedCollection } from '../models/DatasetLinkedCollection' import { CitationFormat } from '../models/CitationFormat' import { FormattedCitation } from '../models/FormattedCitation' @@ -73,6 +74,13 @@ export interface IDatasetsRepository { limit?: number, offset?: number ): Promise + getDatasetVersions( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + excludeFiles?: 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..fc55f440 --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetVersions.ts @@ -0,0 +1,32 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetVersionSubset } from '../models/DatasetVersion' +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} [excludeFiles] - Exclude files (optional, default: true). + * @returns {Promise} - A DatasetVersionSubset containing the versions and total count. + */ + async execute( + datasetId: number | string, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + excludeFiles?: boolean + ): Promise { + return await this.datasetsRepository.getDatasetVersions(datasetId, limit, offset, excludeMetadataBlocks, excludeFiles) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index b8edb5b3..19a2fda6 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' @@ -67,6 +68,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) @@ -101,6 +103,7 @@ export { deaccessionDataset, getDatasetDownloadCount, getDatasetVersionsSummaries, + getDatasetVersions, deleteDatasetDraft, linkDataset, unlinkDataset, @@ -133,6 +136,7 @@ export { TermsOfUse } from './domain/models/Dataset' export { DatasetPreview } from './domain/models/DatasetPreview' +export { DatasetVersion } from './domain/models/DatasetVersion' 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 849cf658..4e1ac92f 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -21,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/DatasetVersion' import { DatasetLinkedCollection } from '../../domain/models/DatasetLinkedCollection' import { CitationFormat } from '../../domain/models/CitationFormat' import { transformDatasetLinkedCollectionsResponseToDatasetLinkedCollection } from './transformers/datasetLinkedCollectionsTransformers' @@ -337,6 +338,45 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async getDatasetVersions( + datasetId: string | number, + limit?: number, + offset?: number, + excludeMetadataBlocks?: boolean, + excludeFiles?: 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()) + } + + if (excludeFiles !== undefined) { + queryParams.set('excludeFiles', excludeFiles.toString()) + } + + return this.doGet( + this.buildApiEndpoint(this.datasetsResourceName, 'versions', datasetId), + true, + queryParams + ) + .then((response) => ({ + versions: response.data.data, + totalCount: response.data.totalCount + })) + .catch((error) => { + throw error + }) + } + public async deleteDatasetDraft(datasetId: string | number): Promise { return this.doDelete( this.buildApiEndpoint(this.datasetsResourceName, 'versions/:draft', datasetId) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 5e3fa4b1..d495b5f9 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -1555,6 +1555,204 @@ 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 dataset versions when dataset exists', 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.totalCount).toBeGreaterThan(0) + expect(actual.versions[0].versionState).toBe('DRAFT') + expect(actual.versions[0].latestVersionPublishingState).toBe('DRAFT') + expect(actual.versions[0].isInReviewState).toBe(false) + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should return dataset versions correctly after first publish', 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 actual = await sut.getDatasetVersions(testDatasetIds.numericId) + + expect(actual.versions.length).toBeGreaterThan(0) + expect(actual.totalCount).toBeGreaterThan(0) + expect(actual.versions[0].versionNumber).toBe(1) + expect(actual.versions[0].versionMinorNumber).toBe(0) + expect(actual.versions[0].versionState).toBe('RELEASED') + expect(actual.versions[0].isInReviewState).toBe(false) + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + test('should return dataset versions correctly after deaccessioned', 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.totalCount).toBeGreaterThan(0) + expect(actual.versions[0].versionNumber).toBe(1) + expect(actual.versions[0].versionMinorNumber).toBe(0) + expect(actual.versions[0].versionState).toBe('DEACCESSIONED') + expect(actual.versions[0].isInReviewState).toBe(false) + + 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.totalCount).toEqual(2) + expect(actual.versions[0].versionState).toBe('DRAFT') + expect(actual.versions[0].isInReviewState).toBe(false) + + expect(actual.versions[1].versionNumber).toBe(1) + expect(actual.versions[1].versionMinorNumber).toBe(0) + expect(actual.versions[1].versionState).toBe('RELEASED') + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + + 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 + ) + }) + + 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.totalCount).toBe(22) + expect(firstPage.versions[0].versionNumber).toBe(1) + expect(firstPage.versions[0].versionMinorNumber).toBe(21) + expect(firstPage.versions[4].versionNumber).toBe(1) + expect(firstPage.versions[4].versionMinorNumber).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.totalCount).toBe(22) + expect(firstPage.versions[0].versionNumber).toBe(1) + expect(firstPage.versions[0].versionMinorNumber).toBe(16) + expect(firstPage.versions[4].versionNumber).toBe(1) + expect(firstPage.versions[4].versionMinorNumber).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.totalCount).toBe(22) + expect(firstPage.versions[0].versionNumber).toBe(1) + expect(firstPage.versions[0].versionMinorNumber).toBe(11) + expect(firstPage.versions[4].versionNumber).toBe(1) + expect(firstPage.versions[4].versionMinorNumber).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 + expect(allVersions.totalCount).toBe(22) + + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }, 180000) + }) + describe('getDatasetDownloadCount', () => { const testGetDatasetDownloadCountCollectionAlias = 'testGetDatasetDownloadCountCollection' let testDatasetIds: CreatedDatasetIdentifiers diff --git a/test/testHelpers/datasets/datasetVersionsHelper.ts b/test/testHelpers/datasets/datasetVersionsHelper.ts new file mode 100644 index 00000000..8ffc911c --- /dev/null +++ b/test/testHelpers/datasets/datasetVersionsHelper.ts @@ -0,0 +1,26 @@ +import { DatasetVersion, DatasetVersionState } from '../../../src' + +export const createDatasetVersionModel = ( + props?: Partial +): DatasetVersion => ({ + id: 1, + datasetId: 1, + datasetPersistentId: 'doi:10.5072/FK2/AAENBT', + datasetType: 'dataset', + storageIdentifier: 's3://datasets/1', + internalVersionNumber: 1, + versionState: DatasetVersionState.DRAFT, + latestVersionPublishingState: DatasetVersionState.DRAFT, + isInReviewState: false, + lastUpdateTime: '2021-01-01T00:00:00Z', + createTime: '2021-01-01T00:00:00Z', + publicationDate: '2021-01-01', + citationDate: '2021-01-01', + license: { + 'name': 'CC BY 4.0 (Creative Commons Attribution 4.0 International)', + 'uri': 'cc-by', + 'iconUri': 'https://licensebuttons.net/l/by/4.0/88x31.png' + }, + fileAccessRequest: false, + ...props +}) diff --git a/test/unit/datasets/GetDatasetVersions.test.ts b/test/unit/datasets/GetDatasetVersions.test.ts new file mode 100644 index 00000000..c9507ce5 --- /dev/null +++ b/test/unit/datasets/GetDatasetVersions.test.ts @@ -0,0 +1,40 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { createDatasetVersionModel } from '../../testHelpers/datasets/datasetVersionsHelper' +import { GetDatasetVersions } from '../../../src/datasets/domain/useCases/GetDatasetVersions' +import { DatasetVersionSubset } from '../../../src/datasets/domain/models/DatasetVersion' + +const testDatasetId = 1 + +describe('execute', () => { + test('should return dataset versions summaries on repository success', async () => { + const testDatasetVersionsSubset: DatasetVersionSubset = { + versions: [createDatasetVersionModel()], + totalCount: 1 + } + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersionsSummaries = jest + .fn() + .mockResolvedValue(testDatasetVersionsSubset) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId) + + expect(actual).toEqual(testDatasetVersionsSubset) + expect(datasetsRepositoryStub.getDatasetVersionsSummaries).toHaveBeenCalledWith( + testDatasetId, + undefined, + undefined + ) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersionsSummaries = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new GetDatasetVersions(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId)).rejects.toThrow(ReadError) + }) +})