diff --git a/docs/useCases.md b/docs/useCases.md index 3630da65..b2a0c4ae 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -16,6 +16,7 @@ The different use cases currently available in the package are classified below, - [List All Collection Items](#list-all-collection-items) - [Collections write use cases](#collections-write-use-cases) - [Create a Collection](#create-a-collection) + - [Update a Collection](#update-a-collection) - [Publish a Collection](#publish-a-collection) - [Datasets](#Datasets) - [Datasets read use cases](#datasets-read-use-cases) @@ -232,6 +233,34 @@ The above example creates the new collection in the `root` collection since no c The use case returns a number, which is the identifier of the created collection. +#### Update a Collection + +Updates an existing collection, given a collection identifier and a [CollectionDTO](../src/collections/domain/dtos/CollectionDTO.ts) including the updated collection data. + +##### Example call: + +```typescript +import { updateCollection } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 12345 +const collectionDTO: CollectionDTO = { + alias: alias, + name: 'Updated Collection Name', + contacts: ['dataverse@test.com'], + type: CollectionType.DEPARTMENT +} + +updateCollection.execute(collectionIdOrAlias, collectionDTO) + +/* ... */ +``` + +_See [use case](../src/collections/domain/useCases/UpdateCollection.ts) implementation_. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + #### Publish a Collection Publishes a Collection, given the collection identifier. diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index d5812852..c024779a 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -22,4 +22,8 @@ export interface ICollectionsRepository { offset?: number, collectionSearchCriteria?: CollectionSearchCriteria ): Promise + updateCollection( + collectionIdOrAlias: number | string, + updatedCollection: CollectionDTO + ): Promise } diff --git a/src/collections/domain/useCases/UpdateCollection.ts b/src/collections/domain/useCases/UpdateCollection.ts new file mode 100644 index 00000000..f1068086 --- /dev/null +++ b/src/collections/domain/useCases/UpdateCollection.ts @@ -0,0 +1,26 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { CollectionDTO } from '../dtos/CollectionDTO' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class UpdateCollection implements UseCase { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Updates an existing collection, given a collection identifier and a CollectionDTO including the updated collection data. + * + * @param {number | string} [collectionIdOrAlias] - A generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId) + * @param {CollectionDTO} [newCollection] - CollectionDTO object including the updated collection data. + * @returns {Promise} -This method does not return anything upon successful completion. + * @throws {WriteError} - If there are errors while writing data. + */ + async execute( + collectionIdOrAlias: number | string, + updatedCollection: CollectionDTO + ): Promise { + return await this.collectionsRepository.updateCollection(collectionIdOrAlias, updatedCollection) + } +} diff --git a/src/collections/index.ts b/src/collections/index.ts index 02b68df1..b148b55d 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -4,6 +4,7 @@ import { GetCollectionFacets } from './domain/useCases/GetCollectionFacets' import { GetCollectionUserPermissions } from './domain/useCases/GetCollectionUserPermissions' import { GetCollectionItems } from './domain/useCases/GetCollectionItems' import { PublishCollection } from './domain/useCases/PublishCollection' +import { UpdateCollection } from './domain/useCases/UpdateCollection' import { CollectionsRepository } from './infra/repositories/CollectionsRepository' @@ -15,6 +16,7 @@ const getCollectionFacets = new GetCollectionFacets(collectionsRepository) const getCollectionUserPermissions = new GetCollectionUserPermissions(collectionsRepository) const getCollectionItems = new GetCollectionItems(collectionsRepository) const publishCollection = new PublishCollection(collectionsRepository) +const updateCollection = new UpdateCollection(collectionsRepository) export { getCollection, @@ -22,7 +24,8 @@ export { getCollectionFacets, getCollectionUserPermissions, getCollectionItems, - publishCollection + publishCollection, + updateCollection } export { Collection, CollectionInputLevel } from './domain/models/Collection' export { CollectionFacet } from './domain/models/CollectionFacet' diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index 167a749e..84623409 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -67,36 +67,7 @@ export class CollectionsRepository extends ApiRepository implements ICollections collectionDTO: CollectionDTO, parentCollectionId: number | string = ROOT_COLLECTION_ALIAS ): Promise { - const dataverseContacts: NewCollectionContactRequestPayload[] = collectionDTO.contacts.map( - (contact) => ({ - contactEmail: contact - }) - ) - - const inputLevelsRequestBody: NewCollectionInputLevelRequestPayload[] = - collectionDTO.inputLevels?.map((inputLevel) => ({ - datasetFieldTypeName: inputLevel.datasetFieldName, - include: inputLevel.include, - required: inputLevel.required - })) - - const requestBody: NewCollectionRequestPayload = { - alias: collectionDTO.alias, - name: collectionDTO.name, - dataverseContacts: dataverseContacts, - dataverseType: collectionDTO.type, - ...(collectionDTO.description && { - description: collectionDTO.description - }), - ...(collectionDTO.affiliation && { - affiliation: collectionDTO.affiliation - }), - metadataBlocks: { - metadataBlockNames: collectionDTO.metadataBlockNames, - facetIds: collectionDTO.facetIds, - inputLevels: inputLevelsRequestBody - } - } + const requestBody = this.createCreateOrUpdateRequestBody(collectionDTO) return this.doPost(`/${this.collectionsResourceName}/${parentCollectionId}`, requestBody) .then((response) => response.data.data.id) @@ -185,6 +156,50 @@ export class CollectionsRepository extends ApiRepository implements ICollections }) } + public async updateCollection( + collectionIdOrAlias: string | number, + updatedCollection: CollectionDTO + ): Promise { + const requestBody = this.createCreateOrUpdateRequestBody(updatedCollection) + + return this.doPut(`/${this.collectionsResourceName}/${collectionIdOrAlias}`, requestBody) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + + private createCreateOrUpdateRequestBody( + collectionDTO: CollectionDTO + ): NewCollectionRequestPayload { + const dataverseContacts: NewCollectionContactRequestPayload[] = collectionDTO.contacts.map( + (contact) => ({ + contactEmail: contact + }) + ) + + const inputLevelsRequestBody: NewCollectionInputLevelRequestPayload[] = + collectionDTO.inputLevels?.map((inputLevel) => ({ + datasetFieldTypeName: inputLevel.datasetFieldName, + include: inputLevel.include, + required: inputLevel.required + })) + + return { + alias: collectionDTO.alias, + name: collectionDTO.name, + dataverseContacts: dataverseContacts, + dataverseType: collectionDTO.type, + ...(collectionDTO.description && { description: collectionDTO.description }), + ...(collectionDTO.affiliation && { affiliation: collectionDTO.affiliation }), + metadataBlocks: { + metadataBlockNames: collectionDTO.metadataBlockNames, + facetIds: collectionDTO.facetIds, + inputLevels: inputLevelsRequestBody + } + } + } + private applyCollectionSearchCriteriaToQueryParams( queryParams: GetCollectionItemsQueryParams, collectionSearchCriteria: CollectionSearchCriteria diff --git a/test/environment/.env b/test/environment/.env index 80e9a14e..6e32dcbb 100644 --- a/test/environment/.env +++ b/test/environment/.env @@ -1,6 +1,6 @@ POSTGRES_VERSION=13 DATAVERSE_DB_USER=dataverse SOLR_VERSION=9.3.0 -DATAVERSE_IMAGE_REGISTRY=docker.io -DATAVERSE_IMAGE_TAG=unstable +DATAVERSE_IMAGE_REGISTRY=ghcr.io +DATAVERSE_IMAGE_TAG=10904-edit-dataverse-collection DATAVERSE_BOOTSTRAP_TIMEOUT=5m diff --git a/test/functional/collections/UpdateCollection.test.ts b/test/functional/collections/UpdateCollection.test.ts new file mode 100644 index 00000000..4fbde611 --- /dev/null +++ b/test/functional/collections/UpdateCollection.test.ts @@ -0,0 +1,54 @@ +import { + ApiConfig, + WriteError, + createCollection, + getCollection, + updateCollection +} from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { createCollectionDTO } from '../../testHelpers/collections/collectionHelper' + +describe('execute', () => { + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should successfully update a new collection', async () => { + const testNewCollectionAlias = 'updateCollection-functional-test' + const testNewCollection = createCollectionDTO(testNewCollectionAlias) + await createCollection.execute(testNewCollection) + const testNewName = 'Updated Name' + testNewCollection.name = testNewName + expect.assertions(1) + try { + await updateCollection.execute(testNewCollectionAlias, testNewCollection) + } catch (error) { + throw new Error('Collection should be updated') + } finally { + const updatedCollection = await getCollection.execute(testNewCollectionAlias) + expect(updatedCollection.name).toBe(testNewName) + } + }) + + test('should throw an error when the parent collection does not exist', async () => { + const testNewCollection = createCollectionDTO() + expect.assertions(2) + let writeError: WriteError + try { + await updateCollection.execute(TestConstants.TEST_DUMMY_COLLECTION_ID, testNewCollection) + throw new Error('Use case should throw an error') + } catch (error) { + writeError = error + } finally { + expect(writeError).toBeInstanceOf(WriteError) + expect(writeError.message).toEqual( + `There was an error when writing the resource. Reason was: [404] Can't find dataverse with identifier='${TestConstants.TEST_DUMMY_COLLECTION_ID}'` + ) + } + }) +}) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index be293662..e77245ef 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -464,4 +464,67 @@ describe('CollectionsRepository', () => { ).rejects.toThrow(expectedError) }) }) + + describe('updateCollection', () => { + const testUpdatedCollectionAlias = 'updateCollection-test-updatedAlias' + + afterAll(async () => { + await deleteCollectionViaApi(testUpdatedCollectionAlias) + }) + + test('should update the collection', async () => { + // First we create a test collection using a CollectionDTO and createCollection method + const collectionDTO = createCollectionDTO('updatedCollection-test-originalAlias') + const testUpdateCollectionId = await sut.createCollection(collectionDTO) + const createdCollection = await sut.getCollection(testUpdateCollectionId) + expect(createdCollection.id).toBe(testUpdateCollectionId) + expect(createdCollection.alias).toBe(collectionDTO.alias) + expect(createdCollection.name).toBe(collectionDTO.name) + expect(createdCollection.affiliation).toBe(collectionDTO.affiliation) + expect(createdCollection.inputLevels?.length).toBe(1) + const inputLevel = createdCollection.inputLevels?.[0] + expect(inputLevel?.datasetFieldName).toBe('geographicCoverage') + expect(inputLevel?.include).toBe(true) + expect(inputLevel?.required).toBe(true) + + // Now we update CollectionDTO and verify updates are correctly persisted after calling updateCollection method + collectionDTO.alias = testUpdatedCollectionAlias + const updatedCollectionName = 'updatedCollectionName' + collectionDTO.name = updatedCollectionName + const updatedCollectionAffiliation = 'updatedCollectionAffiliation' + collectionDTO.affiliation = updatedCollectionAffiliation + const updatedInputLevels = [ + { + datasetFieldName: 'country', + required: false, + include: true + } + ] + collectionDTO.inputLevels = updatedInputLevels + await sut.updateCollection(testUpdateCollectionId, collectionDTO) + const updatedCollection = await sut.getCollection(testUpdateCollectionId) + expect(updatedCollection.id).toBe(testUpdateCollectionId) + expect(updatedCollection.alias).toBe(testUpdatedCollectionAlias) + expect(updatedCollection.name).toBe(updatedCollectionName) + expect(updatedCollection.affiliation).toBe(updatedCollectionAffiliation) + expect(updatedCollection.inputLevels?.length).toBe(1) + const updatedInputLevel = updatedCollection.inputLevels?.[0] + expect(updatedInputLevel?.datasetFieldName).toBe('country') + expect(updatedInputLevel?.include).toBe(true) + expect(updatedInputLevel?.required).toBe(false) + }) + + test('should return error when collection does not exist', async () => { + const expectedError = new WriteError( + `[404] Can't find dataverse with identifier='${TestConstants.TEST_DUMMY_COLLECTION_ID}'` + ) + const testCollectionAlias = 'updateCollection-not-found-test' + await expect( + sut.updateCollection( + TestConstants.TEST_DUMMY_COLLECTION_ID, + createCollectionDTO(testCollectionAlias) + ) + ).rejects.toThrow(expectedError) + }) + }) }) diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index c1bdf958..ecad1c4c 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -191,6 +191,64 @@ describe('CollectionsRepository', () => { }) }) + describe('updateCollection', () => { + const testUpdatedCollection = createCollectionDTO() + const testAlias = 'testCollectionAlias' + + const testCreatedCollectionId = 1 + const testCreateCollectionResponse = { + data: { + status: 'OK', + data: { + id: testCreatedCollectionId + } + } + } + + const expectedUpdatedCollectionRequestPayloadJson = JSON.stringify( + createNewCollectionRequestPayload() + ) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/${testAlias}` + + test('should call the API with a correct request payload', async () => { + jest.spyOn(axios, 'put').mockResolvedValue(testCreateCollectionResponse) + + // API Key auth + await sut.updateCollection(testAlias, testUpdatedCollection) + + expect(axios.put).toHaveBeenCalledWith( + expectedApiEndpoint, + expectedUpdatedCollectionRequestPayloadJson, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + await sut.updateCollection(testAlias, testUpdatedCollection) + + expect(axios.put).toHaveBeenCalledWith( + expectedApiEndpoint, + expectedUpdatedCollectionRequestPayloadJson, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'put').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as WriteError + await sut.updateCollection(testAlias, testUpdatedCollection).catch((e) => (error = e)) + + expect(axios.put).toHaveBeenCalledWith( + expectedApiEndpoint, + expectedUpdatedCollectionRequestPayloadJson, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(Error) + }) + }) + describe('getCollectionFacets', () => { const testFacetsSuccessfulResponse = { data: { diff --git a/test/unit/collections/UpdateCollection.test.ts b/test/unit/collections/UpdateCollection.test.ts new file mode 100644 index 00000000..60d734d6 --- /dev/null +++ b/test/unit/collections/UpdateCollection.test.ts @@ -0,0 +1,28 @@ +import { UpdateCollection } from '../../../src/collections/domain/useCases/UpdateCollection' +import { createCollectionDTO } from '../../testHelpers/collections/collectionHelper' +import { WriteError } from '../../../src' +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' + +describe('execute', () => { + const testCollection = createCollectionDTO() + + test('should return undefined when repository call is successful', async () => { + const collectionsRepositoryStub = {} + collectionsRepositoryStub.updateCollection = jest.fn().mockResolvedValue(undefined) + + const sut = new UpdateCollection(collectionsRepositoryStub) + + const actual = await sut.execute(1, testCollection) + + expect(actual).toEqual(undefined) + }) + + test('should throw WriteError when the repository raises an error', async () => { + const collectionsRepositoryStub = {} + const testWriteError = new WriteError('Test error') + collectionsRepositoryStub.updateCollection = jest.fn().mockRejectedValue(testWriteError) + + const sut = new UpdateCollection(collectionsRepositoryStub) + await expect(sut.execute(1, testCollection)).rejects.toThrow(testWriteError) + }) +})