From 76fa6362215d8393686575a2d45260e9100b5261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 10 Sep 2025 16:48:09 -0300 Subject: [PATCH 01/11] feat: use case --- .../repositories/ICollectionsRepository.ts | 7 ++++ .../useCases/GetCollectionsForLinking.ts | 27 +++++++++++++ src/collections/index.ts | 6 ++- .../repositories/CollectionsRepository.ts | 40 ++++++++++++++++++- test/environment/.env | 4 +- 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/collections/domain/useCases/GetCollectionsForLinking.ts diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index 820a1356..b512e880 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -10,6 +10,8 @@ import { CollectionUserPermissions } from '../models/CollectionUserPermissions' import { PublicationStatus } from '../../../core/domain/models/PublicationStatus' import { CollectionItemType } from '../../../collections/domain/models/CollectionItemType' import { CollectionLinks } from '../models/CollectionLinks' +import { CollectionSummary } from '../models/CollectionSummary' +import { LinkingObjectType } from '../useCases/GetCollectionsForLinking' export interface ICollectionsRepository { getCollection(collectionIdOrAlias: number | string): Promise @@ -60,4 +62,9 @@ export interface ICollectionsRepository { linkingCollectionIdOrAlias: number | string ): Promise getCollectionLinks(collectionIdOrAlias: number | string): Promise + getCollectionsForLinking( + objectType: LinkingObjectType, + id: number | string, + searchTerm: string + ): Promise } diff --git a/src/collections/domain/useCases/GetCollectionsForLinking.ts b/src/collections/domain/useCases/GetCollectionsForLinking.ts new file mode 100644 index 00000000..80738295 --- /dev/null +++ b/src/collections/domain/useCases/GetCollectionsForLinking.ts @@ -0,0 +1,27 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' +import { CollectionSummary } from '../models/CollectionSummary' + +export type LinkingObjectType = 'collection' | 'dataset' + +export class GetCollectionsForLinking implements UseCase { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Returns an array of CollectionSummary (id, alias, displayName) to which the given Dataverse collection or Dataset may be linked. + * @param objectType - 'dataverse' when providing a collection identifier/alias; 'dataset' when providing a dataset persistentId. + * @param id - For objectType 'dataverse', a numeric id or alias string. For 'dataset', the persistentId string (e.g., doi:...) + * @param searchTerm - Optional search term to filter by collection name. Defaults to empty string (no filtering). + */ + async execute( + objectType: LinkingObjectType, + id: number | string, + searchTerm = '' + ): Promise { + return await this.collectionsRepository.getCollectionsForLinking(objectType, id, searchTerm) + } +} diff --git a/src/collections/index.ts b/src/collections/index.ts index 05e49954..59e2e50b 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -15,6 +15,7 @@ import { DeleteCollectionFeaturedItem } from './domain/useCases/DeleteCollection import { LinkCollection } from './domain/useCases/LinkCollection' import { UnlinkCollection } from './domain/useCases/UnlinkCollection' import { GetCollectionLinks } from './domain/useCases/GetCollectionLinks' +import { GetCollectionsForLinking } from './domain/useCases/GetCollectionsForLinking' const collectionsRepository = new CollectionsRepository() @@ -34,6 +35,7 @@ const deleteCollectionFeaturedItem = new DeleteCollectionFeaturedItem(collection const linkCollection = new LinkCollection(collectionsRepository) const unlinkCollection = new UnlinkCollection(collectionsRepository) const getCollectionLinks = new GetCollectionLinks(collectionsRepository) +const getCollectionsForLinking = new GetCollectionsForLinking(collectionsRepository) export { getCollection, @@ -51,7 +53,8 @@ export { deleteCollectionFeaturedItem, linkCollection, unlinkCollection, - getCollectionLinks + getCollectionLinks, + getCollectionsForLinking } export { Collection, CollectionInputLevel } from './domain/models/Collection' export { CollectionFacet } from './domain/models/CollectionFacet' @@ -62,3 +65,4 @@ export { CollectionItemType } from './domain/models/CollectionItemType' export { CollectionSearchCriteria } from './domain/models/CollectionSearchCriteria' export { FeaturedItem } from './domain/models/FeaturedItem' export { FeaturedItemsDTO } from './domain/dtos/FeaturedItemsDTO' +export { CollectionSummary } from './domain/models/CollectionSummary' diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index 704367e2..4a2f8e47 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -38,6 +38,8 @@ import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' import { PublicationStatus } from '../../../core/domain/models/PublicationStatus' import { ReadError } from '../../../core/domain/repositories/ReadError' import { CollectionLinks } from '../../domain/models/CollectionLinks' +import { CollectionSummary } from '../../domain/models/CollectionSummary' +import { LinkingObjectType } from '../../domain/useCases/GetCollectionsForLinking' export interface NewCollectionRequestPayload { alias: string @@ -93,7 +95,6 @@ export enum GetMyDataCollectionItemsQueryParams { export class CollectionsRepository extends ApiRepository implements ICollectionsRepository { private readonly collectionsResourceName: string = 'dataverses' - public async getCollection( collectionIdOrAlias: number | string = ROOT_COLLECTION_ID ): Promise { @@ -485,4 +486,41 @@ export class CollectionsRepository extends ApiRepository implements ICollections throw error }) } + + public async getCollectionsForLinking( + objectType: LinkingObjectType, + id: number | string, + searchTerm: string + ): Promise { + let path: string + const queryParams = new URLSearchParams() + if (objectType === 'collection') { + path = `/${this.collectionsResourceName}/${id}/dataverse/linkingDataverses` + } else { + path = `/${this.collectionsResourceName}/:persistentId/dataset/linkingDataverses` + queryParams.set('persistentId', String(id)) + } + + if (searchTerm) { + queryParams.set('searchTerm', searchTerm) + } + + return this.doGet(path, true, queryParams) + .then((response) => { + const payload = response.data.data as { + id: number + alias: string + name: string + }[] + + return payload.map((item) => ({ + id: item.id, + alias: item.alias, + displayName: item.name + })) + }) + .catch((error) => { + throw error + }) + } } diff --git a/test/environment/.env b/test/environment/.env index e7b54bde..3a9a818d 100644 --- a/test/environment/.env +++ b/test/environment/.env @@ -1,6 +1,6 @@ POSTGRES_VERSION=17 DATAVERSE_DB_USER=dataverse SOLR_VERSION=9.8.0 -DATAVERSE_IMAGE_REGISTRY=docker.io -DATAVERSE_IMAGE_TAG=unstable +DATAVERSE_IMAGE_REGISTRY=ghcr.io +DATAVERSE_IMAGE_TAG=11710-find-dataverses-for-linking DATAVERSE_BOOTSTRAP_TIMEOUT=5m From 5695023a8f948900b304154ca9d21418d2daa129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 10 Sep 2025 16:48:23 -0300 Subject: [PATCH 02/11] test: add unit and integration test --- .../collections/CollectionsRepository.test.ts | 57 +++++++++++++++++++ .../collections/CollectionsRepository.test.ts | 49 ++++++++++++++++ .../GetCollectionsForLinking.test.ts | 28 +++++++++ 3 files changed, 134 insertions(+) create mode 100644 test/unit/collections/GetCollectionsForLinking.test.ts diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index d1afd76d..7205678a 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -804,6 +804,63 @@ describe('CollectionsRepository', () => { }) }) + describe('getCollectionsForLinking', () => { + const linkingTargetAlias = 'collectionsRepositoryLinkTarget' + + beforeAll(async () => { + await createCollectionViaApi(linkingTargetAlias) + }) + + afterAll(async () => { + await deleteCollectionViaApi(linkingTargetAlias) + }) + + test('should list collections for linking for a given collection alias', async () => { + const results = await sut.getCollectionsForLinking( + 'collection', + testCollectionAlias, + 'Scientific' + ) + + expect(Array.isArray(results)).toBe(true) + // Should contain the newly created linking target collection among candidates + const found = results.find((c) => c.alias === linkingTargetAlias) + expect(found).toBeDefined() + expect(found?.id).toBeGreaterThan(0) + expect(found?.displayName).toBe('Scientific Research') + }) + + test('should list collections for linking for a given dataset persistentId', async () => { + // Create a temporary dataset to query linking candidates + const { persistentId, numericId } = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + + const results = await sut.getCollectionsForLinking('dataset', persistentId, 'Scientific') + + // Cleanup dataset (unpublished) + await deleteUnpublishedDatasetViaApi(numericId) + + expect(Array.isArray(results)).toBe(true) + const found = results.find((c) => c.alias === linkingTargetAlias) + expect(found).toBeDefined() + expect(found?.displayName).toBe('Scientific Research') + }) + + it('should return error when collection does not exist', async () => { + await expect( + sut.getCollectionsForLinking('collection', TestConstants.TEST_DUMMY_COLLECTION_ALIAS, '') + ).rejects.toThrow(ReadError) + }) + + it('should return error when dataset does not exist', async () => { + await expect( + sut.getCollectionsForLinking('dataset', TestConstants.TEST_DUMMY_PERSISTENT_ID, '') + ).rejects.toThrow(ReadError) + }) + }) + describe('getCollectionItems for published tabular file', () => { let testDatasetIds: CreatedDatasetIdentifiers const testTextFile4Name = 'test-file-4.tab' diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index 950a9cdf..a5501119 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -567,6 +567,55 @@ describe('CollectionsRepository', () => { }) }) + describe('getCollectionsForLinking', () => { + test('should call dataverse variant with numeric id and search term', async () => { + const payload = { + data: { + status: 'OK', + data: [ + { id: 1, alias: 'dv1', name: 'DV 1' }, + { id: 2, alias: 'dv2', name: 'DV 2' } + ] + } + } + jest.spyOn(axios, 'get').mockResolvedValue(payload) + + const actual = await sut.getCollectionsForLinking('collection', 99, 'abc') + const expectedEndpoint = `${TestConstants.TEST_API_URL}/dataverses/99/dataverse/linkingDataverses` + const expectedParams = new URLSearchParams({ searchTerm: 'abc' }) + const expectedConfig = { + params: expectedParams, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + expect(axios.get).toHaveBeenCalledWith(expectedEndpoint, expectedConfig) + expect(actual).toEqual([ + { id: 1, alias: 'dv1', displayName: 'DV 1' }, + { id: 2, alias: 'dv2', displayName: 'DV 2' } + ]) + }) + + test('should call dataset variant using persistentId and map results', async () => { + const payload = { + data: { + status: 'OK', + data: [{ id: 3, alias: 'dv3', name: 'DV 3' }] + } + } + jest.spyOn(axios, 'get').mockResolvedValue(payload) + + const pid = 'doi:10.5072/FK2/J8SJZB' + const actual = await sut.getCollectionsForLinking('dataset', pid, '') + const expectedEndpoint = `${TestConstants.TEST_API_URL}/dataverses/:persistentId/dataset/linkingDataverses` + const expectedParams = new URLSearchParams({ persistentId: pid }) + const expectedConfig = { + params: expectedParams, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + expect(axios.get).toHaveBeenCalledWith(expectedEndpoint, expectedConfig) + expect(actual).toEqual([{ id: 3, alias: 'dv3', displayName: 'DV 3' }]) + }) + }) + describe('deleteCollection', () => { const deleteTestCollectionAlias = 'deleteCollection-unit-test' const deleteTestCollectionId = 123 diff --git a/test/unit/collections/GetCollectionsForLinking.test.ts b/test/unit/collections/GetCollectionsForLinking.test.ts new file mode 100644 index 00000000..390fe147 --- /dev/null +++ b/test/unit/collections/GetCollectionsForLinking.test.ts @@ -0,0 +1,28 @@ +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { GetCollectionsForLinking } from '../../../src/collections/domain/useCases/GetCollectionsForLinking' +import { CollectionSummary, ReadError } from '../../../src' + +const sample: CollectionSummary[] = [ + { id: 1, alias: 'col1', displayName: 'Collection 1' }, + { id: 2, alias: 'col2', displayName: 'Collection 2' } +] + +describe('GetCollectionsForLinking', () => { + test('should return collections for linking on success', async () => { + const repo: ICollectionsRepository = {} as ICollectionsRepository + repo.getCollectionsForLinking = jest.fn().mockResolvedValue(sample) + + const uc = new GetCollectionsForLinking(repo) + await expect(uc.execute('collection', 123, 'foo')).resolves.toEqual(sample) + expect(repo.getCollectionsForLinking).toHaveBeenCalledWith('collection', 123, 'foo') + }) + + test('should return error result on repository error', async () => { + const repo: ICollectionsRepository = {} as ICollectionsRepository + repo.getCollectionsForLinking = jest.fn().mockRejectedValue(new ReadError('x')) + + const uc = new GetCollectionsForLinking(repo) + await expect(uc.execute('dataset', 'doi:10.123/ABC')).rejects.toThrow(ReadError) + expect(repo.getCollectionsForLinking).toHaveBeenCalledWith('dataset', 'doi:10.123/ABC', '') + }) +}) From 8387b4e9e815d039956671c6fa1d4236df3a993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 10 Sep 2025 16:48:39 -0300 Subject: [PATCH 03/11] docs: add use case docs --- docs/useCases.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/useCases.md b/docs/useCases.md index 7fc7b955..0b022447 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) - [List My Data Collection Items](#list-my-data-collection-items) - [Get Collection Featured Items](#get-collection-featured-items) + - [Get Collections for Linking](#get-collections-for-linking) - [Collections write use cases](#collections-write-use-cases) - [Create a Collection](#create-a-collection) - [Update a Collection](#update-a-collection) @@ -324,6 +325,56 @@ The `collectionIdOrAlias` is a generic collection identifier, which can be eithe If no collection identifier is specified, the default collection identifier; `:root` will be used. If you want to search for a different collection, you must add the collection identifier as a parameter in the use case call. +#### Get Collections for Linking + +Returns an array of [CollectionSummary](../src/collections/domain/models/CollectionSummary.ts) (id, alias, displayName) representing the Dataverse collections to which a given Dataverse collection or Dataset may be linked. + +This use case supports an optional `searchTerm` to filter by collection name. + +##### Example calls: + +```typescript +import { getCollectionsForLinking } from '@iqss/dataverse-client-javascript' + +/* ... */ + +// Case 1: For a given Dataverse collection (by numeric id or alias) +const collectionIdOrAlias: number | string = 'collectionAlias' // or 123 +const searchTerm = 'searchOn' + +getCollectionsForLinking + .execute('dataverse', collectionIdOrAlias, searchTerm) + .then((collections) => { + // collections: CollectionSummary[] + /* ... */ + }) + .catch((error: Error) => { + /* ... */ + }) + +/* ... */ + +// Case 2: For a given Dataset (by persistent identifier) +const persistentId = 'doi:10.5072/FK2/J8SJZB' + +getCollectionsForLinking + .execute('dataset', persistentId, searchTerm) + .then((collections) => { + // collections: CollectionSummary[] + /* ... */ + }) + .catch((error: Error) => { + /* ... */ + }) +``` + +_See [use case](../src/collections/domain/useCases/GetCollectionsForLinking.ts) implementation_. + +Notes: + +- When the first argument is `'collection'`, the second argument can be a numeric collection id or a collection alias. +- When the first argument is `'dataset'`, the second argument must be the dataset persistent identifier string (e.g., `doi:...`). + ### Collections Write Use Cases #### Create a Collection From e4c088080eec5e463bec4cf6da52d2c17c237f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Tue, 16 Sep 2025 09:28:40 -0300 Subject: [PATCH 04/11] feat: update linkDataset and unlinkDataset methods to accept string or number for dataset and collection identifiers --- .../repositories/IDatasetsRepository.ts | 4 +-- src/datasets/domain/useCases/LinkDataset.ts | 8 ++--- src/datasets/domain/useCases/UnlinkDataset.ts | 8 ++--- .../infra/repositories/DatasetsRepository.ts | 24 ++++++++++--- .../datasets/DatasetsRepository.test.ts | 36 +++++++++++++++++++ 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 499dfed0..c0ad2bf7 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -65,8 +65,8 @@ export interface IDatasetsRepository { ): Promise getDatasetVersionsSummaries(datasetId: number | string): Promise deleteDatasetDraft(datasetId: number | string): Promise - linkDataset(datasetId: number, collectionAlias: string): Promise - unlinkDataset(datasetId: number, collectionAlias: string): Promise + linkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise + unlinkDataset(datasetId: number | string, collectionIdOrAlias: number | string): Promise getDatasetLinkedCollections(datasetId: number | string): Promise getDatasetAvailableCategories(datasetId: number | string): Promise getDatasetCitationInOtherFormats( diff --git a/src/datasets/domain/useCases/LinkDataset.ts b/src/datasets/domain/useCases/LinkDataset.ts index be7f732f..4e953b17 100644 --- a/src/datasets/domain/useCases/LinkDataset.ts +++ b/src/datasets/domain/useCases/LinkDataset.ts @@ -11,11 +11,11 @@ export class LinkDataset implements UseCase { /** * Creates a link between a Dataset and a Collection. * - * @param {number} [datasetId] - The dataset id. - * @param {string} [collectionAlias] - The collection alias. + * @param {number | string} [datasetId] - The dataset id (numeric) or persistent identifier string. + * @param {number | string} [collectionIdOrAlias] - The collection identifier (numeric id) or alias. * @returns {Promise} - This method does not return anything upon successful completion. */ - async execute(datasetId: number, collectionAlias: string): Promise { - return await this.datasetsRepository.linkDataset(datasetId, collectionAlias) + async execute(datasetId: number | string, collectionIdOrAlias: number | string): Promise { + return await this.datasetsRepository.linkDataset(datasetId, collectionIdOrAlias) } } diff --git a/src/datasets/domain/useCases/UnlinkDataset.ts b/src/datasets/domain/useCases/UnlinkDataset.ts index d2d8eff5..8b2142fb 100644 --- a/src/datasets/domain/useCases/UnlinkDataset.ts +++ b/src/datasets/domain/useCases/UnlinkDataset.ts @@ -11,11 +11,11 @@ export class UnlinkDataset implements UseCase { /** * Removes a link between a Dataset and a Collection. * - * @param {number} [datasetId] - The dataset id. - * @param {string} [collectionAlias] - The collection alias. + * @param {number | string} [datasetId] - The dataset id (numeric) or persistent identifier string. + * @param {number | string} [collectionIdOrAlias] - The collection identifier (numeric id) or alias. * @returns {Promise} - This method does not return anything upon successful completion. */ - async execute(datasetId: number, collectionAlias: string): Promise { - return await this.datasetsRepository.unlinkDataset(datasetId, collectionAlias) + async execute(datasetId: number | string, collectionIdOrAlias: number | string): Promise { + return await this.datasetsRepository.unlinkDataset(datasetId, collectionIdOrAlias) } } diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 9a38eaae..c1c166f5 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -323,16 +323,32 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } - public async linkDataset(datasetId: number, collectionAlias: string): Promise { - return this.doPut(`/${this.datasetsResourceName}/${datasetId}/link/${collectionAlias}`, {}) + public async linkDataset( + datasetId: number | string, + collectionIdOrAlias: number | string + ): Promise { + const endpoint = this.buildApiEndpoint( + this.datasetsResourceName, + `link/${collectionIdOrAlias}`, + datasetId + ) + return this.doPut(endpoint, {}) .then(() => undefined) .catch((error) => { throw error }) } - public async unlinkDataset(datasetId: number, collectionAlias: string): Promise { - return this.doDelete(`/${this.datasetsResourceName}/${datasetId}/deleteLink/${collectionAlias}`) + public async unlinkDataset( + datasetId: number | string, + collectionIdOrAlias: number | string + ): Promise { + const endpoint = this.buildApiEndpoint( + this.datasetsResourceName, + `deleteLink/${collectionIdOrAlias}`, + datasetId + ) + return this.doDelete(endpoint) .then(() => undefined) .catch((error) => { throw error diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index ba9c9d79..5acb38bc 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -1533,6 +1533,21 @@ describe('DatasetsRepository', () => { sut.linkDataset(testDatasetIds.numericId, 'nonExistentCollectionAlias') ).rejects.toThrow() }) + + test('should link a dataset to another collection using persistent id', async () => { + const persistentCollectionAlias = 'testLinkDatasetCollectionPersistent' + await createCollectionViaApi(persistentCollectionAlias) + + const actual = await sut.linkDataset(testDatasetIds.persistentId, persistentCollectionAlias) + + expect(actual).toBeUndefined() + + const linkedCollections = await sut.getDatasetLinkedCollections(testDatasetIds.numericId) + const aliases = linkedCollections.map((c) => c.alias) + expect(aliases).toContain(persistentCollectionAlias) + + await deleteCollectionViaApi(persistentCollectionAlias) + }) }) describe('unlinkDataset', () => { @@ -1578,6 +1593,27 @@ describe('DatasetsRepository', () => { sut.unlinkDataset(testDatasetIds.numericId, testCollectionAlias) ).rejects.toThrow() }) + + test('should unlink a dataset from a collection using persistent id', async () => { + const persistentCollectionAlias = 'testUnlinkDatasetCollectionPersistent' + await createCollectionViaApi(persistentCollectionAlias) + + await sut.linkDataset(testDatasetIds.persistentId, persistentCollectionAlias) + const linkedCollections = await sut.getDatasetLinkedCollections(testDatasetIds.numericId) + const aliases = linkedCollections.map((c) => c.alias) + expect(aliases).toContain(persistentCollectionAlias) + + const actual = await sut.unlinkDataset(testDatasetIds.persistentId, persistentCollectionAlias) + + expect(actual).toBeUndefined() + const updatedLinkedCollections = await sut.getDatasetLinkedCollections( + testDatasetIds.numericId + ) + const updatedAliases = updatedLinkedCollections.map((c) => c.alias) + expect(updatedAliases).not.toContain(persistentCollectionAlias) + + await deleteCollectionViaApi(persistentCollectionAlias) + }) }) describe('getDatasetLinkedCollections', () => { From e30ad1843614f71361e50593f4a9a013d036e8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 19 Sep 2025 08:43:00 -0300 Subject: [PATCH 05/11] feat: add alreadyLinked parameter to getCollectionsForLinking to get candidates for unlinking --- docs/useCases.md | 13 ++++++ .../repositories/ICollectionsRepository.ts | 3 +- .../useCases/GetCollectionsForLinking.ts | 14 +++++- .../repositories/CollectionsRepository.ts | 7 ++- .../collections/CollectionsRepository.test.ts | 43 +++++++++++++++++-- 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/useCases.md b/docs/useCases.md index 6d9fec4a..c87f08e8 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -367,6 +367,19 @@ getCollectionsForLinking .catch((error: Error) => { /* ... */ }) + +// Case 3: [alreadyLinked] Optional flag. When true, returns collections currently linked (candidates to unlink). Defaults to false. +const alreadyLinked = true + +getCollectionsForLinking + .execute('dataset', persistentId, searchTerm, alreadyLinked) + .then((collections) => { + // collections: CollectionSummary[] + /* ... */ + }) + .catch((error: Error) => { + /* ... */ + }) ``` _See [use case](../src/collections/domain/useCases/GetCollectionsForLinking.ts) implementation_. diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index b512e880..bc8960c8 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -65,6 +65,7 @@ export interface ICollectionsRepository { getCollectionsForLinking( objectType: LinkingObjectType, id: number | string, - searchTerm: string + searchTerm: string, + alreadyLinked: boolean ): Promise } diff --git a/src/collections/domain/useCases/GetCollectionsForLinking.ts b/src/collections/domain/useCases/GetCollectionsForLinking.ts index 80738295..42c66fc2 100644 --- a/src/collections/domain/useCases/GetCollectionsForLinking.ts +++ b/src/collections/domain/useCases/GetCollectionsForLinking.ts @@ -4,6 +4,9 @@ import { CollectionSummary } from '../models/CollectionSummary' export type LinkingObjectType = 'collection' | 'dataset' +// TODO:ME - Add to the interface and here the alreadyLinking param to get collections for unlinking +// @param alreadyLinking - Optional flag. When true, returns collections currently linked (candidates to unlink). Defaults to false. + export class GetCollectionsForLinking implements UseCase { private collectionsRepository: ICollectionsRepository @@ -16,12 +19,19 @@ export class GetCollectionsForLinking implements UseCase { * @param objectType - 'dataverse' when providing a collection identifier/alias; 'dataset' when providing a dataset persistentId. * @param id - For objectType 'dataverse', a numeric id or alias string. For 'dataset', the persistentId string (e.g., doi:...) * @param searchTerm - Optional search term to filter by collection name. Defaults to empty string (no filtering). + * @param alreadyLinked - Optional flag. When true, returns collections currently linked (candidates to unlink). Defaults to false. */ async execute( objectType: LinkingObjectType, id: number | string, - searchTerm = '' + searchTerm = '', + alreadyLinked = false ): Promise { - return await this.collectionsRepository.getCollectionsForLinking(objectType, id, searchTerm) + return await this.collectionsRepository.getCollectionsForLinking( + objectType, + id, + searchTerm, + alreadyLinked + ) } } diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index 4a2f8e47..e0e459b0 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -490,7 +490,8 @@ export class CollectionsRepository extends ApiRepository implements ICollections public async getCollectionsForLinking( objectType: LinkingObjectType, id: number | string, - searchTerm: string + searchTerm: string, + alreadyLinked: boolean ): Promise { let path: string const queryParams = new URLSearchParams() @@ -505,6 +506,10 @@ export class CollectionsRepository extends ApiRepository implements ICollections queryParams.set('searchTerm', searchTerm) } + if (alreadyLinked) { + queryParams.set('alreadyLinking', 'true') + } + return this.doGet(path, true, queryParams) .then((response) => { const payload = response.data.data as { diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 7205678a..8a04097e 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -819,7 +819,8 @@ describe('CollectionsRepository', () => { const results = await sut.getCollectionsForLinking( 'collection', testCollectionAlias, - 'Scientific' + 'Scientific', + false ) expect(Array.isArray(results)).toBe(true) @@ -837,7 +838,12 @@ describe('CollectionsRepository', () => { testCollectionAlias ) - const results = await sut.getCollectionsForLinking('dataset', persistentId, 'Scientific') + const results = await sut.getCollectionsForLinking( + 'dataset', + persistentId, + 'Scientific', + false + ) // Cleanup dataset (unpublished) await deleteUnpublishedDatasetViaApi(numericId) @@ -848,15 +854,44 @@ describe('CollectionsRepository', () => { expect(found?.displayName).toBe('Scientific Research') }) + test('should return collections for unlking when sending alreadyLinked param to true', async () => { + // Link the test collection with the linking target collection + await sut.linkCollection(testCollectionAlias, linkingTargetAlias) + + const collectionsForLinking = await sut.getCollectionsForLinking( + 'collection', + testCollectionAlias, + '', + false + ) + + const collectionsForUnlinking = await sut.getCollectionsForLinking( + 'collection', + testCollectionAlias, + '', + true + ) + + expect(collectionsForLinking.length).toBe(0) + expect(collectionsForUnlinking.length).toBeGreaterThan(0) + expect(collectionsForUnlinking[0].alias).toBe(linkingTargetAlias) + expect(collectionsForUnlinking[0].displayName).toBe('Scientific Research') + }) + it('should return error when collection does not exist', async () => { await expect( - sut.getCollectionsForLinking('collection', TestConstants.TEST_DUMMY_COLLECTION_ALIAS, '') + sut.getCollectionsForLinking( + 'collection', + TestConstants.TEST_DUMMY_COLLECTION_ALIAS, + '', + false + ) ).rejects.toThrow(ReadError) }) it('should return error when dataset does not exist', async () => { await expect( - sut.getCollectionsForLinking('dataset', TestConstants.TEST_DUMMY_PERSISTENT_ID, '') + sut.getCollectionsForLinking('dataset', TestConstants.TEST_DUMMY_PERSISTENT_ID, '', false) ).rejects.toThrow(ReadError) }) }) From 4e1c1a01346f0e98378255ec12d8ebb117665cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 19 Sep 2025 08:47:46 -0300 Subject: [PATCH 06/11] fix: unit tests --- test/unit/collections/CollectionsRepository.test.ts | 4 ++-- test/unit/collections/GetCollectionsForLinking.test.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index a5501119..d099acb1 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -580,7 +580,7 @@ describe('CollectionsRepository', () => { } jest.spyOn(axios, 'get').mockResolvedValue(payload) - const actual = await sut.getCollectionsForLinking('collection', 99, 'abc') + const actual = await sut.getCollectionsForLinking('collection', 99, 'abc', false) const expectedEndpoint = `${TestConstants.TEST_API_URL}/dataverses/99/dataverse/linkingDataverses` const expectedParams = new URLSearchParams({ searchTerm: 'abc' }) const expectedConfig = { @@ -604,7 +604,7 @@ describe('CollectionsRepository', () => { jest.spyOn(axios, 'get').mockResolvedValue(payload) const pid = 'doi:10.5072/FK2/J8SJZB' - const actual = await sut.getCollectionsForLinking('dataset', pid, '') + const actual = await sut.getCollectionsForLinking('dataset', pid, '', false) const expectedEndpoint = `${TestConstants.TEST_API_URL}/dataverses/:persistentId/dataset/linkingDataverses` const expectedParams = new URLSearchParams({ persistentId: pid }) const expectedConfig = { diff --git a/test/unit/collections/GetCollectionsForLinking.test.ts b/test/unit/collections/GetCollectionsForLinking.test.ts index 390fe147..79511fe8 100644 --- a/test/unit/collections/GetCollectionsForLinking.test.ts +++ b/test/unit/collections/GetCollectionsForLinking.test.ts @@ -14,7 +14,7 @@ describe('GetCollectionsForLinking', () => { const uc = new GetCollectionsForLinking(repo) await expect(uc.execute('collection', 123, 'foo')).resolves.toEqual(sample) - expect(repo.getCollectionsForLinking).toHaveBeenCalledWith('collection', 123, 'foo') + expect(repo.getCollectionsForLinking).toHaveBeenCalledWith('collection', 123, 'foo', false) }) test('should return error result on repository error', async () => { @@ -23,6 +23,11 @@ describe('GetCollectionsForLinking', () => { const uc = new GetCollectionsForLinking(repo) await expect(uc.execute('dataset', 'doi:10.123/ABC')).rejects.toThrow(ReadError) - expect(repo.getCollectionsForLinking).toHaveBeenCalledWith('dataset', 'doi:10.123/ABC', '') + expect(repo.getCollectionsForLinking).toHaveBeenCalledWith( + 'dataset', + 'doi:10.123/ABC', + '', + false + ) }) }) From 3c6da0a7df4b5d69c0bd489e6691a24b1a53e521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 19 Sep 2025 08:52:06 -0300 Subject: [PATCH 07/11] fix: add wait after linking for test --- test/integration/collections/CollectionsRepository.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 8a04097e..3b27f7c5 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -858,6 +858,8 @@ describe('CollectionsRepository', () => { // Link the test collection with the linking target collection await sut.linkCollection(testCollectionAlias, linkingTargetAlias) + await new Promise((resolve) => setTimeout(resolve, 2000)) + const collectionsForLinking = await sut.getCollectionsForLinking( 'collection', testCollectionAlias, From 9d20668c92bcfcdfe7f01e69f87ed1b20643289e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 19 Sep 2025 09:26:33 -0300 Subject: [PATCH 08/11] fix: make test assertions better --- .../collections/CollectionsRepository.test.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 3b27f7c5..d525edfd 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -805,20 +805,23 @@ describe('CollectionsRepository', () => { }) describe('getCollectionsForLinking', () => { + const linkingParentCollection = 'collectionsRepositoryLinkingTestParentCollection' const linkingTargetAlias = 'collectionsRepositoryLinkTarget' beforeAll(async () => { - await createCollectionViaApi(linkingTargetAlias) + await createCollectionViaApi(linkingParentCollection) + await createCollectionViaApi(linkingTargetAlias, linkingParentCollection) }) afterAll(async () => { await deleteCollectionViaApi(linkingTargetAlias) + await deleteCollectionViaApi(linkingParentCollection) }) test('should list collections for linking for a given collection alias', async () => { const results = await sut.getCollectionsForLinking( 'collection', - testCollectionAlias, + linkingParentCollection, 'Scientific', false ) @@ -835,7 +838,7 @@ describe('CollectionsRepository', () => { // Create a temporary dataset to query linking candidates const { persistentId, numericId } = await createDataset.execute( TestConstants.TEST_NEW_DATASET_DTO, - testCollectionAlias + linkingParentCollection ) const results = await sut.getCollectionsForLinking( @@ -854,30 +857,28 @@ describe('CollectionsRepository', () => { expect(found?.displayName).toBe('Scientific Research') }) - test('should return collections for unlking when sending alreadyLinked param to true', async () => { - // Link the test collection with the linking target collection - await sut.linkCollection(testCollectionAlias, linkingTargetAlias) - - await new Promise((resolve) => setTimeout(resolve, 2000)) - - const collectionsForLinking = await sut.getCollectionsForLinking( + test('should return collections for unlinking when sending alreadyLinked param to true', async () => { + const collectionsForUnlinkingBefore = await sut.getCollectionsForLinking( 'collection', - testCollectionAlias, + linkingParentCollection, '', - false + true ) - const collectionsForUnlinking = await sut.getCollectionsForLinking( + // Link the test collection with the linking target collection + await sut.linkCollection(linkingParentCollection, linkingTargetAlias) + + const collectionsForUnlinkingAfter = await sut.getCollectionsForLinking( 'collection', - testCollectionAlias, + linkingParentCollection, '', true ) - expect(collectionsForLinking.length).toBe(0) - expect(collectionsForUnlinking.length).toBeGreaterThan(0) - expect(collectionsForUnlinking[0].alias).toBe(linkingTargetAlias) - expect(collectionsForUnlinking[0].displayName).toBe('Scientific Research') + expect(collectionsForUnlinkingBefore.length).toBe(0) + expect(collectionsForUnlinkingAfter.length).toBeGreaterThan(0) + expect(collectionsForUnlinkingAfter[0].alias).toBe(linkingTargetAlias) + expect(collectionsForUnlinkingAfter[0].displayName).toBe('Scientific Research') }) it('should return error when collection does not exist', async () => { From d37c5f8860fdc01ca20842f388a10dafd2ab3f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 19 Sep 2025 09:31:29 -0300 Subject: [PATCH 09/11] remove comment --- src/collections/domain/useCases/GetCollectionsForLinking.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/collections/domain/useCases/GetCollectionsForLinking.ts b/src/collections/domain/useCases/GetCollectionsForLinking.ts index 42c66fc2..c9486c9f 100644 --- a/src/collections/domain/useCases/GetCollectionsForLinking.ts +++ b/src/collections/domain/useCases/GetCollectionsForLinking.ts @@ -4,9 +4,6 @@ import { CollectionSummary } from '../models/CollectionSummary' export type LinkingObjectType = 'collection' | 'dataset' -// TODO:ME - Add to the interface and here the alreadyLinking param to get collections for unlinking -// @param alreadyLinking - Optional flag. When true, returns collections currently linked (candidates to unlink). Defaults to false. - export class GetCollectionsForLinking implements UseCase { private collectionsRepository: ICollectionsRepository From ff42b7f650aea27c3aab3c40dc7069b4e8e2efb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Mon, 22 Sep 2025 11:16:01 -0300 Subject: [PATCH 10/11] fix: map linked dataset correctly according to model --- .../repositories/transformers/collectionTransformers.ts | 8 +++++++- .../integration/collections/CollectionsRepository.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/collections/infra/repositories/transformers/collectionTransformers.ts b/src/collections/infra/repositories/transformers/collectionTransformers.ts index a26c4718..fa23b8ed 100644 --- a/src/collections/infra/repositories/transformers/collectionTransformers.ts +++ b/src/collections/infra/repositories/transformers/collectionTransformers.ts @@ -159,7 +159,13 @@ export const transformCollectionLinksResponseToCollectionLinks = ( const responseDataPayload = response.data.data const linkedCollections = responseDataPayload.linkedDataverses const collectionsLinkingToThis = responseDataPayload.dataversesLinkingToThis - const linkedDatasets = responseDataPayload.linkedDatasets + const linkedDatasets = responseDataPayload.linkedDatasets.map( + (ld: { identifier: string; title: string }) => ({ + persistentId: ld.identifier, + title: ld.title + }) + ) + return { linkedCollections, collectionsLinkingToThis, diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index d525edfd..116457e1 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -2093,16 +2093,18 @@ describe('CollectionsRepository', () => { const thirdCollectionAlias = 'getCollectionLinksThird' const fourthCollectionAlias = 'getCollectionLinksFourth' let childDatasetNumericId: number + let childDatasetPersistentId: string beforeAll(async () => { await createCollectionViaApi(firstCollectionAlias) await createCollectionViaApi(secondCollectionAlias) await createCollectionViaApi(thirdCollectionAlias) await createCollectionViaApi(fourthCollectionAlias) - const { numericId: createdId } = await createDataset.execute( + const { numericId: createdId, persistentId: createdPid } = await createDataset.execute( TestConstants.TEST_NEW_DATASET_DTO, fourthCollectionAlias ) childDatasetNumericId = createdId + childDatasetPersistentId = createdPid await sut.linkCollection(secondCollectionAlias, firstCollectionAlias) await sut.linkCollection(firstCollectionAlias, thirdCollectionAlias) await sut.linkCollection(firstCollectionAlias, fourthCollectionAlias) @@ -2131,6 +2133,7 @@ describe('CollectionsRepository', () => { expect(collectionLinks.linkedDatasets[0].title).toBe( 'Dataset created using the createDataset use case' ) + expect(collectionLinks.linkedDatasets[0].persistentId).toBe(childDatasetPersistentId) }) test('should return error when collection does not exist', async () => { From b1ada1778e4e739a38f9bd22f506771654e4ab1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Wed, 24 Sep 2025 15:28:14 -0300 Subject: [PATCH 11/11] docs: remove dataverse wording --- docs/useCases.md | 2 +- src/collections/domain/useCases/GetCollectionsForLinking.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/useCases.md b/docs/useCases.md index c87f08e8..db3a8cd3 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -344,7 +344,7 @@ const collectionIdOrAlias: number | string = 'collectionAlias' // or 123 const searchTerm = 'searchOn' getCollectionsForLinking - .execute('dataverse', collectionIdOrAlias, searchTerm) + .execute('collection', collectionIdOrAlias, searchTerm) .then((collections) => { // collections: CollectionSummary[] /* ... */ diff --git a/src/collections/domain/useCases/GetCollectionsForLinking.ts b/src/collections/domain/useCases/GetCollectionsForLinking.ts index c9486c9f..e01e156e 100644 --- a/src/collections/domain/useCases/GetCollectionsForLinking.ts +++ b/src/collections/domain/useCases/GetCollectionsForLinking.ts @@ -13,8 +13,8 @@ export class GetCollectionsForLinking implements UseCase { /** * Returns an array of CollectionSummary (id, alias, displayName) to which the given Dataverse collection or Dataset may be linked. - * @param objectType - 'dataverse' when providing a collection identifier/alias; 'dataset' when providing a dataset persistentId. - * @param id - For objectType 'dataverse', a numeric id or alias string. For 'dataset', the persistentId string (e.g., doi:...) + * @param objectType - 'collection' when providing a collection identifier/alias; 'dataset' when providing a dataset persistentId. + * @param id - For objectType 'collection', a numeric id or alias string. For 'dataset', the persistentId string (e.g., doi:...) * @param searchTerm - Optional search term to filter by collection name. Defaults to empty string (no filtering). * @param alreadyLinked - Optional flag. When true, returns collections currently linked (candidates to unlink). Defaults to false. */