diff --git a/CHANGELOG.md b/CHANGELOG.md index 98944e2b..b161347c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Guestbooks: Added `getGuestbookResponsesByGuestbookId` use case and repository support for retrieving paginated guestbook responses with total count as structured JSON. +- Guestbooks: Added `downloadGuestbookResponsesByCollectionId` and `downloadGuestbookResponsesOfAGuestbook` use cases and repository support for exporting guestbook responses as raw CSV content. +- Guestbooks: Added optional `includeStats` support to `getGuestbooksByCollectionId`, returning `usageCount` and `responseCount` when requested. - Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats. - Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. - Collections: Added theme information when retrieving a collection using `getCollection`. diff --git a/docs/useCases.md b/docs/useCases.md index 1ce33145..5433e00a 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -138,6 +138,9 @@ The different use cases currently available in the package are classified below, - [Guestbooks read use cases](#guestbooks-read-use-cases) - [Get a Guestbook](#get-a-guestbook) - [Get Guestbooks By Collection Id](#get-guestbooks-by-collection-id) + - [Get Guestbook Responses By Guestbook Id](#get-guestbook-responses-by-guestbook-id) + - [Download Guestbook Responses By Collection Id](#download-guestbook-responses-by-collection-id) + - [Download Guestbook Responses Of A Guestbook](#download-guestbook-responses-of-a-guestbook) - [Guestbooks write use cases](#guestbooks-write-use-cases) - [Create a Guestbook](#create-a-guestbook) - [Set Guestbook Enabled](#set-guestbook-enabled) @@ -3168,6 +3171,7 @@ _See [use case](../src/guestbooks/domain/useCases/GetGuestbook.ts) implementatio #### Get Guestbooks By Collection Id Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries available for a collection. +Set `includeStats` to `true` to include `usageCount` and `responseCount` for each guestbook. ##### Example call: @@ -3175,14 +3179,80 @@ Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries av import { getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' const collectionIdOrAlias = 'root' +const includeStats = true -getGuestbooksByCollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => { - /* ... */ -}) +getGuestbooksByCollectionId + .execute(collectionIdOrAlias, includeStats) + .then((guestbooks: Guestbook[]) => { + /* ... */ + }) ``` _See [use case](../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts) implementation_. +#### Get Guestbook Responses By Guestbook Id + +Returns a [GuestbookResponseSubset](../src/guestbooks/domain/models/GuestbookResponse.ts) containing paginated guestbook response entries and the total response count for a guestbook. + +##### Example call: + +```typescript +import { getGuestbookResponsesByGuestbookId } from '@iqss/dataverse-client-javascript' + +const guestbookId = 123 +const limit = 10 +const offset = 0 + +getGuestbookResponsesByGuestbookId + .execute(guestbookId, limit, offset) + .then((guestbookResponseSubset: GuestbookResponseSubset) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts) implementation_. + +#### Download Guestbook Responses By Collection Id + +Downloads all guestbook responses for a collection and returns the raw response body, typically CSV content. + +##### Example call: + +```typescript +import { downloadGuestbookResponsesByCollectionId } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' + +downloadGuestbookResponsesByCollectionId + .execute(collectionIdOrAlias) + .then((csvResponse: string) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts) implementation_. + +#### Download Guestbook Responses Of A Guestbook + +Downloads guestbook responses for one guestbook in a collection and returns the raw response body, typically CSV content. + +##### Example call: + +```typescript +import { downloadGuestbookResponsesOfAGuestbook } from '@iqss/dataverse-client-javascript' + +const collectionIdOrAlias = 'root' +const guestbookId = 123 + +downloadGuestbookResponsesOfAGuestbook + .execute(collectionIdOrAlias, guestbookId) + .then((csvResponse: string) => { + /* ... */ + }) +``` + +_See [use case](../src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts) implementation_. + ### Guestbooks Write Use Cases #### Create a Guestbook diff --git a/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts b/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts new file mode 100644 index 00000000..7a08bdd0 --- /dev/null +++ b/src/guestbooks/domain/dtos/GuestbookResponsesDTO.ts @@ -0,0 +1,13 @@ +import { Guestbook } from '../models/Guestbook' +import { GuestbookResponse } from '../models/GuestbookResponse' + +export interface GuestbookResponsesDTO { + guestbook: Guestbook + responses: GuestbookResponse[] + pagination?: GuestbookResponsesPaginationDTO +} + +export interface GuestbookResponsesPaginationDTO { + next?: string + totalResponses: number +} diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts index 2a2f3c5b..595e801e 100644 --- a/src/guestbooks/domain/models/Guestbook.ts +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -25,4 +25,6 @@ export interface Guestbook { customQuestions: GuestbookCustomQuestion[] createTime: string dataverseId: number + usageCount?: number + responseCount?: number } diff --git a/src/guestbooks/domain/models/GuestbookResponse.ts b/src/guestbooks/domain/models/GuestbookResponse.ts new file mode 100644 index 00000000..6d915f8f --- /dev/null +++ b/src/guestbooks/domain/models/GuestbookResponse.ts @@ -0,0 +1,32 @@ +export interface GuestbookResponse { + id: number + dataset: string + datasetPid: string + date: string + type: EventType + fileName?: string + fileId?: number + filePid?: string + userName: string + email?: string + institution?: string + position?: string + customQuestions?: GuestbookResponseCustomQuestion[] +} + +export interface GuestbookResponseCustomQuestion { + question: string + response: string +} + +export interface GuestbookResponseSubset { + guestbookResponses: GuestbookResponse[] + totalGuestbookResponseCount: number +} + +export enum EventType { + ACCESS_REQUEST = 'AccessRequest', + DOWNLOAD = 'Download', + SUBSET = 'Subset', + EXPLORE = 'Explore' +} diff --git a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts index 87ce91ab..3030457c 100644 --- a/src/guestbooks/domain/repositories/IGuestbooksRepository.ts +++ b/src/guestbooks/domain/repositories/IGuestbooksRepository.ts @@ -1,5 +1,6 @@ import { CreateGuestbookDTO } from '../dtos/CreateGuestbookDTO' import { Guestbook } from '../models/Guestbook' +import { GuestbookResponseSubset } from '../models/GuestbookResponse' export interface IGuestbooksRepository { createGuestbook( @@ -7,7 +8,20 @@ export interface IGuestbooksRepository { guestbook: CreateGuestbookDTO ): Promise getGuestbook(guestbookId: number): Promise - getGuestbooksByCollectionId(collectionIdOrAlias: number | string): Promise + getGuestbooksByCollectionId( + collectionIdOrAlias: number | string, + includeStats?: boolean, + includeInherited?: boolean + ): Promise + getGuestbookResponsesByGuestbookId( + guestbookId: number, + limit?: number, + offset?: number + ): Promise + downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias: number | string, + guestbookId?: number + ): Promise setGuestbookEnabled( collectionIdOrAlias: number | string, guestbookId: number, diff --git a/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts new file mode 100644 index 00000000..d70b26d4 --- /dev/null +++ b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId.ts @@ -0,0 +1,18 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class DownloadGuestbookResponsesByCollectionId implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Downloads all guestbook responses for a collection. + * + * @param {number | string} collectionIdOrAlias - Collection alias/identifier or numeric database id. + * @returns {Promise} Raw response body returned by the Dataverse API. + */ + async execute(collectionIdOrAlias: number | string): Promise { + return await this.guestbooksRepository.downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias + ) + } +} diff --git a/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts new file mode 100644 index 00000000..64d047cc --- /dev/null +++ b/src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class DownloadGuestbookResponsesOfAGuestbook implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Downloads guestbook responses for one guestbook in a collection. + * + * @param {number | string} collectionIdOrAlias - Collection alias/identifier or numeric database id. + * @param {number} guestbookId - Guestbook identifier to restrict the export. + * @returns {Promise} Raw response body returned by the Dataverse API. + */ + async execute(collectionIdOrAlias: number | string, guestbookId: number): Promise { + return await this.guestbooksRepository.downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias, + guestbookId + ) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts b/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts new file mode 100644 index 00000000..0dfc5dd2 --- /dev/null +++ b/src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId.ts @@ -0,0 +1,23 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { GuestbookResponseSubset } from '../models/GuestbookResponse' +import { IGuestbooksRepository } from '../repositories/IGuestbooksRepository' + +export class GetGuestbookResponsesByGuestbookId implements UseCase { + constructor(private readonly guestbooksRepository: IGuestbooksRepository) {} + + /** + * Returns paginated guestbook responses for one guestbook. + * + * @param {number} guestbookId - Guestbook identifier. + * @param {number} limit - Maximum number of responses to return. + * @param {number} offset - Number of responses to skip. + * @returns {Promise} + */ + async execute(guestbookId: number, limit = 10, offset = 0): Promise { + return await this.guestbooksRepository.getGuestbookResponsesByGuestbookId( + guestbookId, + limit, + offset + ) + } +} diff --git a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts index 003bdb07..9748456e 100644 --- a/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts +++ b/src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts @@ -9,9 +9,23 @@ export class GetGuestbooksByCollectionId implements UseCase { * Returns all guestbooks available for a given collection. * * @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias). + * @param {boolean} [includeStats=false] - Include usage and response counts for each guestbook. + * @param {boolean} [includeInherited=false] - Include guestbooks inherited from hierarchical owners. * @returns {Promise} */ - async execute(collectionIdOrAlias: number | string): Promise { - return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias) + async execute( + collectionIdOrAlias: number | string, + includeStats = false, + includeInherited = false + ): Promise { + if (!includeStats && !includeInherited) { + return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias) + } + + return await this.guestbooksRepository.getGuestbooksByCollectionId( + collectionIdOrAlias, + includeStats, + includeInherited + ) } } diff --git a/src/guestbooks/index.ts b/src/guestbooks/index.ts index 29d22988..7c5bdd0a 100644 --- a/src/guestbooks/index.ts +++ b/src/guestbooks/index.ts @@ -1,6 +1,9 @@ import { GuestbooksRepository } from './infra/repositories/GuestbooksRepository' import { CreateGuestbook } from './domain/useCases/CreateGuestbook' +import { DownloadGuestbookResponsesByCollectionId } from './domain/useCases/DownloadGuestbookResponsesByCollectionId' +import { DownloadGuestbookResponsesOfAGuestbook } from './domain/useCases/DownloadGuestbookResponsesOfAGuestbook' import { GetGuestbook } from './domain/useCases/GetGuestbook' +import { GetGuestbookResponsesByGuestbookId } from './domain/useCases/GetGuestbookResponsesByGuestbookId' import { GetGuestbooksByCollectionId } from './domain/useCases/GetGuestbooksByCollectionId' import { SetGuestbookEnabled } from './domain/useCases/SetGuestbookEnabled' import { AssignDatasetGuestbook } from './domain/useCases/AssignDatasetGuestbook' @@ -9,7 +12,16 @@ import { RemoveDatasetGuestbook } from './domain/useCases/RemoveDatasetGuestbook const guestbooksRepository = new GuestbooksRepository() const createGuestbook = new CreateGuestbook(guestbooksRepository) +const downloadGuestbookResponsesByCollectionId = new DownloadGuestbookResponsesByCollectionId( + guestbooksRepository +) +const downloadGuestbookResponsesOfAGuestbook = new DownloadGuestbookResponsesOfAGuestbook( + guestbooksRepository +) const getGuestbook = new GetGuestbook(guestbooksRepository) +const getGuestbookResponsesByGuestbookId = new GetGuestbookResponsesByGuestbookId( + guestbooksRepository +) const getGuestbooksByCollectionId = new GetGuestbooksByCollectionId(guestbooksRepository) const setGuestbookEnabled = new SetGuestbookEnabled(guestbooksRepository) const assignDatasetGuestbook = new AssignDatasetGuestbook(guestbooksRepository) @@ -17,7 +29,10 @@ const removeDatasetGuestbook = new RemoveDatasetGuestbook(guestbooksRepository) export { createGuestbook, + downloadGuestbookResponsesByCollectionId, + downloadGuestbookResponsesOfAGuestbook, getGuestbook, + getGuestbookResponsesByGuestbookId, getGuestbooksByCollectionId, setGuestbookEnabled, assignDatasetGuestbook, @@ -29,4 +44,14 @@ export { CreateGuestbookCustomQuestionDTO, CreateGuestbookOptionDTO } from './domain/dtos/CreateGuestbookDTO' +export { + GuestbookResponsesDTO, + GuestbookResponsesPaginationDTO +} from './domain/dtos/GuestbookResponsesDTO' export { Guestbook, GuestbookCustomQuestion, GuestbookOption } from './domain/models/Guestbook' +export { + EventType, + GuestbookResponse, + GuestbookResponseCustomQuestion, + GuestbookResponseSubset +} from './domain/models/GuestbookResponse' diff --git a/src/guestbooks/infra/repositories/GuestbooksRepository.ts b/src/guestbooks/infra/repositories/GuestbooksRepository.ts index 6f9812e6..6112accd 100644 --- a/src/guestbooks/infra/repositories/GuestbooksRepository.ts +++ b/src/guestbooks/infra/repositories/GuestbooksRepository.ts @@ -1,11 +1,14 @@ import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' import { CreateGuestbookDTO } from '../../domain/dtos/CreateGuestbookDTO' +import { GuestbookResponsesDTO } from '../../domain/dtos/GuestbookResponsesDTO' import { Guestbook } from '../../domain/models/Guestbook' +import { GuestbookResponseSubset } from '../../domain/models/GuestbookResponse' import { IGuestbooksRepository } from '../../domain/repositories/IGuestbooksRepository' export class GuestbooksRepository extends ApiRepository implements IGuestbooksRepository { private readonly guestbooksResourceName: string = 'guestbooks' private readonly datasetsResourceName: string = 'datasets' + private readonly dataversesResourceName: string = 'dataverses' public async createGuestbook( collectionIdOrAlias: number | string, @@ -33,11 +36,19 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe } public async getGuestbooksByCollectionId( - collectionIdOrAlias: number | string + collectionIdOrAlias: number | string, + includeStats = false, + includeInherited = false ): Promise { + const queryParams = { + ...(includeStats ? { includeStats } : {}), + ...(includeInherited ? { includeInherited } : {}) + } + return this.doGet( this.buildApiEndpoint(this.guestbooksResourceName, `${collectionIdOrAlias}/list`), - true + true, + queryParams ) .then((response) => response.data.data as Guestbook[]) .catch((error) => { @@ -45,6 +56,45 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe }) } + public async getGuestbookResponsesByGuestbookId( + guestbookId: number, + limit = 10, + offset = 0 + ): Promise { + return this.doGet( + this.buildApiEndpoint(this.guestbooksResourceName, 'responses', guestbookId), + true, + { limit, offset } + ) + .then((response) => { + const responseData = response.data.data as GuestbookResponsesDTO + + return { + guestbookResponses: responseData.responses, + totalGuestbookResponseCount: responseData.pagination?.totalResponses ?? 0 + } + }) + .catch((error) => { + throw error + }) + } + + public async downloadGuestbookResponsesByCollectionId( + collectionIdOrAlias: number | string, + guestbookId?: number + ): Promise { + const endpoint = this.buildApiEndpoint( + this.dataversesResourceName, + `${collectionIdOrAlias}/guestbookResponses` + ) + + return this.doGet(endpoint, true, guestbookId ? { guestbookId } : {}) + .then((response) => response.data as string) + .catch((error) => { + throw error + }) + } + public async setGuestbookEnabled( collectionIdOrAlias: number | string, guestbookId: number, diff --git a/test/integration/guestbooks/GuestbooksRepository.test.ts b/test/integration/guestbooks/GuestbooksRepository.test.ts index 4674d3e0..1f503178 100644 --- a/test/integration/guestbooks/GuestbooksRepository.test.ts +++ b/test/integration/guestbooks/GuestbooksRepository.test.ts @@ -9,15 +9,28 @@ import { DatasetNotNumberedVersion, getDataset } from '../../../src/datasets' -import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper' +import { + deletePublishedDatasetViaApi, + deleteUnpublishedDatasetViaApi, + publishDatasetViaApi, + waitForNoLocks +} from '../../testHelpers/datasets/datasetHelper' import { createCollectionViaApi, - deleteCollectionViaApi + deleteCollectionViaApi, + publishCollectionViaApi } from '../../testHelpers/collections/collectionHelper' import { CollectionPayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload' +import { AccessRepository } from '../../../src/access/infra/repositories/AccessRepository' +import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO' +import { testTextFile1Name, uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' +import { FileOrderCriteria } from '../../../src/files/domain/models/FileCriteria' describe('GuestbooksRepository', () => { const sut = new GuestbooksRepository() + const accessRepository = new AccessRepository() + const filesRepository = new FilesRepository() const testCollectionAlias = 'testGuestbooksRepository' let testCollectionId: number let createdGuestbookId: number @@ -70,6 +83,7 @@ describe('GuestbooksRepository', () => { await createCollectionViaApi(testCollectionAlias).then( (collectionPayload: CollectionPayload) => (testCollectionId = collectionPayload.id) ) + await publishCollectionViaApi(testCollectionAlias) }) afterAll(async () => { @@ -105,7 +119,10 @@ describe('GuestbooksRepository', () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) const actual = await sut.getGuestbooksByCollectionId(testCollectionId) expect(actual.length).toBeGreaterThan(0) - expect(actual.some((guestbook) => guestbook.id === createdGuestbookId)).toBe(true) + const createdGuestbook = actual.find((guestbook) => guestbook.id === createdGuestbookId) + expect(createdGuestbook).toBeDefined() + expect(createdGuestbook?.usageCount).toBeUndefined() + expect(createdGuestbook?.responseCount).toBeUndefined() }) test('should list guestbooks for collection by collection alias', async () => { @@ -118,11 +135,236 @@ describe('GuestbooksRepository', () => { expect(actual.some((guestbook) => guestbook.id === createdByAliasGuestbookId)).toBe(true) }) + test('should list guestbooks for collection with stats', async () => { + const createdGuestbookIdWithStats = await sut.createGuestbook( + testCollectionAlias, + createGuestbookDTO + ) + const actual = await sut.getGuestbooksByCollectionId(testCollectionAlias, true) + const createdGuestbookWithStats = actual.find( + (guestbook) => guestbook.id === createdGuestbookIdWithStats + ) + + expect(createdGuestbookWithStats).toBeDefined() + expect(createdGuestbookWithStats?.usageCount).toEqual(expect.any(Number)) + expect(createdGuestbookWithStats?.responseCount).toEqual(expect.any(Number)) + }) + + test('should increment usageCount when assigned by the dataset admin and responseCount only when a guest submits a response', async () => { + let statsDatasetIds: CreatedDatasetIdentifiers | undefined + let statsDatasetPublished = false + const guestbookResponse: GuestbookResponseDTO = { + guestbookResponse: { + name: 'Guestbook Stats Test', + email: 'guestbook-stats@example.edu' + } + } + const statsGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: 'guestbook stats test', + customQuestions: [] + }) + + try { + const initialStats = await getGuestbookStats(statsGuestbookId) + statsDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + await uploadFileViaApi(statsDatasetIds.numericId, testTextFile1Name) + const datasetFiles = await filesRepository.getDatasetFiles( + statsDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + const fileId = datasetFiles.files[0].id + + await sut.assignDatasetGuestbook(statsDatasetIds.numericId, statsGuestbookId) + + const statsAfterAssignment = await getGuestbookStats(statsGuestbookId) + expect(statsAfterAssignment.usageCount).toBe((initialStats.usageCount ?? 0) + 1) + expect(statsAfterAssignment.responseCount).toBe(initialStats.responseCount ?? 0) + + await publishDatasetViaApi(statsDatasetIds.numericId) + statsDatasetPublished = true + await waitForNoLocks(statsDatasetIds.numericId, 10) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + undefined, + () => null + ) + await accessRepository.submitGuestbookForDatafileDownload(fileId, guestbookResponse) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + const statsAfterResponse = await getGuestbookStats(statsGuestbookId) + expect(statsAfterResponse.usageCount).toBe(statsAfterAssignment.usageCount) + expect(statsAfterResponse.responseCount).toBe((statsAfterAssignment.responseCount ?? 0) + 1) + } finally { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + if (statsDatasetIds !== undefined) { + if (statsDatasetPublished) { + await deletePublishedDatasetViaApi(statsDatasetIds.persistentId) + } else { + await deleteUnpublishedDatasetViaApi(statsDatasetIds.numericId) + } + } + } + }) + + test('should include hierarchical owner guestbooks when includeInherited is true', async () => { + const uniqueSuffix = Date.now().toString() + const childCollectionAlias = `testGuestbooksInheritedChild${uniqueSuffix}` + const parentGuestbookName = `parent inherited guestbook ${uniqueSuffix}` + const childGuestbookName = `child inherited guestbook ${uniqueSuffix}` + + let childCollectionId: number | undefined + + try { + await createCollectionViaApi(childCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + await publishCollectionViaApi(childCollectionAlias) + + const parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + const childGuestbookId = await sut.createGuestbook(childCollectionAlias, { + ...createGuestbookDTO, + name: childGuestbookName, + customQuestions: [] + }) + + const withoutInherited = await sut.getGuestbooksByCollectionId(childCollectionAlias) + const withInherited = await sut.getGuestbooksByCollectionId( + childCollectionAlias, + false, + true + ) + + expect(childCollectionId).toBeDefined() + expect(withoutInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withoutInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(false) + + expect(withInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(true) + + const inheritedGuestbook = withInherited.find( + (guestbook) => guestbook.id === parentGuestbookId + ) + expect(inheritedGuestbook?.name).toBe(parentGuestbookName) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(childCollectionAlias) + } + } + }) + + test('should not include hierarchical owner guestbooks when includeInherited is false', async () => { + const uniqueSuffix = Date.now().toString() + const childCollectionAlias = `testGuestbooksNoInheritedChild${uniqueSuffix}` + const parentGuestbookName = `parent non inherited guestbook ${uniqueSuffix}` + const childGuestbookName = `child non inherited guestbook ${uniqueSuffix}` + + let childCollectionId: number | undefined + + try { + await createCollectionViaApi(childCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + await publishCollectionViaApi(childCollectionAlias) + + const parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + const childGuestbookId = await sut.createGuestbook(childCollectionAlias, { + ...createGuestbookDTO, + name: childGuestbookName, + customQuestions: [] + }) + + const withoutInherited = await sut.getGuestbooksByCollectionId( + childCollectionAlias, + false, + false + ) + + expect(childCollectionId).toBeDefined() + expect(withoutInherited.some((guestbook) => guestbook.id === childGuestbookId)).toBe(true) + expect(withoutInherited.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(false) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(childCollectionAlias) + } + } + }) + + test('should return inherited guestbooks for unpublished child collection when includeInherited is true', async () => { + const uniqueSuffix = Date.now().toString() + const unpublishedChildCollectionAlias = `testGuestbooksUnpublishedChild${uniqueSuffix}` + const parentGuestbookName = `parent unpublished child guestbook ${uniqueSuffix}` + let childCollectionId: number | undefined + let parentGuestbookId: number | undefined + + try { + await createCollectionViaApi(unpublishedChildCollectionAlias, testCollectionAlias).then( + (collectionPayload: CollectionPayload) => (childCollectionId = collectionPayload.id) + ) + + parentGuestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: parentGuestbookName, + customQuestions: [] + }) + + const actual = await sut.getGuestbooksByCollectionId( + unpublishedChildCollectionAlias, + false, + true + ) + + expect(childCollectionId).toBeDefined() + expect(parentGuestbookId).toBeDefined() + expect(actual.some((guestbook) => guestbook.id === parentGuestbookId)).toBe(true) + expect(actual.some((guestbook) => guestbook.name === parentGuestbookName)).toBe(true) + } finally { + if (childCollectionId !== undefined) { + await deleteCollectionViaApi(unpublishedChildCollectionAlias) + } + } + }) + test('should return error when collection does not exist', async () => { await expect(sut.getGuestbooksByCollectionId(999999)).rejects.toThrow(ReadError) }) }) + const getGuestbookStats = async (guestbookId: number) => { + const guestbooks = await sut.getGuestbooksByCollectionId(testCollectionAlias, true) + const guestbook = guestbooks.find((guestbook) => guestbook.id === guestbookId) + + if (guestbook === undefined) { + throw new Error(`Guestbook ${guestbookId} was not found in collection stats.`) + } + + return guestbook + } + describe('getGuestbook', () => { test('should get guestbook by id', async () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) @@ -136,6 +378,108 @@ describe('GuestbooksRepository', () => { }) }) + describe('getGuestbookResponsesByGuestbookId', () => { + test('should return responses for one guestbook', async () => { + const setup = await createGuestbookDownloadSetup('guestbook responses endpoint test') + + try { + const actual = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId) + + expect(actual.guestbookResponses.length).toBeGreaterThan(0) + expect(actual.totalGuestbookResponseCount).toBeGreaterThanOrEqual(1) + expect(actual.guestbookResponses[0].datasetPid).toBe( + setup.datasetPersistentId.split('/').slice(-2).join('/') + ) + expect(actual.guestbookResponses[0].email).toBe(setup.email) + expect(actual.guestbookResponses[0].fileName).toBe(testTextFile1Name) + } finally { + await cleanupGuestbookDownloadSetup(setup) + } + }) + + test('should return paginated responses for one guestbook', async () => { + const setup = await createGuestbookDownloadSetup('paginated guestbook responses test') + const secondResponseEmail = `guestbook-pagination-${Date.now()}@example.edu` + + try { + await submitGuestbookDownloadResponse( + setup.fileId, + `Guestbook Pagination ${Date.now()}`, + secondResponseEmail + ) + + const firstPage = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId, 1, 0) + const secondPage = await sut.getGuestbookResponsesByGuestbookId(setup.guestbookId, 1, 1) + + expect(firstPage.guestbookResponses).toHaveLength(1) + expect(secondPage.guestbookResponses).toHaveLength(1) + expect(firstPage.totalGuestbookResponseCount).toBeGreaterThanOrEqual(2) + expect(secondPage.totalGuestbookResponseCount).toBeGreaterThanOrEqual(2) + expect(firstPage.guestbookResponses[0].email).not.toBe( + secondPage.guestbookResponses[0].email + ) + expect([ + firstPage.guestbookResponses[0].email, + secondPage.guestbookResponses[0].email + ]).toEqual(expect.arrayContaining([setup.email, secondResponseEmail])) + } finally { + await cleanupGuestbookDownloadSetup(setup) + } + }) + + test('should return error when guestbook does not exist', async () => { + await expect(sut.getGuestbookResponsesByGuestbookId(999999)).rejects.toThrow(ReadError) + }) + }) + + describe('downloadGuestbookResponsesByCollectionId', () => { + test('should download all guestbook responses for a collection without guestbook id', async () => { + const setups: GuestbookDownloadSetup[] = [] + + try { + setups.push(await createGuestbookDownloadSetup('all responses export test one')) + setups.push(await createGuestbookDownloadSetup('all responses export test two')) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(testCollectionAlias) + + expect(actual).toContain('Guestbook, Dataset, Dataset PID, Date, Type, File Name') + expect(actual).toContain(setups[0].guestbookName) + expect(actual).toContain(setups[0].datasetPersistentId) + expect(actual).toContain(setups[0].email) + expect(actual).toContain(setups[1].guestbookName) + expect(actual).toContain(setups[1].datasetPersistentId) + expect(actual).toContain(setups[1].email) + } finally { + await cleanupGuestbookDownloadSetups(setups) + } + }) + + test('should download responses only for the specified guestbook with guestbook id', async () => { + const setups: GuestbookDownloadSetup[] = [] + + try { + setups.push(await createGuestbookDownloadSetup('single guestbook export target test')) + setups.push(await createGuestbookDownloadSetup('single guestbook export other test')) + + const actual = await sut.downloadGuestbookResponsesByCollectionId( + testCollectionAlias, + setups[0].guestbookId + ) + + expect(actual).toContain('Guestbook, Dataset, Dataset PID, Date, Type, File Name') + expect(actual).toContain(setups[0].guestbookName) + expect(actual).toContain(setups[0].datasetPersistentId) + expect(actual).toContain(setups[0].email) + expect(actual).toContain(testTextFile1Name) + expect(actual).not.toContain(setups[1].guestbookName) + expect(actual).not.toContain(setups[1].datasetPersistentId) + expect(actual).not.toContain(setups[1].email) + } finally { + await cleanupGuestbookDownloadSetups(setups) + } + }) + }) + describe('setGuestbookEnabled', () => { test('should disable guestbook', async () => { createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO) @@ -233,4 +577,90 @@ describe('GuestbooksRepository', () => { }) }) }) + + interface GuestbookDownloadSetup { + guestbookId: number + guestbookName: string + datasetNumericId: number + datasetPersistentId: string + fileId: number + email: string + } + + const createGuestbookDownloadSetup = async ( + guestbookName: string + ): Promise => { + const uniqueSuffix = Date.now().toString() + const guestbookId = await sut.createGuestbook(testCollectionAlias, { + ...createGuestbookDTO, + name: `${guestbookName}-${uniqueSuffix}`, + customQuestions: [] + }) + const datasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + await uploadFileViaApi(datasetIds.numericId, testTextFile1Name) + const datasetFiles = await filesRepository.getDatasetFiles( + datasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + const fileId = datasetFiles.files[0].id + const email = `guestbook-download-${uniqueSuffix}@example.edu` + + await sut.assignDatasetGuestbook(datasetIds.numericId, guestbookId) + await publishDatasetViaApi(datasetIds.numericId) + await waitForNoLocks(datasetIds.numericId, 10) + + await submitGuestbookDownloadResponse(fileId, `Guestbook Download ${uniqueSuffix}`, email) + + return { + guestbookId, + guestbookName: `${guestbookName}-${uniqueSuffix}`, + datasetNumericId: datasetIds.numericId, + datasetPersistentId: datasetIds.persistentId, + fileId, + email + } + } + + const submitGuestbookDownloadResponse = async (fileId: number, name: string, email: string) => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + undefined, + () => null + ) + await accessRepository.submitGuestbookForDatafileDownload(fileId, { + guestbookResponse: { + name, + email + } + }) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + } + + const cleanupGuestbookDownloadSetup = async (setup: { + datasetNumericId: number + datasetPersistentId: string + }) => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await deletePublishedDatasetViaApi(setup.datasetPersistentId) + } + + const cleanupGuestbookDownloadSetups = async (setups: GuestbookDownloadSetup[]) => { + await Promise.all(setups.map((setup) => cleanupGuestbookDownloadSetup(setup))) + } }) diff --git a/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts b/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts new file mode 100644 index 00000000..002f75e8 --- /dev/null +++ b/test/unit/guestbooks/DownloadGuestbookResponsesByCollectionId.test.ts @@ -0,0 +1,32 @@ +import { ReadError } from '../../../src' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { DownloadGuestbookResponsesByCollectionId } from '../../../src/guestbooks/domain/useCases/DownloadGuestbookResponsesByCollectionId' + +describe('DownloadGuestbookResponsesByCollectionId', () => { + const collectionIdOrAlias = 'collectionAlias' + const csvResponse = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + + test('should download guestbook responses for collection', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest.fn().mockResolvedValue(csvResponse) + + const sut = new DownloadGuestbookResponsesByCollectionId(repository) + const actual = await sut.execute(collectionIdOrAlias) + + expect(repository.downloadGuestbookResponsesByCollectionId).toHaveBeenCalledWith( + collectionIdOrAlias + ) + expect(actual).toEqual(csvResponse) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new DownloadGuestbookResponsesByCollectionId(repository) + + await expect(sut.execute(collectionIdOrAlias)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts b/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts new file mode 100644 index 00000000..857829de --- /dev/null +++ b/test/unit/guestbooks/DownloadGuestbookResponsesOfAGuestbook.test.ts @@ -0,0 +1,34 @@ +import { ReadError } from '../../../src' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { DownloadGuestbookResponsesOfAGuestbook } from '../../../src/guestbooks/domain/useCases/DownloadGuestbookResponsesOfAGuestbook' + +describe('DownloadGuestbookResponsesOfAGuestbook', () => { + const collectionIdOrAlias = 'collectionAlias' + const guestbookId = 12 + const csvResponse = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + + test('should download guestbook responses for one guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest.fn().mockResolvedValue(csvResponse) + + const sut = new DownloadGuestbookResponsesOfAGuestbook(repository) + const actual = await sut.execute(collectionIdOrAlias, guestbookId) + + expect(repository.downloadGuestbookResponsesByCollectionId).toHaveBeenCalledWith( + collectionIdOrAlias, + guestbookId + ) + expect(actual).toEqual(csvResponse) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.downloadGuestbookResponsesByCollectionId = jest + .fn() + .mockRejectedValue(new ReadError()) + const sut = new DownloadGuestbookResponsesOfAGuestbook(repository) + + await expect(sut.execute(collectionIdOrAlias, guestbookId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts b/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts new file mode 100644 index 00000000..d3bf5b06 --- /dev/null +++ b/test/unit/guestbooks/GetGuestbookResponsesByGuestbookId.test.ts @@ -0,0 +1,74 @@ +import { ReadError } from '../../../src' +import { + EventType, + GuestbookResponse, + GuestbookResponseSubset +} from '../../../src/guestbooks/domain/models/GuestbookResponse' +import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositories/IGuestbooksRepository' +import { GetGuestbookResponsesByGuestbookId } from '../../../src/guestbooks/domain/useCases/GetGuestbookResponsesByGuestbookId' + +describe('GetGuestbookResponsesByGuestbookId', () => { + const guestbookId = 12 + const limit = 10 + const offset = 0 + const guestbookResponses: GuestbookResponse[] = [ + { + id: 13, + dataset: 'Replication Data for:', + datasetPid: 'FK2/BQEPWW', + date: '2026-06-08T23:50:49Z', + type: EventType.DOWNLOAD, + fileName: 'dp_statistics_for_grade_grouped_by_student_id.html', + fileId: 3, + userName: 'Guest', + email: 'guest@example.edu', + customQuestions: [ + { + question: 'What is your intended use?', + response: 'Teaching' + } + ] + } + ] + const guestbookResponseSubset: GuestbookResponseSubset = { + guestbookResponses, + totalGuestbookResponseCount: guestbookResponses.length + } + + test('should return guestbook responses for one guestbook', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest + .fn() + .mockResolvedValue(guestbookResponseSubset) + + const sut = new GetGuestbookResponsesByGuestbookId(repository) + const actual = await sut.execute(guestbookId) + + expect(repository.getGuestbookResponsesByGuestbookId).toHaveBeenCalledWith( + guestbookId, + limit, + offset + ) + expect(actual).toEqual(guestbookResponseSubset) + }) + + test('should pass pagination to repository', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest + .fn() + .mockResolvedValue(guestbookResponseSubset) + + const sut = new GetGuestbookResponsesByGuestbookId(repository) + await sut.execute(guestbookId, 25, 50) + + expect(repository.getGuestbookResponsesByGuestbookId).toHaveBeenCalledWith(guestbookId, 25, 50) + }) + + test('should throw ReadError when repository fails', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbookResponsesByGuestbookId = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetGuestbookResponsesByGuestbookId(repository) + + await expect(sut.execute(guestbookId)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts index 527e7b7f..d912e283 100644 --- a/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts +++ b/test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts @@ -4,7 +4,7 @@ import { IGuestbooksRepository } from '../../../src/guestbooks/domain/repositori import { GetGuestbooksByCollectionId } from '../../../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId' describe('GetGuestbooksByCollectionId', () => { - const guestbooks: Guestbook[] = [ + const guestbooksWithoutStats: Guestbook[] = [ { id: 12, name: 'test', @@ -18,17 +18,68 @@ describe('GetGuestbooksByCollectionId', () => { dataverseId: 10 } ] + const guestbooksWithStats: Guestbook[] = [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10, + usageCount: 3, + responseCount: 2 + } + ] const collectionId = 'collectionAlias' test('should return guestbooks for collection', async () => { const repository: IGuestbooksRepository = {} as IGuestbooksRepository - repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooks) + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) const sut = new GetGuestbooksByCollectionId(repository) const actual = await sut.execute(collectionId) expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId) - expect(actual).toEqual(guestbooks) + expect(actual).toEqual(guestbooksWithoutStats) + expect(actual[0].usageCount).toBeUndefined() + expect(actual[0].responseCount).toBeUndefined() + }) + + test('should request guestbooks with stats when includeStats is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, true, false) + expect(actual).toEqual(guestbooksWithStats) + }) + + test('should request guestbooks with inherited guestbooks when includeInherited is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, false, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, false, true) + expect(actual).toEqual(guestbooksWithoutStats) + }) + + test('should request guestbooks with options object when includeInherited is true', async () => { + const repository: IGuestbooksRepository = {} as IGuestbooksRepository + repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooksWithoutStats) + + const sut = new GetGuestbooksByCollectionId(repository) + const actual = await sut.execute(collectionId, undefined, true) + + expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, false, true) + expect(actual).toEqual(guestbooksWithoutStats) }) test('should throw ReadError when repository fails', async () => { diff --git a/test/unit/guestbooks/GuestbooksRepository.test.ts b/test/unit/guestbooks/GuestbooksRepository.test.ts new file mode 100644 index 00000000..f820254d --- /dev/null +++ b/test/unit/guestbooks/GuestbooksRepository.test.ts @@ -0,0 +1,230 @@ +import axios from 'axios' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { GuestbooksRepository } from '../../../src/guestbooks/infra/repositories/GuestbooksRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { TestConstants } from '../../testHelpers/TestConstants' +import { EventType } from '../../../src/guestbooks/domain/models/GuestbookResponse' + +describe('GuestbooksRepository', () => { + const sut = new GuestbooksRepository() + const collectionIdOrAlias = 'collectionAlias' + const guestbooksResponseWithoutStats = { + data: { + status: 'OK', + data: [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10 + } + ] + } + } + const guestbooksResponse = { + data: { + status: 'OK', + data: [ + { + id: 12, + name: 'test', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2024-01-01T00:00:00Z', + dataverseId: 10, + usageCount: 3, + responseCount: 2 + } + ] + } + } + const guestbookResponsesResponse = { + data: { + status: 'OK', + data: [ + { + id: 13, + dataset: 'Replication Data for:', + datasetPid: 'FK2/BQEPWW', + date: '2026-06-08T23:50:49Z', + type: EventType.DOWNLOAD, + fileName: 'dp_statistics_for_grade_grouped_by_student_id.html', + fileId: 3, + userName: 'Guest', + email: 'guest@example.edu' + } + ] + } + } + const guestbookResponsesOfAGuestbookResponse = { + data: { + status: 'OK', + data: { + guestbook: guestbooksResponse.data.data[0], + responses: guestbookResponsesResponse.data.data, + pagination: { + next: `${TestConstants.TEST_API_URL}/guestbooks/12/responses?limit=10&offset=10`, + totalResponses: 1 + } + } + } + } + const guestbookResponsesCsv = + 'Guestbook,Dataset,Dataset PID,Date,Type,File Name,File Id,File PID,User Name,Email,Institution,Position,Custom Questions' + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + + jest.clearAllMocks() + }) + + describe('getGuestbooksByCollectionId', () => { + test('should list guestbooks without stats by default', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponseWithoutStats) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(guestbooksResponseWithoutStats.data.data) + expect(actual[0].usageCount).toBeUndefined() + expect(actual[0].responseCount).toBeUndefined() + }) + + test('should list guestbooks with stats when includeStats is true', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponse) + + const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias, true) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`, + { + params: { + includeStats: true + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual[0].usageCount).toBe(3) + expect(actual[0].responseCount).toBe(2) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.getGuestbooksByCollectionId(collectionIdOrAlias, true)).rejects.toThrow( + ReadError + ) + }) + }) + + describe('getGuestbookResponsesByGuestbookId', () => { + test('should list guestbook responses for a guestbook', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbookResponsesOfAGuestbookResponse) + + const actual = await sut.getGuestbookResponsesByGuestbookId(12) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12/responses`, + { + params: { + limit: 10, + offset: 0 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual({ + guestbookResponses: guestbookResponsesOfAGuestbookResponse.data.data.responses, + totalGuestbookResponseCount: + guestbookResponsesOfAGuestbookResponse.data.data.pagination.totalResponses + }) + }) + + test('should list guestbook responses with pagination', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(guestbookResponsesOfAGuestbookResponse) + + const actual = await sut.getGuestbookResponsesByGuestbookId(12, 25, 50) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/guestbooks/12/responses`, + { + params: { + limit: 25, + offset: 50 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual({ + guestbookResponses: guestbookResponsesOfAGuestbookResponse.data.data.responses, + totalGuestbookResponseCount: + guestbookResponsesOfAGuestbookResponse.data.data.pagination.totalResponses + }) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect(sut.getGuestbookResponsesByGuestbookId(12)).rejects.toThrow(ReadError) + }) + }) + + describe('downloadGuestbookResponsesByCollectionId', () => { + test('should download guestbook responses for collection', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: guestbookResponsesCsv }) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/${collectionIdOrAlias}/guestbookResponses`, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(guestbookResponsesCsv) + }) + + test('should download guestbook responses filtered by guestbook id', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: guestbookResponsesCsv }) + + const actual = await sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias, 12) + + expect(axios.get).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/${collectionIdOrAlias}/guestbookResponses`, + { + params: { + guestbookId: 12 + }, + headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers + } + ) + expect(actual).toStrictEqual(guestbookResponsesCsv) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect( + sut.downloadGuestbookResponsesByCollectionId(collectionIdOrAlias) + ).rejects.toThrow(ReadError) + }) + }) +})