Skip to content

Commit 96b99ac

Browse files
committed
feat: extend guestbook with counts
1 parent 1dc5d8b commit 96b99ac

9 files changed

Lines changed: 226 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
88

99
### Added
1010

11+
- Guestbooks: Added optional `includeStats` support to `getGuestbooksByCollectionId`, returning `usageCount` and `responseCount` when requested.
12+
1113
### Changed
1214

1315
### Fixed

docs/useCases.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,17 +2932,21 @@ _See [use case](../src/guestbooks/domain/useCases/GetGuestbook.ts) implementatio
29322932
#### Get Guestbooks By Collection Id
29332933

29342934
Returns all [Guestbook](../src/guestbooks/domain/models/Guestbook.ts) entries available for a collection.
2935+
Set `includeStats` to `true` to include `usageCount` and `responseCount` for each guestbook.
29352936

29362937
##### Example call:
29372938

29382939
```typescript
29392940
import { getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript'
29402941

29412942
const collectionIdOrAlias = 'root'
2943+
const includeStats = true
29422944

2943-
getGuestbooksByCollectionId.execute(collectionIdOrAlias).then((guestbooks: Guestbook[]) => {
2944-
/* ... */
2945-
})
2945+
getGuestbooksByCollectionId
2946+
.execute(collectionIdOrAlias, includeStats)
2947+
.then((guestbooks: Guestbook[]) => {
2948+
/* ... */
2949+
})
29462950
```
29472951

29482952
_See [use case](../src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts) implementation_.

src/guestbooks/domain/models/Guestbook.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ export interface Guestbook {
2525
customQuestions: GuestbookCustomQuestion[]
2626
createTime: string
2727
dataverseId: number
28+
usageCount?: number
29+
responseCount?: number
2830
}

src/guestbooks/domain/repositories/IGuestbooksRepository.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ export interface IGuestbooksRepository {
77
guestbook: CreateGuestbookDTO
88
): Promise<number>
99
getGuestbook(guestbookId: number): Promise<Guestbook>
10-
getGuestbooksByCollectionId(collectionIdOrAlias: number | string): Promise<Guestbook[]>
10+
getGuestbooksByCollectionId(
11+
collectionIdOrAlias: number | string,
12+
includeStats?: boolean
13+
): Promise<Guestbook[]>
1114
setGuestbookEnabled(
1215
collectionIdOrAlias: number | string,
1316
guestbookId: number,

src/guestbooks/domain/useCases/GetGuestbooksByCollectionId.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ export class GetGuestbooksByCollectionId implements UseCase<Guestbook[]> {
99
* Returns all guestbooks available for a given collection.
1010
*
1111
* @param {number | string} collectionIdOrAlias - Collection identifier (numeric id or alias).
12+
* @param {boolean} [includeStats=false] - Include usage and response counts for each guestbook.
1213
* @returns {Promise<Guestbook[]>}
1314
*/
14-
async execute(collectionIdOrAlias: number | string): Promise<Guestbook[]> {
15-
return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias)
15+
async execute(collectionIdOrAlias: number | string, includeStats = false): Promise<Guestbook[]> {
16+
if (!includeStats) {
17+
return await this.guestbooksRepository.getGuestbooksByCollectionId(collectionIdOrAlias)
18+
}
19+
20+
return await this.guestbooksRepository.getGuestbooksByCollectionId(
21+
collectionIdOrAlias,
22+
includeStats
23+
)
1624
}
1725
}

src/guestbooks/infra/repositories/GuestbooksRepository.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ export class GuestbooksRepository extends ApiRepository implements IGuestbooksRe
3333
}
3434

