From 7761c4e91b00817aa09293b742f4cfeff9883375 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 12 Nov 2026 13:56:56 -0500 Subject: [PATCH] feat: expose collection guestbook inheritance setting --- CHANGELOG.md | 2 + docs/useCases.md | 14 ++++- src/collections/domain/dtos/CollectionDTO.ts | 2 + src/collections/domain/models/Collection.ts | 2 + .../repositories/CollectionsRepository.ts | 8 +++ .../transformers/CollectionPayload.ts | 1 + .../transformers/collectionTransformers.ts | 3 ++ .../collections/UpdateCollection.test.ts | 19 +++++++ .../collections/CollectionsRepository.test.ts | 53 +++++++++++++++++++ .../testHelpers/users/builtinUserApiHelper.ts | 4 ++ .../collections/CollectionsRepository.test.ts | 46 ++++++++++++++++ 11 files changed, 153 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d822c2..d9036f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Collections: Exposed optional `guestbookRoot` through `getCollection`, `createCollection`, and `updateCollection` to read and persist guestbook inheritance settings (Dataverse 6.12+). + ### Changed ### Fixed diff --git a/docs/useCases.md b/docs/useCases.md index 64a2c8d6..f9c75e23 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -166,6 +166,8 @@ The different use cases currently available in the package are classified below, Returns a [Collection](../src/collections/domain/models/Collection.ts) instance, given the search parameters to identify it. +The optional `guestbookRoot` property reports guestbook inheritance: `false` includes guestbooks from parent collections; `true` stops inheritance. It is undefined when the server does not expose this setting (before Dataverse 6.12). + ##### Example call: ```typescript @@ -522,6 +524,8 @@ Notes: Creates a new Collection, given a [CollectionDTO](../src/collections/domain/dtos/CollectionDTO.ts) object and an optional parent collection identifier, which defaults to `:root`. +On Dataverse 6.12 or later, optionally set `guestbookRoot` to `false` to include parent guestbooks or `true` to stop inheritance. Omit it to use the server default. + ##### Example call: ```typescript @@ -602,7 +606,15 @@ The `collectionIdOrAlias` is a generic collection identifier, which can be eithe #### Update a Collection -Updates an existing collection, given a collection identifier and a [CollectionDTO](../src/collections/domain/dtos/CollectionDTO.ts) including the updated collection data. +Updates an existing collection, given a collection identifier and a partial [CollectionDTO](../src/collections/domain/dtos/CollectionDTO.ts) including the updated collection data. + +On Dataverse 6.12 or later, persist the "include guestbooks from parent collections" setting using `guestbookRoot`. The value is the inverse of the checkbox: `false` includes parent guestbooks and `true` stops inheritance. Omitting the property leaves the setting unchanged. + +```typescript +await updateCollection.execute(collectionIdOrAlias, { guestbookRoot: false }) +const collection = await getCollection.execute(collectionIdOrAlias) +const includeParentGuestbooks = collection.guestbookRoot === false +``` ##### Example call: diff --git a/src/collections/domain/dtos/CollectionDTO.ts b/src/collections/domain/dtos/CollectionDTO.ts index df1e215c..7ef1373e 100644 --- a/src/collections/domain/dtos/CollectionDTO.ts +++ b/src/collections/domain/dtos/CollectionDTO.ts @@ -5,6 +5,8 @@ export interface CollectionDTO { name: string contacts: string[] type: CollectionType + /** True stops guestbook inheritance; false includes guestbooks from parent collections. */ + guestbookRoot?: boolean affiliation?: string description?: string metadataBlockNames?: string[] diff --git a/src/collections/domain/models/Collection.ts b/src/collections/domain/models/Collection.ts index 28b99403..07b127d0 100644 --- a/src/collections/domain/models/Collection.ts +++ b/src/collections/domain/models/Collection.ts @@ -8,6 +8,8 @@ export interface Collection { alias: string name: string isReleased: boolean + /** True stops guestbook inheritance; false includes guestbooks from parent collections. */ + guestbookRoot?: boolean affiliation?: string description?: string isPartOf: DvObjectOwnerNode diff --git a/src/collections/infra/repositories/CollectionsRepository.ts b/src/collections/infra/repositories/CollectionsRepository.ts index 99dddaf3..75d565a1 100644 --- a/src/collections/infra/repositories/CollectionsRepository.ts +++ b/src/collections/infra/repositories/CollectionsRepository.ts @@ -50,6 +50,7 @@ export interface NewCollectionRequestPayload { dataverseContacts: NewCollectionContactRequestPayload[] dataverseType: string description?: string + guestbookRoot?: boolean affiliation?: string metadataBlocks: NewCollectionMetadataBlocksRequestPayload } @@ -401,6 +402,9 @@ export class CollectionsRepository extends ApiRepository implements ICollections dataverseType: collectionDTO.type, ...(collectionDTO.description && { description: collectionDTO.description }), ...(collectionDTO.affiliation && { affiliation: collectionDTO.affiliation }), + ...(collectionDTO.guestbookRoot !== undefined && { + guestbookRoot: collectionDTO.guestbookRoot + }), metadataBlocks: metadataBlocksRequestBody } } @@ -478,6 +482,10 @@ export class CollectionsRepository extends ApiRepository implements ICollections requestBody.affiliation = collectionDTO.affiliation } + if (collectionDTO.guestbookRoot !== undefined) { + requestBody.guestbookRoot = collectionDTO.guestbookRoot + } + if (metadataBlocksRequestBody !== undefined) { requestBody.metadataBlocks = metadataBlocksRequestBody } diff --git a/src/collections/infra/repositories/transformers/CollectionPayload.ts b/src/collections/infra/repositories/transformers/CollectionPayload.ts index a52e8021..dc6543e6 100644 --- a/src/collections/infra/repositories/transformers/CollectionPayload.ts +++ b/src/collections/infra/repositories/transformers/CollectionPayload.ts @@ -4,6 +4,7 @@ export interface CollectionPayload { id: number alias: string name: string + guestbookRoot?: boolean affiliation?: string isReleased: boolean description?: string diff --git a/src/collections/infra/repositories/transformers/collectionTransformers.ts b/src/collections/infra/repositories/transformers/collectionTransformers.ts index 4aa71d0a..763405d8 100644 --- a/src/collections/infra/repositories/transformers/collectionTransformers.ts +++ b/src/collections/infra/repositories/transformers/collectionTransformers.ts @@ -75,6 +75,9 @@ const transformPayloadToCollection = (collectionPayload: CollectionPayload): Col isFacetRoot: collectionPayload.isFacetRoot, description: collectionPayload.description, childCount: collectionPayload.childCount, + ...(collectionPayload.guestbookRoot !== undefined && { + guestbookRoot: collectionPayload.guestbookRoot + }), ...(collectionPayload.theme && { theme: collectionPayload.theme }), diff --git a/test/functional/collections/UpdateCollection.test.ts b/test/functional/collections/UpdateCollection.test.ts index 56ed98d1..f882f074 100644 --- a/test/functional/collections/UpdateCollection.test.ts +++ b/test/functional/collections/UpdateCollection.test.ts @@ -4,6 +4,7 @@ import { createCollection, getCollection, updateCollection, + deleteCollection, CollectionDTO } from '../../../src' import { TestConstants } from '../../testHelpers/TestConstants' @@ -19,6 +20,24 @@ describe('execute', () => { ) }) + test('persists guestbook inheritance through public use cases', async () => { + const dto = { ...createCollectionDTO('guestbook-root-functional'), guestbookRoot: true } + const id = await createCollection.execute(dto) + try { + expect((await getCollection.execute(id)).guestbookRoot).toBe(true) + await updateCollection.execute(dto.alias, { guestbookRoot: false }) + expect((await getCollection.execute(dto.alias)).guestbookRoot).toBe(false) + await updateCollection.execute(id, { name: 'Updated guestbook collection' }) + expect((await getCollection.execute(id)).guestbookRoot).toBe(false) + await updateCollection.execute(id, { guestbookRoot: true }) + const collection = await getCollection.execute(id) + expect(collection.guestbookRoot).toBe(true) + expect(collection.name).toBe('Updated guestbook collection') + } finally { + await deleteCollection.execute(id) + } + }) + test('should successfully update a new collection', async () => { const testNewCollectionAlias = 'updateCollection-functional-test' const testNewCollection = createCollectionDTO(testNewCollectionAlias) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 0e30d5f2..3a053a24 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -1,3 +1,7 @@ +import { + createBuiltInUser, + deleteAuthenticatedUserViaAdminApi +} from '../../testHelpers/users/builtinUserApiHelper' import { CollectionsRepository } from '../../../src/collections/infra/repositories/CollectionsRepository' import { TestConstants } from '../../testHelpers/TestConstants' import { @@ -88,6 +92,55 @@ describe('CollectionsRepository', () => { await deleteCollectionViaApi(testCollectionAlias) }) + describe('guestbookRoot', () => { + const authenticate = (key?: string) => + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, key) + + beforeEach(() => authenticate(process.env.TEST_API_KEY)) + afterEach(() => authenticate(process.env.TEST_API_KEY)) + + test('allows anonymous reads but rejects guestbookRoot updates without permission', async () => { + const username = `guestbookReader${Date.now()}` + const otherUserKey = await createBuiltInUser(username) + const id = await sut.createCollection({ + ...createCollectionDTO('guestbook-root-permissions'), + guestbookRoot: false + }) + try { + await sut.publishCollection(id) + authenticate() + expect((await sut.getCollection(id)).guestbookRoot).toBe(false) + authenticate(otherUserKey) + await expect(sut.updateCollection(id, { guestbookRoot: true })).rejects.toMatchObject({ + message: expect.stringContaining(`[401] User @${username} is not permitted`) + }) + authenticate(process.env.TEST_API_KEY) + expect((await sut.getCollection(id)).guestbookRoot).toBe(false) + } finally { + authenticate(process.env.TEST_API_KEY) + await sut.deleteCollection(id) + await deleteAuthenticatedUserViaAdminApi(username) + } + }) + + test.each([true, false])( + 'persists guestbookRoot=%s on creation and update', + async (guestbookRoot) => { + const dto = { ...createCollectionDTO(`guestbook-root-${guestbookRoot}`), guestbookRoot } + const id = await sut.createCollection(dto) + try { + expect((await sut.getCollection(id)).guestbookRoot).toBe(guestbookRoot) + await sut.updateCollection(dto.alias, { guestbookRoot: !guestbookRoot }) + expect((await sut.getCollection(dto.alias)).guestbookRoot).toBe(!guestbookRoot) + await sut.updateCollection(id, { name: 'Renamed collection' }) + expect((await sut.getCollection(id)).guestbookRoot).toBe(!guestbookRoot) + } finally { + await sut.deleteCollection(id) + } + } + ) + }) + describe('getCollection', () => { describe('by default `root` Id', () => { test('should return the root collection of the Dataverse installation if no parameter is passed AS `root`', async () => { diff --git a/test/testHelpers/users/builtinUserApiHelper.ts b/test/testHelpers/users/builtinUserApiHelper.ts index d91e08a3..09b08eac 100644 --- a/test/testHelpers/users/builtinUserApiHelper.ts +++ b/test/testHelpers/users/builtinUserApiHelper.ts @@ -25,3 +25,7 @@ export const createBuiltInUser = async (userName: string): Promise => { throw new Error(`Error while creating API token`) } } + +export const deleteAuthenticatedUserViaAdminApi = async (userIdentifier: string): Promise => { + await axios.delete(`${TestConstants.TEST_API_URL}/admin/authenticatedUsers/${userIdentifier}`) +} diff --git a/test/unit/collections/CollectionsRepository.test.ts b/test/unit/collections/CollectionsRepository.test.ts index 075034cc..9ab3b5cf 100644 --- a/test/unit/collections/CollectionsRepository.test.ts +++ b/test/unit/collections/CollectionsRepository.test.ts @@ -118,6 +118,52 @@ describe('CollectionsRepository', () => { jest.clearAllMocks() }) + describe('guestbookRoot', () => { + test.each([true, false, undefined])('reads guestbookRoot=%s', async (guestbookRoot) => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { data: { ...createCollectionPayload(), guestbookRoot } } + }) + + const collection = await sut.getCollection('test-collection') + + expect(collection.guestbookRoot).toBe(guestbookRoot) + expect(Object.prototype.hasOwnProperty.call(collection, 'guestbookRoot')).toBe( + guestbookRoot !== undefined + ) + }) + + test.each([true, false, undefined])('creates with guestbookRoot=%s', async (guestbookRoot) => { + jest.spyOn(axios, 'post').mockResolvedValue({ data: { data: { id: 1 } } }) + + await sut.createCollection({ ...createCollectionDTO(), guestbookRoot }) + + const requestBody = JSON.parse(jest.mocked(axios.post).mock.calls[0][1] as string) + expect(requestBody).toEqual({ ...createNewCollectionRequestPayload(), guestbookRoot }) + expect(Object.prototype.hasOwnProperty.call(requestBody, 'guestbookRoot')).toBe( + guestbookRoot !== undefined + ) + + expect(axios.post).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/:root`, + expect.any(String), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + }) + + test.each([123, 'test-collection'])('updates only guestbookRoot by %s', async (identifier) => { + jest.spyOn(axios, 'put').mockResolvedValue({ data: { data: {} } }) + for (const guestbookRoot of [true, false]) { + await sut.updateCollection(identifier, { guestbookRoot }) + + expect(axios.put).toHaveBeenLastCalledWith( + `${TestConstants.TEST_API_URL}/dataverses/${identifier}`, + JSON.stringify({ guestbookRoot }), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + } + }) + }) + describe('getCollection', () => { const expectedRequestConfigApiKey = { params: {