From c023fd545cdfbe2cac24ffaa552df5151ce5740f Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 19 Feb 2026 14:32:06 -0500 Subject: [PATCH 1/5] feat: Add Guestbook and Access Repository, and their use cases accordingly --- CHANGELOG.md | 2 + docs/useCases.md | 198 ++++++++++++++++++ .../domain/dtos/GuestbookResponseDTO.ts | 10 + .../domain/repositories/IAccessRepository.ts | 24 +++ .../SubmitGuestbookForDatafileDownload.ts | 18 ++ .../SubmitGuestbookForDatafilesDownload.ts | 24 +++ .../SubmitGuestbookForDatasetDownload.ts | 24 +++ ...ubmitGuestbookForDatasetVersionDownload.ts | 27 +++ src/access/index.ts | 25 +++ .../infra/repositories/AccessRepository.ts | 82 ++++++++ .../domain/dtos/CreateGuestbookDTO.ts | 25 +++ src/guestbooks/domain/models/Guestbook.ts | 28 +++ .../repositories/IGuestbooksRepository.ts | 16 ++ .../domain/useCases/CreateGuestbook.ts | 18 ++ .../domain/useCases/GetGuestbook.ts | 17 ++ .../useCases/GetGuestbooksByCollectionId.ts | 17 ++ .../domain/useCases/SetGuestbookEnabled.ts | 26 +++ src/guestbooks/index.ts | 21 ++ .../repositories/GuestbooksRepository.ts | 63 ++++++ src/index.ts | 2 + .../access/AccessRepository.test.ts | 125 +++++++++++ .../guestbooks/GuestbooksRepository.test.ts | 146 +++++++++++++ .../access/SubmitGuestbookDownloads.test.ts | 70 +++++++ test/unit/guestbooks/CreateGuestbook.test.ts | 64 ++++++ test/unit/guestbooks/GetGuestbook.test.ts | 38 ++++ ...GetGuestbooksByDataverseIdentifier.test.ts | 41 ++++ .../guestbooks/SetGuestbookEnabled.test.ts | 24 +++ 27 files changed, 1175 insertions(+) create mode 100644 src/access/domain/dtos/GuestbookResponseDTO.ts create mode 100644 src/access/domain/repositories/IAccessRepository.ts create mode 100644 src/access/domain/useCases/SubmitGuestbookForDatafileDownload.ts create mode 100644 src/access/domain/useCases/SubmitGuestbookForDatafilesDownload.ts create mode 100644 src/access/domain/useCases/SubmitGuestbookForDatasetDownload.ts create mode 100644 src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload.ts create mode 100644 src/access/index.ts create mode 100644 src/access/infra/repositories/AccessRepository.ts create mode 100644 src/guestbooks/domain/dtos/CreateGuestbookDTO.ts create mode 100644 src/guestbooks/domain/models/Guestbook.ts create mode 100644 src/guestbooks/domain/repositories/IGuestbooksRepository.ts create mode 100644 src/guestbooks/domain/useCases/CreateGuestbook.ts create mode 100644 src/guestbooks/domain/useCases/GetGuestbook.ts create mode 100644 src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts create mode 100644 src/guestbooks/domain/useCases/SetGuestbookEnabled.ts create mode 100644 src/guestbooks/index.ts create mode 100644 src/guestbooks/infra/repositories/GuestbooksRepository.ts create mode 100644 test/integration/access/AccessRepository.test.ts create mode 100644 test/integration/guestbooks/GuestbooksRepository.test.ts create mode 100644 test/unit/access/SubmitGuestbookDownloads.test.ts create mode 100644 test/unit/guestbooks/CreateGuestbook.test.ts create mode 100644 test/unit/guestbooks/GetGuestbook.test.ts create mode 100644 test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts create mode 100644 test/unit/guestbooks/SetGuestbookEnabled.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f3a2c92..092fa810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - New Use Case: [Get a Template](./docs/useCases.md#get-a-template) under Templates. - New Use Case: [Delete a Template](./docs/useCases.md#delete-a-template) under Templates. - New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access). +- Guestbooks: Added use cases and repository support for Guestbook CRUD. +- Access: Added a dedicated `access` module for guestbook-at-request and download terms/guestbook submission endpoints. ### Changed diff --git a/docs/useCases.md b/docs/useCases.md index f77a40e1..944d6ea4 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -123,6 +123,19 @@ The different use cases currently available in the package are classified below, - [Get External Tools](#get-external-tools) - [Get Dataset External Tool Resolved](#get-dataset-external-tool-resolved) - [Get File External Tool Resolved](#get-file-external-tool-resolved) +- [Guestbooks](#Guestbooks) + - [Guestbooks read use cases](#guestbooks-read-use-cases) + - [Get a Guestbook](#get-a-guestbook) + - [Get Guestbooks By Collection Id](#get-guestbooks-by-collection-id) + - [Guestbooks write use cases](#guestbooks-write-use-cases) + - [Create a Guestbook](#create-a-guestbook) + - [Set Guestbook Enabled](#set-guestbook-enabled) +- [Access](#Access) + - [Access write use cases](#access-write-use-cases) + - [Submit Guestbook For Datafile Download](#submit-guestbook-for-datafile-download) + - [Submit Guestbook For Datafiles Download](#submit-guestbook-for-datafiles-download) + - [Submit Guestbook For Dataset Download](#submit-guestbook-for-dataset-download) + - [Submit Guestbook For Dataset Version Download](#submit-guestbook-for-dataset-version-download) ## Collections @@ -2767,3 +2780,188 @@ getFileExternalToolResolved ``` _See [use case](../src/externalTools/domain/useCases/GetfileExternalToolResolved.ts) implementation_. + +## Guestbooks + +### Guestbooks Read Use Cases + +#### Get a Guestbook + +Returns a [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) by its id. + +##### Example call: + +```typescript +import { getGuestbook } from '@iqss/dataverse-client-javascript' + +const guestbookId = 123 + +getGuestbook.execute(guestbookId).then((guestbook: Guestbook) => { + /* ... */ +}) +``` + +_See [use case](../src/guestbooks/domain/useCases/GetGuestbook.ts) implementation_. + +#### Get Guestbooks By Collection Id + +Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries available for a collection. + +##### Example call: + +```typescript +import { getGuestbooksBycollectionId } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' + +getGuestbooksBycollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => { + /* ... */ +}) +``` + +_See [use case](../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts) implementation_. + +### Guestbooks Write Use Cases + +#### Create a Guestbook + +Creates a guestbook on a collection using [CreateGuestbookDTO](../src/guestbooks/domain/dtos/CreateGuestbookDTO.ts). + +##### Example call: + +```typescript +import { createGuestbook } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' +const guestbook: CreateGuestbookDTO = { + name: 'my test guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'Describe yourself', + required: false, + displayOrder: 1, + type: 'textarea', + hidden: false + } + ] +} + +createGuestbook.execute(guestbook, collectionIdOrAlias).then(() => { + /* ... */ +}) +``` + +_See [use case](../src/guestbooks/domain/useCases/CreateGuestbook.ts) implementation_. + +#### Set Guestbook Enabled + +Enables or disables a guestbook in a collection. + +##### Example call: + +```typescript +import { setGuestbookEnabled } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' +const guestbookId = 123 + +setGuestbookEnabled.execute(collectionIdOrAlias, guestbookId, false).then(() => { + /* ... */ +}) +``` + +_See [use case](../src/guestbooks/domain/useCases/SetGuestbookEnabled.ts) implementation_. + +## Access + +### Access Write Use Cases + +#### Submit Guestbook For Datafile Download + +Submits guestbook answers for a datafile and returns a signed URL. + +##### Example call: + +```typescript +import { submitGuestbookForDatafileDownload } from '@iqss/dataverse-client-javascript' + +submitGuestbookForDatafileDownload + .execute(10, { + guestbookResponse: { + answers: [ + { id: 123, value: 'Good' }, + { id: 124, value: ['Multi', 'Line'] } + ] + } + }) + .then((signedUrl: string) => { + /* ... */ + }) +``` + +_See [use case](../src/access/domain/useCases/SubmitGuestbookForDatafileDownload.ts) implementation_. + +#### Submit Guestbook For Datafiles Download + +Submits guestbook answers for multiple files and returns a signed URL. + +##### Example call: + +```typescript +import { submitGuestbookForDatafilesDownload } from '@iqss/dataverse-client-javascript' + +submitGuestbookForDatafilesDownload + .execute([10, 11], { + guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + }) + .then((signedUrl: string) => { + /* ... */ + }) +``` + +_See [use case](../src/access/domain/useCases/SubmitGuestbookForDatafilesDownload.ts) implementation_. + +#### Submit Guestbook For Dataset Download + +Submits guestbook answers for dataset download and returns a signed URL. + +##### Example call: + +```typescript +import { submitGuestbookForDatasetDownload } from '@iqss/dataverse-client-javascript' + +submitGuestbookForDatasetDownload + .execute('doi:10.5072/FK2/XXXXXX', { + guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + }) + .then((signedUrl: string) => { + /* ... */ + }) +``` + +_See [use case](../src/access/domain/useCases/SubmitGuestbookForDatasetDownload.ts) implementation_. + +#### Submit Guestbook For Dataset Version Download + +Submits guestbook answers for a specific dataset version and returns a signed URL. + +##### Example call: + +```typescript +import { submitGuestbookForDatasetVersionDownload } from '@iqss/dataverse-client-javascript' + +submitGuestbookForDatasetVersionDownload + .execute(10, ':latest', { + guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + }) + .then((signedUrl: string) => { + /* ... */ + }) +``` + +_See [use case](../src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload.ts) implementation_. diff --git a/src/access/domain/dtos/GuestbookResponseDTO.ts b/src/access/domain/dtos/GuestbookResponseDTO.ts new file mode 100644 index 00000000..7e29d562 --- /dev/null +++ b/src/access/domain/dtos/GuestbookResponseDTO.ts @@ -0,0 +1,10 @@ +export interface GuestbookAnswerDTO { + id: number | string + value: string | string[] +} + +export interface GuestbookResponseDTO { + guestbookResponse: { + answers: GuestbookAnswerDTO[] + } +} diff --git a/src/access/domain/repositories/IAccessRepository.ts b/src/access/domain/repositories/IAccessRepository.ts new file mode 100644 index 00000000..484842ed --- /dev/null +++ b/src/access/domain/repositories/IAccessRepository.ts @@ -0,0 +1,24 @@ +import { GuestbookResponseDTO } from '../dtos/GuestbookResponseDTO' + +export interface IAccessRepository { + submitGuestbookForDatafileDownload( + fileId: number | string, + guestbookResponse: GuestbookResponseDTO + ): Promise + + submitGuestbookForDatafilesDownload( + fileIds: string | Array, + guestbookResponse: GuestbookResponseDTO + ): Promise + + submitGuestbookForDatasetDownload( + datasetId: number | string, + guestbookResponse: GuestbookResponseDTO + ): Promise + + submitGuestbookForDatasetVersionDownload( + datasetId: number | string, + versionId: string, + guestbookResponse: GuestbookResponseDTO + ): Promise +} diff --git a/src/access/domain/useCases/SubmitGuestbookForDatafileDownload.ts b/src/access/domain/useCases/SubmitGuestbookForDatafileDownload.ts new file mode 100644 index 00000000..357da78f --- /dev/null +++ b/src/access/domain/useCases/SubmitGuestbookForDatafileDownload.ts @@ -0,0 +1,18 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseDTO } from '../dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../repositories/IAccessRepository' + +export class SubmitGuestbookForDatafileDownload implements UseCase { + constructor(private readonly accessRepository: IAccessRepository) {} + + /** + * Submits a guestbook response for a single datafile download request and returns a signed URL. + * + * @param {number | string} fileId - Datafile identifier (numeric id or persistent id). + * @param {GuestbookResponseDTO} guestbookResponse - Guestbook response payload. + * @returns {Promise} - Signed URL for the download. + */ + async execute(fileId: number | string, guestbookResponse: GuestbookResponseDTO): Promise { + return await this.accessRepository.submitGuestbookForDatafileDownload(fileId, guestbookResponse) + } +} diff --git a/src/access/domain/useCases/SubmitGuestbookForDatafilesDownload.ts b/src/access/domain/useCases/SubmitGuestbookForDatafilesDownload.ts new file mode 100644 index 00000000..d682da8d --- /dev/null +++ b/src/access/domain/useCases/SubmitGuestbookForDatafilesDownload.ts @@ -0,0 +1,24 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseDTO } from '../dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../repositories/IAccessRepository' + +export class SubmitGuestbookForDatafilesDownload implements UseCase { + constructor(private readonly accessRepository: IAccessRepository) {} + + /** + * Submits a guestbook response for multiple datafiles download request and returns a signed URL. + * + * @param {string | Array} fileIds - Comma-separated string or array of file ids. + * @param {GuestbookResponseDTO} guestbookResponse - Guestbook response payload. + * @returns {Promise} - Signed URL for the download. + */ + async execute( + fileIds: string | Array, + guestbookResponse: GuestbookResponseDTO + ): Promise { + return await this.accessRepository.submitGuestbookForDatafilesDownload( + fileIds, + guestbookResponse + ) + } +} diff --git a/src/access/domain/useCases/SubmitGuestbookForDatasetDownload.ts b/src/access/domain/useCases/SubmitGuestbookForDatasetDownload.ts new file mode 100644 index 00000000..1ac3113e --- /dev/null +++ b/src/access/domain/useCases/SubmitGuestbookForDatasetDownload.ts @@ -0,0 +1,24 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseDTO } from '../dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../repositories/IAccessRepository' + +export class SubmitGuestbookForDatasetDownload implements UseCase { + constructor(private readonly accessRepository: IAccessRepository) {} + + /** + * Submits a guestbook response for dataset download request and returns a signed URL. + * + * @param {number | string} datasetId - Dataset identifier (numeric id or persistent id). + * @param {GuestbookResponseDTO} guestbookResponse - Guestbook response payload. + * @returns {Promise} - Signed URL for the download. + */ + async execute( + datasetId: number | string, + guestbookResponse: GuestbookResponseDTO + ): Promise { + return await this.accessRepository.submitGuestbookForDatasetDownload( + datasetId, + guestbookResponse + ) + } +} diff --git a/src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload.ts b/src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload.ts new file mode 100644 index 00000000..3d811f14 --- /dev/null +++ b/src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload.ts @@ -0,0 +1,27 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseDTO } from '../dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../repositories/IAccessRepository' + +export class SubmitGuestbookForDatasetVersionDownload implements UseCase { + constructor(private readonly accessRepository: IAccessRepository) {} + + /** + * Submits a guestbook response for a specific dataset version download request and returns a signed URL. + * + * @param {number | string} datasetId - Dataset identifier (numeric id or persistent id). + * @param {string} versionId - Dataset version identifier (for example, ':latest' or '1.0'). + * @param {GuestbookResponseDTO} guestbookResponse - Guestbook response payload. + * @returns {Promise} - Signed URL for the download. + */ + async execute( + datasetId: number | string, + versionId: string, + guestbookResponse: GuestbookResponseDTO + ): Promise { + return await this.accessRepository.submitGuestbookForDatasetVersionDownload( + datasetId, + versionId, + guestbookResponse + ) + } +} diff --git a/src/access/index.ts b/src/access/index.ts new file mode 100644 index 00000000..ecea7218 --- /dev/null +++ b/src/access/index.ts @@ -0,0 +1,25 @@ +import { AccessRepository } from './infra/repositories/AccessRepository' +import { SubmitGuestbookForDatafileDownload } from './domain/useCases/SubmitGuestbookForDatafileDownload' +import { SubmitGuestbookForDatafilesDownload } from './domain/useCases/SubmitGuestbookForDatafilesDownload' +import { SubmitGuestbookForDatasetDownload } from './domain/useCases/SubmitGuestbookForDatasetDownload' +import { SubmitGuestbookForDatasetVersionDownload } from './domain/useCases/SubmitGuestbookForDatasetVersionDownload' + +const accessRepository = new AccessRepository() + +const submitGuestbookForDatafileDownload = new SubmitGuestbookForDatafileDownload(accessRepository) +const submitGuestbookForDatafilesDownload = new SubmitGuestbookForDatafilesDownload( + accessRepository +) +const submitGuestbookForDatasetDownload = new SubmitGuestbookForDatasetDownload(accessRepository) +const submitGuestbookForDatasetVersionDownload = new SubmitGuestbookForDatasetVersionDownload( + accessRepository +) + +export { + submitGuestbookForDatafileDownload, + submitGuestbookForDatafilesDownload, + submitGuestbookForDatasetDownload, + submitGuestbookForDatasetVersionDownload +} + +export { GuestbookResponseDTO } from './domain/dtos/GuestbookResponseDTO' diff --git a/src/access/infra/repositories/AccessRepository.ts b/src/access/infra/repositories/AccessRepository.ts new file mode 100644 index 00000000..dc45deec --- /dev/null +++ b/src/access/infra/repositories/AccessRepository.ts @@ -0,0 +1,82 @@ +import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { GuestbookResponseDTO } from '../../domain/dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../../domain/repositories/IAccessRepository' + +export class AccessRepository extends ApiRepository implements IAccessRepository { + private readonly accessResourceName = 'access' + + public async submitGuestbookForDatafileDownload( + fileId: number | string, + guestbookResponse: GuestbookResponseDTO + ): Promise { + const endpoint = this.buildApiEndpoint(`${this.accessResourceName}/datafile`, undefined, fileId) + return this.doPost(endpoint, guestbookResponse, { signed: true }) + .then((response) => { + const signedUrl = response.data.data.signedUrl + return signedUrl + }) + .catch((error) => { + throw error + }) + } + + public async submitGuestbookForDatafilesDownload( + fileIds: string | Array, + guestbookResponse: GuestbookResponseDTO + ): Promise { + return this.doPost( + this.buildApiEndpoint( + this.accessResourceName, + `datafiles/${Array.isArray(fileIds) ? fileIds.join(',') : fileIds}` + ), + guestbookResponse, + { signed: true } + ) + .then((response) => { + const signedUrl = response.data.data.signedUrl + return signedUrl + }) + .catch((error) => { + throw error + }) + } + + public async submitGuestbookForDatasetDownload( + datasetId: number | string, + guestbookResponse: GuestbookResponseDTO + ): Promise { + const endpoint = this.buildApiEndpoint( + `${this.accessResourceName}/dataset`, + undefined, + datasetId + ) + return this.doPost(endpoint, guestbookResponse, { signed: true }) + .then((response) => { + const signedUrl = response.data.data.signedUrl + return signedUrl + }) + .catch((error) => { + throw error + }) + } + + public async submitGuestbookForDatasetVersionDownload( + datasetId: number | string, + versionId: string, + guestbookResponse: GuestbookResponseDTO + ): Promise { + const endpoint = this.buildApiEndpoint( + `${this.accessResourceName}/dataset`, + `versions/${versionId}`, + datasetId + ) + return this.doPost(endpoint, guestbookResponse, { signed: true }) + .then((response) => { + const signedUrl = response.data.data.signedUrl + return signedUrl + }) + .catch((error) => { + throw error + }) + } +} diff --git a/src/guestbooks/domain/dtos/CreateGuestbookDTO.ts b/src/guestbooks/domain/dtos/CreateGuestbookDTO.ts new file mode 100644 index 00000000..02aa51ef --- /dev/null +++ b/src/guestbooks/domain/dtos/CreateGuestbookDTO.ts @@ -0,0 +1,25 @@ +export type CreateGuestbookQuestionTypeDTO = 'text' | 'textarea' | 'options' + +export interface CreateGuestbookOptionDTO { + value: string + displayOrder: number +} + +export interface CreateGuestbookCustomQuestionDTO { + question: string + required: boolean + displayOrder: number + type: CreateGuestbookQuestionTypeDTO + hidden: boolean + optionValues?: CreateGuestbookOptionDTO[] +} + +export interface CreateGuestbookDTO { + name: string + enabled: boolean + emailRequired: boolean + nameRequired: boolean + institutionRequired: boolean + positionRequired: boolean + customQuestions: CreateGuestbookCustomQuestionDTO[] +} diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts new file mode 100644 index 00000000..2a2f3c5b --- /dev/null +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -0,0 +1,28 @@ +export type GuestbookQuestionType = 'text' | 'textarea' | 'options' + +export interface GuestbookOption { + value: string + displayOrder: number +} + +export interface GuestbookCustomQuestion { + question: string + required: boolean + displayOrder: number + type: GuestbookQuestionType + hidden: boolean + optionValues?: GuestbookOption[] +} + +export interface Guestbook { + id: number + name: string + enabled: boolean + emailRequired: boolean + nameRequired: boolean + institutionRequired: boolean + positionRequired: boolean + customQuestions: GuestbookCustomQuestion[] + createTime: string + dataverseId: number +} diff --git a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts new file mode 100644 index 00000000..27a5beb6 --- /dev/null +++ b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts @@ -0,0 +1,16 @@ +import { CreateGuestbookDTO } from '../dtos/CreateGuestbookDTO' +import { Guestbook } from '../models/Guestbook' + +export interface IGuestbooksRepository { + createGuestbook( + collectionIdOrAlias: number | string, + guestbook: CreateGuestbookDTO + ): Promise + getGuestbook(guestbookId: number): Promise + getGuestbooksBycollectionId(collectionIdOrAlias: number | string): Promise + setGuestbookEnabled( + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean + ): Promise +} diff --git a/src/guestbooks/domain/useCases/CreateGuestbook.ts b/src/guestbooks/domain/useCases/CreateGuestbook.ts new file mode 100644 index 00000000..977d1232 --- /dev/null +++ b/src/guestbooks/domain/useCases/CreateGuestbook.ts @@ -0,0 +1,18 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { CreateGuestbookDTO } from '../dtos/CreateGuestbookDTO' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class CreateGuestbook implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Creates a guestbook for the given collection. + * + * @param {CreateGuestbookDTO} guestbook - Guestbook creation payload. + * @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias). + * @returns {Promise} + */ + async execute(guestbook: CreateGuestbookDTO, collectionIdOrAlias: number | string) { + return await this.guestbooksRepository.createGuestbook(collectionIdOrAlias, guestbook) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbook.ts b/src/guestbooks/domain/useCases/GetGuestbook.ts new file mode 100644 index 00000000..7ef85940 --- /dev/null +++ b/src/guestbooks/domain/useCases/GetGuestbook.ts @@ -0,0 +1,17 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' +import { Guestbook } from '../models/Guestbook' + +export class GetGuestbook implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Returns a guestbook by id. + * + * @param {number} guestbookId - Guestbook identifier. + * @returns {Promise} + */ + async execute(guestbookId: number): Promise { + return await this.guestbooksRepository.getGuestbook(guestbookId) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts new file mode 100644 index 00000000..b60f1ccc --- /dev/null +++ b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts @@ -0,0 +1,17 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' +import { Guestbook } from '../models/Guestbook' + +export class GetGuestbooksBycollectionId implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Returns all guestbooks available for a given collection. + * + * @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias). + * @returns {Promise} + */ + async execute(collectionIdOrAlias: number | string): Promise { + return await this.guestbooksRepository.getGuestbooksBycollectionId(collectionIdOrAlias) + } +} diff --git a/src/guestbooks/domain/useCases/SetGuestbookEnabled.ts b/src/guestbooks/domain/useCases/SetGuestbookEnabled.ts new file mode 100644 index 00000000..85e55138 --- /dev/null +++ b/src/guestbooks/domain/useCases/SetGuestbookEnabled.ts @@ -0,0 +1,26 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class SetGuestbookEnabled implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Enables or disables a guestbook in a collection. + * + * @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias). + * @param {number} guestbookId - Guestbook identifier. + * @param {boolean} enabled - Desired enabled state. + * @returns {Promise} + */ + async execute( + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean + ): Promise { + return await this.guestbooksRepository.setGuestbookEnabled( + collectionIdOrAlias, + guestbookId, + enabled + ) + } +} diff --git a/src/guestbooks/index.ts b/src/guestbooks/index.ts new file mode 100644 index 00000000..208e912b --- /dev/null +++ b/src/guestbooks/index.ts @@ -0,0 +1,21 @@ +import { GuestbooksRepository } from './infra/repositories/GuestbooksRepository' +import { CreateGuestbook } from './domain/useCases/CreateGuestbook' +import { GetGuestbook } from './domain/useCases/GetGuestbook' +import { GetGuestbooksBycollectionId } from './domain/useCases/GetGuestbooksByCollectionId' +import { SetGuestbookEnabled } from './domain/useCases/SetGuestbookEnabled' + +const guestbooksRepository = new GuestbooksRepository() + +const createGuestbook = new CreateGuestbook(guestbooksRepository) +const getGuestbook = new GetGuestbook(guestbooksRepository) +const getGuestbooksBycollectionId = new GetGuestbooksBycollectionId(guestbooksRepository) +const setGuestbookEnabled = new SetGuestbookEnabled(guestbooksRepository) + +export { createGuestbook, getGuestbook, getGuestbooksBycollectionId, setGuestbookEnabled } + +export { + CreateGuestbookDTO, + CreateGuestbookCustomQuestionDTO, + CreateGuestbookOptionDTO +} from './domain/dtos/CreateGuestbookDTO' +export { Guestbook, GuestbookCustomQuestion, GuestbookOption } from './domain/models/Guestbook' diff --git a/src/guestbooks/infra/repositories/GuestbooksRepository.ts b/src/guestbooks/infra/repositories/GuestbooksRepository.ts new file mode 100644 index 00000000..55fc0de4 --- /dev/null +++ b/src/guestbooks/infra/repositories/GuestbooksRepository.ts @@ -0,0 +1,63 @@ +import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { CreateGuestbookDTO } from '../../domain/dtos/CreateGuestbookDTO' +import { Guestbook } from '../../domain/models/Guestbook' +import { IGuestbooksRepository } from '../../domain/repositories/IGuestbooksRepository' + +export class GuestbooksRepository extends ApiRepository implements IGuestbooksRepository { + private readonly guestbooksResourceName: string = 'guestbooks' + + public async createGuestbook( + collectionIdOrAlias: number | string, + guestbook: CreateGuestbookDTO + ): Promise { + return this.doPost( + this.buildApiEndpoint(this.guestbooksResourceName, undefined, collectionIdOrAlias), + guestbook + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + + public async getGuestbook(guestbookId: number): Promise { + return this.doGet( + this.buildApiEndpoint(this.guestbooksResourceName, undefined, guestbookId), + true + ) + .then((response) => response.data.data as Guestbook) + .catch((error) => { + throw error + }) + } + + public async getGuestbooksBycollectionId( + collectionIdOrAlias: number | string + ): Promise { + return this.doGet( + this.buildApiEndpoint(this.guestbooksResourceName, 'list', collectionIdOrAlias), + true + ) + .then((response) => response.data.data as Guestbook[]) + .catch((error) => { + throw error + }) + } + + public async setGuestbookEnabled( + collectionIdOrAlias: number | string, + guestbookId: number, + enabled: boolean + ): Promise { + const endpoint = this.buildApiEndpoint( + this.guestbooksResourceName, + `${guestbookId}/enabled`, + collectionIdOrAlias + ) + return this.doPut(endpoint, enabled) + .then(() => undefined) + .catch((error) => { + throw error + }) + } +} diff --git a/src/index.ts b/src/index.ts index 578f1924..efe54b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,5 @@ export * from './search' export * from './licenses' export * from './externalTools' export * from './templates' +export * from './guestbooks' +export * from './access' diff --git a/test/integration/access/AccessRepository.test.ts b/test/integration/access/AccessRepository.test.ts new file mode 100644 index 00000000..b355c7c9 --- /dev/null +++ b/test/integration/access/AccessRepository.test.ts @@ -0,0 +1,125 @@ +import { AccessRepository } from '../../../src/access/infra/repositories/AccessRepository' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' +import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO' +import { + CreatedDatasetIdentifiers, + createDataset, + DatasetNotNumberedVersion, + WriteError +} from '../../../src' +import { uploadFileViaApi, testTextFile1Name } from '../../testHelpers/files/filesHelper' +import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' +import { FileOrderCriteria } from '../../../src/files/domain/models/FileCriteria' +import { deletePublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper' + +describe('AccessRepository', () => { + const sut: AccessRepository = new AccessRepository() + const filesRepository: FilesRepository = new FilesRepository() + let testDatasetIds: CreatedDatasetIdentifiers + let testFileId: number + + const guestbookResponse: GuestbookResponseDTO = { + guestbookResponse: { + answers: [{ id: 1, value: 'question 1' }] + } + } + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + + try { + testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name) + const filesSubset = await filesRepository.getDatasetFiles( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + testFileId = filesSubset.files[0].id + } catch (error) { + throw new Error('Tests beforeAll(): Error while setting up access integration test data.') + } + }) + + afterAll(async () => { + try { + await deletePublishedDatasetViaApi(testDatasetIds.persistentId) + } catch (error) { + throw new Error('Tests afterAll(): Error while cleaning up access integration test data.') + } + }) + + describe('submitGuestbookForDatafileDownload', () => { + test('should return signed url for datafile download', async () => { + const actual = await sut.submitGuestbookForDatafileDownload(testFileId, guestbookResponse) + + expect(actual).toEqual(expect.any(String)) + }) + + test('should return error when datafile does not exist', async () => { + const nonExistentId = 999999999 + await expect( + sut.submitGuestbookForDatafileDownload(nonExistentId, guestbookResponse) + ).rejects.toThrow(WriteError) + }) + }) + + describe('submitGuestbookForDatafilesDownload', () => { + test('should return signed url for datafiles download', async () => { + const actual = await sut.submitGuestbookForDatafilesDownload([testFileId], guestbookResponse) + + expect(actual).toEqual(expect.any(String)) + expect(actual.length).toBeGreaterThan(0) + }) + }) + + describe('submitGuestbookForDatasetDownload', () => { + test('should return signed url for dataset download', async () => { + const actual = await sut.submitGuestbookForDatasetDownload( + testDatasetIds.numericId, + guestbookResponse + ) + + expect(actual).toEqual(expect.any(String)) + }) + + test('should return error when dataset does not exist', async () => { + const nonExistentId = 999999999 + await expect( + sut.submitGuestbookForDatasetDownload(nonExistentId, guestbookResponse) + ).rejects.toThrow(WriteError) + }) + }) + + describe('submitGuestbookForDatasetVersionDownload', () => { + test('should return signed url for dataset version download', async () => { + const actual = await sut.submitGuestbookForDatasetVersionDownload( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + guestbookResponse + ) + + expect(actual).toEqual(expect.any(String)) + }) + + test('should return error when dataset version does not exist', async () => { + const nonExistentId = 999999999 + await expect( + sut.submitGuestbookForDatasetVersionDownload( + nonExistentId, + DatasetNotNumberedVersion.LATEST, + guestbookResponse + ) + ).rejects.toThrow(WriteError) + }) + }) +}) diff --git a/test/integration/guestbooks/GuestbooksRepository.test.ts b/test/integration/guestbooks/GuestbooksRepository.test.ts new file mode 100644 index 00000000..55ce4ef3 --- /dev/null +++ b/test/integration/guestbooks/GuestbooksRepository.test.ts @@ -0,0 +1,146 @@ +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { ApiConfig, ReadError, WriteError } from '../../../src' +import { GuestbooksRepository } from '../../../src/guestbooks/infra/repositories/GuestbooksRepository' +import { CreateGuestbookDTO } from '../../../src/guestbooks/domain/dtos/CreateGuestbookDTO' +import { TestConstants } from '../../testHelpers/TestConstants' +import { + createCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { CollectionPayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload' + +describe('GuestbooksRepository', () => { + const sut = new GuestbooksRepository() + const testCollectionAlias = 'testGuestbooksRepository' + let testCollectionId: number + let createdGuestbookId: number + + const createGuestbookDTO: CreateGuestbookDTO = { + name: 'my test guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: "how's your day", + required: true, + displayOrder: 0, + type: 'text', + hidden: false + }, + { + question: 'Describe yourself', + required: false, + displayOrder: 1, + type: 'textarea', + hidden: false + }, + { + question: 'What color car do you drive', + required: true, + displayOrder: 2, + type: 'options', + hidden: false, + optionValues: [ + { value: 'Red', displayOrder: 0 }, + { value: 'White', displayOrder: 1 }, + { value: 'Yellow', displayOrder: 2 }, + { value: 'Purple', displayOrder: 3 } + ] + } + ] + } + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + + await createCollectionViaApi(testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (testCollectionId = collectionPayload.id) + ) + }) + + afterAll(async () => { + await deleteCollectionViaApi(testCollectionAlias) + }) + + describe('createGuestbook', () => { + test('should create guestbook for collection', async () => { + const actual = await sut.createGuestbook(testCollectionId, createGuestbookDTO) + expect(actual).toBeUndefined() + }) + + test('should create guestbook for collection by collection alias', async () => { + const actual = await sut.createGuestbook(testCollectionAlias, createGuestbookDTO) + expect(actual).toBeUndefined() + }) + + test('should return error when collection does not exist', async () => { + await expect(sut.createGuestbook(999999, createGuestbookDTO)).rejects.toThrow(WriteError) + }) + }) + + describe('getGuestbooksBycollectionId', () => { + test('should list guestbooks for collection', async () => { + await sut.createGuestbook(testCollectionId, createGuestbookDTO) + const actual = await sut.getGuestbooksBycollectionId(testCollectionId) + console.log('actual guestbooks: ', actual) + expect(actual.length).toBeGreaterThan(0) + createdGuestbookId = actual[0].id as number + }) + + test('should list guestbooks for collection by collection alias', async () => { + await sut.createGuestbook(testCollectionAlias, createGuestbookDTO) + const actual = await sut.getGuestbooksBycollectionId(testCollectionAlias) + console.log('actual guestbooks: ', actual) + expect(actual.length).toBeGreaterThan(0) + }) + + test('should return error when collection does not exist', async () => { + await expect(sut.getGuestbooksBycollectionId(999999)).rejects.toThrow(ReadError) + }) + }) + + describe('getGuestbook', () => { + test('should get guestbook by id', async () => { + await sut.createGuestbook(testCollectionId, createGuestbookDTO) + const actual = await sut.getGuestbook(createdGuestbookId as number) + console.log('getGuestbook guestbooks: ', actual) + expect(actual.id).toBe(createdGuestbookId) + expect(actual.name).toBe(createGuestbookDTO.name) + }) + + test('should return error when guestbook does not exist', async () => { + await expect(sut.getGuestbook(999999)).rejects.toThrow(ReadError) + }) + }) + + describe('setGuestbookEnabled', () => { + test('should disable guestbook', async () => { + await sut.createGuestbook(testCollectionId, createGuestbookDTO) + + await sut.setGuestbookEnabled(testCollectionId, createdGuestbookId as number, false) + const actual = await sut.getGuestbook(createdGuestbookId as number) + + expect(actual.enabled).toBe(false) + }) + + test('should enable guestbook', async () => { + await sut.setGuestbookEnabled(testCollectionId, createdGuestbookId as number, true) + const actual = await sut.getGuestbook(createdGuestbookId as number) + + expect(actual.enabled).toBe(true) + }) + + test('should return error when guestbook does not exist', async () => { + await expect(sut.setGuestbookEnabled(testCollectionId, 999999, false)).rejects.toThrow( + WriteError + ) + }) + }) +}) diff --git a/test/unit/access/SubmitGuestbookDownloads.test.ts b/test/unit/access/SubmitGuestbookDownloads.test.ts new file mode 100644 index 00000000..a40aef3a --- /dev/null +++ b/test/unit/access/SubmitGuestbookDownloads.test.ts @@ -0,0 +1,70 @@ +import { WriteError } from '../../../src' +import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO' +import { IAccessRepository } from '../../../src/access/domain/repositories/IAccessRepository' +import { SubmitGuestbookForDatafileDownload } from '../../../src/access/domain/useCases/SubmitGuestbookForDatafileDownload' +import { SubmitGuestbookForDatafilesDownload } from '../../../src/access/domain/useCases/SubmitGuestbookForDatafilesDownload' +import { SubmitGuestbookForDatasetDownload } from '../../../src/access/domain/useCases/SubmitGuestbookForDatasetDownload' +import { SubmitGuestbookForDatasetVersionDownload } from '../../../src/access/domain/useCases/SubmitGuestbookForDatasetVersionDownload' + +describe('guestbook download use cases', () => { + const guestbookResponse: GuestbookResponseDTO = { + guestbookResponse: { + answers: [{ id: 1, value: 'question 1' }] + } + } + + test('should submit datafile download and return signed url', async () => { + const repository: IAccessRepository = {} as IAccessRepository + repository.submitGuestbookForDatafileDownload = jest + .fn() + .mockResolvedValue('https://signed.datafile') + const sut = new SubmitGuestbookForDatafileDownload(repository) + + const actual = await sut.execute(1, guestbookResponse) + + expect(repository.submitGuestbookForDatafileDownload).toHaveBeenCalledWith(1, guestbookResponse) + expect(actual).toEqual('https://signed.datafile') + }) + + test('should submit datafiles download and return signed url', async () => { + const repository: IAccessRepository = {} as IAccessRepository + repository.submitGuestbookForDatafilesDownload = jest + .fn() + .mockResolvedValue('https://signed.datafiles') + const sut = new SubmitGuestbookForDatafilesDownload(repository) + + const actual = await sut.execute([1, 2], guestbookResponse) + + expect(repository.submitGuestbookForDatafilesDownload).toHaveBeenCalledWith( + [1, 2], + guestbookResponse + ) + expect(actual).toEqual('https://signed.datafiles') + }) + + test('should submit dataset download and return signed url', async () => { + const repository: IAccessRepository = {} as IAccessRepository + repository.submitGuestbookForDatasetDownload = jest + .fn() + .mockResolvedValue('https://signed.dataset') + const sut = new SubmitGuestbookForDatasetDownload(repository) + + const actual = await sut.execute('doi:10.5072/FK2/TEST', guestbookResponse) + + expect(repository.submitGuestbookForDatasetDownload).toHaveBeenCalledWith( + 'doi:10.5072/FK2/TEST', + guestbookResponse + ) + expect(actual).toEqual('https://signed.dataset') + }) + + test('should throw WriteError when dataset version download fails', async () => { + const repository: IAccessRepository = {} as IAccessRepository + repository.submitGuestbookForDatasetVersionDownload = jest + .fn() + .mockRejectedValue(new WriteError()) + const sut = new SubmitGuestbookForDatasetVersionDownload(repository) + + await expect(sut.execute(10, '2.0', guestbookResponse)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/guestbooks/CreateGuestbook.test.ts b/test/unit/guestbooks/CreateGuestbook.test.ts new file mode 100644 index 00000000..ee1e4daf --- /dev/null +++ b/test/unit/guestbooks/CreateGuestbook.test.ts @@ -0,0 +1,64 @@ +import { WriteError } from '../../../src' +import { CreateGuestbookDTO } from '../../../src/guestbooks/domain/dtos/CreateGuestbookDTO' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { CreateGuestbook } from '../../../src/guestbooks/domain/useCases/CreateGuestbook' + +describe('CreateGuestbook', () => { + const createGuestbookDTO: CreateGuestbookDTO = { + name: 'my test guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: "how's your day", + required: true, + displayOrder: 0, + type: 'text', + hidden: false + }, + { + question: 'Describe yourself', + required: false, + displayOrder: 1, + type: 'textarea', + hidden: false + }, + { + question: 'What color car do you drive', + required: true, + displayOrder: 2, + type: 'options', + hidden: false, + optionValues: [ + { value: 'Red', displayOrder: 0 }, + { value: 'White', displayOrder: 1 }, + { value: 'Yellow', displayOrder: 2 }, + { value: 'Purple', displayOrder: 3 } + ] + } + ] + } + const collectionId = 'testCollection' + + test('should create guestbook for collection', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.createGuestbook = jest.fn().mockResolvedValue(undefined) + + const sut = new CreateGuestbook(repository) + const actual = await sut.execute(createGuestbookDTO, collectionId) + + expect(repository.createGuestbook).toHaveBeenCalledWith(collectionId, createGuestbookDTO) + expect(actual).toBeUndefined() + }) + + test('should throw WriteError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.createGuestbook = jest.fn().mockRejectedValue(new WriteError()) + const sut = new CreateGuestbook(repository) + + await expect(sut.execute(createGuestbookDTO, collectionId)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbook.test.ts b/test/unit/guestbooks/GetGuestbook.test.ts new file mode 100644 index 00000000..4b56c834 --- /dev/null +++ b/test/unit/guestbooks/GetGuestbook.test.ts @@ -0,0 +1,38 @@ +import { ReadError } from '../../../src' +import { Guestbook } from '../../../src/guestbooks/domain/models/Guestbook' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { GetGuestbook } from '../../../src/guestbooks/domain/useCases/GetGuestbook' + +describe('execute', () => { + const guestbook: Guestbook = { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 34 + } + + test('should return guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbook = jest.fn().mockResolvedValue(guestbook) + + const sut = new GetGuestbook(repository) + const actual = await sut.execute(12) + + expect(repository.getGuestbook).toHaveBeenCalledWith(12) + expect(actual).toEqual(guestbook) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbook = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetGuestbook(repository) + + await expect(sut.execute(111111)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts b/test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts new file mode 100644 index 00000000..f4abd706 --- /dev/null +++ b/test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts @@ -0,0 +1,41 @@ +import { ReadError } from '../../../src' +import { Guestbook } from '../../../src/guestbooks/domain/models/Guestbook' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { GetGuestbooksBycollectionId } from '../../../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId' + +describe('GetGuestbooksBycollectionId', () => { + const guestbooks: Guestbook[] = [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10 + } + ] + const collectionId = 'collectionAlias' + + test('should return guestbooks for collection', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksBycollectionId = jest.fn().mockResolvedValue(guestbooks) + + const sut = new GetGuestbooksBycollectionId(repository) + const actual = await sut.execute(collectionId) + + expect(repository.getGuestbooksBycollectionId).toHaveBeenCalledWith(collectionId) + expect(actual).toEqual(guestbooks) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksBycollectionId = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetGuestbooksBycollectionId(repository) + + await expect(sut.execute(collectionId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/SetGuestbookEnabled.test.ts b/test/unit/guestbooks/SetGuestbookEnabled.test.ts new file mode 100644 index 00000000..8bfae9ac --- /dev/null +++ b/test/unit/guestbooks/SetGuestbookEnabled.test.ts @@ -0,0 +1,24 @@ +import { WriteError } from '../../../src' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { SetGuestbookEnabled } from '../../../src/guestbooks/domain/useCases/SetGuestbookEnabled' + +describe('execute', () => { + test('should set enabled status', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.setGuestbookEnabled = jest.fn().mockResolvedValue(undefined) + const sut = new SetGuestbookEnabled(repository) + + const actual = await sut.execute('collectionAlias', 12, false) + + expect(repository.setGuestbookEnabled).toHaveBeenCalledWith('collectionAlias', 12, false) + expect(actual).toBeUndefined() + }) + + test('should throw WriteError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.setGuestbookEnabled = jest.fn().mockRejectedValue(new WriteError()) + const sut = new SetGuestbookEnabled(repository) + + await expect(sut.execute('collectionAlias', 999, true)).rejects.toThrow(WriteError) + }) +}) From 1f61ab14f81cb6c0df46f260324cacec62af311a Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 19 Feb 2026 15:14:12 -0500 Subject: [PATCH 2/5] use other image tag for test --- test/environment/.env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/environment/.env b/test/environment/.env index e7b54bde..a1b87c15 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=12001-api-support-termofuse-guestbook DATAVERSE_BOOTSTRAP_TIMEOUT=5m From 812dd229e14b7f399b30983759d92ef87c69af62 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 19 Feb 2026 15:26:50 -0500 Subject: [PATCH 3/5] fix: test failing --- src/guestbooks/infra/repositories/GuestbooksRepository.ts | 7 +++---- test/integration/guestbooks/GuestbooksRepository.test.ts | 3 --- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/guestbooks/infra/repositories/GuestbooksRepository.ts b/src/guestbooks/infra/repositories/GuestbooksRepository.ts index 55fc0de4..b22d0a79 100644 --- a/src/guestbooks/infra/repositories/GuestbooksRepository.ts +++ b/src/guestbooks/infra/repositories/GuestbooksRepository.ts @@ -11,7 +11,7 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe guestbook: CreateGuestbookDTO ): Promise { return this.doPost( - this.buildApiEndpoint(this.guestbooksResourceName, undefined, collectionIdOrAlias), + this.buildApiEndpoint(this.guestbooksResourceName, `${collectionIdOrAlias}`), guestbook ) .then(() => undefined) @@ -35,7 +35,7 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe collectionIdOrAlias: number | string ): Promise { return this.doGet( - this.buildApiEndpoint(this.guestbooksResourceName, 'list', collectionIdOrAlias), + this.buildApiEndpoint(this.guestbooksResourceName, `${collectionIdOrAlias}/list`), true ) .then((response) => response.data.data as Guestbook[]) @@ -51,8 +51,7 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe ): Promise { const endpoint = this.buildApiEndpoint( this.guestbooksResourceName, - `${guestbookId}/enabled`, - collectionIdOrAlias + `${collectionIdOrAlias}/${guestbookId}/enabled` ) return this.doPut(endpoint, enabled) .then(() => undefined) diff --git a/test/integration/guestbooks/GuestbooksRepository.test.ts b/test/integration/guestbooks/GuestbooksRepository.test.ts index 55ce4ef3..5258f170 100644 --- a/test/integration/guestbooks/GuestbooksRepository.test.ts +++ b/test/integration/guestbooks/GuestbooksRepository.test.ts @@ -89,7 +89,6 @@ describe('GuestbooksRepository', () => { test('should list guestbooks for collection', async () => { await sut.createGuestbook(testCollectionId, createGuestbookDTO) const actual = await sut.getGuestbooksBycollectionId(testCollectionId) - console.log('actual guestbooks: ', actual) expect(actual.length).toBeGreaterThan(0) createdGuestbookId = actual[0].id as number }) @@ -97,7 +96,6 @@ describe('GuestbooksRepository', () => { test('should list guestbooks for collection by collection alias', async () => { await sut.createGuestbook(testCollectionAlias, createGuestbookDTO) const actual = await sut.getGuestbooksBycollectionId(testCollectionAlias) - console.log('actual guestbooks: ', actual) expect(actual.length).toBeGreaterThan(0) }) @@ -110,7 +108,6 @@ describe('GuestbooksRepository', () => { test('should get guestbook by id', async () => { await sut.createGuestbook(testCollectionId, createGuestbookDTO) const actual = await sut.getGuestbook(createdGuestbookId as number) - console.log('getGuestbook guestbooks: ', actual) expect(actual.id).toBe(createdGuestbookId) expect(actual.name).toBe(createGuestbookDTO.name) }) From c3f76f8cb3c5cf67a91372ba3b246b01684eab22 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 19 Feb 2026 15:46:27 -0500 Subject: [PATCH 4/5] fix: copilot reviews --- CHANGELOG.md | 2 +- docs/useCases.md | 24 ++++++++++++++++--- .../useCases/GetGuestbooksByCollectionId.ts | 2 +- src/guestbooks/index.ts | 4 ++-- ...ts => GetGuestbooksByCollectionId.test.ts} | 8 +++---- 5 files changed, 29 insertions(+), 11 deletions(-) rename test/unit/guestbooks/{GetGuestbooksByDataverseIdentifier.test.ts => GetGuestbooksByCollectionId.test.ts} (85%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 092fa810..b699a0d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - New Use Case: [Get a Template](./docs/useCases.md#get-a-template) under Templates. - New Use Case: [Delete a Template](./docs/useCases.md#delete-a-template) under Templates. - New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access). -- Guestbooks: Added use cases and repository support for Guestbook CRUD. +- Guestbooks: Added use cases and repository support for guestbook creation, listing, and enabling/disabling. - Access: Added a dedicated `access` module for guestbook-at-request and download terms/guestbook submission endpoints. ### Changed diff --git a/docs/useCases.md b/docs/useCases.md index 944d6ea4..5e908b92 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -2917,7 +2917,13 @@ import { submitGuestbookForDatafilesDownload } from '@iqss/dataverse-client-java submitGuestbookForDatafilesDownload .execute([10, 11], { - guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + guestbookResponse: { + answers: [ + { id: 123, value: 'Good' }, + { id: 124, value: ['Multi', 'Line'] }, + { id: 125, value: 'Yellow' } + ] + } }) .then((signedUrl: string) => { /* ... */ @@ -2937,7 +2943,13 @@ import { submitGuestbookForDatasetDownload } from '@iqss/dataverse-client-javasc submitGuestbookForDatasetDownload .execute('doi:10.5072/FK2/XXXXXX', { - guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + guestbookResponse: { + answers: [ + { id: 123, value: 'Good' }, + { id: 124, value: ['Multi', 'Line'] }, + { id: 125, value: 'Yellow' } + ] + } }) .then((signedUrl: string) => { /* ... */ @@ -2957,7 +2969,13 @@ import { submitGuestbookForDatasetVersionDownload } from '@iqss/dataverse-client submitGuestbookForDatasetVersionDownload .execute(10, ':latest', { - guestbookResponse: { answers: [{ id: 123, value: 'Good' }] } + guestbookResponse: { + answers: [ + { id: 123, value: 'Good' }, + { id: 124, value: ['Multi', 'Line'] }, + { id: 125, value: 'Yellow' } + ] + } }) .then((signedUrl: string) => { /* ... */ diff --git a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts index b60f1ccc..d43b9b2c 100644 --- a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts +++ b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts @@ -2,7 +2,7 @@ import { UseCase } from '../../../core/domain/useCases/UseCase' import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' import { Guestbook } from '../models/Guestbook' -export class GetGuestbooksBycollectionId implements UseCase { +export class GetGuestbooksByCollectionId implements UseCase { constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} /** diff --git a/src/guestbooks/index.ts b/src/guestbooks/index.ts index 208e912b..524aeb11 100644 --- a/src/guestbooks/index.ts +++ b/src/guestbooks/index.ts @@ -1,14 +1,14 @@ import { GuestbooksRepository } from './infra/repositories/GuestbooksRepository' import { CreateGuestbook } from './domain/useCases/CreateGuestbook' import { GetGuestbook } from './domain/useCases/GetGuestbook' -import { GetGuestbooksBycollectionId } from './domain/useCases/GetGuestbooksByCollectionId' +import { GetGuestbooksByCollectionId } from './domain/useCases/GetGuestbooksByCollectionId' import { SetGuestbookEnabled } from './domain/useCases/SetGuestbookEnabled' const guestbooksRepository = new GuestbooksRepository() const createGuestbook = new CreateGuestbook(guestbooksRepository) const getGuestbook = new GetGuestbook(guestbooksRepository) -const getGuestbooksBycollectionId = new GetGuestbooksBycollectionId(guestbooksRepository) +const getGuestbooksBycollectionId = new GetGuestbooksByCollectionId(guestbooksRepository) const setGuestbookEnabled = new SetGuestbookEnabled(guestbooksRepository) export { createGuestbook, getGuestbook, getGuestbooksBycollectionId, setGuestbookEnabled } diff --git a/test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts similarity index 85% rename from test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts rename to test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts index f4abd706..bf3d4899 100644 --- a/test/unit/guestbooks/GetGuestbooksByDataverseIdentifier.test.ts +++ b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts @@ -1,9 +1,9 @@ import { ReadError } from '../../../src' import { Guestbook } from '../../../src/guestbooks/domain/models/Guestbook' import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' -import { GetGuestbooksBycollectionId } from '../../../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId' +import { GetGuestbooksByCollectionId } from '../../../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId' -describe('GetGuestbooksBycollectionId', () => { +describe('GetGuestbooksByCollectionId', () => { const guestbooks: Guestbook[] = [ { id: 12, @@ -24,7 +24,7 @@ describe('GetGuestbooksBycollectionId', () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository repository.getGuestbooksBycollectionId = jest.fn().mockResolvedValue(guestbooks) - const sut = new GetGuestbooksBycollectionId(repository) + const sut = new GetGuestbooksByCollectionId(repository) const actual = await sut.execute(collectionId) expect(repository.getGuestbooksBycollectionId).toHaveBeenCalledWith(collectionId) @@ -34,7 +34,7 @@ describe('GetGuestbooksBycollectionId', () => { test('should throw ReadError when repository fails', async () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository repository.getGuestbooksBycollectionId = jest.fn().mockRejectedValue(new ReadError()) - const sut = new GetGuestbooksBycollectionId(repository) + const sut = new GetGuestbooksByCollectionId(repository) await expect(sut.execute(collectionId)).rejects.toThrow(ReadError) }) From bba1993fbce6095b20884622215b08735a6ac2ac Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 19 Feb 2026 15:54:30 -0500 Subject: [PATCH 5/5] fix: update collectionId to Uppercase --- docs/useCases.md | 4 ++-- .../domain/repositories/IGuestbooksRepository.ts | 2 +- .../domain/useCases/GetGuestbooksByCollectionId.ts | 2 +- src/guestbooks/index.ts | 4 ++-- src/guestbooks/infra/repositories/GuestbooksRepository.ts | 2 +- test/integration/guestbooks/GuestbooksRepository.test.ts | 8 ++++---- test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/useCases.md b/docs/useCases.md index 5e908b92..cc335522 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -2810,11 +2810,11 @@ Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries av ##### Example call: ```typescript -import { getGuestbooksBycollectionId } from '@iqss/dataverse-client-javascript' +import { getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' const collectionIdOrAlias = 'root' -getGuestbooksBycollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => { +getGuestbooksByCollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => { /* ... */ }) ``` diff --git a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts index 27a5beb6..c5c4869d 100644 --- a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts +++ b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts @@ -7,7 +7,7 @@ export interface IGuestbooksRepository { guestbook: CreateGuestbookDTO ): Promise getGuestbook(guestbookId: number): Promise - getGuestbooksBycollectionId(collectionIdOrAlias: number | string): Promise + getGuestbooksByCollectionId(collectionIdOrAlias: number | string): Promise setGuestbookEnabled( collectionIdOrAlias: number | string, guestbookId: number, diff --git a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts index d43b9b2c..003bdb07 100644 --- a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts +++ b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts @@ -12,6 +12,6 @@ export class GetGuestbooksByCollectionId implements UseCase { * @returns {Promise} */ async execute(collectionIdOrAlias: number | string): Promise { - return await this.guestbooksRepository.getGuestbooksBycollectionId(collectionIdOrAlias) + return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias) } } diff --git a/src/guestbooks/index.ts b/src/guestbooks/index.ts index 524aeb11..e40b9199 100644 --- a/src/guestbooks/index.ts +++ b/src/guestbooks/index.ts @@ -8,10 +8,10 @@ const guestbooksRepository = new GuestbooksRepository() const createGuestbook = new CreateGuestbook(guestbooksRepository) const getGuestbook = new GetGuestbook(guestbooksRepository) -const getGuestbooksBycollectionId = new GetGuestbooksByCollectionId(guestbooksRepository) +const getGuestbooksByCollectionId = new GetGuestbooksByCollectionId(guestbooksRepository) const setGuestbookEnabled = new SetGuestbookEnabled(guestbooksRepository) -export { createGuestbook, getGuestbook, getGuestbooksBycollectionId, setGuestbookEnabled } +export { createGuestbook, getGuestbook, getGuestbooksByCollectionId, setGuestbookEnabled } export { CreateGuestbookDTO, diff --git a/src/guestbooks/infra/repositories/GuestbooksRepository.ts b/src/guestbooks/infra/repositories/GuestbooksRepository.ts index b22d0a79..f96fae5d 100644 --- a/src/guestbooks/infra/repositories/GuestbooksRepository.ts +++ b/src/guestbooks/infra/repositories/GuestbooksRepository.ts @@ -31,7 +31,7 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe }) } - public async getGuestbooksBycollectionId( + public async getGuestbooksByCollectionId( collectionIdOrAlias: number | string ): Promise { return this.doGet( diff --git a/test/integration/guestbooks/GuestbooksRepository.test.ts b/test/integration/guestbooks/GuestbooksRepository.test.ts index 5258f170..c0965de7 100644 --- a/test/integration/guestbooks/GuestbooksRepository.test.ts +++ b/test/integration/guestbooks/GuestbooksRepository.test.ts @@ -85,22 +85,22 @@ describe('GuestbooksRepository', () => { }) }) - describe('getGuestbooksBycollectionId', () => { + describe('getGuestbooksByCollectionId', () => { test('should list guestbooks for collection', async () => { await sut.createGuestbook(testCollectionId, createGuestbookDTO) - const actual = await sut.getGuestbooksBycollectionId(testCollectionId) + const actual = await sut.getGuestbooksByCollectionId(testCollectionId) expect(actual.length).toBeGreaterThan(0) createdGuestbookId = actual[0].id as number }) test('should list guestbooks for collection by collection alias', async () => { await sut.createGuestbook(testCollectionAlias, createGuestbookDTO) - const actual = await sut.getGuestbooksBycollectionId(testCollectionAlias) + const actual = await sut.getGuestbooksByCollectionId(testCollectionAlias) expect(actual.length).toBeGreaterThan(0) }) test('should return error when collection does not exist', async () => { - await expect(sut.getGuestbooksBycollectionId(999999)).rejects.toThrow(ReadError) + await expect(sut.getGuestbooksByCollectionId(999999)).rejects.toThrow(ReadError) }) }) diff --git a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts index bf3d4899..527e7b7f 100644 --- a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts +++ b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts @@ -22,18 +22,18 @@ describe('GetGuestbooksByCollectionId', () => { test('should return guestbooks for collection', async () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository - repository.getGuestbooksBycollectionId = jest.fn().mockResolvedValue(guestbooks) + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooks) const sut = new GetGuestbooksByCollectionId(repository) const actual = await sut.execute(collectionId) - expect(repository.getGuestbooksBycollectionId).toHaveBeenCalledWith(collectionId) + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId) expect(actual).toEqual(guestbooks) }) test('should throw ReadError when repository fails', async () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository - repository.getGuestbooksBycollectionId = jest.fn().mockRejectedValue(new ReadError()) + repository.getGuestbooksByCollectionId = jest.fn().mockRejectedValue(new ReadError()) const sut = new GetGuestbooksByCollectionId(repository) await expect(sut.execute(collectionId)).rejects.toThrow(ReadError)