3535
public async getGuestbooksByCollectionId(
36-
collectionIdOrAlias: number | string
36+
collectionIdOrAlias: number | string,
37+
includeStats = false
3738
): Promise<Guestbook[]> {
3839
return this.doGet(
3940
this.buildApiEndpoint(this.guestbooksResourceName, `${collectionIdOrAlias}/list`),
40-
true
41+
true,
42+
includeStats ? { includeStats } : {}
4143
)
4244
.then((response) => response.data.data as Guestbook[])
4345
.catch((error) => {

test/integration/guestbooks/GuestbooksRepository.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,24 @@ import {
99
DatasetNotNumberedVersion,
1010
getDataset
1111
} from '../../../src/datasets'
12-
import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper'
12+
import {
13+
deletePublishedDatasetViaApi,
14+
deleteUnpublishedDatasetViaApi,
15+
publishDatasetViaApi,
16+
waitForNoLocks
17+
} from '../../testHelpers/datasets/datasetHelper'
1318
import {
1419
createCollectionViaApi,
1520
deleteCollectionViaApi
1621
} from '../../testHelpers/collections/collectionHelper'
1722
import { CollectionPayload } from '../../../src/collections/infra/repositories/transformers/CollectionPayload'
23+
import { AccessRepository } from '../../../src/access/infra/repositories/AccessRepository'
24+
import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO'
25+
import { testTextFile1Name, uploadFileViaApi } from '../../testHelpers/files/filesHelper'
1826

1927
describe('GuestbooksRepository', () => {
2028
const sut = new GuestbooksRepository()
29+
const accessRepository = new AccessRepository()
2130
const testCollectionAlias = 'testGuestbooksRepository'
2231
let testCollectionId: number
2332
let createdGuestbookId: number
@@ -118,11 +127,100 @@ describe('GuestbooksRepository', () => {
118127
expect(actual.some((guestbook) => guestbook.id === createdByAliasGuestbookId)).toBe(true)
119128
})
120129

130+
test('should list guestbooks for collection with stats', async () => {
131+
const createdGuestbookIdWithStats = await sut.createGuestbook(
132+
testCollectionAlias,
133+
createGuestbookDTO
134+
)
135+
const actual = await sut.getGuestbooksByCollectionId(testCollectionAlias, true)
136+
const createdGuestbookWithStats = actual.find(
137+
(guestbook) => guestbook.id === createdGuestbookIdWithStats
138+
)
139+
140+
expect(createdGuestbookWithStats).toBeDefined()
141+
expect(createdGuestbookWithStats?.usageCount).toEqual(expect.any(Number))
142+
expect(createdGuestbookWithStats?.responseCount).toEqual(expect.any(Number))
143+
})
144+
145+
test('should increment usageCount when assigned to a dataset and responseCount when a response is submitted', async () => {
146+
let statsDatasetIds: CreatedDatasetIdentifiers | undefined
147+
let statsDatasetPublished = false
148+
const guestbookResponse: GuestbookResponseDTO = {
149+
guestbookResponse: {
150+
name: 'Guestbook Stats Test',
151+
email: 'guestbook-stats@example.edu'
152+
}
153+
}
154+
const statsGuestbookId = await sut.createGuestbook(testCollectionAlias, {
155+
...createGuestbookDTO,
156+
name: 'guestbook stats test',
157+
customQuestions: []
158+
})
159+
160+
try {
161+
const initialStats = await getGuestbookStats(statsGuestbookId)
162+
statsDatasetIds = await createDataset.execute(
163+
TestConstants.TEST_NEW_DATASET_DTO,
164+
testCollectionAlias
165+
)
166+
await uploadFileViaApi(statsDatasetIds.numericId, testTextFile1Name)
167+
168+
await sut.assignDatasetGuestbook(statsDatasetIds.numericId, statsGuestbookId)
169+
170+
const statsAfterAssignment = await getGuestbookStats(statsGuestbookId)
171+
expect(statsAfterAssignment.usageCount).toBe((initialStats.usageCount ?? 0) + 1)
172+
expect(statsAfterAssignment.responseCount).toBe(initialStats.responseCount ?? 0)
173+
174+
await publishDatasetViaApi(statsDatasetIds.numericId)
175+
statsDatasetPublished = true
176+
await waitForNoLocks(statsDatasetIds.numericId, 10)
177+
178+
ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined)
179+
await accessRepository.submitGuestbookForDatasetDownload(
180+
statsDatasetIds.numericId,
181+
guestbookResponse
182+
)
183+
184+
ApiConfig.init(
185+
TestConstants.TEST_API_URL,
186+
DataverseApiAuthMechanism.API_KEY,
187+
process.env.TEST_API_KEY
188+
)
189+
const statsAfterResponse = await getGuestbookStats(statsGuestbookId)
190+
expect(statsAfterResponse.usageCount).toBe(statsAfterAssignment.usageCount)
191+
expect(statsAfterResponse.responseCount).toBe((statsAfterAssignment.responseCount ?? 0) + 1)
192+
} finally {
193+
ApiConfig.init(
194+
TestConstants.TEST_API_URL,
195+
DataverseApiAuthMechanism.API_KEY,
196+
process.env.TEST_API_KEY
197+
)
198+
if (statsDatasetIds !== undefined) {
199+
if (statsDatasetPublished) {
200+
await deletePublishedDatasetViaApi(statsDatasetIds.persistentId)
201+
} else {
202+
await deleteUnpublishedDatasetViaApi(statsDatasetIds.numericId)
203+
}
204+
}
205+
}
206+
})
207+
121208
test('should return error when collection does not exist', async () => {
122209
await expect(sut.getGuestbooksByCollectionId(999999)).rejects.toThrow(ReadError)
123210
})
124211
})
125212

213+
const getGuestbookStats = async (guestbookId: number) => {
214+
const guestbooks = await sut.getGuestbooksByCollectionId(testCollectionAlias, true)
215+
const guestbook = guestbooks.find((guestbook) => guestbook.id === guestbookId)
216+
217+
if (guestbook === undefined) {
218+
throw new Error(`Guestbook ${guestbookId} was not found in collection stats.`)
219+
}
220+
221+
return guestbook
222+
}
223+
126224
describe('getGuestbook', () => {
127225
test('should get guestbook by id', async () => {
128226
createdGuestbookId = await sut.createGuestbook(testCollectionId, createGuestbookDTO)

test/unit/guestbooks/GetGuestbooksByCollectionId.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ describe('GetGuestbooksByCollectionId', () => {
1515
positionRequired: false,
1616
customQuestions: [],
1717
createTime: '2024-01-01T00:00:00Z',
18-
dataverseId: 10
18+
dataverseId: 10,
19+
usageCount: 3,
20+
responseCount: 2
1921
}
2022
]
2123
const collectionId = 'collectionAlias'
@@ -31,6 +33,17 @@ describe('GetGuestbooksByCollectionId', () => {
3133
expect(actual).toEqual(guestbooks)
3234
})
3335

36+
test('should request guestbooks with stats when includeStats is true', async () => {
37+
const repository: IGuestbooksRepository = {} as IGuestbooksRepository
38+
repository.getGuestbooksByCollectionId = jest.fn().mockResolvedValue(guestbooks)
39+
40+
const sut = new GetGuestbooksByCollectionId(repository)
41+
const actual = await sut.execute(collectionId, true)
42+
43+
expect(repository.getGuestbooksByCollectionId).toHaveBeenCalledWith(collectionId, true)
44+
expect(actual).toEqual(guestbooks)
45+
})
46+
3447
test('should throw ReadError when repository fails', async () => {
3548
const repository: IGuestbooksRepository = {} as IGuestbooksRepository
3649
repository.getGuestbooksByCollectionId = jest.fn().mockRejectedValue(new ReadError())
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import axios from 'axios'
2+
import {
3+
ApiConfig,
4+
DataverseApiAuthMechanism
5+
} from '../../../src/core/infra/repositories/ApiConfig'
6+
import { GuestbooksRepository } from '../../../src/guestbooks/infra/repositories/GuestbooksRepository'
7+
import { ReadError } from '../../../src/core/domain/repositories/ReadError'
8+
import { TestConstants } from '../../testHelpers/TestConstants'
9+
10+
describe('GuestbooksRepository', () => {
11+
const sut = new GuestbooksRepository()
12+
const collectionIdOrAlias = 'collectionAlias'
13+
const guestbooksResponse = {
14+
data: {
15+
status: 'OK',
16+
data: [
17+
{
18+
id: 12,
19+
name: 'test',
20+
enabled: true,
21+
emailRequired: true,
22+
nameRequired: true,
23+
institutionRequired: false,
24+
positionRequired: false,
25+
customQuestions: [],
26+
createTime: '2024-01-01T00:00:00Z',
27+
dataverseId: 10,
28+
usageCount: 3,
29+
responseCount: 2
30+
}
31+
]
32+
}
33+
}
34+
35+
beforeEach(() => {
36+
ApiConfig.init(
37+
TestConstants.TEST_API_URL,
38+
DataverseApiAuthMechanism.API_KEY,
39+
TestConstants.TEST_DUMMY_API_KEY
40+
)
41+
42+
jest.clearAllMocks()
43+
})
44+
45+
describe('getGuestbooksByCollectionId', () => {
46+
test('should list guestbooks without stats by default', async () => {
47+
jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponse)
48+
49+
const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias)
50+
51+
expect(axios.get).toHaveBeenCalledWith(
52+
`${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`,
53+
TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY
54+
)
55+
expect(actual).toStrictEqual(guestbooksResponse.data.data)
56+
})
57+
58+
test('should list guestbooks with stats when includeStats is true', async () => {
59+
jest.spyOn(axios, 'get').mockResolvedValue(guestbooksResponse)
60+
61+
const actual = await sut.getGuestbooksByCollectionId(collectionIdOrAlias, true)
62+
63+
expect(axios.get).toHaveBeenCalledWith(
64+
`${TestConstants.TEST_API_URL}/guestbooks/${collectionIdOrAlias}/list`,
65+
{
66+
params: {
67+
includeStats: true
68+
},
69+
headers: TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY.headers
70+
}
71+
)
72+
expect(actual[0].usageCount).toBe(3)
73+
expect(actual[0].responseCount).toBe(2)
74+
})
75+
76+
test('should return error result on error response', async () => {
77+
jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE)
78+
79+
await expect(sut.getGuestbooksByCollectionId(collectionIdOrAlias, true)).rejects.toThrow(
80+
ReadError
81+
)
82+
})
83+
})
84+
})

0 commit comments

Comments
 (0)