From c61129561b9ce43a4288e3a33b1812c41dd65b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Jan 2025 11:06:01 -0300 Subject: [PATCH 1/6] feat: infra domain and use cases --- .../domain/models/CollectionFeaturedItem.ts | 7 ++++++ .../repositories/ICollectionsRepository.ts | 4 +++ .../useCases/GetCollectionFeaturedItems.ts | 25 +++++++++++++++++++ .../repositories/CollectionsRepository.ts | 14 +++++++++++ .../CollectionFeaturedItemPayload.ts | 7 ++++++ .../collectionFeaturedItemsTransformer.ts | 14 +++++++++++ 6 files changed, 71 insertions(+) create mode 100644 src/collections/domain/models/CollectionFeaturedItem.ts create mode 100644 src/collections/domain/useCases/GetCollectionFeaturedItems.ts create mode 100644 src/collections/infra/repositories/transformers/CollectionFeaturedItemPayload.ts create mode 100644 src/collections/infra/repositories/transformers/collectionFeaturedItemsTransformer.ts diff --git a/src/collections/domain/models/CollectionFeaturedItem.ts b/src/collections/domain/models/CollectionFeaturedItem.ts new file mode 100644 index 00000000..88a3777d --- /dev/null +++ b/src/collections/domain/models/CollectionFeaturedItem.ts @@ -0,0 +1,7 @@ +export interface CollectionFeaturedItem { + id: number + content: string + imageFileName?: string + imageFileUrl?: string + displayOrder: number +} diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index c024779a..91d395aa 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -1,6 +1,7 @@ import { CollectionDTO } from '../dtos/CollectionDTO' import { Collection } from '../models/Collection' import { CollectionFacet } from '../models/CollectionFacet' +import { CollectionFeaturedItem } from '../models/CollectionFeaturedItem' import { CollectionItemSubset } from '../models/CollectionItemSubset' import { CollectionSearchCriteria } from '../models/CollectionSearchCriteria' import { CollectionUserPermissions } from '../models/CollectionUserPermissions' @@ -26,4 +27,7 @@ export interface ICollectionsRepository { collectionIdOrAlias: number | string, updatedCollection: CollectionDTO ): Promise + getCollectionFeaturedItems( + collectionIdOrAlias: number | string + ): Promise } diff --git a/src/collections/domain/useCases/GetCollectionFeaturedItems.ts b/src/collections/domain/useCases/GetCollectionFeaturedItems.ts new file mode 100644 index 00000000..21b66ef1 --- /dev/null +++ b/src/collections/domain/useCases/GetCollectionFeaturedItems.ts @@ -0,0 +1,25 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' +import { ROOT_COLLECTION_ID } from '../models/Collection' +import { CollectionFeaturedItem } from '../models/CollectionFeaturedItem' + +export class GetCollectionFeaturedItems implements UseCase { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Returns a CollectionFeaturedItem array containing the featured items of the requested collection, given the collection identifier or alias. + * + * @param {number | string} [collectionIdOrAlias = ':root'] - A generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId) + * If this parameter is not set, the default value is: ':root' + * @returns {Promise} + */ + async execute( + collectionIdOrAlias: number | string = ROOT_COLLECTION_ID + ): Promise { + return await this.collectionsRepository.getCollectionFeaturedItems(collectionIdOrAlias) + } +} diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index bee82863..fc77a833 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -17,6 +17,8 @@ import { SortType } from '../../domain/models/CollectionSearchCriteria' import { CollectionItemType } from '../../domain/models/CollectionItemType' +import { CollectionFeaturedItem } from '../../domain/models/CollectionFeaturedItem' +import { transformCollectionFeaturedItemsPayloadToCollectionFeaturedItems } from './transformers/collectionFeaturedItemsTransformer' export interface NewCollectionRequestPayload { alias: string @@ -240,4 +242,16 @@ export class CollectionsRepository extends ApiRepository implements ICollections }) } } + + public async getCollectionFeaturedItems( + collectionIdOrAlias: number | string + ): Promise { + return this.doGet(`/${this.collectionsResourceName}/${collectionIdOrAlias}/featuredItems`, true) + .then((response) => + transformCollectionFeaturedItemsPayloadToCollectionFeaturedItems(response.data.data) + ) + .catch((error) => { + throw error + }) + } } diff --git a/src/collections/infra/repositories/transformers/CollectionFeaturedItemPayload.ts b/src/collections/infra/repositories/transformers/CollectionFeaturedItemPayload.ts new file mode 100644 index 00000000..34ba0dcf --- /dev/null +++ b/src/collections/infra/repositories/transformers/CollectionFeaturedItemPayload.ts @@ -0,0 +1,7 @@ +export interface CollectionFeaturedItemPayload { + id: number + content: string + imageFileName: string | null + imageFileUrl: string | null + displayOrder: number +} diff --git a/src/collections/infra/repositories/transformers/collectionFeaturedItemsTransformer.ts b/src/collections/infra/repositories/transformers/collectionFeaturedItemsTransformer.ts new file mode 100644 index 00000000..ab8e1f70 --- /dev/null +++ b/src/collections/infra/repositories/transformers/collectionFeaturedItemsTransformer.ts @@ -0,0 +1,14 @@ +import { CollectionFeaturedItem } from '../../../domain/models/CollectionFeaturedItem' +import { CollectionFeaturedItemPayload } from './CollectionFeaturedItemPayload' + +export const transformCollectionFeaturedItemsPayloadToCollectionFeaturedItems = ( + collectionFeaturedItemsPayload: CollectionFeaturedItemPayload[] +): CollectionFeaturedItem[] => { + return collectionFeaturedItemsPayload.map((collectionFeaturedItemPayload) => ({ + id: collectionFeaturedItemPayload.id, + content: collectionFeaturedItemPayload.content, + imageFileUrl: collectionFeaturedItemPayload.imageFileUrl, + imageFileName: collectionFeaturedItemPayload.imageFileName, + displayOrder: collectionFeaturedItemPayload.displayOrder + })) +} From e7246c910d784e2031cd5fe2fd863fe9e112414a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Jan 2025 12:27:54 -0300 Subject: [PATCH 2/6] test: integration cases --- src/collections/index.ts | 7 +- test/environment/.env | 4 +- .../collections/CollectionsRepository.test.ts | 67 +++++++++++++++- .../collectionFeaturedItemsHelper.ts | 79 +++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 test/testHelpers/collections/collectionFeaturedItemsHelper.ts diff --git a/src/collections/index.ts b/src/collections/index.ts index b148b55d..7e149b53 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -5,7 +5,7 @@ import { GetCollectionUserPermissions } from './domain/useCases/GetCollectionUse import { GetCollectionItems } from './domain/useCases/GetCollectionItems' import { PublishCollection } from './domain/useCases/PublishCollection' import { UpdateCollection } from './domain/useCases/UpdateCollection' - +import { GetCollectionFeaturedItems } from './domain/useCases/GetCollectionFeaturedItems' import { CollectionsRepository } from './infra/repositories/CollectionsRepository' const collectionsRepository = new CollectionsRepository() @@ -17,6 +17,7 @@ const getCollectionUserPermissions = new GetCollectionUserPermissions(collection const getCollectionItems = new GetCollectionItems(collectionsRepository) const publishCollection = new PublishCollection(collectionsRepository) const updateCollection = new UpdateCollection(collectionsRepository) +const getCollectionFeaturedItems = new GetCollectionFeaturedItems(collectionsRepository) export { getCollection, @@ -25,7 +26,8 @@ export { getCollectionUserPermissions, getCollectionItems, publishCollection, - updateCollection + updateCollection, + getCollectionFeaturedItems } export { Collection, CollectionInputLevel } from './domain/models/Collection' export { CollectionFacet } from './domain/models/CollectionFacet' @@ -34,3 +36,4 @@ export { CollectionDTO, CollectionInputLevelDTO } from './domain/dtos/Collection export { CollectionPreview } from './domain/models/CollectionPreview' export { CollectionItemType } from './domain/models/CollectionItemType' export { CollectionSearchCriteria } from './domain/models/CollectionSearchCriteria' +export { CollectionFeaturedItem } from './domain/models/CollectionFeaturedItem' diff --git a/test/environment/.env b/test/environment/.env index 80e9a14e..bf08e36f 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=10943-featured-items DATAVERSE_BOOTSTRAP_TIMEOUT=5m diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index e763e105..c67b75a3 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -28,6 +28,11 @@ import { OrderType, SortType } from '../../../src/collections/domain/models/CollectionSearchCriteria' +import { ROOT_COLLECTION_ID } from '../../../src/collections/domain/models/Collection' +import { + createCollectionFeaturedItemViaApi, + deleteCollectionFeaturedItemViaApi +} from '../../testHelpers/collections/collectionFeaturedItemsHelper' describe('CollectionsRepository', () => { const testCollectionAlias = 'collectionsRepositoryTestCollection' @@ -293,7 +298,7 @@ describe('CollectionsRepository', () => { const expectedFileMd5 = '68b22040025784da775f55cfcb6dee2e' const expectedDatasetCitationFragment = - 'Admin, Dataverse; Owner, Dataverse, 2024, "Dataset created using the createDataset use case' + 'Admin, Dataverse; Owner, Dataverse, 2025, "Dataset created using the createDataset use case' const expectedDatasetDescription = 'Dataset created using the createDataset use case' const expectedFileName = 'test-file-1.txt' const expectedCollectionsName = 'Scientific Research' @@ -720,4 +725,64 @@ describe('CollectionsRepository', () => { ).rejects.toThrow(expectedError) }) }) + + describe('getCollectionFeaturedItems', () => { + let tetFeaturedItemId: number + + beforeAll(async () => { + try { + const featuredItemCreated = await createCollectionFeaturedItemViaApi(testCollectionAlias, { + content: '

Test content

', + displayOrder: 1, + withFile: true, + fileName: 'featured-item-test-image.png' + }) + + tetFeaturedItemId = featuredItemCreated.id + } catch (error) { + throw new Error(`Error while creating collection featured item in ${testCollectionAlias}`) + } + }) + + afterAll(async () => { + try { + await deleteCollectionFeaturedItemViaApi(tetFeaturedItemId) + } catch (error) { + throw new Error( + `Tests afterAll(): Error while deleting test dataset with id ${tetFeaturedItemId}` + ) + } + }) + + test('should return empty featured items array given a valid collection alias when collection has no featured items', async () => { + const featuredItemsResponse = await sut.getCollectionFeaturedItems(ROOT_COLLECTION_ID) + + expect(featuredItemsResponse).toStrictEqual([]) + }) + + test('should return featured items array given a valid collection alias when collection has featured items', async () => { + const featuredItemsResponse = await sut.getCollectionFeaturedItems(testCollectionAlias) + console.log({ featuredItemsResponse }) + + expect(featuredItemsResponse.length).toBe(1) + expect(featuredItemsResponse[0].id).toBe(tetFeaturedItemId) + expect(featuredItemsResponse[0].displayOrder).toBe(1) + expect(featuredItemsResponse[0].content).toBe('

Test content

') + expect(featuredItemsResponse[0].imageFileUrl).toBe( + 'http://localhost:8080/api/access/dataverseFeatureItemImage/1' + ) + expect(featuredItemsResponse[0].imageFileName).toBe('featured-item-test-image.png') + }) + + test('should return error when collection does not exist', async () => { + const invalidCollectionAlias = 'invalid-collection-alias' + const expectedError = new ReadError( + `[404] Can't find dataverse with identifier='${invalidCollectionAlias}'` + ) + + await expect(sut.getCollectionFeaturedItems(invalidCollectionAlias)).rejects.toThrow( + expectedError + ) + }) + }) }) diff --git a/test/testHelpers/collections/collectionFeaturedItemsHelper.ts b/test/testHelpers/collections/collectionFeaturedItemsHelper.ts new file mode 100644 index 00000000..1a20ce77 --- /dev/null +++ b/test/testHelpers/collections/collectionFeaturedItemsHelper.ts @@ -0,0 +1,79 @@ +import axios from 'axios' +import { File, Blob } from '@web-std/file' +import { CollectionFeaturedItem } from '../../../src/collections/domain/models/CollectionFeaturedItem' +import { ROOT_COLLECTION_ID } from '../../../src/collections/domain/models/Collection' +import { TestConstants } from '../TestConstants' + +interface CreateCollectionFeaturedItemData { + content: string + displayOrder?: number + withFile?: boolean + fileName?: string +} + +export async function createCollectionFeaturedItemViaApi( + collectionAlias: string, + { + content, + displayOrder = 1, + withFile = false, + fileName = 'test-image.png' + }: CreateCollectionFeaturedItemData +): Promise { + try { + if (collectionAlias == undefined) { + collectionAlias = ROOT_COLLECTION_ID + } + + const formData = new FormData() + formData.append('content', content) + formData.append('displayOrder', displayOrder.toString()) + + if (withFile) { + const file = createImageFile(fileName) + + formData.append('file', file) + } + + return await axios + .post(`${TestConstants.TEST_API_URL}/dataverses/${collectionAlias}/featuredItem`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'X-Dataverse-Key': process.env.TEST_API_KEY + } + }) + .then((response) => response.data.data) + } catch (error) { + console.log(error) + throw new Error(`Error while creating collection featured item in ${collectionAlias}`) + } +} + +export async function deleteCollectionFeaturedItemViaApi(featuredItemId: number): Promise { + try { + return await axios.delete( + `${TestConstants.TEST_API_URL}/dataverseFeaturedItems/${featuredItemId}`, + { + headers: { 'Content-Type': 'application/json', 'X-Dataverse-Key': process.env.TEST_API_KEY } + } + ) + } catch (error) { + throw new Error(`Error while deleting collection featured item with id ${featuredItemId}`) + } +} + +export function createImageFile(fileName = 'test-image.png'): File { + // Binary data for a 1x1 black pixel PNG image + const imageData = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82 + ]) + + const blob = new Blob([imageData], { type: 'image/png' }) + const imageFile = new File([blob], fileName, { type: 'image/png' }) + + return imageFile +} From 1143cf11d24bc4a3b42d4efcc87b92fd850e5883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Jan 2025 16:55:06 -0300 Subject: [PATCH 3/6] test: functional cases --- .../GetCollectionFeaturedItems.test.ts | 106 ++++++++++++++++++ .../collections/CollectionsRepository.test.ts | 10 +- 2 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 test/functional/collections/GetCollectionFeaturedItems.test.ts diff --git a/test/functional/collections/GetCollectionFeaturedItems.test.ts b/test/functional/collections/GetCollectionFeaturedItems.test.ts new file mode 100644 index 00000000..5865710d --- /dev/null +++ b/test/functional/collections/GetCollectionFeaturedItems.test.ts @@ -0,0 +1,106 @@ +import { ApiConfig, ReadError, getCollectionFeaturedItems } from '../../../src' +import { TestConstants } from '../../testHelpers/TestConstants' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + createCollectionFeaturedItemViaApi, + deleteCollectionFeaturedItemViaApi +} from '../../testHelpers/collections/collectionFeaturedItemsHelper' +import { + createCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { ROOT_COLLECTION_ID } from '../../../src/collections/domain/models/Collection' + +describe('execute', () => { + const testCollectionAlias = 'collectionsRepositoryTestCollection' + let testFeaturedItemId: number + + beforeEach(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + beforeAll(async () => { + try { + await createCollectionViaApi(testCollectionAlias) + + const featuredItemCreated = await createCollectionFeaturedItemViaApi(testCollectionAlias, { + content: '

Test content

', + displayOrder: 1, + withFile: true, + fileName: 'featured-item-test-image.png' + }) + + testFeaturedItemId = featuredItemCreated.id + } catch (error) { + throw new Error(`Error while creating collection featured item in ${testCollectionAlias}`) + } + }) + + afterAll(async () => { + try { + await deleteCollectionFeaturedItemViaApi(testFeaturedItemId) + await deleteCollectionViaApi(testCollectionAlias) + } catch (error) { + throw new Error( + `Tests afterAll(): Error while deleting featured item with id ${testFeaturedItemId}` + ) + } + }) + + test('should return featured items array given a valid collection alias that has featured items', async () => { + const featuredItemsResponse = await getCollectionFeaturedItems.execute(testCollectionAlias) + + expect(featuredItemsResponse.length).toBe(1) + expect(featuredItemsResponse[0].id).toBe(testFeaturedItemId) + expect(featuredItemsResponse[0].displayOrder).toBe(1) + expect(featuredItemsResponse[0].content).toBe('

Test content

') + expect(featuredItemsResponse[0].imageFileUrl).toBe( + 'http://localhost:8080/api/access/dataverseFeatureItemImage/1' + ) + expect(featuredItemsResponse[0].imageFileName).toBe('featured-item-test-image.png') + }) + + it('should return imageFileUrl and imageFileName as undefined when featured item does not have an image', async () => { + const featuredItemCreated = await createCollectionFeaturedItemViaApi(testCollectionAlias, { + content: '

Test content

', + displayOrder: 2 + }) + + const featuredItemsResponse = await getCollectionFeaturedItems.execute(testCollectionAlias) + + expect(featuredItemsResponse.length).toBe(2) + expect(featuredItemsResponse[1].id).toBe(featuredItemCreated.id) + expect(featuredItemsResponse[1].displayOrder).toBe(2) + expect(featuredItemsResponse[1].content).toBe('

Test content

') + expect(featuredItemsResponse[1].imageFileUrl).toBeUndefined() + expect(featuredItemsResponse[1].imageFileName).toBeUndefined() + + await deleteCollectionFeaturedItemViaApi(featuredItemCreated.id) + }) + + test('should return empty featured items array given a valid collection alias that has no featured items', async () => { + const featuredItemsResponse = await getCollectionFeaturedItems.execute(ROOT_COLLECTION_ID) + + expect(featuredItemsResponse).toStrictEqual([]) + }) + + test('should throw an error when collection does not exist', async () => { + const invalidCollectionAlias = 'invalid-collection-alias' + let readError: ReadError | undefined + + try { + await getCollectionFeaturedItems.execute(invalidCollectionAlias) + } catch (error) { + readError = error + } finally { + expect(readError).toBeInstanceOf(ReadError) + expect((readError as ReadError).message).toEqual( + `There was an error when reading the resource. Reason was: [404] Can't find dataverse with identifier='${invalidCollectionAlias}'` + ) + } + }) +}) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index c67b75a3..c2f1fe97 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -727,7 +727,7 @@ describe('CollectionsRepository', () => { }) describe('getCollectionFeaturedItems', () => { - let tetFeaturedItemId: number + let testFeaturedItemId: number beforeAll(async () => { try { @@ -738,7 +738,7 @@ describe('CollectionsRepository', () => { fileName: 'featured-item-test-image.png' }) - tetFeaturedItemId = featuredItemCreated.id + testFeaturedItemId = featuredItemCreated.id } catch (error) { throw new Error(`Error while creating collection featured item in ${testCollectionAlias}`) } @@ -746,10 +746,10 @@ describe('CollectionsRepository', () => { afterAll(async () => { try { - await deleteCollectionFeaturedItemViaApi(tetFeaturedItemId) + await deleteCollectionFeaturedItemViaApi(testFeaturedItemId) } catch (error) { throw new Error( - `Tests afterAll(): Error while deleting test dataset with id ${tetFeaturedItemId}` + `Tests afterAll(): Error while deleting featured item with id ${testFeaturedItemId}` ) } }) @@ -765,7 +765,7 @@ describe('CollectionsRepository', () => { console.log({ featuredItemsResponse }) expect(featuredItemsResponse.length).toBe(1) - expect(featuredItemsResponse[0].id).toBe(tetFeaturedItemId) + expect(featuredItemsResponse[0].id).toBe(testFeaturedItemId) expect(featuredItemsResponse[0].displayOrder).toBe(1) expect(featuredItemsResponse[0].content).toBe('

Test content

') expect(featuredItemsResponse[0].imageFileUrl).toBe( From 30dbbffb1052ed408abe499996f5ea7d760d2845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Jan 2025 17:22:00 -0300 Subject: [PATCH 4/6] test: unit cases --- .../collectionFeaturedItemsHelper.ts | 39 +++++++++++++++++++ .../collections/collectionHelper.ts | 3 +- .../GetCollectionFeaturedItems.test.ts | 29 ++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/unit/collections/GetCollectionFeaturedItems.test.ts diff --git a/test/testHelpers/collections/collectionFeaturedItemsHelper.ts b/test/testHelpers/collections/collectionFeaturedItemsHelper.ts index 1a20ce77..e9c7db2c 100644 --- a/test/testHelpers/collections/collectionFeaturedItemsHelper.ts +++ b/test/testHelpers/collections/collectionFeaturedItemsHelper.ts @@ -3,6 +3,7 @@ import { File, Blob } from '@web-std/file' import { CollectionFeaturedItem } from '../../../src/collections/domain/models/CollectionFeaturedItem' import { ROOT_COLLECTION_ID } from '../../../src/collections/domain/models/Collection' import { TestConstants } from '../TestConstants' +import { CollectionFeaturedItemPayload } from '../../../src/collections/infra/repositories/transformers/CollectionFeaturedItemPayload' interface CreateCollectionFeaturedItemData { content: string @@ -62,6 +63,44 @@ export async function deleteCollectionFeaturedItemViaApi(featuredItemId: number) } } +export const createCollectionFeaturedItemsModel = (): CollectionFeaturedItem[] => { + return [ + { + id: 1, + content: 'This is a featured item', + displayOrder: 1, + imageFileName: 'test-image.png', + imageFileUrl: 'http://localhost:8080/api/access/dataverseFeatureItemImage/1' + }, + { + id: 2, + content: 'This is another featured item', + displayOrder: 2, + imageFileName: undefined, + imageFileUrl: undefined + } + ] +} + +export const createCollectionFeaturedItemsPayload = (): CollectionFeaturedItemPayload[] => { + return [ + { + id: 1, + content: 'This is a featured item', + displayOrder: 1, + imageFileName: 'test-image.png', + imageFileUrl: 'http://localhost:8080/api/access/dataverseFeatureItemImage/1' + }, + { + id: 2, + content: 'This is another featured item', + displayOrder: 2, + imageFileName: null, + imageFileUrl: null + } + ] +} + export function createImageFile(fileName = 'test-image.png'): File { // Binary data for a 1x1 black pixel PNG image const imageData = Uint8Array.from([ diff --git a/test/testHelpers/collections/collectionHelper.ts b/test/testHelpers/collections/collectionHelper.ts index f2d2e5ae..65de5d4a 100644 --- a/test/testHelpers/collections/collectionHelper.ts +++ b/test/testHelpers/collections/collectionHelper.ts @@ -8,6 +8,8 @@ import { NewCollectionRequestPayload } from '../../../src/collections/infra/repo import { CollectionFacetPayload } from '../../../src/collections/infra/repositories/transformers/CollectionFacetPayload' import { CollectionType } from '../../../src/collections/domain/models/CollectionType' +export const ROOT_COLLECTION_ALIAS = 'root' + const COLLECTION_ID = 11111 const COLLECTION_IS_RELEASED = true const COLLECTION_ALIAS_STR = 'secondCollection' @@ -205,4 +207,3 @@ export const createCollectionFacetRequestPayload = (): CollectionFacetPayload => displayName: 'testDisplayName' } } -export const ROOT_COLLECTION_ALIAS = 'root' diff --git a/test/unit/collections/GetCollectionFeaturedItems.test.ts b/test/unit/collections/GetCollectionFeaturedItems.test.ts new file mode 100644 index 00000000..a8235051 --- /dev/null +++ b/test/unit/collections/GetCollectionFeaturedItems.test.ts @@ -0,0 +1,29 @@ +import { ICollectionsRepository } from '../../../src/collections/domain/repositories/ICollectionsRepository' +import { ReadError } from '../../../src' +import { createCollectionFeaturedItemsModel } from '../../testHelpers/collections/collectionFeaturedItemsHelper' +import { GetCollectionFeaturedItems } from '../../../src/collections/domain/useCases/GetCollectionFeaturedItems' + +describe('execute', () => { + test('should return collection featured items on repository success', async () => { + const testFeaturedItems = createCollectionFeaturedItemsModel() + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.getCollectionFeaturedItems = jest + .fn() + .mockResolvedValue(testFeaturedItems) + const testGetCollectionFeaturedItems = new GetCollectionFeaturedItems(collectionRepositoryStub) + + const actual = await testGetCollectionFeaturedItems.execute(1) + + expect(actual).toEqual(testFeaturedItems) + }) + + test('should return error result on repository error', async () => { + const collectionRepositoryStub: ICollectionsRepository = {} as ICollectionsRepository + collectionRepositoryStub.getCollectionFeaturedItems = jest + .fn() + .mockRejectedValue(new ReadError()) + const testGetCollectionFeaturedItems = new GetCollectionFeaturedItems(collectionRepositoryStub) + + await expect(testGetCollectionFeaturedItems.execute(1)).rejects.toThrow(ReadError) + }) +}) From 559c89cf2bbf3725ad7b4c3ba0d181996ad50f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Fri, 3 Jan 2025 17:27:47 -0300 Subject: [PATCH 5/6] chore: add docs --- docs/useCases.md | 28 +++++++++++++++++++ .../useCases/GetCollectionFeaturedItems.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/useCases.md b/docs/useCases.md index 6faa4ca7..8fd39054 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -14,6 +14,7 @@ The different use cases currently available in the package are classified below, - [Get Collection Facets](#get-collection-facets) - [Get User Permissions on a Collection](#get-user-permissions-on-a-collection) - [List All Collection Items](#list-all-collection-items) + - [Get Collection Featured Items](#get-collection-featured-items) - [Collections write use cases](#collections-write-use-cases) - [Create a Collection](#create-a-collection) - [Update a Collection](#update-a-collection) @@ -202,6 +203,33 @@ This use case supports the following optional parameters depending on the search - **offset**: (number) Offset for pagination. - **collectionSearchCriteria**: ([CollectionSearchCriteria](../src/collections/domain/models/CollectionSearchCriteria.ts)) Supports filtering the collection items by different properties. +#### Get Collection Featured Items + +Returns a [CollectionFeaturedItem](../src/collections/domain/models/CollectionFeaturedItem.ts) array containing the featured items of the requested collection, given the collection identifier or alias. + +##### Example call: + +```typescript +import { getCollectionFeaturedItems } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 12345 + +getCollectionFeaturedItems + .execute(collectionId) + .then((featuredItems: CollectionFeaturedItem[]) => { + /* ... */ + }) + .catch((error: Error) => { + /* ... */ + }) +``` + +_See [use case](../src/collections/domain/useCases/GetCollectionFeaturedItems.ts)_ definition. + +The `collectionIdOrAlias` is a generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId). + +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. + ### Collections Write Use Cases #### Create a Collection diff --git a/src/collections/domain/useCases/GetCollectionFeaturedItems.ts b/src/collections/domain/useCases/GetCollectionFeaturedItems.ts index 21b66ef1..7a2a0adc 100644 --- a/src/collections/domain/useCases/GetCollectionFeaturedItems.ts +++ b/src/collections/domain/useCases/GetCollectionFeaturedItems.ts @@ -15,7 +15,7 @@ export class GetCollectionFeaturedItems implements UseCase} + * @returns {Promise} */ async execute( collectionIdOrAlias: number | string = ROOT_COLLECTION_ID From 3f120186598221b878f2b20cbef530e190ec3e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Saracca?= Date: Tue, 7 Jan 2025 17:26:53 -0300 Subject: [PATCH 6/6] feat: add updateCollectionFeaturedItems use case --- docs/useCases.md | 27 +++++++++++ .../domain/dtos/CollectionFeaturedItemsDTO.ts | 9 ++++ .../repositories/ICollectionsRepository.ts | 5 +++ .../useCases/UpdateCollectionFeaturedItems.ts | 32 +++++++++++++ src/collections/index.ts | 6 ++- .../repositories/CollectionsRepository.ts | 45 +++++++++++++++++++ src/core/infra/repositories/ApiRepository.ts | 5 ++- 7 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 src/collections/domain/dtos/CollectionFeaturedItemsDTO.ts create mode 100644 src/collections/domain/useCases/UpdateCollectionFeaturedItems.ts diff --git a/docs/useCases.md b/docs/useCases.md index 8fd39054..92cd9222 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) + - [Update Collection Featured Items](#update-collection-featured-items) - [Datasets](#Datasets) - [Datasets read use cases](#datasets-read-use-cases) - [Get a Dataset](#get-a-dataset) @@ -313,6 +314,32 @@ The `collectionIdOrAlias` is a generic collection identifier, which can be eithe _See [use case](../src/collections/domain/useCases/PublishCollection.ts)_ definition. +#### Update Collection Featured Items + +Updates all featured items, given a collection identifier and a CollectionFeaturedItemsDTO. + +##### Example call: + +```typescript +import { updateCollectionFeaturedItems } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const collectionIdOrAlias = 12345 + +updateCollectionFeaturedItems + .execute(collectionIdOrAlias) + .then((collectionFeaturedItems: CollectionFeaturedItem[]) => { + /* ... */ + }) + +/* ... */ +``` + +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/UpdateCollectionFeaturedItems.ts)_ definition. + ## Datasets ### Datasets Read Use Cases diff --git a/src/collections/domain/dtos/CollectionFeaturedItemsDTO.ts b/src/collections/domain/dtos/CollectionFeaturedItemsDTO.ts new file mode 100644 index 00000000..7a60052d --- /dev/null +++ b/src/collections/domain/dtos/CollectionFeaturedItemsDTO.ts @@ -0,0 +1,9 @@ +export type CollectionFeaturedItemsDTO = CollectionFeaturedItemDTO[] + +export interface CollectionFeaturedItemDTO { + id?: number + content: string + displayOrder: number + file?: File + keepFile: boolean +} diff --git a/src/collections/domain/repositories/ICollectionsRepository.ts b/src/collections/domain/repositories/ICollectionsRepository.ts index 91d395aa..f12df3aa 100644 --- a/src/collections/domain/repositories/ICollectionsRepository.ts +++ b/src/collections/domain/repositories/ICollectionsRepository.ts @@ -1,4 +1,5 @@ import { CollectionDTO } from '../dtos/CollectionDTO' +import { CollectionFeaturedItemsDTO } from '../dtos/CollectionFeaturedItemsDTO' import { Collection } from '../models/Collection' import { CollectionFacet } from '../models/CollectionFacet' import { CollectionFeaturedItem } from '../models/CollectionFeaturedItem' @@ -30,4 +31,8 @@ export interface ICollectionsRepository { getCollectionFeaturedItems( collectionIdOrAlias: number | string ): Promise + updateCollectionFeaturedItems( + collectionIdOrAlias: number | string, + featuredItemDTOs: CollectionFeaturedItemsDTO + ): Promise } diff --git a/src/collections/domain/useCases/UpdateCollectionFeaturedItems.ts b/src/collections/domain/useCases/UpdateCollectionFeaturedItems.ts new file mode 100644 index 00000000..10d22b5a --- /dev/null +++ b/src/collections/domain/useCases/UpdateCollectionFeaturedItems.ts @@ -0,0 +1,32 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { CollectionFeaturedItemsDTO } from '../dtos/CollectionFeaturedItemsDTO' +import { ROOT_COLLECTION_ID } from '../models/Collection' +import { CollectionFeaturedItem } from '../models/CollectionFeaturedItem' +import { ICollectionsRepository } from '../repositories/ICollectionsRepository' + +export class UpdateCollectionFeaturedItems implements UseCase { + private collectionsRepository: ICollectionsRepository + + constructor(collectionsRepository: ICollectionsRepository) { + this.collectionsRepository = collectionsRepository + } + + /** + * Updates all featured items, given a collection identifier and a CollectionFeaturedItemsDTO. + * + * @param {number | string} [collectionIdOrAlias = ':root'] - A generic collection identifier, which can be either a string (for queries by CollectionAlias), or a number (for queries by CollectionId) + * If this parameter is not set, the default value is: ':root' + * @param {CollectionFeaturedItemsDTO} [newCollectionFeaturedItems] - CollectionFeaturedItemsDTO object including the updated collection featured items data. + * @returns {Promise} -This method returns the updated collection featured items upon successful completion. + * @throws {WriteError} - If there are errors while writing data. + */ + async execute( + collectionIdOrAlias: number | string = ROOT_COLLECTION_ID, + featuredItemsDTO: CollectionFeaturedItemsDTO + ): Promise { + return await this.collectionsRepository.updateCollectionFeaturedItems( + collectionIdOrAlias, + featuredItemsDTO + ) + } +} diff --git a/src/collections/index.ts b/src/collections/index.ts index 7e149b53..99721a7d 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -7,6 +7,7 @@ import { PublishCollection } from './domain/useCases/PublishCollection' import { UpdateCollection } from './domain/useCases/UpdateCollection' import { GetCollectionFeaturedItems } from './domain/useCases/GetCollectionFeaturedItems' import { CollectionsRepository } from './infra/repositories/CollectionsRepository' +import { UpdateCollectionFeaturedItems } from './domain/useCases/UpdateCollectionFeaturedItems' const collectionsRepository = new CollectionsRepository() @@ -18,6 +19,7 @@ const getCollectionItems = new GetCollectionItems(collectionsRepository) const publishCollection = new PublishCollection(collectionsRepository) const updateCollection = new UpdateCollection(collectionsRepository) const getCollectionFeaturedItems = new GetCollectionFeaturedItems(collectionsRepository) +const updateCollectionFeaturedItems = new UpdateCollectionFeaturedItems(collectionsRepository) export { getCollection, @@ -27,7 +29,8 @@ export { getCollectionItems, publishCollection, updateCollection, - getCollectionFeaturedItems + getCollectionFeaturedItems, + updateCollectionFeaturedItems } export { Collection, CollectionInputLevel } from './domain/models/Collection' export { CollectionFacet } from './domain/models/CollectionFacet' @@ -37,3 +40,4 @@ export { CollectionPreview } from './domain/models/CollectionPreview' export { CollectionItemType } from './domain/models/CollectionItemType' export { CollectionSearchCriteria } from './domain/models/CollectionSearchCriteria' export { CollectionFeaturedItem } from './domain/models/CollectionFeaturedItem' +export { CollectionFeaturedItemsDTO } from './domain/dtos/CollectionFeaturedItemsDTO' diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index fc77a833..ffd41fb4 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -19,6 +19,8 @@ import { import { CollectionItemType } from '../../domain/models/CollectionItemType' import { CollectionFeaturedItem } from '../../domain/models/CollectionFeaturedItem' import { transformCollectionFeaturedItemsPayloadToCollectionFeaturedItems } from './transformers/collectionFeaturedItemsTransformer' +import { CollectionFeaturedItemsDTO } from '../../domain/dtos/CollectionFeaturedItemsDTO' +import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' export interface NewCollectionRequestPayload { alias: string @@ -254,4 +256,47 @@ export class CollectionsRepository extends ApiRepository implements ICollections throw error }) } + + public async updateCollectionFeaturedItems( + collectionIdOrAlias: number | string, + featuredItemsDTO: CollectionFeaturedItemsDTO + ): Promise { + const featuredItemsFormData = + this.defineUpdateCollectionFeaturedItemsRequestBody(featuredItemsDTO) + + return this.doPut( + `/${this.collectionsResourceName}/${collectionIdOrAlias}/featuredItems`, + featuredItemsFormData, + undefined, + ApiConstants.CONTENT_TYPE_MULTIPART_FORM_DATA + ) + .then((response) => + transformCollectionFeaturedItemsPayloadToCollectionFeaturedItems(response.data.data) + ) + .catch((error) => { + throw error + }) + } + + private defineUpdateCollectionFeaturedItemsRequestBody( + featuredItemsDTO: CollectionFeaturedItemsDTO + ): FormData { + const formData = new FormData() + + featuredItemsDTO.forEach((item) => { + const { id, content, displayOrder, file, keepFile } = item + + // TODO: We need to configure this project to use strict typescript rules or at least strictNullChecks: true + // id is inferred here as number but it should be a number | undefined, same for file + // This config change should be done in a separate issue because it will require changes in some other ts files + + formData.append(`items[${item.displayOrder}][id]`, id ? id.toString() : '0') + formData.append(`items[${item.displayOrder}][displayOrder]`, displayOrder.toString()) + formData.append(`items[${item.displayOrder}][content]`, content) + formData.append(`items[${item.displayOrder}][file]`, file ? file : 'false') + formData.append(`items[${item.displayOrder}][keepFile]`, keepFile.toString()) + }) + + return formData + } } diff --git a/src/core/infra/repositories/ApiRepository.ts b/src/core/infra/repositories/ApiRepository.ts index fb0726ac..9c59d615 100644 --- a/src/core/infra/repositories/ApiRepository.ts +++ b/src/core/infra/repositories/ApiRepository.ts @@ -30,9 +30,10 @@ export abstract class ApiRepository { public async doPut( apiEndpoint: string, data: string | object, - queryParams: object = {} + queryParams: object = {}, + contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON ): Promise { - return await this.doRequest('put', apiEndpoint, data, queryParams) + return await this.doRequest('put', apiEndpoint, data, queryParams, contentType) } public async doDelete(apiEndpoint: string, queryParams: object = {}): Promise {