diff --git a/docs/useCases.md b/docs/useCases.md index 26317d1c..b4bca638 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -36,6 +36,7 @@ The different use cases currently available in the package are classified below, - [Create a Dataset](#create-a-dataset) - [Update a Dataset](#update-a-dataset) - [Publish a Dataset](#publish-a-dataset) + - [Deaccession a Dataset](#deaccession-a-dataset) - [Files](#Files) - [Files read use cases](#files-read-use-cases) - [Get a File](#get-a-file) @@ -753,6 +754,35 @@ The `versionUpdateType` parameter can be a [VersionUpdateType](../src/datasets/d - `VersionUpdateType.MAJOR` - `VersionUpdateType.UPDATE_CURRENT` +#### Deaccession a Dataset + +Deaccession a Dataset, given its identifier, version, and deaccessionDatasetDTO to perform. + +##### Example call: + +```typescript +import { deaccessionDataset } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 1 +const version = ':latestPublished' +const deaccessionDatasetDTO = { + deaccessionReason: 'Description of the deaccession reason.', + deaccessionForwardURL: 'https://demo.dataverse.org' +} + +deaccessionDataset.execute(datasetId, version, deaccessionDatasetDTO) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/DeaccessionDataset.ts) implementation_. +The `datasetId` parameter can be a string for persistent identifiers, or a number for numeric identifiers. +The `version` parameter should be a string or a [DatasetNotNumberedVersion](../src/datasets/domain/models/DatasetNotNumberedVersion.ts) enum value. + +You cannot deaccession a dataset more than once. If you call this endpoint twice for the same dataset version, you will get a not found error on the second call, since the dataset you are looking for will no longer be published since it is already deaccessioned. + ## Files ### Files read use cases diff --git a/src/datasets/domain/dtos/DatasetDeaccessionDTO.ts b/src/datasets/domain/dtos/DatasetDeaccessionDTO.ts new file mode 100644 index 00000000..1ebcfeed --- /dev/null +++ b/src/datasets/domain/dtos/DatasetDeaccessionDTO.ts @@ -0,0 +1,4 @@ +export interface DatasetDeaccessionDTO { + deaccessionReason: string + deaccessionForwardURL?: string +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index c5cd44d4..dd2b954d 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -4,6 +4,7 @@ import { DatasetPreviewSubset } from '../models/DatasetPreviewSubset' import { DatasetUserPermissions } from '../models/DatasetUserPermissions' import { CreatedDatasetIdentifiers } from '../models/CreatedDatasetIdentifiers' import { DatasetDTO } from '../dtos/DatasetDTO' +import { DatasetDeaccessionDTO } from '../dtos/DatasetDeaccessionDTO' import { MetadataBlock } from '../../../metadataBlocks' import { DatasetVersionDiff } from '../models/DatasetVersionDiff' @@ -45,4 +46,9 @@ export interface IDatasetsRepository { dataset: DatasetDTO, datasetMetadataBlocks: MetadataBlock[] ): Promise + deaccessionDataset( + datasetId: number | string, + datasetVersionId: string, + deaccessionDTO: DatasetDeaccessionDTO + ): Promise } diff --git a/src/datasets/domain/useCases/DeaccessionDataset.ts b/src/datasets/domain/useCases/DeaccessionDataset.ts new file mode 100644 index 00000000..94144c56 --- /dev/null +++ b/src/datasets/domain/useCases/DeaccessionDataset.ts @@ -0,0 +1,31 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' +import { DatasetDeaccessionDTO } from '../dtos/DatasetDeaccessionDTO' +import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' + +export class DeaccessionDataset implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Deaccession a dataset, given a dataset id, a dataset version id, and a DatasetDeaccessionDTO object. + * @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {string | DatasetNotNumberedVersion} [datasetVersionId] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. + * @returns A promise that resolves when the dataset is deaccessioned + * @throws An error if the dataset could not be deaccessioned + */ + async execute( + datasetId: number | string, + datasetVersionId: string | DatasetNotNumberedVersion, + DatasetDeaccessionDTO: DatasetDeaccessionDTO + ): Promise { + return await this.datasetsRepository.deaccessionDataset( + datasetId, + datasetVersionId, + DatasetDeaccessionDTO + ) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 2eaaed5d..b13e8aad 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -16,6 +16,7 @@ import { MultipleMetadataFieldValidator } from './domain/useCases/validators/Mul import { PublishDataset } from './domain/useCases/PublishDataset' import { UpdateDataset } from './domain/useCases/UpdateDataset' import { GetDatasetVersionDiff } from './domain/useCases/GetDatasetVersionDiff' +import { DeaccessionDataset } from './domain/useCases/DeaccessionDataset' const datasetsRepository = new DatasetsRepository() @@ -46,6 +47,7 @@ const updateDataset = new UpdateDataset( metadataBlocksRepository, datasetResourceValidator ) +const deaccessionDataset = new DeaccessionDataset(datasetsRepository) export { getDataset, @@ -59,7 +61,8 @@ export { getDatasetVersionDiff, publishDataset, createDataset, - updateDataset + updateDataset, + deaccessionDataset } export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' @@ -85,5 +88,6 @@ export { DatasetMetadataBlockValuesDTO, DatasetMetadataChildFieldValueDTO } from './domain/dtos/DatasetDTO' +export { DatasetDeaccessionDTO } from './domain/dtos/DatasetDeaccessionDTO' export { CreatedDatasetIdentifiers } from './domain/models/CreatedDatasetIdentifiers' export { VersionUpdateType } from './domain/models/Dataset' diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index a4491291..5fd02917 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -11,6 +11,7 @@ import { DatasetLock } from '../../domain/models/DatasetLock' import { CreatedDatasetIdentifiers } from '../../domain/models/CreatedDatasetIdentifiers' import { DatasetPreviewSubset } from '../../domain/models/DatasetPreviewSubset' import { DatasetDTO } from '../../domain/dtos/DatasetDTO' +import { DatasetDeaccessionDTO } from '../../domain/dtos/DatasetDeaccessionDTO' import { MetadataBlock } from '../../../metadataBlocks' import { transformDatasetModelToNewDatasetRequestPayload } from './transformers/datasetTransformers' import { transformDatasetLocksResponseToDatasetLocks } from './transformers/datasetLocksTransformers' @@ -214,4 +215,23 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi throw error }) } + + public async deaccessionDataset( + datasetId: string | number, + datasetVersionId: string, + deaccessionDTO: DatasetDeaccessionDTO + ): Promise { + return this.doPost( + this.buildApiEndpoint( + this.datasetsResourceName, + `versions/${datasetVersionId}/deaccession`, + datasetId + ), + deaccessionDTO + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } } diff --git a/test/functional/datasets/DeaccessionDataset.test.ts b/test/functional/datasets/DeaccessionDataset.test.ts new file mode 100644 index 00000000..102b2c89 --- /dev/null +++ b/test/functional/datasets/DeaccessionDataset.test.ts @@ -0,0 +1,161 @@ +import { + deaccessionDataset, + DatasetDeaccessionDTO, + createDataset, + publishDataset, + VersionUpdateType +} from '../../../src/datasets' +import { ApiConfig, WriteError } from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + waitForNoLocks, + deletePublishedDatasetViaApi, + deleteUnpublishedDatasetViaApi +} from '../../testHelpers/datasets/datasetHelper' + +const testDataset = { + 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: 'Dataset created using the createDataset use case', + author: [ + { + authorName: 'Admin, Dataverse', + authorAffiliation: 'Dataverse.org' + }, + { + authorName: 'Owner, Dataverse', + authorAffiliation: 'Dataversedemo.org' + } + ], + datasetContact: [ + { + datasetContactEmail: 'finch@mailinator.com', + datasetContactName: 'Finch, Fiona' + } + ], + dsDescription: [ + { + dsDescriptionValue: 'This is the description of the dataset.' + } + ], + subject: ['Medicine, Health and Life Sciences'] + } + } + ] +} + +describe('execute', () => { + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should deaccession a dataset when required fields are sent', async () => { + const createdDatasetIdentifiers = await createDataset.execute(testDataset) + + const response = await publishDataset.execute( + createdDatasetIdentifiers.persistentId, + VersionUpdateType.MAJOR + ) + await waitForNoLocks(createdDatasetIdentifiers.numericId, 10) + + expect(response).toBeUndefined() + + const testDeaccessionDatasetDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Description of the deaccession reason.', + deaccessionForwardURL: 'https://demo.dataverse.org' + } + + const actual = await deaccessionDataset.execute( + createdDatasetIdentifiers.numericId, + '1.0', + testDeaccessionDatasetDTO + ) + + expect(actual).toBeUndefined() + + await deletePublishedDatasetViaApi(createdDatasetIdentifiers.persistentId) + }) + + test('should throw an error when the dataset id is incorrect', async () => { + const createdDatasetIdentifiers = await createDataset.execute(testDataset) + + const testDeaccessionDatasetDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Description of the deaccession reason.', + deaccessionForwardURL: 'https://demo.dataverse.org' + } + + await expect( + deaccessionDataset.execute( + createdDatasetIdentifiers.numericId, + ':latest-published', + testDeaccessionDatasetDTO + ) + ).rejects.toThrow(Error) + + await deleteUnpublishedDatasetViaApi(createdDatasetIdentifiers.numericId) + }) + + test('should not deaccession a dataset when it is not published', async () => { + const createdDatasetIdentifiers = await createDataset.execute(testDataset) + const testDeaccessionDatasetDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Description of the deaccession reason.' + } + + await expect( + deaccessionDataset.execute( + createdDatasetIdentifiers.numericId, + ':latest-published', + testDeaccessionDatasetDTO + ) + ).rejects.toBeInstanceOf(WriteError) + + await deleteUnpublishedDatasetViaApi(createdDatasetIdentifiers.numericId) + }) + + test('should not deaccession a dataset when it is deaccessioned once', async () => { + const createdDatasetIdentifiers = await createDataset.execute(testDataset) + + const response = await publishDataset.execute( + createdDatasetIdentifiers.persistentId, + VersionUpdateType.MAJOR + ) + await waitForNoLocks(createdDatasetIdentifiers.numericId, 10) + + expect(response).toBeUndefined() + + const testDeaccessionDatasetDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Description of the deaccession reason.', + deaccessionForwardURL: 'https://demo.dataverse.org' + } + + const actual = await deaccessionDataset.execute( + createdDatasetIdentifiers.numericId, + '1.0', + testDeaccessionDatasetDTO + ) + + expect(actual).toBeUndefined() + + await expect( + deaccessionDataset.execute( + createdDatasetIdentifiers.numericId, + '1.0', + testDeaccessionDatasetDTO + ) + ).rejects.toThrow(Error) + + await deletePublishedDatasetViaApi(createdDatasetIdentifiers.persistentId) + }) +}) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index c1d6aa85..1f8add69 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -17,7 +17,8 @@ import { VersionUpdateType, createDataset, CreatedDatasetIdentifiers, - DatasetDTO + DatasetDTO, + DatasetDeaccessionDTO } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -866,4 +867,62 @@ describe('DatasetsRepository', () => { ).rejects.toThrow(expectedError) }) }) + + describe('deaccessionDataset', () => { + test('should deaccession a dataset', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(testDatasetIds.numericId) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const deaccessionDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Deaccessioning the dataset for testing purposes' + } + + const actual = await sut.deaccessionDataset(testDatasetIds.numericId, '1.0', deaccessionDTO) + + expect(actual).toBeUndefined() + + const dataset = await sut.getDataset(testDatasetIds.numericId, '1.0', true, false) + + expect(dataset.versionInfo.state).toBe('DEACCESSIONED') + }) + + test('should return error when dataset is deaccessioned', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(testDatasetIds.numericId) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const deaccessionDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Deaccessioning the dataset for testing purposes' + } + + const actual = await sut.deaccessionDataset(testDatasetIds.numericId, '1.0', deaccessionDTO) + + expect(actual).toBeUndefined() + + await expect( + sut.deaccessionDataset(testDatasetIds.numericId, '1.0', deaccessionDTO) + ).rejects.toBeInstanceOf(WriteError) + }) + + test('should return error when dataset is not published', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + const deaccessionDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Deaccessioning the dataset for testing purposes' + } + + await expect( + sut.deaccessionDataset(testDatasetIds.numericId, ':latest-published', deaccessionDTO) + ).rejects.toBeInstanceOf(WriteError) + }) + + test('should return error when dataset does not exist', async () => { + await expect( + sut.deaccessionDataset(nonExistentTestDatasetId, '1.0', { + deaccessionReason: 'Deaccessioning the dataset for testing purposes' + }) + ).rejects.toBeInstanceOf(WriteError) + }) + }) }) diff --git a/test/testHelpers/datasets/datasetHelper.ts b/test/testHelpers/datasets/datasetHelper.ts index 50e4e1d1..84935471 100644 --- a/test/testHelpers/datasets/datasetHelper.ts +++ b/test/testHelpers/datasets/datasetHelper.ts @@ -12,6 +12,7 @@ import { DatasetDTO, DatasetMetadataFieldValueDTO } from '../../../src/datasets/domain/dtos/DatasetDTO' +import { DatasetDeaccessionDTO } from '../../../src/datasets/domain/dtos/DatasetDeaccessionDTO' import { MetadataBlock, MetadataFieldType } from '../../../src' import { NewDatasetRequestPayload, @@ -788,3 +789,9 @@ export const createUpdateDatasetRequestPayload = (): UpdateDatasetRequestPayload ] } } + +export const createDatasetDeaccessionDTO = (): DatasetDeaccessionDTO => { + return { + deaccessionReason: 'Test reason.' + } +} diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index 55671c8b..64a1e464 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -24,6 +24,7 @@ import { } from '../../testHelpers/datasets/datasetPreviewHelper' import { createDatasetDTO, + createDatasetDeaccessionDTO, createDatasetMetadataBlockModel, createNewDatasetRequestPayload } from '../../testHelpers/datasets/datasetHelper' @@ -902,4 +903,57 @@ describe('DatasetsRepository', () => { expect(error).toBeInstanceOf(Error) }) }) + + describe('deaccsionDataset', () => { + const version = '1.0' + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/versions/${version}/deaccession` + const expectedApiKeyRequestConfig = { + ...TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + } + const testDeaccessionDatasetDTO = createDatasetDeaccessionDTO() + const testDeaccessionDatasetJSON = JSON.stringify(testDeaccessionDatasetDTO) + test('should return nothing when providing id, version update type and response is successful', async () => { + jest.spyOn(axios, 'post').mockResolvedValue(undefined) + + let actual = await sut.deaccessionDataset( + testDatasetModel.id, + version, + testDeaccessionDatasetDTO + ) + + expect(axios.post).toHaveBeenCalledWith( + expectedApiEndpoint, + testDeaccessionDatasetJSON, + expectedApiKeyRequestConfig + ) + expect(actual).toBeUndefined() + + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + actual = await sut.deaccessionDataset(testDatasetModel.id, version, testDeaccessionDatasetDTO) + + expect(axios.post).toHaveBeenCalledWith( + expectedApiEndpoint, + testDeaccessionDatasetJSON, + expectedApiKeyRequestConfig + ) + expect(actual).toBeUndefined() + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'post').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error: WriteError | undefined = undefined + await sut + .deaccessionDataset(testDatasetModel.id, version, testDeaccessionDatasetDTO) + .catch((e) => (error = e)) + + expect(axios.post).toHaveBeenCalledWith( + expectedApiEndpoint, + testDeaccessionDatasetJSON, + expectedApiKeyRequestConfig + ) + expect(error).toBeInstanceOf(Error) + }) + }) }) diff --git a/test/unit/datasets/DeaccessionDataset.test.ts b/test/unit/datasets/DeaccessionDataset.test.ts new file mode 100644 index 00000000..a116e2b4 --- /dev/null +++ b/test/unit/datasets/DeaccessionDataset.test.ts @@ -0,0 +1,27 @@ +import { DeaccessionDataset } from '../../../src/datasets/domain/useCases/DeaccessionDataset' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError, DatasetDeaccessionDTO } from '../../../src' + +const deaccessionDatasetDTO: DatasetDeaccessionDTO = { + deaccessionReason: 'Deaccessioning the dataset for testing purposes' +} + +describe('execute', () => { + test('should return undefined on repository success', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.deaccessionDataset = jest.fn().mockResolvedValue(undefined) + const sut = new DeaccessionDataset(datasetsRepositoryStub) + + const actual = await sut.execute(1, '1.0', deaccessionDatasetDTO) + + expect(actual).toEqual(undefined) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.deaccessionDataset = jest.fn().mockRejectedValue(new WriteError()) + const sut = new DeaccessionDataset(datasetsRepositoryStub) + + await expect(sut.execute(111, '1.0', deaccessionDatasetDTO)).rejects.toThrow(WriteError) + }) +})