diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d822c2..8e59cfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Datasets: Added `returnDatasetToAuthor` to return drafts in review with a reason, supporting numeric and persistent identifiers and configured authentication. + +- 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..b20f3d1e 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,10 @@ 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) + - [Return Dataset To Author](#return-dataset-to-author) - [Create a Dataset](#create-a-dataset) - [Update a Dataset](#update-a-dataset) - [Update a Dataset License](#update-a-dataset-license) @@ -967,6 +974,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 +1489,71 @@ 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_. + +#### Return Dataset To Author + +Returns a draft currently in review to its authors. Requires authentication and publish permission. The server removes the review lock, records the reason, and notifies authors. Backend validation and permission failures are exposed as `WriteError`. + +```typescript +import { returnDatasetToAuthor } from '@iqss/dataverse-client-javascript' + +await returnDatasetToAuthor.execute(datasetId, 'Please add missing metadata.') // Promise +``` + +`datasetId` accepts a numeric ID or persistent identifier. `reasonForReturn` is required unless the server disables that requirement; the SDK sends the supplied text unchanged. + +_See [use case](../src/datasets/domain/useCases/curation/ReturnDatasetToAuthor.ts) implementation and [API documentation](https://guides.dataverse.org/en/latest/api/native-api.html#return-a-dataset-to-author)._ + +#### 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..a1b5248a 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -22,8 +22,17 @@ 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 + returnDatasetToAuthor(datasetId: number | string, reasonForReturn: 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/ReturnDatasetToAuthor.ts b/src/datasets/domain/useCases/curation/ReturnDatasetToAuthor.ts new file mode 100644 index 00000000..77482f94 --- /dev/null +++ b/src/datasets/domain/useCases/curation/ReturnDatasetToAuthor.ts @@ -0,0 +1,17 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' + +export class ReturnDatasetToAuthor implements UseCase { + constructor(private readonly datasetsRepository: IDatasetsRepository) {} + + /** + * Returns a draft in review to its authors. Requires publish permission. + * The server records the reason and notifies the authors. + * @param datasetId Numeric dataset ID or persistent identifier. + * @param reasonForReturn Reason sent to the authors; required unless disabled by server configuration. + * @see https://guides.dataverse.org/en/latest/api/native-api.html#return-a-dataset-to-author + */ + async execute(datasetId: number | string, reasonForReturn: string): Promise { + return await this.datasetsRepository.returnDatasetToAuthor(datasetId, reasonForReturn) + } +} 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..faca94e1 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -38,8 +38,22 @@ 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 { ReturnDatasetToAuthor } from './domain/useCases/curation/ReturnDatasetToAuthor' +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 returnDatasetToAuthor = new ReturnDatasetToAuthor(datasetsRepository) +const submitDatasetForReview = new SubmitDatasetForReview(datasetsRepository) const getDataset = new GetDataset(datasetsRepository) const getDatasetLocks = new GetDatasetLocks(datasetsRepository) @@ -93,7 +107,16 @@ 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, + returnDatasetToAuthor, + submitDatasetForReview, getDataset, getDatasetLocks, getDatasetCitation, diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index a0e80a29..e5658274 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,62 @@ 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 returnDatasetToAuthor( + datasetId: number | string, + reasonForReturn: string + ): Promise { + await this.doPost(this.buildCurationEndpoint(datasetId, 'returnToAuthor'), { reasonForReturn }) + } + + 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..3c82759d --- /dev/null +++ b/test/functional/datasets/CurationWorkflow.test.ts @@ -0,0 +1,54 @@ +import { + getAllowedDatasetCurationLabels, + getDatasetCurationStatus, + getDatasetCurationStatusHistory, + setDatasetCurationStatus, + deleteDatasetCurationStatus, + submitDatasetForReview, + returnDatasetToAuthor, + 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 })]) + ) + await returnDatasetToAuthor.execute(id, 'Please revise the description.') + expect(await getDatasetLocks.execute(id)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ lockType: DatasetLockType.IN_REVIEW })]) + ) + expect((await submitDatasetForReview.execute(id)).inReview).toBe(true) + }) +}) diff --git a/test/integration/datasets/CurationWorkflow.test.ts b/test/integration/datasets/CurationWorkflow.test.ts new file mode 100644 index 00000000..06f587d5 --- /dev/null +++ b/test/integration/datasets/CurationWorkflow.test.ts @@ -0,0 +1,241 @@ +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('returnDatasetToAuthor', () => { + test('should remove the review lock and keep the draft editable', async () => { + await sut.submitDatasetForReview(id) + expect(await sut.returnDatasetToAuthor(id, 'Please add missing metadata.')).toBeUndefined() + expect(await getDatasetLocks.execute(id)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ lockType: DatasetLockType.IN_REVIEW })]) + ) + expect( + (await getDataset.execute(id, DatasetNotNumberedVersion.DRAFT)).versionInfo.state + ).toBe(DatasetVersionState.DRAFT) + expect((await sut.submitDatasetForReview(id)).inReview).toBe(true) + }) + test('should reject a draft not in review', async () => { + await expect(sut.returnDatasetToAuthor(id, 'Please add metadata.')).rejects.toThrow( + /not.*review/i + ) + }) + test('should reject an empty reason and retain the review lock', async () => { + await sut.submitDatasetForReview(id) + await expect(sut.returnDatasetToAuthor(id, '')).rejects.toThrow(/\[400\]/) + expect(await getDatasetLocks.execute(id)).toEqual( + expect.arrayContaining([expect.objectContaining({ lockType: DatasetLockType.IN_REVIEW })]) + ) + }) + }) + 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: 'returnDatasetToAuthor', + execute: () => sut.returnDatasetToAuthor(fixture.dataset.numericId, 'Please add metadata.') + }, + { + 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('returnDatasetToAuthor should reject a released dataset', async () => { + await expect( + sut.returnDatasetToAuthor(fixture.dataset.numericId, 'Please revise.') + ).rejects.toThrow(/not.*review/i) + }) + 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..996201ab --- /dev/null +++ b/test/testHelpers/datasets/curationWorkflowHelper.ts @@ -0,0 +1,123 @@ +import axios from 'axios' +import { randomUUID } from 'node:crypto' +import { + ApiConfig, + createCollection, + createDataset, + deleteCollection, + deleteDatasetDraft, + returnDatasetToAuthor, + 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 returnDatasetToAuthor.execute(dataset.numericId, 'Integration test cleanup') + } + 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..df6150a7 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -1290,3 +1290,246 @@ 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([]) + }) +}) + +describe('DatasetsRepository.returnDatasetToAuthor', () => { + const sut = new DatasetsRepository() + const reasonForReturn = 'Please fix metadata.\nKeep Unicode: résumé & notes.' + beforeEach(() => + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + ) + afterEach(() => { + jest.restoreAllMocks() + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + TestConstants.TEST_DUMMY_API_KEY + ) + }) + test.each([42, 'doi:10.5072/FK2/A+B&C?#résumé'])( + 'posts JSON with authentication for %s', + async (id) => { + const request = jest + .spyOn(axios, 'post') + .mockResolvedValue({ data: { data: { inReview: false } } }) + const endpoint = + typeof id === 'number' + ? `/datasets/${id}/returnToAuthor` + : `/datasets/:persistentId/returnToAuthor?persistentId=${encodeURIComponent(id)}` + expect(await sut.returnDatasetToAuthor(id, reasonForReturn)).toBeUndefined() + expect(request).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}${endpoint}`, + JSON.stringify({ reasonForReturn }), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + } + ) + test('supports session cookies', async () => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + const request = jest.spyOn(axios, 'post').mockResolvedValue({ data: {} }) + await sut.returnDatasetToAuthor(42, reasonForReturn) + expect(request).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/datasets/42/returnToAuthor`, + JSON.stringify({ reasonForReturn }), + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + }) + test('supports bearer authentication', async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + undefined, + () => 'test-token' + ) + const request = jest.spyOn(axios, 'post').mockResolvedValue({ data: {} }) + await sut.returnDatasetToAuthor(42, reasonForReturn) + expect(request).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/datasets/42/returnToAuthor`, + JSON.stringify({ reasonForReturn }), + { + params: {}, + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' } + } + ) + }) + test.each([400, 401, 403, 404])('preserves backend errors with status %s', async (status) => { + jest + .spyOn(axios, 'post') + .mockRejectedValue({ response: { status, data: { message: 'Return rejected' } } }) + await expect(sut.returnDatasetToAuthor(42, reasonForReturn)).rejects.toEqual( + new WriteError(`[${status}] Return rejected`) + ) + }) +}) 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/ReturnDatasetToAuthor.test.ts b/test/unit/datasets/ReturnDatasetToAuthor.test.ts new file mode 100644 index 00000000..78e5139d --- /dev/null +++ b/test/unit/datasets/ReturnDatasetToAuthor.test.ts @@ -0,0 +1,26 @@ +import { ReturnDatasetToAuthor } from '../../../src/datasets/domain/useCases/curation/ReturnDatasetToAuthor' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError } from '../../../src' + +describe('ReturnDatasetToAuthor.execute', () => { + test.each([42, 'doi:10.5072/FK2/TEST'])( + 'delegates the ID and exact reason for %s', + async (id) => { + const repository = { + returnDatasetToAuthor: jest.fn().mockResolvedValue(undefined) + } as unknown as IDatasetsRepository + const sut = new ReturnDatasetToAuthor(repository) + expect(await sut.execute(id, ' Please fix metadata. ')).toBeUndefined() + expect(repository.returnDatasetToAuthor).toHaveBeenCalledWith(id, ' Please fix metadata. ') + } + ) + test('preserves write errors', async () => { + const error = new WriteError('[403] Permission denied') + const repository = { + returnDatasetToAuthor: jest.fn().mockRejectedValue(error) + } as unknown as IDatasetsRepository + await expect(new ReturnDatasetToAuthor(repository).execute(42, 'Fix metadata')).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) + }) +})