diff --git a/docs/useCases.md b/docs/useCases.md index ac16db09..8a3bd8fc 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -27,6 +27,7 @@ The different use cases currently available in the package are classified below, - [Get Dataset Locks](#get-dataset-locks) - [Get Dataset Summary Field Names](#get-dataset-summary-field-names) - [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) - [Datasets write use cases](#datasets-write-use-cases) - [Create a Dataset](#create-a-dataset) @@ -457,6 +458,36 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetUserPermissions.ts) im The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. +#### Get Differences between Two Dataset Versions + +Returns an instance of [DatasetVersionDiff](../src/datasets/domain/models/DatasetVersionDiff.ts) that contains the differences between two Dataset Versions. + +##### Example call: + +```typescript +import { getDatasetVersionDiff } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +const oldVersion = '1.0' +const newVersion = '2.0' + +lgetDatasetVersionDiff + .execute(datasetId, oldVersion, newVersion) + .then((versionDiff: DatasetVersionDiff) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/GetDatasetVersionDiff.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +The `oldVersion` and `newVersion` parameters specify the versions of the dataset to compare. + #### List All Datasets Returns an instance of [DatasetPreviewSubset](../src/datasets/domain/models/DatasetPreviewSubset.ts) that contains reduced information for each dataset that the calling user can access in the installation. diff --git a/src/datasets/domain/models/DatasetVersionDiff.ts b/src/datasets/domain/models/DatasetVersionDiff.ts new file mode 100644 index 00000000..e0f94fcf --- /dev/null +++ b/src/datasets/domain/models/DatasetVersionDiff.ts @@ -0,0 +1,48 @@ +export interface DatasetVersionDiff { + oldVersion: VersionSummary + newVersion: VersionSummary + metadataChanges?: MetadataBlockDiff[] + filesAdded?: FileSummary[] + filesRemoved?: FileSummary[] + fileChanges?: FileDiff[] + filesReplaced?: FileReplacement[] + termsOfAccess?: FieldDiff[] +} + +export interface FileSummary { + fileName: string + MD5: string + type: string + fileId: number + filePath: string + description: string + isRestricted: boolean + tags: string[] + categories: string[] +} + +export interface VersionSummary { + versionNumber: string + lastUpdatedDate: string +} +export interface MetadataBlockDiff { + blockName: string + changed: FieldDiff[] +} + +export interface FileDiff { + fileName: string + md5: string + fileId: number + changed: FieldDiff[] +} + +export interface FileReplacement { + oldFile: FileSummary + newFile: FileSummary +} +export interface FieldDiff { + fieldName: string + oldValue: string + newValue: string +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 6d5abf8e..c5cd44d4 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -5,6 +5,7 @@ import { DatasetUserPermissions } from '../models/DatasetUserPermissions' import { CreatedDatasetIdentifiers } from '../models/CreatedDatasetIdentifiers' import { DatasetDTO } from '../dtos/DatasetDTO' import { MetadataBlock } from '../../../metadataBlocks' +import { DatasetVersionDiff } from '../models/DatasetVersionDiff' export interface IDatasetsRepository { getDataset( @@ -28,6 +29,11 @@ export interface IDatasetsRepository { getDatasetSummaryFieldNames(): Promise getPrivateUrlDatasetCitation(token: string): Promise getDatasetUserPermissions(datasetId: number | string): Promise + getDatasetVersionDiff( + datasetId: number | string, + newVersionId: string, + oldVersionId: string + ): Promise createDataset( newDataset: DatasetDTO, datasetMetadataBlocks: MetadataBlock[], diff --git a/src/datasets/domain/useCases/GetDatasetVersionDiff.ts b/src/datasets/domain/useCases/GetDatasetVersionDiff.ts new file mode 100644 index 00000000..d1499d7a --- /dev/null +++ b/src/datasets/domain/useCases/GetDatasetVersionDiff.ts @@ -0,0 +1,29 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' +import { DatasetVersionDiff } from '../models/DatasetVersionDiff' + +export class GetDatasetVersionDiff implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns a DatasetVersionDiff instance, which contains the differences between the two given versions. + * @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {string } [oldVersionId] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. + * @param {string } [newVersionId] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. + */ + async execute( + datasetId: number | string, + oldVersionId: string, + newVersionId: string + ): Promise { + return await this.datasetsRepository.getDatasetVersionDiff( + datasetId, + oldVersionId, + newVersionId + ) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 71c340cd..2eaaed5d 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -15,6 +15,7 @@ import { SingleMetadataFieldValidator } from './domain/useCases/validators/Singl import { MultipleMetadataFieldValidator } from './domain/useCases/validators/MultipleMetadataFieldValidator' import { PublishDataset } from './domain/useCases/PublishDataset' import { UpdateDataset } from './domain/useCases/UpdateDataset' +import { GetDatasetVersionDiff } from './domain/useCases/GetDatasetVersionDiff' const datasetsRepository = new DatasetsRepository() @@ -26,6 +27,7 @@ const getAllDatasetPreviews = new GetAllDatasetPreviews(datasetsRepository) const getDatasetUserPermissions = new GetDatasetUserPermissions(datasetsRepository) const getDatasetSummaryFieldNames = new GetDatasetSummaryFieldNames(datasetsRepository) const getPrivateUrlDatasetCitation = new GetPrivateUrlDatasetCitation(datasetsRepository) +const getDatasetVersionDiff = new GetDatasetVersionDiff(datasetsRepository) const singleMetadataFieldValidator = new SingleMetadataFieldValidator() const metadataFieldValidator = new MetadataFieldValidator( new SingleMetadataFieldValidator(), @@ -54,6 +56,7 @@ export { getDatasetUserPermissions, getDatasetSummaryFieldNames, getPrivateUrlDatasetCitation, + getDatasetVersionDiff, publishDataset, createDataset, updateDataset @@ -73,6 +76,7 @@ export { DatasetMetadataFieldValue } from './domain/models/Dataset' export { DatasetPreview } from './domain/models/DatasetPreview' +export { DatasetVersionDiff } from './domain/models/DatasetVersionDiff' export { DatasetPreviewSubset } from './domain/models/DatasetPreviewSubset' export { DatasetDTO, diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 212d7e5c..a4491291 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -15,6 +15,8 @@ import { MetadataBlock } from '../../../metadataBlocks' import { transformDatasetModelToNewDatasetRequestPayload } from './transformers/datasetTransformers' import { transformDatasetLocksResponseToDatasetLocks } from './transformers/datasetLocksTransformers' import { transformDatasetPreviewsResponseToDatasetPreviewSubset } from './transformers/datasetPreviewsTransformers' +import { DatasetVersionDiff } from '../../domain/models/DatasetVersionDiff' +import { transformDatasetVersionDiffResponseToDatasetVersionDiff } from './transformers/datasetVersionDiffTransformers' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -141,6 +143,24 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async getDatasetVersionDiff( + datasetId: string | number, + oldVersionId: string, + newVersionId: string + ): Promise { + return this.doGet( + this.buildApiEndpoint( + this.datasetsResourceName, + `versions/${oldVersionId}/compare/${newVersionId}`, + datasetId + ), + true + ) + .then((response) => transformDatasetVersionDiffResponseToDatasetVersionDiff(response)) + .catch((error) => { + throw error + }) + } public async createDataset( newDataset: DatasetDTO, datasetMetadataBlocks: MetadataBlock[], diff --git a/src/datasets/infra/repositories/transformers/DatasetVersionDiffPayload.ts b/src/datasets/infra/repositories/transformers/DatasetVersionDiffPayload.ts new file mode 100644 index 00000000..b9016732 --- /dev/null +++ b/src/datasets/infra/repositories/transformers/DatasetVersionDiffPayload.ts @@ -0,0 +1,48 @@ +export interface DatasetVersionDiffPayload { + oldVersion: VersionSummaryPayload + newVersion: VersionSummaryPayload + metadataChanges: MetadataBlockDiffPayload[] + filesAdded: FileSummaryPayload[] + filesRemoved: FileSummaryPayload[] + fileChanges: FileDiffPayload[] + filesReplaced: FileReplacementPayload[] + TermsOfAccess: FieldDiffPayload[] +} + +export interface FileSummaryPayload { + fileName: string + MD5: string + type: string + fileId: number + filePath: string + description: string + isRestricted: boolean + tags: string[] + categories: string[] +} + +export interface VersionSummaryPayload { + versionNumber: string + lastUpdatedDate: string +} +export interface MetadataBlockDiffPayload { + blockName: string + changed: FieldDiffPayload[] +} + +export interface FileDiffPayload { + fileName: string + md5: string + fileId: number + changed: FieldDiffPayload[] +} +export interface FieldDiffPayload { + fieldName: string + oldValue: string + newValue: string +} + +export interface FileReplacementPayload { + oldFile: FileSummaryPayload + newFile: FileSummaryPayload +} diff --git a/src/datasets/infra/repositories/transformers/datasetVersionDiffTransformers.ts b/src/datasets/infra/repositories/transformers/datasetVersionDiffTransformers.ts new file mode 100644 index 00000000..9072c6bc --- /dev/null +++ b/src/datasets/infra/repositories/transformers/datasetVersionDiffTransformers.ts @@ -0,0 +1,19 @@ +import { AxiosResponse } from 'axios' +import { DatasetVersionDiff } from '../../../domain/models/DatasetVersionDiff' + +export const transformDatasetVersionDiffResponseToDatasetVersionDiff = ( + response: AxiosResponse +): DatasetVersionDiff => { + const datasetVersionDiffPayload = response.data.data + const retValue = { + oldVersion: datasetVersionDiffPayload.oldVersion, + newVersion: datasetVersionDiffPayload.newVersion, + metadataChanges: datasetVersionDiffPayload.metadataChanges, + filesAdded: datasetVersionDiffPayload.filesAdded, + filesRemoved: datasetVersionDiffPayload.filesRemoved, + fileChanges: datasetVersionDiffPayload.fileChanges, + filesReplaced: datasetVersionDiffPayload.filesReplaced, + termsOfAccess: datasetVersionDiffPayload.TermsOfAccess + } + return retValue +} diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index db561395..f946812f 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -16,7 +16,8 @@ import { DatasetPreviewSubset, VersionUpdateType, createDataset, - CreatedDatasetIdentifiers + CreatedDatasetIdentifiers, + DatasetDTO } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -31,6 +32,45 @@ import { deleteCollectionViaApi, ROOT_COLLECTION_ALIAS } from '../../testHelpers/collections/collectionHelper' +import { testTextFile1Name, uploadFileViaApi } from '../../testHelpers/files/filesHelper' + +const TEST_DIFF_DATASET_DTO: DatasetDTO = { + license: { + name: 'CC0 1.0', + uri: 'http://creativecommons.org/publicdomain/zero/1.0', + iconUri: 'https://licensebuttons.net/p/zero/1.0/88x31.png' + }, + metadataBlockValues: [ + { + name: 'citation', + fields: { + title: 'Updated Dataset Title', + author: [ + { + authorName: 'Smith, John', + authorAffiliation: 'Dataverse.org' + }, + { + authorName: 'Owner, Dataverse', + authorAffiliation: 'Dataversedemo.org' + } + ], + datasetContact: [ + { + datasetContactEmail: 'bird@mailinator.com', + datasetContactName: 'Bird, Fiona' + } + ], + dsDescription: [ + { + dsDescriptionValue: 'This is the updated description of the dataset.' + } + ], + subject: ['Medicine, Health and Life Sciences'] + } + } + ] +} describe('DatasetsRepository', () => { const testCollectionAlias = 'datasetsRepositoryTestCollection' @@ -429,6 +469,107 @@ describe('DatasetsRepository', () => { expect(typeof actualDatasetCitation).toBe('string') }) }) + describe('getDatasetVersionDiff', () => { + let testDatasetIds: CreatedDatasetIdentifiers + + beforeEach(async () => { + testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + // Dataset is in draft, so we need to publish it first + await sut.publishDataset(testDatasetIds.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(testDatasetIds.numericId, 10) + }) + + test('should return dataset metadata diff between two dataset versions', async () => { + // Update dataset + const metadataBlocksRepository = new MetadataBlocksRepository() + const citationMetadataBlock = await metadataBlocksRepository.getMetadataBlockByName( + 'citation' + ) + + await sut.updateDataset(testDatasetIds.numericId, TEST_DIFF_DATASET_DTO, [ + citationMetadataBlock + ]) + const actual = await sut.getDatasetVersionDiff( + testDatasetIds.numericId, + '1.0', + DatasetNotNumberedVersion.DRAFT + ) + expect(actual.metadataChanges[0]).not.toBeUndefined() + expect(actual.metadataChanges[0].blockName).toEqual('Citation Metadata') + }) + + test('should return added file diff between two dataset versions', async () => { + const fileMetadata = { + description: 'test description', + directoryLabel: 'directoryLabel', + categories: ['category1', 'category2'] + } + + const uploadResponse = await uploadFileViaApi( + testDatasetIds.numericId, + testTextFile1Name, + fileMetadata + ) + + const fileId = uploadResponse.data.data.files[0].dataFile.id + const expectedFilesAdded = [ + { + fileName: 'test-file-1.txt', + type: 'text/plain', + isRestricted: false, + description: fileMetadata.description, + filePath: fileMetadata.directoryLabel, + categories: fileMetadata.categories, + MD5: '68b22040025784da775f55cfcb6dee2e', + fileId: fileId + } + ] + const actual = await sut.getDatasetVersionDiff( + testDatasetIds.numericId, + '1.0', + DatasetNotNumberedVersion.DRAFT + ) + expect(actual.filesAdded).toEqual(expectedFilesAdded) + }) + + test('should return diff between :latestPublished and :draft', async () => { + const fileMetadata = { + description: 'test description', + directoryLabel: 'directoryLabel', + categories: ['category1', 'category2'] + } + + const uploadResponse = await uploadFileViaApi( + testDatasetIds.numericId, + testTextFile1Name, + fileMetadata + ) + + const fileId = uploadResponse.data.data.files[0].dataFile.id + const expectedFilesAdded = [ + { + fileName: 'test-file-1.txt', + type: 'text/plain', + isRestricted: false, + description: fileMetadata.description, + filePath: fileMetadata.directoryLabel, + categories: fileMetadata.categories, + MD5: '68b22040025784da775f55cfcb6dee2e', + fileId: fileId + } + ] + const actual = await sut.getDatasetVersionDiff( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST_PUBLISHED, + DatasetNotNumberedVersion.DRAFT + ) + expect(actual.filesAdded).toEqual(expectedFilesAdded) + }) + + afterEach(async () => { + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + }) + }) describe('createDataset', () => { test('should create a dataset with the provided dataset citation fields', async () => { diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 073c4307..5d8b6821 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -8,7 +8,11 @@ import { createMultipartFileBlob, createSinglepartFileBlob, registerFileViaApi, - uploadFileViaApi + uploadFileViaApi, + testTextFile1Name, + testTextFile2Name, + testTextFile3Name, + testTabFile4Name } from '../../testHelpers/files/filesHelper' import { ReadError } from '../../../src/core/domain/repositories/ReadError' import { @@ -43,10 +47,6 @@ describe('FilesRepository', () => { let testDatasetIds: CreatedDatasetIdentifiers - const testTextFile1Name = 'test-file-1.txt' - const testTextFile2Name = 'test-file-2.txt' - const testTextFile3Name = 'test-file-3.txt' - const testTabFile4Name = 'test-file-4.tab' const testCategoryName = 'testCategory' const nonExistentFiledId = 200 diff --git a/test/testHelpers/datasets/datasetVersionDiffHelper.ts b/test/testHelpers/datasets/datasetVersionDiffHelper.ts new file mode 100644 index 00000000..34119142 --- /dev/null +++ b/test/testHelpers/datasets/datasetVersionDiffHelper.ts @@ -0,0 +1,77 @@ +import { + DatasetVersionDiff, + VersionSummary, + MetadataBlockDiff, + FileSummary, + FileDiff, + FileReplacement, + FieldDiff +} from '../../../src/datasets/domain/models/DatasetVersionDiff' + +export const createDatasetVersionDiff = (): DatasetVersionDiff => { + const versionSummary: VersionSummary = { + versionNumber: '1.0', + lastUpdatedDate: '2023-05-15T08:21:03Z' + } + + const metadataBlockDiff: MetadataBlockDiff = { + blockName: 'citation', + changed: [ + { + fieldName: 'title', + oldValue: 'Old Title', + newValue: 'New Title' + } + ] + } + + const fileSummary: FileSummary = { + fileName: 'file1.txt', + MD5: 'd41d8cd98f00b204e9800998ecf8427e', + type: 'text/plain', + fileId: 1, + filePath: '/path/to/file1.txt', + description: 'Test file', + isRestricted: false, + tags: ['tag1'], + categories: ['category1'] + } + + const fileDiff: FileDiff = { + fileName: 'file1.txt', + md5: 'd41d8cd98f00b204e9800998ecf8427e', + fileId: 1, + changed: [ + { + fieldName: 'description', + oldValue: 'Old description', + newValue: 'New description' + } + ] + } + + const fileReplacement: FileReplacement = { + oldFile: fileSummary, + newFile: { + ...fileSummary, + fileName: 'file2.txt' + } + } + + const fieldDiff: FieldDiff = { + fieldName: 'termsOfAccess', + oldValue: 'Old terms', + newValue: 'New terms' + } + + return { + oldVersion: versionSummary, + newVersion: versionSummary, + metadataChanges: [metadataBlockDiff], + filesAdded: [fileSummary], + filesRemoved: [fileSummary], + fileChanges: [fileDiff], + filesReplaced: [fileReplacement], + termsOfAccess: [fieldDiff] + } +} diff --git a/test/testHelpers/files/filesHelper.ts b/test/testHelpers/files/filesHelper.ts index 3d4c1f52..abe6bc0b 100644 --- a/test/testHelpers/files/filesHelper.ts +++ b/test/testHelpers/files/filesHelper.ts @@ -9,8 +9,13 @@ import { FilePayload } from '../../../src/files/infra/repositories/transformers/ interface FileMetadata { categories?: string[] + description?: string + directoryLabel?: string } - +export const testTextFile1Name = 'test-file-1.txt' +export const testTextFile2Name = 'test-file-2.txt' +export const testTextFile3Name = 'test-file-3.txt' +export const testTabFile4Name = 'test-file-4.tab' export const createFileModel = (): FileModel => { return { id: 1, diff --git a/test/unit/datasets/GetDatasetVersionDiff.test.ts b/test/unit/datasets/GetDatasetVersionDiff.test.ts new file mode 100644 index 00000000..8c6cb5ee --- /dev/null +++ b/test/unit/datasets/GetDatasetVersionDiff.test.ts @@ -0,0 +1,29 @@ +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { createDatasetVersionDiff } from '../../testHelpers/datasets/datasetVersionDiffHelper' +import { GetDatasetVersionDiff } from '../../../src/datasets/domain/useCases/GetDatasetVersionDiff' + +describe('execute', () => { + const testDatasetId = 1 + + test('should return dataset version diff on repository success', async () => { + const testDatasetVersionDiff = [createDatasetVersionDiff()] + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersionDiff = jest + .fn() + .mockResolvedValue(testDatasetVersionDiff) + const sut = new GetDatasetVersionDiff(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId, '1.0', '2.0') + + expect(actual).toEqual(testDatasetVersionDiff) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDatasetVersionDiff = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetDatasetVersionDiff(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId, '1.0', '2.0')).rejects.toThrow(ReadError) + }) +})