diff --git a/.github/workflows/copy_labels.yml b/.github/workflows/copy_labels.yml new file mode 100644 index 00000000..de532b48 --- /dev/null +++ b/.github/workflows/copy_labels.yml @@ -0,0 +1,26 @@ +name: Copy labels from issue to pull request + +on: + pull_request: + types: [opened] + +jobs: + copy-labels: + runs-on: ubuntu-latest + name: Copy labels from linked issues + steps: + - name: copy-labels + uses: michalvankodev/copy-issue-labels@v1.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + labels-to-exclude: | + Size: 3 + Size: 10 + Size: 20 + Size: 33 + Size: 80 + Original size: 3 + Original size: 10 + Original size: 20 + Original size: 33 + Original size: 80 diff --git a/docs/useCases.md b/docs/useCases.md index 6a5be49d..c57afb27 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -19,6 +19,7 @@ The different use cases currently available in the package are classified below, - [Create a Collection](#create-a-collection) - [Update a Collection](#update-a-collection) - [Publish a Collection](#publish-a-collection) + - [Delete a Collection](#delete-a-collection) - [Update Collection Featured Items](#update-collection-featured-items) - [Delete Collection Featured Items](#delete-collection-featured-items) - [Datasets](#Datasets) @@ -320,6 +321,24 @@ The `collectionIdOrAlias` is a generic collection identifier, which can be eithe _See [use case](../src/collections/domain/useCases/PublishCollection.ts)_ definition. +### Delete a Collection + +```typescript +import { deleteCollection } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 12345 + +deleteCollection.execute(collectionIdOrAlias) + +/* ... */ +``` + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + +_See [use case](../src/collections/domain/useCases/DeleteCollection.ts)_ definition. + #### Update Collection Featured Items Updates all featured items, given a collection identifier and a CollectionFeaturedItemsDTO. diff --git a/src/collections/domain/models/Collection.ts b/src/collections/domain/models/Collection.ts index d2016e84..cbcf01ca 100644 --- a/src/collections/domain/models/Collection.ts +++ b/src/collections/domain/models/Collection.ts @@ -15,6 +15,7 @@ export interface Collection { contacts?: CollectionContact[] isMetadataBlockRoot: boolean isFacetRoot: boolean + childCount: number } export interface CollectionInputLevel { diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index 85f6c42f..be784204 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -14,6 +14,7 @@ export interface ICollectionsRepository { parentCollectionId: number | string ): Promise publishCollection(collectionIdOrAlias: number | string): Promise + deleteCollection(collectionIdOrAlias: number | string): Promise getCollectionFacets(collectionIdOrAlias: number | string): Promise getCollectionUserPermissions( collectionIdOrAlias: number | string diff --git a/src/collections/domain/useCases/DeleteCollection.ts b/src/collections/domain/useCases/DeleteCollection.ts new file mode 100644 index 00000000..0148c307 --- /dev/null +++ b/src/collections/domain/useCases/DeleteCollection.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class DeleteCollection implements UseCase { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Deletes the Dataverse collection whose database ID or alias is given: + * + * @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) + * @returns {Promise} -This method does not return anything upon successful completion. + */ + async execute(collectionIdOrAlias: number | string): Promise { + return await this.collectionsRepository.deleteCollection(collectionIdOrAlias) + } +} diff --git a/src/collections/index.ts b/src/collections/index.ts index e3a50dce..f65f121a 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -9,6 +9,7 @@ import { GetCollectionFeaturedItems } from './domain/useCases/GetCollectionFeatu import { CollectionsRepository } from './infra/repositories/CollectionsRepository' import { UpdateCollectionFeaturedItems } from './domain/useCases/UpdateCollectionFeaturedItems' import { DeleteCollectionFeaturedItems } from './domain/useCases/DeleteCollectionFeaturedItems' +import { DeleteCollection } from './domain/useCases/DeleteCollection' const collectionsRepository = new CollectionsRepository() @@ -22,6 +23,7 @@ const updateCollection = new UpdateCollection(collectionsRepository) const getCollectionFeaturedItems = new GetCollectionFeaturedItems(collectionsRepository) const updateCollectionFeaturedItems = new UpdateCollectionFeaturedItems(collectionsRepository) const deleteCollectionFeaturedItems = new DeleteCollectionFeaturedItems(collectionsRepository) +const deleteCollection = new DeleteCollection(collectionsRepository) export { getCollection, @@ -33,7 +35,8 @@ export { updateCollection, getCollectionFeaturedItems, updateCollectionFeaturedItems, - deleteCollectionFeaturedItems + deleteCollectionFeaturedItems, + deleteCollection } 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 239313b6..b3897f22 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -70,7 +70,8 @@ export class CollectionsRepository extends ApiRepository implements ICollections collectionIdOrAlias: number | string = ROOT_COLLECTION_ID ): Promise { return this.doGet(`/${this.collectionsResourceName}/${collectionIdOrAlias}`, true, { - returnOwners: true + returnOwners: true, + returnChildCount: true }) .then((response) => transformCollectionResponseToCollection(response)) .catch((error) => { @@ -102,6 +103,7 @@ export class CollectionsRepository extends ApiRepository implements ICollections throw error }) } + public async publishCollection(collectionIdOrAlias: string | number): Promise { return this.doPost( `/${this.collectionsResourceName}/${collectionIdOrAlias}/actions/:publish`, @@ -113,6 +115,14 @@ export class CollectionsRepository extends ApiRepository implements ICollections }) } + public async deleteCollection(collectionIdOrAlias: number | string): Promise { + return this.doDelete(`/${this.collectionsResourceName}/${collectionIdOrAlias}`) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + public async getCollectionUserPermissions( collectionIdOrAlias: number | string ): Promise { diff --git a/src/collections/infra/repositories/transformers/CollectionPayload.ts b/src/collections/infra/repositories/transformers/CollectionPayload.ts index 2323bf1a..f478ca13 100644 --- a/src/collections/infra/repositories/transformers/CollectionPayload.ts +++ b/src/collections/infra/repositories/transformers/CollectionPayload.ts @@ -13,6 +13,7 @@ export interface CollectionPayload { dataverseType: string isMetadataBlockRoot: boolean isFacetRoot: boolean + childCount: number } export interface CollectionInputLevelPayload { diff --git a/src/collections/infra/repositories/transformers/collectionTransformers.ts b/src/collections/infra/repositories/transformers/collectionTransformers.ts index cdcd9a17..33c6e9b7 100644 --- a/src/collections/infra/repositories/transformers/collectionTransformers.ts +++ b/src/collections/infra/repositories/transformers/collectionTransformers.ts @@ -53,6 +53,7 @@ const transformPayloadToCollection = (collectionPayload: CollectionPayload): Col isMetadataBlockRoot: collectionPayload.isMetadataBlockRoot, isFacetRoot: collectionPayload.isFacetRoot, description: collectionPayload.description, + childCount: collectionPayload.childCount, ...(collectionPayload.isPartOf && { isPartOf: transformPayloadToOwnerNode(collectionPayload.isPartOf) }), diff --git a/test/functional/collections/DeleteCollection.test.ts b/test/functional/collections/DeleteCollection.test.ts new file mode 100644 index 00000000..65862023 --- /dev/null +++ b/test/functional/collections/DeleteCollection.test.ts @@ -0,0 +1,55 @@ +import { + ApiConfig, + ReadError, + WriteError, + createCollection, + deleteCollection, + getCollection +} 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 delete a collection', async () => { + const testCollectionAlias = 'deleteCollection-functional-test' + const testNewCollection = createCollectionDTO(testCollectionAlias) + await createCollection.execute(testNewCollection) + + expect.assertions(1) + try { + await deleteCollection.execute(testCollectionAlias) + } catch (error) { + throw new Error('Collection should be deleted') + } finally { + const expectedError = new ReadError( + `[404] Can't find dataverse with identifier='${testCollectionAlias}'` + ) + await expect(getCollection.execute(testCollectionAlias)).rejects.toThrow(expectedError) + } + }) + + test('should throw an error when the collection does not exist', async () => { + expect.assertions(2) + let writeError: WriteError | undefined = undefined + try { + await deleteCollection.execute(TestConstants.TEST_DUMMY_COLLECTION_ID) + throw new Error('Use case should throw an error') + } catch (error) { + writeError = error as WriteError + } 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 3fb13607..04ac86a6 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -47,6 +47,7 @@ describe('CollectionsRepository', () => { const sut: CollectionsRepository = new CollectionsRepository() let testCollectionId: number const currentYear = new Date().getFullYear() + beforeAll(async () => { ApiConfig.init( TestConstants.TEST_API_URL, @@ -127,6 +128,34 @@ describe('CollectionsRepository', () => { ) }) }) + + test('should return childCount correctly', async () => { + const parentCollectionAlias = 'childCountTestCollection' + const childCollectionAlias = 'childCountTestChildCollection' + + await createCollectionViaApi(parentCollectionAlias, ROOT_COLLECTION_ALIAS) + await createCollectionViaApi(childCollectionAlias, parentCollectionAlias) + const { numericId: childDatasetNumericId } = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + parentCollectionAlias + ) + + const actual = await sut.getCollection(parentCollectionAlias) + + expect(actual.childCount).toBe(2) + + await deleteCollectionViaApi(childCollectionAlias) + + const actualAfterDeletion = await sut.getCollection(parentCollectionAlias) + + expect(actualAfterDeletion.childCount).toBe(1) + + await deleteUnpublishedDatasetViaApi(childDatasetNumericId) + + const actualAfterDatasetDeletion = await sut.getCollection(parentCollectionAlias) + + expect(actualAfterDatasetDeletion.childCount).toBe(0) + }) }) describe('publishCollection', () => { @@ -146,6 +175,7 @@ describe('CollectionsRepository', () => { expect(createdCollection.name).toBe(newCollectionDTO.name) }) }) + describe('createCollection', () => { const testCreateCollectionAlias1 = 'createCollection-test-1' const testCreateCollectionAlias2 = 'createCollection-test-2' @@ -247,6 +277,35 @@ describe('CollectionsRepository', () => { }) }) + describe('deleteCollection', () => { + test('should delete collection successfully', async () => { + const collectionAlias = 'deleteCollection-test' + const collectionDTO = createCollectionDTO(collectionAlias) + await sut.createCollection(collectionDTO) + + const createdCollection = await sut.getCollection(collectionAlias) + + expect(createdCollection.alias).toBe(collectionAlias) + + const deleteResult = await sut.deleteCollection(collectionAlias) + const expectedError = new ReadError( + `[404] Can't find dataverse with identifier='${collectionAlias}'` + ) + + expect(deleteResult).toBeUndefined() + await expect(sut.getCollection(collectionAlias)).rejects.toThrow(expectedError) + }) + + 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}'` + ) + await expect(sut.deleteCollection(TestConstants.TEST_DUMMY_COLLECTION_ID)).rejects.toThrow( + expectedError + ) + }) + }) + describe('getCollectionFacets', () => { test('should return collection facets given a valid collection alias', async () => { const actual = await sut.getCollectionFacets(testCollectionAlias) diff --git a/test/testHelpers/collections/collectionHelper.ts b/test/testHelpers/collections/collectionHelper.ts index cc8c4dc1..b1e129fe 100644 --- a/test/testHelpers/collections/collectionHelper.ts +++ b/test/testHelpers/collections/collectionHelper.ts @@ -46,7 +46,8 @@ export const createCollectionModel = (): Collection => { } ], isMetadataBlockRoot: true, - isFacetRoot: true + isFacetRoot: true, + childCount: 0 } return collectionModel } @@ -75,7 +76,8 @@ export const createCollectionPayload = (): CollectionPayload => { } ], isMetadataBlockRoot: true, - isFacetRoot: true + isFacetRoot: true, + childCount: 0 } return collectionPayload } diff --git a/test/unit/auth/BearerTokenMechanism.test.ts b/test/unit/auth/BearerTokenMechanism.test.ts index ba5514cf..b168eae5 100644 --- a/test/unit/auth/BearerTokenMechanism.test.ts +++ b/test/unit/auth/BearerTokenMechanism.test.ts @@ -46,7 +46,8 @@ describe('BearerTokenMechanism', () => { const expectedRequestConfigBearerToken = { headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_BEARER_TOKEN.headers, params: { - returnOwners: true + returnOwners: true, + returnChildCount: true } } diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index 0c4bfb94..d95075ed 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -66,7 +66,8 @@ describe('CollectionsRepository', () => { describe('getCollection', () => { const expectedRequestConfigApiKey = { params: { - returnOwners: true + returnOwners: true, + returnChildCount: true }, headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers } @@ -569,4 +570,87 @@ describe('CollectionsRepository', () => { expect(error).toBeInstanceOf(Error) }) }) + + describe('deleteCollection', () => { + const deleteTestCollectionAlias = 'deleteCollection-unit-test' + const deleteTestCollectionId = 123 + + describe('by numeric id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/${deleteTestCollectionId}` + + test('should delete a collection when providing a valid id', async () => { + jest.spyOn(axios, 'delete').mockResolvedValue({ data: { status: 'OK' } }) + + // API Key auth + await sut.deleteCollection(deleteTestCollectionId) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + await sut.deleteCollection(deleteTestCollectionId) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'delete').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as WriteError + await sut.deleteCollection(deleteTestCollectionId).catch((e) => (error = e)) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(WriteError) + }) + }) + + describe('by alias id', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/dataverses/${deleteTestCollectionAlias}` + + test('should delete a collection when providing a valid alias', async () => { + jest.spyOn(axios, 'delete').mockResolvedValue({ data: { status: 'OK' } }) + + // API Key auth + await sut.deleteCollection(deleteTestCollectionAlias) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + + // Session cookie auth + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + + await sut.deleteCollection(deleteTestCollectionAlias) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'delete').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as WriteError + await sut.deleteCollection(deleteTestCollectionAlias).catch((e) => (error = e)) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(error).toBeInstanceOf(WriteError) + }) + }) + }) }) diff --git a/test/unit/collections/DeleteCollection.test.ts b/test/unit/collections/DeleteCollection.test.ts new file mode 100644 index 00000000..52cff4d9 --- /dev/null +++ b/test/unit/collections/DeleteCollection.test.ts @@ -0,0 +1,23 @@ +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { WriteError } from '../../../src' +import { DeleteCollection } from '../../../src/collections/domain/useCases/DeleteCollection' + +describe('execute', () => { + test('should return undefined on repository success', async () => { + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.deleteCollection = jest.fn().mockResolvedValue(undefined) + const testDeleteCollection = new DeleteCollection(collectionRepositoryStub) + + const actual = await testDeleteCollection.execute(1) + + expect(actual).toEqual(undefined) + }) + + test('should return error result on repository error', async () => { + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.deleteCollection = jest.fn().mockRejectedValue(new WriteError()) + const testDeleteCollection = new DeleteCollection(collectionRepositoryStub) + + await expect(testDeleteCollection.execute(1)).rejects.toThrow(WriteError) + }) +})