diff --git a/CHANGELOG.md b/CHANGELOG.md index 32bb51b6..b9c7890f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Datasets: Added `updateDatasetLicense` use case and repository method to support Dataverse endpoint `PUT /datasets/{id}/license`, for updating dataset license or custom terms + ### Changed ### Fixed diff --git a/docs/useCases.md b/docs/useCases.md index ff537daa..4def0c57 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -44,6 +44,7 @@ The different use cases currently available in the package are classified below, - [Datasets write use cases](#datasets-write-use-cases) - [Create a Dataset](#create-a-dataset) - [Update a Dataset](#update-a-dataset) + - [Update a Dataset License](#update-a-dataset-license) - [Publish a Dataset](#publish-a-dataset) - [Deaccession a Dataset](#deaccession-a-dataset) - [Delete a Draft Dataset](#delete-a-draft-dataset) @@ -977,6 +978,43 @@ updateDataset.execute(datasetId, datasetDTO) _See [use case](../src/datasets/domain/useCases/UpdateDataset.ts) implementation_. +#### Update a Dataset License + +Updates the license of a dataset by applying it to the draft version. If no draft exists, a new one is automatically created by the API. Supports predefined licenses (by name) or custom terms of use and access. + +##### Example calls: + +```typescript +import { + updateDatasetLicense, + DatasetLicenseUpdateRequest +} from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 1 + +const predefinedPayload: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } +await updateDatasetLicense.execute(datasetId, predefinedPayload) + +const customPayload: DatasetLicenseUpdateRequest = { + customTerms: { + termsOfUse: 'Your terms of use', + confidentialityDeclaration: 'Your confidentiality declaration', + specialPermissions: 'Your special permissions', + restrictions: 'Your restrictions', + citationRequirements: 'Your citation requirements', + depositorRequirements: 'Your depositor requirements', + conditions: 'Your conditions', + disclaimer: 'Your disclaimer' + } +} + +updateDatasetLicense.execute(datasetId, customPayload) +``` + +_See [use case](../src/datasets/domain/useCases/UpdateDatasetLicense.ts) implementation_. + The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. #### Publish a Dataset diff --git a/src/datasets/domain/dtos/DatasetLicenseUpdateRequest.ts b/src/datasets/domain/dtos/DatasetLicenseUpdateRequest.ts new file mode 100644 index 00000000..db20307e --- /dev/null +++ b/src/datasets/domain/dtos/DatasetLicenseUpdateRequest.ts @@ -0,0 +1,6 @@ +import { CustomTerms } from '../models/Dataset' + +export interface DatasetLicenseUpdateRequest { + name?: string + customTerms?: CustomTerms +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 3fe1c7ab..57c314b0 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -14,6 +14,7 @@ import { CitationFormat } from '../models/CitationFormat' import { FormattedCitation } from '../models/FormattedCitation' import { DatasetTemplate } from '../models/DatasetTemplate' import { DatasetType } from '../models/DatasetType' +import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest' export interface IDatasetsRepository { getDataset( @@ -89,4 +90,8 @@ export interface IDatasetsRepository { licenses: string[] ): Promise deleteDatasetType(datasetTypeId: number): Promise + updateDatasetLicense( + datasetId: number | string, + payload: DatasetLicenseUpdateRequest + ): Promise } diff --git a/src/datasets/domain/useCases/UpdateDatasetLicense.ts b/src/datasets/domain/useCases/UpdateDatasetLicense.ts new file mode 100644 index 00000000..3886ed2b --- /dev/null +++ b/src/datasets/domain/useCases/UpdateDatasetLicense.ts @@ -0,0 +1,23 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' +import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest' + +export class UpdateDatasetLicense implements UseCase { + private readonly datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Updates the license of a dataset by applying it to the draft version. If no draft exists, a new one is created by the API. + * Supports either predefined license by name or custom terms of use and access. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {DatasetLicenseUpdateRequest} payload - The payload containing the license name or custom terms of use and access. + * @returns {Promise} - This method does not return anything upon successful completion. + */ + async execute(datasetId: number | string, payload: DatasetLicenseUpdateRequest): Promise { + return this.datasetsRepository.updateDatasetLicense(datasetId, payload) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 6b93a7cd..2acedddf 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -32,6 +32,7 @@ import { SetAvailableLicensesForDatasetType } from './domain/useCases/SetAvailab import { DeleteDatasetType } from './domain/useCases/DeleteDatasetType' import { GetDatasetCitationInOtherFormats } from './domain/useCases/GetDatasetCitationInOtherFormats' import { GetDatasetTemplates } from './domain/useCases/GetDatasetTemplates' +import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense' const datasetsRepository = new DatasetsRepository() @@ -80,6 +81,7 @@ const setAvailableLicensesForDatasetType = new SetAvailableLicensesForDatasetTyp const deleteDatasetType = new DeleteDatasetType(datasetsRepository) const getDatasetCitationInOtherFormats = new GetDatasetCitationInOtherFormats(datasetsRepository) const getDatasetTemplates = new GetDatasetTemplates(datasetsRepository) +const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository) export { getDataset, @@ -109,7 +111,8 @@ export { addDatasetType, linkDatasetTypeWithMetadataBlocks, setAvailableLicensesForDatasetType, - deleteDatasetType + deleteDatasetType, + updateDatasetLicense } export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' @@ -136,6 +139,7 @@ export { DatasetMetadataBlockValuesDTO, DatasetMetadataChildFieldValueDTO } from './domain/dtos/DatasetDTO' +export { DatasetLicenseUpdateRequest } from './domain/dtos/DatasetLicenseUpdateRequest' 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 99b380df..cc89136a 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -29,6 +29,7 @@ import { DatasetTemplate } from '../../domain/models/DatasetTemplate' import { DatasetTemplatePayload } from './transformers/DatasetTemplatePayload' import { transformDatasetTemplatePayloadToDatasetTemplate } from './transformers/datasetTemplateTransformers' import { DatasetType } from '../../domain/models/DatasetType' +import { DatasetLicenseUpdateRequest } from '../../domain/dtos/DatasetLicenseUpdateRequest' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -448,4 +449,18 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi throw error }) } + + public async updateDatasetLicense( + datasetId: number | string, + payload: DatasetLicenseUpdateRequest + ): Promise { + return this.doPut( + this.buildApiEndpoint(this.datasetsResourceName, 'license', datasetId), + payload + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } } diff --git a/test/functional/datasets/UpdateDatasetLicense.test.ts b/test/functional/datasets/UpdateDatasetLicense.test.ts new file mode 100644 index 00000000..4f1683a8 --- /dev/null +++ b/test/functional/datasets/UpdateDatasetLicense.test.ts @@ -0,0 +1,72 @@ +import { + ApiConfig, + createDataset, + getDataset, + publishDataset, + updateDatasetLicense, + DatasetLicenseUpdateRequest +} from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + waitForNoLocks, + deleteUnpublishedDatasetViaApi, + deletePublishedDatasetViaApi +} from '../../testHelpers/datasets/datasetHelper' +import { DatasetNotNumberedVersion, VersionUpdateType } from '../../../src/datasets' + +describe('execute', () => { + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should update the license of a draft dataset (predefined by name)', async () => { + const created = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + const payload: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } + const response = await updateDatasetLicense.execute(created.numericId, payload) + + expect(response).toBeUndefined() + + const after = await getDataset.execute( + created.numericId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + expect(after.license?.name).toBe('CC BY 4.0') + + await deleteUnpublishedDatasetViaApi(created.numericId) + }) + + test('should update the license of a published dataset (custom terms creates draft)', async () => { + const created = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await publishDataset.execute(created.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(created.numericId, 10) + + const payload: DatasetLicenseUpdateRequest = { + customTerms: { + termsOfUse: 'Updated terms of use (functional test)' + } + } + const response = await updateDatasetLicense.execute(created.numericId, payload) + + expect(response).toBeUndefined() + + const draft = await getDataset.execute( + created.numericId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + expect(draft.license).toBeUndefined() + expect(draft.termsOfUse.customTerms?.termsOfUse).toBe('Updated terms of use (functional test)') + + await deletePublishedDatasetViaApi(created.persistentId) + }) +}) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 5374b5f8..fbf00568 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -27,7 +27,8 @@ import { addDatasetType, deleteDatasetType, linkDatasetTypeWithMetadataBlocks, - setAvailableLicensesForDatasetType + setAvailableLicensesForDatasetType, + DatasetLicenseUpdateRequest } from '../../../src/datasets' import { ApiConfig, WriteError } from '../../../src' import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' @@ -1800,4 +1801,158 @@ describe('DatasetsRepository', () => { }) }) }) + + describe('updateDatasetLicense', () => { + test('should update the license of a published dataset', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(testDatasetIds.numericId) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const DatasetBefore = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + expect(DatasetBefore.license?.name).toBe('CC0 1.0') // default license + + const payload: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } + await sut.updateDatasetLicense(testDatasetIds.numericId, payload) + + const DatasetAfter = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + expect(DatasetAfter.license?.name).toBe('CC BY 4.0') + }) + + test('should update the license of a draft dataset', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + const DatasetBefore = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false + ) + expect(DatasetBefore.license?.name).toBe('CC0 1.0') // default license + const predefined: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } + await sut.updateDatasetLicense(testDatasetIds.numericId, predefined) + + const datasetAfter = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + + expect(datasetAfter.license?.name).toBe('CC BY 4.0') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should set custom terms of use and access on the draft version', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + const custom: DatasetLicenseUpdateRequest = { + customTerms: { + termsOfUse: 'Your terms of use', + confidentialityDeclaration: 'Your confidentiality declaration', + specialPermissions: 'Your special permissions', + restrictions: 'Your restrictions', + citationRequirements: 'Your citation requirements', + depositorRequirements: 'Your depositor requirements', + conditions: 'Your conditions', + disclaimer: 'Your disclaimer' + } + } + const actual = await sut.updateDatasetLicense(testDatasetIds.numericId, custom) + + expect(actual).toBeUndefined() + + const datasetAfter = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + + expect(datasetAfter.license).toBeUndefined() + expect(datasetAfter.termsOfUse.customTerms?.termsOfUse).toBe('Your terms of use') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should set custom terms of use and access on the published version', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(testDatasetIds.numericId) + await waitForNoLocks(testDatasetIds.numericId, 10) + + const custom: DatasetLicenseUpdateRequest = { + customTerms: { + termsOfUse: 'Your terms of use', + confidentialityDeclaration: 'Your confidentiality declaration', + specialPermissions: 'Your special permissions', + restrictions: 'Your restrictions', + citationRequirements: 'Your citation requirements', + depositorRequirements: 'Your depositor requirements', + conditions: 'Your conditions', + disclaimer: 'Your disclaimer' + } + } + const actual = await sut.updateDatasetLicense(testDatasetIds.numericId, custom) + + expect(actual).toBeUndefined() + + const datasetAfter = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + + expect(datasetAfter.license).toBeUndefined() + expect(datasetAfter.termsOfUse.customTerms?.termsOfUse).toBe('Your terms of use') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should return error when dataset does not exist', async () => { + const expectedError = new WriteError( + `[404] Dataset with ID ${nonExistentTestDatasetId} not found.` + ) + + await expect( + sut.updateDatasetLicense(nonExistentTestDatasetId, { name: 'CC BY 4.0' }) + ).rejects.toThrow(expectedError) + }) + + test('should accept persistent id when updating license on draft dataset', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await sut.updateDatasetLicense(testDatasetIds.persistentId, { name: 'CC BY 4.0' }) + + const draftAfter = await sut.getDataset( + testDatasetIds.persistentId, + DatasetNotNumberedVersion.DRAFT, + false, + false + ) + expect(draftAfter.license?.name).toBe('CC BY 4.0') + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + + test('should return error when payload is empty', async () => { + const testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + + await expect( + sut.updateDatasetLicense(testDatasetIds.numericId, {} as unknown as never) + ).rejects.toBeInstanceOf(WriteError) + + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + }) + }) }) diff --git a/test/unit/datasets/UpdateDatasetLicense.test.ts b/test/unit/datasets/UpdateDatasetLicense.test.ts new file mode 100644 index 00000000..10e8348b --- /dev/null +++ b/test/unit/datasets/UpdateDatasetLicense.test.ts @@ -0,0 +1,28 @@ +import { UpdateDatasetLicense } from '../../../src/datasets/domain/useCases/UpdateDatasetLicense' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { DatasetLicenseUpdateRequest } from '../../../src/datasets/domain/dtos/DatasetLicenseUpdateRequest' +import { WriteError } from '../../../src' + +describe('execute', () => { + test('should return undefined when success', async () => { + const repo: IDatasetsRepository = {} as IDatasetsRepository + repo.updateDatasetLicense = jest.fn().mockResolvedValue(undefined) + const sut = new UpdateDatasetLicense(repo) + + const payload: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } + const actual = await sut.execute(1, payload) + + expect(repo.updateDatasetLicense).toHaveBeenCalledWith(1, payload) + expect(actual).toBeUndefined() + }) + + test('should throw WriteError when repository raises an error', async () => { + const repo: IDatasetsRepository = {} as IDatasetsRepository + repo.updateDatasetLicense = jest.fn().mockRejectedValue(new WriteError()) + const sut = new UpdateDatasetLicense(repo) + + const payload: DatasetLicenseUpdateRequest = { name: 'CC BY 4.0' } + await expect(sut.execute(999, payload)).rejects.toThrow(WriteError) + expect(repo.updateDatasetLicense).toHaveBeenCalledWith(999, payload) + }) +})