From 93321c967c78758422c7a632b92abdd3e9fed897 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Thu, 15 Oct 2026 10:57:40 -0400 Subject: [PATCH] feat: curation workflow use cases --- CHANGELOG.md | 2 + docs/useCases.md | 116 ++++++++++ src/datasets/domain/models/DatasetCuration.ts | 10 + .../repositories/IDatasetsRepository.ts | 8 + .../curation/DeleteDatasetCurationStatus.ts | 14 ++ .../GetAllowedDatasetCurationLabels.ts | 14 ++ .../curation/GetDatasetCurationStatus.ts | 15 ++ .../GetDatasetCurationStatusHistory.ts | 15 ++ .../curation/SetDatasetCurationStatus.ts | 14 ++ .../curation/SubmitDatasetForReview.ts | 15 ++ src/datasets/index.ts | 20 ++ .../infra/repositories/DatasetsRepository.ts | 55 +++++ .../datasetCurationTransformers.ts | 15 ++ .../datasets/CurationWorkflow.test.ts | 48 ++++ .../datasets/CurationWorkflow.test.ts | 207 ++++++++++++++++++ .../datasets/curationWorkflowHelper.ts | 126 +++++++++++ test/unit/datasets/DatasetsRepository.test.ts | 168 ++++++++++++++ .../DeleteDatasetCurationStatus.test.ts | 28 +++ .../GetAllowedDatasetCurationLabels.test.ts | 28 +++ .../datasets/GetDatasetCurationStatus.test.ts | 28 +++ .../GetDatasetCurationStatusHistory.test.ts | 28 +++ .../datasets/SetDatasetCurationStatus.test.ts | 28 +++ .../datasets/SubmitDatasetForReview.test.ts | 28 +++ 23 files changed, 1030 insertions(+) create mode 100644 src/datasets/domain/models/DatasetCuration.ts create mode 100644 src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus.ts create mode 100644 src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels.ts create mode 100644 src/datasets/domain/useCases/curation/GetDatasetCurationStatus.ts create mode 100644 src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory.ts create mode 100644 src/datasets/domain/useCases/curation/SetDatasetCurationStatus.ts create mode 100644 src/datasets/domain/useCases/curation/SubmitDatasetForReview.ts create mode 100644 src/datasets/infra/repositories/transformers/datasetCurationTransformers.ts create mode 100644 test/functional/datasets/CurationWorkflow.test.ts create mode 100644 test/integration/datasets/CurationWorkflow.test.ts create mode 100644 test/testHelpers/datasets/curationWorkflowHelper.ts create mode 100644 test/unit/datasets/DeleteDatasetCurationStatus.test.ts create mode 100644 test/unit/datasets/GetAllowedDatasetCurationLabels.test.ts create mode 100644 test/unit/datasets/GetDatasetCurationStatus.test.ts create mode 100644 test/unit/datasets/GetDatasetCurationStatusHistory.test.ts create mode 100644 test/unit/datasets/SetDatasetCurationStatus.test.ts create mode 100644 test/unit/datasets/SubmitDatasetForReview.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d822c2..2d3fcd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Datasets: Added public use cases for allowed curation labels, current status, status history, setting/removing labels, and submitting drafts for review, with numeric and persistent identifier support. + ### Changed ### Fixed diff --git a/docs/useCases.md b/docs/useCases.md index 64a2c8d6..a092a3fe 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -44,6 +44,9 @@ The different use cases currently available in the package are classified below, - [Unset Template As Default](#unset-template-as-default) - [Datasets](#Datasets) - [Datasets read use cases](#datasets-read-use-cases) + - [Get Allowed Dataset Curation Labels](#get-allowed-dataset-curation-labels) + - [Get Dataset Curation Status](#get-dataset-curation-status) + - [Get Dataset Curation Status History](#get-dataset-curation-status-history) - [Get a Dataset](#get-a-dataset) - [Get Dataset By Private URL Token](#get-dataset-by-private-url-token) - [Get Dataset Citation Text](#get-dataset-citation-text) @@ -65,6 +68,9 @@ The different use cases currently available in the package are classified below, - [Get Dataset Available Dataset Type](#get-dataset-available-dataset-type) - [Get Dataset Upload Limits](#get-dataset-upload-limits) - [Datasets write use cases](#datasets-write-use-cases) + - [Set Dataset Curation Status](#set-dataset-curation-status) + - [Delete Dataset Curation Status](#delete-dataset-curation-status) + - [Submit Dataset For Review](#submit-dataset-for-review) - [Create a Dataset](#create-a-dataset) - [Update a Dataset](#update-a-dataset) - [Update a Dataset License](#update-a-dataset-license) @@ -967,6 +973,65 @@ _See [use case](../src/templates/domain/useCases/UpdateTemplateTermsOfAccess.ts) ### Datasets Read Use Cases +Curation labels require the server setting `:AllowedCurationLabels`, a JSON object mapping label-set names to arrays of labels, for example `{"defaultSet": ["Privacy Review", "Final Approval"]}`. A superuser selects a label set on a collection or dataset using the administration APIs. An explicit dataset selection overrides its collection. `DEFAULT` (or no selection) inherits from the parent collection up to the root; if no set is selected there, labels are disabled. `DISABLED` explicitly disables labels. Defining the setting alone does not select a set. Labels currently allow letters, digits, underscores, and spaces. These administration operations are not exposed by these use cases. + +Allowed-label reads and status mutations require publish permission. Current-status and history reads also require publish permission by default. With `dataverse.ui.show-curation-status-to-all=true`, users with permission to view the draft can read them. The backend enforces these permissions; the SDK attaches the configured API key, session cookie, or bearer token. + +Labels only track curation progress: assigning, changing, or removing one does not submit for review, return to the author, publish, or change review locks. Publication clears the draft label. `DatasetCurationStatus` and `DatasetReviewSubmission` are exported from the package root. + +See the [Dataverse curation API guide](https://guides.dataverse.org/en/latest/api/curation-labels.html) for server configuration and API behavior. + +#### Get Allowed Dataset Curation Labels + +Returns the effective configured labels in server order. Requires publish permission. Labels are returned unchanged, including whitespace. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `ReadError`. + +##### Example call: + +```typescript +import { getAllowedDatasetCurationLabels } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await getAllowedDatasetCurationLabels.execute(datasetId) // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels.ts) implementation_. + +#### Get Dataset Curation Status + +Returns the current curation record, or `null` when no record exists. A removal is a record with no `label` (`undefined` when read); it retains its timestamp and assigner. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `ReadError`. + +##### Example call: + +```typescript +import { getDatasetCurationStatus } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await getDatasetCurationStatus.execute(datasetId) // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/GetDatasetCurationStatus.ts) implementation_. + +#### Get Dataset Curation Status History + +Returns all assignments and removals, newest first, or an empty array when no history exists. Missing legacy `createTime` and `assigner` fields remain `undefined`. Timestamps remain server strings without timezone conversion. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `ReadError`. + +##### Example call: + +```typescript +import { getDatasetCurationStatusHistory } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await getDatasetCurationStatusHistory.execute(datasetId) // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory.ts) implementation_. + #### Get a Dataset Returns a [Dataset](../src/datasets/domain/models/Dataset.ts) instance, given the search parameters to identify it. @@ -1423,6 +1488,57 @@ Example returned value: ### Datasets Write Use Cases +#### Set Dataset Curation Status + +Assigns or changes a configured label on a draft. Requires publish permission. Pass the exact value returned by `getAllowedDatasetCurationLabels`; reserved characters are safely encoded. Unconfigured labels, unchanged labels, and datasets without a draft are rejected by the backend. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `WriteError`. + +##### Example call: + +```typescript +import { setDatasetCurationStatus } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await setDatasetCurationStatus.execute(datasetId, 'Privacy Review') // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/SetDatasetCurationStatus.ts) implementation_. + +#### Delete Dataset Curation Status + +Removes the current draft label and retains a removal record in its history. Requires publish permission. This does not return the dataset to its author or remove review locks. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `WriteError`. + +##### Example call: + +```typescript +import { deleteDatasetCurationStatus } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await deleteDatasetCurationStatus.execute(datasetId) // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus.ts) implementation_. + +#### Submit Dataset For Review + +Submits a draft for review and returns `{ inReview: boolean, message: string }`. Requires an authenticated user with edit permission; anonymous submission is not supported by the backend. This adds a review lock and notifies curators. Drafts already in review and datasets without a draft are rejected. + +The `datasetId` accepts a numeric ID or persistent identifier. Configure authentication with `ApiConfig` before calling the use case. Backend failures propagate as `WriteError`. + +##### Example call: + +```typescript +import { submitDatasetForReview } from '@iqss/dataverse-client-javascript' + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +await submitDatasetForReview.execute(datasetId) // Promise +``` + +_See [use case](../src/datasets/domain/useCases/curation/SubmitDatasetForReview.ts) implementation_. + #### Create a Dataset Creates a new Dataset in a collection, given a [DatasetDTO](../src/datasets/domain/dtos/DatasetDTO.ts) object, an optional collection identifier, which defaults to `:root`, and an optional dataset type. diff --git a/src/datasets/domain/models/DatasetCuration.ts b/src/datasets/domain/models/DatasetCuration.ts new file mode 100644 index 00000000..b789f082 --- /dev/null +++ b/src/datasets/domain/models/DatasetCuration.ts @@ -0,0 +1,10 @@ +export interface DatasetCurationStatus { + label?: string + createTime?: string + assigner?: string +} + +export interface DatasetReviewSubmission { + inReview: boolean + message: string +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index 0a7119af..b91180e5 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -22,8 +22,16 @@ import { DatasetUploadLimits } from '../models/DatasetUploadLimits' import { DatasetReview } from '../models/DatasetReview' import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' +import { DatasetCurationStatus, DatasetReviewSubmission } from '../models/DatasetCuration' export interface IDatasetsRepository { + getAllowedDatasetCurationLabels(datasetId: number | string): Promise + getDatasetCurationStatus(datasetId: number | string): Promise + getDatasetCurationStatusHistory(datasetId: number | string): Promise + setDatasetCurationStatus(datasetId: number | string, label: string): Promise + deleteDatasetCurationStatus(datasetId: number | string): Promise + submitDatasetForReview(datasetId: number | string): Promise + getDataset( datasetId: number | string, datasetVersionId: string, diff --git a/src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus.ts b/src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus.ts new file mode 100644 index 00000000..0bf44fef --- /dev/null +++ b/src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus.ts @@ -0,0 +1,14 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' + +export class DeleteDatasetCurationStatus implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Removes the label from a draft, retaining history. Requires publish permission. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.deleteDatasetCurationStatus(datasetId) + } +} diff --git a/src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels.ts b/src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels.ts new file mode 100644 index 00000000..8cfc4ecd --- /dev/null +++ b/src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels.ts @@ -0,0 +1,14 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' + +export class GetAllowedDatasetCurationLabels implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Returns the effective allowed labels. Requires publish permission. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.getAllowedDatasetCurationLabels(datasetId) + } +} diff --git a/src/datasets/domain/useCases/curation/GetDatasetCurationStatus.ts b/src/datasets/domain/useCases/curation/GetDatasetCurationStatus.ts new file mode 100644 index 00000000..76f84c48 --- /dev/null +++ b/src/datasets/domain/useCases/curation/GetDatasetCurationStatus.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' +import { DatasetCurationStatus } from '../../models/DatasetCuration' + +export class GetDatasetCurationStatus implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Returns the latest curation record, or null if none exists. Visibility is enforced by the server. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.getDatasetCurationStatus(datasetId) + } +} diff --git a/src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory.ts b/src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory.ts new file mode 100644 index 00000000..42332a7a --- /dev/null +++ b/src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' +import { DatasetCurationStatus } from '../../models/DatasetCuration' + +export class GetDatasetCurationStatusHistory implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Returns curation assignments and removals in server order, newest first. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.getDatasetCurationStatusHistory(datasetId) + } +} diff --git a/src/datasets/domain/useCases/curation/SetDatasetCurationStatus.ts b/src/datasets/domain/useCases/curation/SetDatasetCurationStatus.ts new file mode 100644 index 00000000..dc606955 --- /dev/null +++ b/src/datasets/domain/useCases/curation/SetDatasetCurationStatus.ts @@ -0,0 +1,14 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' + +export class SetDatasetCurationStatus implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Assigns a configured label to a draft. Requires publish permission; does not change review or publication state. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string, label: string): Promise { + return await this.datasetsRepository.setDatasetCurationStatus(datasetId, label) + } +} diff --git a/src/datasets/domain/useCases/curation/SubmitDatasetForReview.ts b/src/datasets/domain/useCases/curation/SubmitDatasetForReview.ts new file mode 100644 index 00000000..09b614f5 --- /dev/null +++ b/src/datasets/domain/useCases/curation/SubmitDatasetForReview.ts @@ -0,0 +1,15 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' +import { DatasetReviewSubmission } from '../../models/DatasetCuration' + +export class SubmitDatasetForReview implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Submits a draft for review. Requires authentication and edit permission; anonymous submissions are not supported. + * @param datasetId Numeric dataset ID or persistent identifier. + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.submitDatasetForReview(datasetId) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index afa2252e..450c0cae 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -38,8 +38,20 @@ import { GetDatasetStorageDriver } from './domain/useCases/GetDatasetStorageDriv import { GetDatasetUploadLimits } from './domain/useCases/GetDatasetUploadLimits' import { GetDatasetReviews } from './domain/useCases/GetDatasetReviews' import { ExportDatasetMetadata } from './domain/useCases/ExportDatasetMetadata' +import { GetAllowedDatasetCurationLabels } from './domain/useCases/curation/GetAllowedDatasetCurationLabels' +import { GetDatasetCurationStatus } from './domain/useCases/curation/GetDatasetCurationStatus' +import { GetDatasetCurationStatusHistory } from './domain/useCases/curation/GetDatasetCurationStatusHistory' +import { SetDatasetCurationStatus } from './domain/useCases/curation/SetDatasetCurationStatus' +import { DeleteDatasetCurationStatus } from './domain/useCases/curation/DeleteDatasetCurationStatus' +import { SubmitDatasetForReview } from './domain/useCases/curation/SubmitDatasetForReview' const datasetsRepository = new DatasetsRepository() +const getAllowedDatasetCurationLabels = new GetAllowedDatasetCurationLabels(datasetsRepository) +const getDatasetCurationStatus = new GetDatasetCurationStatus(datasetsRepository) +const getDatasetCurationStatusHistory = new GetDatasetCurationStatusHistory(datasetsRepository) +const setDatasetCurationStatus = new SetDatasetCurationStatus(datasetsRepository) +const deleteDatasetCurationStatus = new DeleteDatasetCurationStatus(datasetsRepository) +const submitDatasetForReview = new SubmitDatasetForReview(datasetsRepository) const getDataset = new GetDataset(datasetsRepository) const getDatasetLocks = new GetDatasetLocks(datasetsRepository) @@ -93,7 +105,15 @@ const getDatasetUploadLimits = new GetDatasetUploadLimits(datasetsRepository) const getDatasetReviews = new GetDatasetReviews(datasetsRepository) const exportDatasetMetadata = new ExportDatasetMetadata(datasetsRepository) +export { DatasetCurationStatus, DatasetReviewSubmission } from './domain/models/DatasetCuration' + export { + getAllowedDatasetCurationLabels, + getDatasetCurationStatus, + getDatasetCurationStatusHistory, + setDatasetCurationStatus, + deleteDatasetCurationStatus, + submitDatasetForReview, getDataset, getDatasetLocks, getDatasetCitation, diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index a0e80a29..cc64621a 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -38,6 +38,12 @@ import { DatasetReview } from '../../domain/models/DatasetReview' import { transformDatasetReviewsResponseToDatasetReviews } from './transformers/datasetReviewTransformers' import { ExportedDatasetMetadata } from '../../domain/models/ExportedDatasetMetadata' import { DatasetPayload } from './transformers/DatasetPayload' +import { DatasetCurationStatus, DatasetReviewSubmission } from '../../domain/models/DatasetCuration' +import { + transformCurationStatus, + transformCurrentCurationStatus, + transformAllowedCurationLabels +} from './transformers/datasetCurationTransformers' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -48,6 +54,55 @@ export interface GetAllDatasetPreviewsQueryParams { export class DatasetsRepository extends ApiRepository implements IDatasetsRepository { private readonly datasetsResourceName: string = 'datasets' + private buildCurationEndpoint(datasetId: number | string, operation: string): string { + return this.buildApiEndpoint( + this.datasetsResourceName, + operation, + typeof datasetId === 'string' ? encodeURIComponent(datasetId) : datasetId + ) + } + + public async getAllowedDatasetCurationLabels(datasetId: number | string): Promise { + const response = await this.doGet( + this.buildCurationEndpoint(datasetId, 'allowedCurationLabels'), + true + ) + return transformAllowedCurationLabels(response.data.data) + } + + public async getDatasetCurationStatus( + datasetId: number | string + ): Promise { + const response = await this.doGet(this.buildCurationEndpoint(datasetId, 'curationStatus'), true) + return transformCurrentCurationStatus(response.data.data) + } + + public async getDatasetCurationStatusHistory( + datasetId: number | string + ): Promise { + const response = await this.doGet( + this.buildCurationEndpoint(datasetId, 'curationStatus'), + true, + { includeHistory: true } + ) + return response.data.data.map(transformCurationStatus) + } + + public async setDatasetCurationStatus(datasetId: number | string, label: string): Promise { + await this.doPut(this.buildCurationEndpoint(datasetId, 'curationStatus'), '', { label }) + } + + public async deleteDatasetCurationStatus(datasetId: number | string): Promise { + await this.doDelete(this.buildCurationEndpoint(datasetId, 'curationStatus')) + } + + public async submitDatasetForReview( + datasetId: number | string + ): Promise { + const response = await this.doPost(this.buildCurationEndpoint(datasetId, 'submitForReview'), '') + return { inReview: response.data.data.inReview, message: response.data.data.message } + } + public async getDatasetSummaryFieldNames(): Promise { return this.doGet(this.buildApiEndpoint(this.datasetsResourceName, 'summaryFieldNames')) .then((response) => response.data.data) diff --git a/src/datasets/infra/repositories/transformers/datasetCurationTransformers.ts b/src/datasets/infra/repositories/transformers/datasetCurationTransformers.ts new file mode 100644 index 00000000..f0bb8da6 --- /dev/null +++ b/src/datasets/infra/repositories/transformers/datasetCurationTransformers.ts @@ -0,0 +1,15 @@ +import { DatasetCurationStatus } from '../../../domain/models/DatasetCuration' + +export const transformCurationStatus = (status: DatasetCurationStatus): DatasetCurationStatus => ({ + ...status +}) + +export const transformCurrentCurationStatus = ( + status: DatasetCurationStatus +): DatasetCurationStatus | null => + Object.keys(status).length === 0 ? null : transformCurationStatus(status) + +export const transformAllowedCurationLabels = (data: { message: string } | string): string[] => { + const labels = typeof data === 'string' ? data : data.message + return labels === '' ? [] : labels.split(',') +} diff --git a/test/functional/datasets/CurationWorkflow.test.ts b/test/functional/datasets/CurationWorkflow.test.ts new file mode 100644 index 00000000..64611110 --- /dev/null +++ b/test/functional/datasets/CurationWorkflow.test.ts @@ -0,0 +1,48 @@ +import { + getAllowedDatasetCurationLabels, + getDatasetCurationStatus, + getDatasetCurationStatusHistory, + setDatasetCurationStatus, + deleteDatasetCurationStatus, + submitDatasetForReview, + getDatasetLocks, + DatasetLockType +} from '../../../src' +import { + CurationWorkflowFixture, + createCurationWorkflowFixture, + useCurationAdmin +} from '../../testHelpers/datasets/curationWorkflowHelper' + +describe('Dataset curation public use cases', () => { + let fixture: CurationWorkflowFixture + beforeAll(async () => { + fixture = await createCurationWorkflowFixture() + }) + beforeEach(useCurationAdmin) + afterEach(useCurationAdmin) + afterAll(async () => { + if (fixture) await fixture.cleanup() + }) + + test('should submit a draft and track the curator’s label changes and removal', async () => { + const id = fixture.dataset.persistentId + expect(await getDatasetCurationStatus.execute(id)).toBeNull() + expect(await getDatasetCurationStatusHistory.execute(id)).toEqual([]) + const labels = await getAllowedDatasetCurationLabels.execute(id) + expect(labels).toEqual(fixture.labels) + expect((await submitDatasetForReview.execute(id)).inReview).toBe(true) + await setDatasetCurationStatus.execute(id, labels[0]) + expect((await getDatasetCurationStatus.execute(id))?.label).toBe(labels[0]) + await setDatasetCurationStatus.execute(id, labels[1]) + expect((await getDatasetCurationStatus.execute(id))?.label).toBe(labels[1]) + await deleteDatasetCurationStatus.execute(id) + expect((await getDatasetCurationStatus.execute(id))?.label).toBeUndefined() + expect( + (await getDatasetCurationStatusHistory.execute(id)).map((status) => status.label) + ).toEqual([undefined, labels[1], labels[0]]) + expect(await getDatasetLocks.execute(id)).toEqual( + expect.arrayContaining([expect.objectContaining({ lockType: DatasetLockType.IN_REVIEW })]) + ) + }) +}) diff --git a/test/integration/datasets/CurationWorkflow.test.ts b/test/integration/datasets/CurationWorkflow.test.ts new file mode 100644 index 00000000..088d6db3 --- /dev/null +++ b/test/integration/datasets/CurationWorkflow.test.ts @@ -0,0 +1,207 @@ +import axios from 'axios' +import { randomUUID } from 'node:crypto' +import { + ApiConfig, + DatasetLockType, + DatasetNotNumberedVersion, + DatasetVersionState, + getDataset, + getDatasetLocks, + publishCollection, + publishDataset, + VersionUpdateType +} from '../../../src' +import { DatasetsRepository } from '../../../src/datasets/infra/repositories/DatasetsRepository' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + CurationWorkflowFixture, + createCurationWorkflowFixture, + useCurationAdmin +} from '../../testHelpers/datasets/curationWorkflowHelper' +import { waitForNoLocks } from '../../testHelpers/datasets/datasetHelper' +import { createBuiltInUser } from '../../testHelpers/users/builtinUserApiHelper' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('DatasetsRepository curation methods', () => { + const sut = new DatasetsRepository() + let fixture: CurationWorkflowFixture + let cleanup: (() => Promise) | undefined + + beforeEach(async () => { + useCurationAdmin() + cleanup = undefined + fixture = await createCurationWorkflowFixture() + cleanup = fixture.cleanup + }) + afterEach(async () => { + useCurationAdmin() + if (cleanup) await cleanup() + }) + + describe.each(['numeric', 'persistent'])('with a %s identifier', (kind) => { + let id: number | string + beforeEach(() => { + id = kind === 'numeric' ? fixture.dataset.numericId : fixture.dataset.persistentId + }) + + describe('getAllowedDatasetCurationLabels', () => { + test('should return the effective configured labels', async () => { + expect(await sut.getAllowedDatasetCurationLabels(id)).toEqual(fixture.labels) + }) + }) + describe('getDatasetCurationStatus', () => { + test('should return null before the first assignment', async () => { + expect(await sut.getDatasetCurationStatus(id)).toBeNull() + }) + test('should return the assigned label, timestamp and user', async () => { + await sut.setDatasetCurationStatus(id, fixture.labels[0]) + const actual = await sut.getDatasetCurationStatus(id) + expect(actual?.label).toBe(fixture.labels[0]) + expect(actual?.createTime).toBeTruthy() + expect(actual?.assigner).toBeTruthy() + }) + }) + describe('getDatasetCurationStatusHistory', () => { + test('should return an empty history before the first assignment', async () => { + expect(await sut.getDatasetCurationStatusHistory(id)).toEqual([]) + }) + test('should include changes and removal in newest-first order', async () => { + await sut.setDatasetCurationStatus(id, fixture.labels[0]) + await sut.setDatasetCurationStatus(id, fixture.labels[1]) + await sut.deleteDatasetCurationStatus(id) + const actual = await sut.getDatasetCurationStatusHistory(id) + expect(actual.map((status) => status.label)).toEqual([ + undefined, + fixture.labels[1], + fixture.labels[0] + ]) + for (const status of actual) { + expect(status.createTime).toBeTruthy() + expect(status.assigner).toBeTruthy() + } + }) + }) + describe('setDatasetCurationStatus', () => { + test('should assign and change labels containing spaces', async () => { + await sut.setDatasetCurationStatus(id, fixture.labels[0]) + await sut.setDatasetCurationStatus(id, fixture.labels[1]) + expect((await sut.getDatasetCurationStatus(id))?.label).toBe(fixture.labels[1]) + }) + test('should reject an unconfigured label', async () => { + await expect( + sut.setDatasetCurationStatus(id, 'Not configured &label=Privacy Review') + ).rejects.toThrow(/label not allowed/i) + }) + test('should leave review locks and publication state unchanged', async () => { + const locks = await getDatasetLocks.execute(id) + await sut.setDatasetCurationStatus(id, fixture.labels[0]) + expect(await getDatasetLocks.execute(id)).toEqual(locks) + expect( + (await getDataset.execute(id, DatasetNotNumberedVersion.DRAFT)).versionInfo.state + ).toBe(DatasetVersionState.DRAFT) + }) + }) + describe('deleteDatasetCurationStatus', () => { + test('should retain a removal record without ending review', async () => { + await sut.submitDatasetForReview(id) + await sut.setDatasetCurationStatus(id, fixture.labels[0]) + const locks = await getDatasetLocks.execute(id) + await sut.deleteDatasetCurationStatus(id) + expect((await sut.getDatasetCurationStatus(id))?.label).toBeUndefined() + expect(await getDatasetLocks.execute(id)).toEqual(locks) + expect( + (await getDataset.execute(id, DatasetNotNumberedVersion.DRAFT)).versionInfo.state + ).toBe(DatasetVersionState.DRAFT) + }) + }) + describe('submitDatasetForReview', () => { + test('should submit a draft and add the review lock', async () => { + const actual = await sut.submitDatasetForReview(id) + expect(actual.inReview).toBe(true) + expect(actual.message).toContain('submitted for review') + expect(await getDatasetLocks.execute(id)).toEqual( + expect.arrayContaining([expect.objectContaining({ lockType: DatasetLockType.IN_REVIEW })]) + ) + }) + test('should reject a draft already in review', async () => { + await sut.submitDatasetForReview(id) + await expect(sut.submitDatasetForReview(id)).rejects.toThrow(/already.*review/i) + }) + }) + }) + + const operations = [ + { + name: 'getAllowedDatasetCurationLabels', + execute: () => sut.getAllowedDatasetCurationLabels(fixture.dataset.numericId) + }, + { + name: 'getDatasetCurationStatus', + execute: () => sut.getDatasetCurationStatus(fixture.dataset.numericId) + }, + { + name: 'getDatasetCurationStatusHistory', + execute: () => sut.getDatasetCurationStatusHistory(fixture.dataset.numericId) + }, + { + name: 'setDatasetCurationStatus', + execute: () => sut.setDatasetCurationStatus(fixture.dataset.numericId, fixture.labels[0]) + }, + { + name: 'deleteDatasetCurationStatus', + execute: () => sut.deleteDatasetCurationStatus(fixture.dataset.numericId) + }, + { + name: 'submitDatasetForReview', + execute: () => sut.submitDatasetForReview(fixture.dataset.numericId) + } + ] + + describe('without dataset permission', () => { + const username = `curation${randomUUID().replace(/-/g, '')}` + let token: string + beforeAll(async () => { + token = await createBuiltInUser(username) + }) + afterAll(async () => { + if (token) + await axios.delete(`${TestConstants.TEST_API_URL}/admin/authenticatedUsers/${username}`, { + headers: { 'X-Dataverse-Key': process.env.TEST_API_KEY } + }) + }) + test.each(operations)('$name should reject another user', async ({ execute }) => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, token) + await expect(execute()).rejects.toThrow(/\[403\]|permission|not permitted|authoriz/i) + }) + }) + describe('without authentication', () => { + test.each(operations)('$name should reject an anonymous request', async ({ execute }) => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY) + await expect(execute()).rejects.toThrow(/\[401\]|authenticat|not permitted/i) + }) + }) + describe('with a published dataset', () => { + beforeEach(async () => { + await publishCollection.execute(fixture.alias) + await sut.submitDatasetForReview(fixture.dataset.numericId) + await sut.setDatasetCurationStatus(fixture.dataset.numericId, fixture.labels[0]) + await publishDataset.execute(fixture.dataset.numericId, VersionUpdateType.MAJOR) + await waitForNoLocks(fixture.dataset.numericId) + }) + test('should clear the review lock and label on publication', async () => { + expect(await getDatasetLocks.execute(fixture.dataset.numericId)).toEqual([]) + expect((await sut.getDatasetCurationStatus(fixture.dataset.numericId))?.label).toBeUndefined() + }) + test.each( + operations.filter(({ name }) => + [ + 'setDatasetCurationStatus', + 'deleteDatasetCurationStatus', + 'submitDatasetForReview' + ].includes(name) + ) + )('$name should reject a released dataset without a draft', async ({ execute }) => { + await expect(execute()).rejects.toThrow(/release|publish/i) + }) + }) +}) diff --git a/test/testHelpers/datasets/curationWorkflowHelper.ts b/test/testHelpers/datasets/curationWorkflowHelper.ts new file mode 100644 index 00000000..1583493d --- /dev/null +++ b/test/testHelpers/datasets/curationWorkflowHelper.ts @@ -0,0 +1,126 @@ +import axios from 'axios' +import { randomUUID } from 'node:crypto' +import { + ApiConfig, + createCollection, + createDataset, + deleteCollection, + deleteDatasetDraft, + getDataset, + getDatasetLocks, + DatasetNotNumberedVersion, + DatasetVersionState, + DatasetLockType, + CreatedDatasetIdentifiers +} from '../../../src' +import { CollectionType } from '../../../src/collections/domain/models/CollectionType' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { deletePublishedDatasetViaApi } from './datasetHelper' +import { TestConstants } from '../TestConstants' + +export const useCurationAdmin = () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) +} + +export interface CurationWorkflowFixture { + dataset: CreatedDatasetIdentifiers + alias: string + labels: string[] + cleanup: () => Promise +} + +export const createCurationWorkflowFixture = async (): Promise => { + useCurationAdmin() + const suffix = randomUUID().replace(/-/g, '') + const alias = `curation${suffix}` + const labelSet = `Curation${suffix}` + const labels = ['Privacy Review', 'Final Approval'] + const headers = { + 'X-Dataverse-Key': process.env.TEST_API_KEY, + 'Content-Type': 'application/json' + } + const settingsUrl = `${TestConstants.TEST_API_URL}/admin/settings/:AllowedCurationLabels` + const existing = await axios.get(settingsUrl, { + headers, + validateStatus: (status) => status === 200 || status === 404 + }) + const original: string | undefined = + existing.status === 404 ? undefined : existing.data.data.message + let settingChanged = false + let collectionCreated = false + let dataset: CreatedDatasetIdentifiers | undefined + + const cleanup = async () => { + useCurationAdmin() + try { + if (dataset) { + const locks = await getDatasetLocks.execute(dataset.numericId) + if (locks.some((lock) => lock.lockType === DatasetLockType.IN_REVIEW)) { + await axios.post( + `${TestConstants.TEST_API_URL}/datasets/${dataset.numericId}/returnToAuthor`, + { reasonForReturn: 'Integration test cleanup' }, + { headers } + ) + } + const latest = await getDataset.execute( + dataset.numericId, + DatasetNotNumberedVersion.LATEST, + true + ) + if (latest.versionInfo.state === DatasetVersionState.DRAFT) { + await deleteDatasetDraft.execute(dataset.numericId) + } else { + await deletePublishedDatasetViaApi(dataset.persistentId) + } + } + if (collectionCreated) await deleteCollection.execute(alias) + } finally { + if (settingChanged) { + if (original === undefined) await axios.delete(settingsUrl, { headers }) + else await axios.put(settingsUrl, original, { headers }) + } + } + } + + try { + await axios.put( + settingsUrl, + { ...(original ? JSON.parse(original) : {}), [labelSet]: labels }, + { headers } + ) + settingChanged = true + await createCollection.execute({ + alias, + name: 'Curation workflow test', + contacts: ['curation@example.org'], + type: CollectionType.LABORATORY, + inheritMetadataBlocksFromParent: true, + inheritFacetsFromParent: true + }) + collectionCreated = true + dataset = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO, alias) + await axios.put( + `${TestConstants.TEST_API_URL}/datasets/${dataset.numericId}/curationLabelSet`, + '', + { + headers, + params: { name: labelSet } + } + ) + return { dataset, alias, labels, cleanup } + } catch (error) { + await cleanup() + if (axios.isAxiosError(error)) { + throw new Error( + `Curation fixture setup failed: ${error.config?.method} ${ + error.config?.url?.split('?')[0] + } [${error.response?.status}] ${JSON.stringify(error.response?.data)}` + ) + } + throw error + } +} diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index 67015187..c2271560 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -1290,3 +1290,171 @@ describe('DatasetsRepository', () => { }) }) }) + +describe('DatasetsRepository curation methods', () => { + const sut = new DatasetsRepository() + const status = { + label: 'Privacy Review', + createTime: '2026-10-14 12:00:00', + assigner: '@curator' + } + const label = 'Author contacted & approval + résumé / final?' + const operations = [ + { + name: 'getAllowedDatasetCurationLabels', + verb: 'get' as const, + path: 'allowedCurationLabels', + params: {}, + data: { message: 'Privacy Review,Final Approval' }, + expected: ['Privacy Review', 'Final Approval'], + execute: (id: number | string) => sut.getAllowedDatasetCurationLabels(id) + }, + { + name: 'getDatasetCurationStatus', + verb: 'get' as const, + path: 'curationStatus', + params: {}, + data: status, + expected: status, + execute: (id: number | string) => sut.getDatasetCurationStatus(id) + }, + { + name: 'getDatasetCurationStatusHistory', + verb: 'get' as const, + path: 'curationStatus', + params: { includeHistory: true }, + data: [status], + expected: [status], + execute: (id: number | string) => sut.getDatasetCurationStatusHistory(id) + }, + { + name: 'setDatasetCurationStatus', + verb: 'put' as const, + path: 'curationStatus', + params: { label }, + data: { message: 'Updated' }, + expected: undefined, + execute: (id: number | string) => sut.setDatasetCurationStatus(id, label) + }, + { + name: 'deleteDatasetCurationStatus', + verb: 'delete' as const, + path: 'curationStatus', + params: {}, + data: { message: 'Deleted' }, + expected: undefined, + execute: (id: number | string) => sut.deleteDatasetCurationStatus(id) + }, + { + name: 'submitDatasetForReview', + verb: 'post' as const, + path: 'submitForReview', + params: {}, + data: { inReview: true, message: 'Submitted for review' }, + expected: { inReview: true, message: 'Submitted for review' }, + execute: (id: number | string) => sut.submitDatasetForReview(id) + } + ] + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + afterEach(() => jest.restoreAllMocks()) + + describe.each(operations)('$name', ({ verb, path, params, data, expected, execute }) => { + test.each([42, 'doi:10.5072/FK2/A+B&C?#résumé'])( + 'should send an authenticated request for %s', + async (id) => { + const request = jest.spyOn(axios, verb).mockResolvedValue({ data: { data } }) + const endpoint = + typeof id === 'number' + ? `/datasets/${id}/${path}` + : `/datasets/:persistentId/${path}?persistentId=${encodeURIComponent(id)}` + const config = { + ...TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY, + params + } + + expect(await execute(id)).toEqual(expected) + const url = `${TestConstants.TEST_API_URL}${endpoint}` + const args = verb === 'post' || verb === 'put' ? [url, '', config] : [url, config] + expect(request).toHaveBeenCalledWith(...args) + } + ) + + test('should support session authentication', async () => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + const request = jest.spyOn(axios, verb).mockResolvedValue({ data: { data } }) + await execute(42) + const config = { + ...TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE, + params + } + const url = `${TestConstants.TEST_API_URL}/datasets/42/${path}` + const args = verb === 'post' || verb === 'put' ? [url, '', config] : [url, config] + expect(request).toHaveBeenCalledWith(...args) + }) + + test('should preserve backend permission errors', async () => { + jest + .spyOn(axios, verb) + .mockRejectedValue({ response: { status: 403, data: { message: 'Permission denied' } } }) + await expect(execute(42)).rejects.toEqual( + verb === 'get' + ? new ReadError('[403] Permission denied') + : new WriteError('[403] Permission denied') + ) + }) + }) + + test.each([{ message: '' }, ''])('should normalize empty allowed labels %p', async (data) => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: { data } }) + expect(await sut.getAllowedDatasetCurationLabels(42)).toEqual([]) + }) + + test('should preserve exact label whitespace', async () => { + jest + .spyOn(axios, 'get') + .mockResolvedValue({ data: { data: ' Privacy Review ,Final Approval' } }) + expect(await sut.getAllowedDatasetCurationLabels(42)).toEqual([ + ' Privacy Review ', + 'Final Approval' + ]) + }) + + test('should normalize an absent current status to null', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: { data: {} } }) + expect(await sut.getDatasetCurationStatus(42)).toBeNull() + }) + + test('should preserve removal records and omitted legacy fields', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + data: [ + { createTime: status.createTime, assigner: status.assigner }, + { label: 'Legacy label' } + ] + } + }) + expect(await sut.getDatasetCurationStatusHistory(42)).toStrictEqual([ + { createTime: status.createTime, assigner: status.assigner }, + { label: 'Legacy label' } + ]) + }) + + test('should preserve a current removal record with no label', async () => { + const removal = { createTime: status.createTime, assigner: status.assigner } + jest.spyOn(axios, 'get').mockResolvedValue({ data: { data: removal } }) + + expect(await sut.getDatasetCurationStatus(42)).toStrictEqual(removal) + }) + + test('should return an empty history unchanged', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: { data: [] } }) + expect(await sut.getDatasetCurationStatusHistory(42)).toEqual([]) + }) +}) diff --git a/test/unit/datasets/DeleteDatasetCurationStatus.test.ts b/test/unit/datasets/DeleteDatasetCurationStatus.test.ts new file mode 100644 index 00000000..6d6427a2 --- /dev/null +++ b/test/unit/datasets/DeleteDatasetCurationStatus.test.ts @@ -0,0 +1,28 @@ +import { DeleteDatasetCurationStatus } from '../../../src/datasets/domain/useCases/curation/DeleteDatasetCurationStatus' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' + +describe('DeleteDatasetCurationStatus', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = undefined + const repository = {} as IDatasetsRepository + repository.deleteDatasetCurationStatus = jest.fn().mockResolvedValue(expected) + const sut = new DeleteDatasetCurationStatus(repository) + + expect(await sut.execute(id)).toEqual(expected) + expect(repository.deleteDatasetCurationStatus).toHaveBeenCalledWith(id) + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new WriteError('Denied') + const repository = {} as IDatasetsRepository + repository.deleteDatasetCurationStatus = jest.fn().mockRejectedValue(error) + const sut = new DeleteDatasetCurationStatus(repository) + + await expect(sut.execute(id)).rejects.toBe(error) + }) +}) diff --git a/test/unit/datasets/GetAllowedDatasetCurationLabels.test.ts b/test/unit/datasets/GetAllowedDatasetCurationLabels.test.ts new file mode 100644 index 00000000..d2a58829 --- /dev/null +++ b/test/unit/datasets/GetAllowedDatasetCurationLabels.test.ts @@ -0,0 +1,28 @@ +import { GetAllowedDatasetCurationLabels } from '../../../src/datasets/domain/useCases/curation/GetAllowedDatasetCurationLabels' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' + +describe('GetAllowedDatasetCurationLabels', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = ['Privacy Review'] + const repository = {} as IDatasetsRepository + repository.getAllowedDatasetCurationLabels = jest.fn().mockResolvedValue(expected) + const sut = new GetAllowedDatasetCurationLabels(repository) + + expect(await sut.execute(id)).toEqual(expected) + expect(repository.getAllowedDatasetCurationLabels).toHaveBeenCalledWith(id) + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new ReadError('Denied') + const repository = {} as IDatasetsRepository + repository.getAllowedDatasetCurationLabels = jest.fn().mockRejectedValue(error) + const sut = new GetAllowedDatasetCurationLabels(repository) + + await expect(sut.execute(id)).rejects.toBe(error) + }) +}) diff --git a/test/unit/datasets/GetDatasetCurationStatus.test.ts b/test/unit/datasets/GetDatasetCurationStatus.test.ts new file mode 100644 index 00000000..b5a2f6ce --- /dev/null +++ b/test/unit/datasets/GetDatasetCurationStatus.test.ts @@ -0,0 +1,28 @@ +import { GetDatasetCurationStatus } from '../../../src/datasets/domain/useCases/curation/GetDatasetCurationStatus' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' + +describe('GetDatasetCurationStatus', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = { label: undefined, createTime: '2026-10-14 12:00:00', assigner: '@curator' } + const repository = {} as IDatasetsRepository + repository.getDatasetCurationStatus = jest.fn().mockResolvedValue(expected) + const sut = new GetDatasetCurationStatus(repository) + + expect(await sut.execute(id)).toEqual(expected) + expect(repository.getDatasetCurationStatus).toHaveBeenCalledWith(id) + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new ReadError('Denied') + const repository = {} as IDatasetsRepository + repository.getDatasetCurationStatus = jest.fn().mockRejectedValue(error) + const sut = new GetDatasetCurationStatus(repository) + + await expect(sut.execute(id)).rejects.toBe(error) + }) +}) diff --git a/test/unit/datasets/GetDatasetCurationStatusHistory.test.ts b/test/unit/datasets/GetDatasetCurationStatusHistory.test.ts new file mode 100644 index 00000000..ea933963 --- /dev/null +++ b/test/unit/datasets/GetDatasetCurationStatusHistory.test.ts @@ -0,0 +1,28 @@ +import { GetDatasetCurationStatusHistory } from '../../../src/datasets/domain/useCases/curation/GetDatasetCurationStatusHistory' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' + +describe('GetDatasetCurationStatusHistory', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = [{ label: undefined, createTime: undefined, assigner: undefined }] + const repository = {} as IDatasetsRepository + repository.getDatasetCurationStatusHistory = jest.fn().mockResolvedValue(expected) + const sut = new GetDatasetCurationStatusHistory(repository) + + expect(await sut.execute(id)).toEqual(expected) + expect(repository.getDatasetCurationStatusHistory).toHaveBeenCalledWith(id) + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new ReadError('Denied') + const repository = {} as IDatasetsRepository + repository.getDatasetCurationStatusHistory = jest.fn().mockRejectedValue(error) + const sut = new GetDatasetCurationStatusHistory(repository) + + await expect(sut.execute(id)).rejects.toBe(error) + }) +}) diff --git a/test/unit/datasets/SetDatasetCurationStatus.test.ts b/test/unit/datasets/SetDatasetCurationStatus.test.ts new file mode 100644 index 00000000..dcbcec78 --- /dev/null +++ b/test/unit/datasets/SetDatasetCurationStatus.test.ts @@ -0,0 +1,28 @@ +import { SetDatasetCurationStatus } from '../../../src/datasets/domain/useCases/curation/SetDatasetCurationStatus' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' + +describe('SetDatasetCurationStatus', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = undefined + const repository = {} as IDatasetsRepository + repository.setDatasetCurationStatus = jest.fn().mockResolvedValue(expected) + const sut = new SetDatasetCurationStatus(repository) + + expect(await sut.execute(id, 'Privacy Review')).toEqual(expected) + expect(repository.setDatasetCurationStatus).toHaveBeenCalledWith(id, 'Privacy Review') + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new WriteError('Denied') + const repository = {} as IDatasetsRepository + repository.setDatasetCurationStatus = jest.fn().mockRejectedValue(error) + const sut = new SetDatasetCurationStatus(repository) + + await expect(sut.execute(id, 'Privacy Review')).rejects.toBe(error) + }) +}) diff --git a/test/unit/datasets/SubmitDatasetForReview.test.ts b/test/unit/datasets/SubmitDatasetForReview.test.ts new file mode 100644 index 00000000..96caa484 --- /dev/null +++ b/test/unit/datasets/SubmitDatasetForReview.test.ts @@ -0,0 +1,28 @@ +import { SubmitDatasetForReview } from '../../../src/datasets/domain/useCases/curation/SubmitDatasetForReview' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' + +describe('SubmitDatasetForReview', () => { + test.each([1, 'doi:10.5072/FK2/ABC'])( + 'should delegate identifier %s and return the result', + async (id) => { + const expected = { inReview: true, message: 'Submitted for review' } + const repository = {} as IDatasetsRepository + repository.submitDatasetForReview = jest.fn().mockResolvedValue(expected) + const sut = new SubmitDatasetForReview(repository) + + expect(await sut.execute(id)).toEqual(expected) + expect(repository.submitDatasetForReview).toHaveBeenCalledWith(id) + } + ) + + test('should propagate repository errors', async () => { + const id = 1 + const error = new WriteError('Denied') + const repository = {} as IDatasetsRepository + repository.submitDatasetForReview = jest.fn().mockRejectedValue(error) + const sut = new SubmitDatasetForReview(repository) + + await expect(sut.execute(id)).rejects.toBe(error) + }) +})