From e7428348dd4994f2c6e39c9af314f01c7d062ce4 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Mon, 3 Aug 2026 16:31:22 -0400 Subject: [PATCH 1/3] feat: previewurl usecases and extend getdataset use case --- CHANGELOG.md | 2 + docs/useCases.md | 94 +++++++++ package-lock.json | 8 +- package.json | 2 +- src/datasets/domain/models/PreviewUrl.ts | 5 + .../repositories/IDatasetsRepository.ts | 7 +- src/datasets/domain/useCases/GetDataset.ts | 7 +- .../useCases/previewUrl/CreatePreviewUrl.ts | 22 ++ .../useCases/previewUrl/DeletePreviewUrl.ts | 20 ++ .../useCases/previewUrl/GetPreviewUrl.ts | 21 ++ src/datasets/index.ts | 10 + .../infra/repositories/DatasetsRepository.ts | 46 +++- .../transformers/datasetTransformers.ts | 10 + .../domain/repositories/IFilesRepository.ts | 12 +- .../domain/useCases/GetDatasetFileCounts.ts | 7 +- src/files/domain/useCases/GetDatasetFiles.ts | 7 +- .../GetDatasetFilesTotalDownloadSize.ts | 7 +- src/files/domain/useCases/GetFile.ts | 7 +- .../domain/useCases/GetFileAndDataset.ts | 7 +- .../infra/repositories/FilesRepository.ts | 51 +++-- .../datasets/DatasetsRepository.test.ts | 197 +++++++++++++++++- .../integration/files/FilesRepository.test.ts | 194 +++++++++++++++++ test/testHelpers/TestConstants.ts | 2 +- test/unit/datasets/CreatePreviewUrl.test.ts | 42 ++++ test/unit/datasets/DatasetsRepository.test.ts | 143 +++++++++++++ test/unit/datasets/DeletePreviewUrl.test.ts | 24 +++ test/unit/datasets/GetDataset.test.ts | 20 ++ test/unit/datasets/GetPreviewUrl.test.ts | 31 +++ test/unit/files/FilesRepository.test.ts | 86 ++++++++ test/unit/files/GetDatasetFileCounts.test.ts | 27 +++ test/unit/files/GetDatasetFiles.test.ts | 33 +++ .../GetDatasetFilesTotalDownloadSize.test.ts | 32 ++- test/unit/files/GetFile.test.ts | 37 +++- test/unit/files/GetFileAndDataset.test.ts | 36 +++- tsconfig.tests.json | 3 +- 35 files changed, 1207 insertions(+), 52 deletions(-) create mode 100644 src/datasets/domain/models/PreviewUrl.ts create mode 100644 src/datasets/domain/useCases/previewUrl/CreatePreviewUrl.ts create mode 100644 src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts create mode 100644 src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts create mode 100644 test/unit/datasets/CreatePreviewUrl.test.ts create mode 100644 test/unit/datasets/DeletePreviewUrl.test.ts create mode 100644 test/unit/datasets/GetPreviewUrl.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4a912c..9cdd407c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Datasets: Added `exportDatasetMetadata` use case, repository method, and `ExportedDatasetMetadata` response type to support exporting dataset metadata by numeric id or persistent id through Dataverse endpoint `GET /datasets/export`. - Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. - Collections: Added theme information when retrieving a collection using `getCollection`. +- Datasets/Files: Added optional Preview URL (`previewUrlToken`) support to `getDataset`, `getDatasetFiles`, `getDatasetFileCounts`, `getDatasetFilesTotalDownloadSize`, `getFile`, and `getFileAndDataset`, so a reviewer using a Preview URL can access an unpublished dataset's metadata and files without needing to log in. When a `previewUrlToken` is provided, it takes priority over any other credentials the caller may have configured (e.g. an already-logged-in user's session), so the Preview URL reliably grants access on its own. +- Datasets: Added `createPreviewUrl`, `getPreviewUrl`, and `deletePreviewUrl` use cases and repository methods to support Dataverse endpoint `/datasets/{id}/previewUrl`, for creating, retrieving, and deleting a dataset's Preview URL. ### Changed diff --git a/docs/useCases.md b/docs/useCases.md index 2df0b987..3e29991f 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -62,6 +62,7 @@ The different use cases currently available in the package are classified below, - [Get Dataset Available Dataset Types](#get-dataset-available-dataset-types) - [Get Dataset Available Dataset Type](#get-dataset-available-dataset-type) - [Get Dataset Upload Limits](#get-dataset-upload-limits) + - [Get Preview URL for a Dataset](#get-preview-url-for-a-dataset) - [Datasets write use cases](#datasets-write-use-cases) - [Create a Dataset](#create-a-dataset) - [Update a Dataset](#update-a-dataset) @@ -75,6 +76,8 @@ The different use cases currently available in the package are classified below, - [Link Dataset Type with Metadata Blocks](#link-dataset-type-with-metadata-blocks) - [Set Available Licenses For Dataset Type](#set-available-licenses-for-dataset-type) - [Delete a Dataset Type](#delete-a-dataset-type) + - [Create a Preview URL for a Dataset](#create-a-preview-url-for-a-dataset) + - [Delete a Preview URL from a Dataset](#delete-a-preview-url-from-a-dataset) - [Files](#Files) - [Files read use cases](#files-read-use-cases) - [Get a File](#get-a-file) @@ -975,6 +978,8 @@ There is an optional third parameter called `includeDeaccessioned`, which indica There is an optional fourth parameter called `keepRawFields`, which indicates whether or not to keep the metadata fields as they are and avoid the transformation to Markdown. The default value is `false`. +There is an optional fifth parameter called `previewUrlToken`, which allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to access an unpublished dataset without needing to log in. When provided, it takes priority over any other credentials that may be configured, so the Preview URL grants access on its own. + #### Get Dataset By Private URL Token Returns a [Dataset](../src/datasets/domain/models/Dataset.ts) instance, given an associated Private URL Token. @@ -1890,6 +1895,86 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetUploadLimits.ts) imple If the backend does not define any quota limits for the dataset, the returned object can be empty (`{}`). +#### Get Preview URL for a Dataset + +Returns a [PreviewUrl](../src/datasets/domain/models/PreviewUrl.ts) instance (`token`, `link`, and `isAnonymizedAccess`) for the given dataset's existing Preview URL, if one has been created. + +##### Example call: + +```typescript +import { getPreviewUrl } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' + +getPreviewUrl.execute(datasetId).then((previewUrl: PreviewUrl) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +Requires permission to manage the dataset's permissions. Throws an error if no Preview URL exists yet for the dataset; see [Create a Preview URL for a Dataset](#create-a-preview-url-for-a-dataset). + +#### Create a Preview URL for a Dataset + +Creates a Preview URL for the given dataset, returning a [PreviewUrl](../src/datasets/domain/models/PreviewUrl.ts) instance. The token in the returned `PreviewUrl` allows a reviewer without credentials to access the dataset's latest (unpublished) version — see the `previewUrlToken` parameter on the read use cases above (for example, [Get a Dataset](#get-a-dataset)). + +##### Example call: + +```typescript +import { createPreviewUrl } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' + +createPreviewUrl.execute(datasetId).then((previewUrl: PreviewUrl) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/previewUrl/CreatePreviewUrl.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +There is an optional second parameter called `anonymizedAccess`. If set to `true`, and Anonymized Access has been enabled on the installation, the created Preview URL will only allow an anonymized view of the dataset. + +Requires permission to manage the dataset's permissions. + +#### Delete a Preview URL from a Dataset + +Deletes the Preview URL for the given dataset, if one exists. + +##### Example call: + +```typescript +import { deletePreviewUrl } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' + +deletePreviewUrl.execute(datasetId).then(() => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts) implementation_. + +The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +Requires permission to manage the dataset's permissions. Throws an error if no Preview URL exists for the dataset. + ## Files ### Files read use cases @@ -1923,6 +2008,8 @@ The optional `datasetVersionId` parameter can correspond to a numeric version id There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the file search. If not set, the default value is `false`. +There is an optional fourth parameter called `previewUrlToken`, which allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to access a file in an unpublished dataset without needing to log in. + #### Get a File and its Dataset Returns a tuple of [FileModel](../src/files/domain/models/FileModel.ts) and [Dataset](../src/datasets/domain/models/Dataset.ts) objects (`[FileModel, Dataset]`), given the search parameters to identify the file. @@ -1954,6 +2041,8 @@ The optional `datasetVersionId` parameter can correspond to a numeric version id There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the file search. If not set, the default value is `false`. +There is an optional fourth parameter called `previewUrlToken`, which allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to access a file in an unpublished dataset without needing to log in. + #### Get File Citation Text Returns the File citation text. @@ -2041,6 +2130,8 @@ There is an optional third parameter called `includeDeaccessioned`, which indica An optional fourth parameter `fileSearchCriteria` receives a [FileSearchCriteria](../src/files/domain/models/FileCriteria.ts) object to retrieve counts only for files that match the specified criteria. +An optional fifth parameter called `previewUrlToken` allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to access file counts in an unpublished dataset without needing to log in. + ##### Example call using optional parameters: ```typescript @@ -2147,6 +2238,8 @@ An optional fourth parameter called `fileSearchCriteria` receives a [FileSearchC An optional fifth parameter called `includeDeaccessioned` indicates whether to consider deaccessioned versions or not in the dataset search. If not set, the default value is `false`. +An optional sixth parameter called `previewUrlToken` allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to get the download size for an unpublished dataset without needing to log in. + ##### Example call using optional parameters: ```typescript @@ -2233,6 +2326,7 @@ This use case supports the following optional parameters depending on the search - **offset**: (number) Offset for pagination. - **fileSearchCriteria**: ([FileSearchCriteria](../src/files/domain/models/FileCriteria.ts)) Supports filtering the files by different file properties. - **fileOrderCriteria**: ([FileOrderCriteria](../src/files/domain/models/FileCriteria.ts)) Supports ordering the results according to different criteria. If not set, the defalt value is `FileOrderCriteria.NAME_AZ`. +- **previewUrlToken**: (string) Allows a reviewer using a [Preview URL](#get-preview-url-for-a-dataset) to list the files of an unpublished dataset without needing to log in. ##### Example call using optional parameters: diff --git a/package-lock.json b/package-lock.json index 375f91bc..6524a2aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "typescript": "^4.9.5" }, "devDependencies": { - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", @@ -1520,9 +1520,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "dependencies": { "expect": "^29.0.0", diff --git a/package.json b/package.json index 282eced4..a685789e 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "homepage": "https://github.com/IQSS/dataverse-client-javascript#readme", "devDependencies": { - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", diff --git a/src/datasets/domain/models/PreviewUrl.ts b/src/datasets/domain/models/PreviewUrl.ts new file mode 100644 index 00000000..c1c92785 --- /dev/null +++ b/src/datasets/domain/models/PreviewUrl.ts @@ -0,0 +1,5 @@ +export interface PreviewUrl { + token: string + link: string + isAnonymizedAccess: boolean +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index a50ad2ff..81c2860a 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -21,13 +21,15 @@ import { DatasetUploadLimits } from '../models/DatasetUploadLimits' import { DatasetReview } from '../models/DatasetReview' import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' +import { PreviewUrl } from '../models/PreviewUrl' export interface IDatasetsRepository { getDataset( datasetId: number | string, datasetVersionId: string, includeDeaccessioned: boolean, - keepRawFields: boolean + keepRawFields: boolean, + previewUrlToken?: string ): Promise getDatasetLocks(datasetId: number | string): Promise getDatasetCitation( @@ -36,6 +38,9 @@ export interface IDatasetsRepository { includeDeaccessioned: boolean ): Promise getPrivateUrlDataset(token: string, keepRawFields: boolean): Promise + createPreviewUrl(datasetId: number | string, anonymizedAccess?: boolean): Promise + getPreviewUrl(datasetId: number | string): Promise + deletePreviewUrl(datasetId: number | string): Promise getAllDatasetPreviews( limit?: number, offset?: number, diff --git a/src/datasets/domain/useCases/GetDataset.ts b/src/datasets/domain/useCases/GetDataset.ts index ad9f24b8..511dc125 100644 --- a/src/datasets/domain/useCases/GetDataset.ts +++ b/src/datasets/domain/useCases/GetDataset.ts @@ -17,19 +17,22 @@ export class GetDataset implements UseCase { * @param {string | DatasetNotNumberedVersion} [datasetVersionId=DatasetNotNumberedVersion.LATEST] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. If this parameter is not set, the default value is: DatasetNotNumberedVersion.LATEST * @param {boolean} [includeDeaccessioned=false] - Indicates whether to consider deaccessioned versions in the dataset search or not. The default value is false * @param {boolean} [keepRawFields=false] - Indicates whether or not the use case should keep the metadata fields as they are and avoid the transformation to markdown. The default value is false. + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise} */ async execute( datasetId: number | string, datasetVersionId: string | DatasetNotNumberedVersion = DatasetNotNumberedVersion.LATEST, includeDeaccessioned = false, - keepRawFields = false + keepRawFields = false, + previewUrlToken?: string ): Promise { return await this.datasetsRepository.getDataset( datasetId, datasetVersionId, includeDeaccessioned, - keepRawFields + keepRawFields, + previewUrlToken ) } } diff --git a/src/datasets/domain/useCases/previewUrl/CreatePreviewUrl.ts b/src/datasets/domain/useCases/previewUrl/CreatePreviewUrl.ts new file mode 100644 index 00000000..44c068c7 --- /dev/null +++ b/src/datasets/domain/useCases/previewUrl/CreatePreviewUrl.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' +import { PreviewUrl } from '../../models/PreviewUrl' + +export class CreatePreviewUrl implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Creates a Preview URL for the given dataset, allowing a reviewer without credentials to access its latest (unpublished) version. Requires permission to manage the dataset's permissions. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {boolean} [anonymizedAccess] - If true, and Anonymized Access is enabled on the installation, the Preview URL will only allow an anonymized view of the dataset (optional). + * @returns {Promise} + */ + async execute(datasetId: number | string, anonymizedAccess?: boolean): Promise { + return await this.datasetsRepository.createPreviewUrl(datasetId, anonymizedAccess) + } +} diff --git a/src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts b/src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts new file mode 100644 index 00000000..5ae49117 --- /dev/null +++ b/src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' + +export class DeletePreviewUrl implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Deletes the Preview URL for the given dataset, if one exists. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise} + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.deletePreviewUrl(datasetId) + } +} diff --git a/src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts b/src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts new file mode 100644 index 00000000..f19dbe85 --- /dev/null +++ b/src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts @@ -0,0 +1,21 @@ +import { UseCase } from '../../../../core/domain/useCases/UseCase' +import { IDatasetsRepository } from '../../repositories/IDatasetsRepository' +import { PreviewUrl } from '../../models/PreviewUrl' + +export class GetPreviewUrl implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Returns the existing Preview URL for the given dataset, if one has been created. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise} + */ + async execute(datasetId: number | string): Promise { + return await this.datasetsRepository.getPreviewUrl(datasetId) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index de7a32b4..b5904b1b 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -5,6 +5,9 @@ import { CreateDataset } from './domain/useCases/CreateDataset' import { GetDatasetLocks } from './domain/useCases/GetDatasetLocks' import { GetDatasetCitation } from './domain/useCases/GetDatasetCitation' import { GetPrivateUrlDataset } from './domain/useCases/GetPrivateUrlDataset' +import { CreatePreviewUrl } from './domain/useCases/previewUrl/CreatePreviewUrl' +import { GetPreviewUrl } from './domain/useCases/previewUrl/GetPreviewUrl' +import { DeletePreviewUrl } from './domain/useCases/previewUrl/DeletePreviewUrl' import { GetAllDatasetPreviews } from './domain/useCases/GetAllDatasetPreviews' import { MetadataFieldValidator } from './domain/useCases/validators/MetadataFieldValidator' import { GetDatasetUserPermissions } from './domain/useCases/GetDatasetUserPermissions' @@ -44,6 +47,9 @@ const getDataset = new GetDataset(datasetsRepository) const getDatasetLocks = new GetDatasetLocks(datasetsRepository) const getDatasetCitation = new GetDatasetCitation(datasetsRepository) const getPrivateUrlDataset = new GetPrivateUrlDataset(datasetsRepository) +const createPreviewUrl = new CreatePreviewUrl(datasetsRepository) +const getPreviewUrl = new GetPreviewUrl(datasetsRepository) +const deletePreviewUrl = new DeletePreviewUrl(datasetsRepository) const getAllDatasetPreviews = new GetAllDatasetPreviews(datasetsRepository) const getDatasetUserPermissions = new GetDatasetUserPermissions(datasetsRepository) const getDatasetSummaryFieldNames = new GetDatasetSummaryFieldNames(datasetsRepository) @@ -96,6 +102,9 @@ export { getDatasetLocks, getDatasetCitation, getPrivateUrlDataset, + createPreviewUrl, + getPreviewUrl, + deletePreviewUrl, getAllDatasetPreviews, getDatasetUserPermissions, getDatasetSummaryFieldNames, @@ -130,6 +139,7 @@ export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVer export { ExportedDatasetMetadata } from './domain/models/ExportedDatasetMetadata' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' export { DatasetLock, DatasetLockType } from './domain/models/DatasetLock' +export { PreviewUrl } from './domain/models/PreviewUrl' export { Dataset, DatasetLicense, diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index 1eba3fa4..0d8e8333 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -3,8 +3,10 @@ import { IDatasetsRepository } from '../../domain/repositories/IDatasetsReposito import { Dataset, VersionUpdateType } from '../../domain/models/Dataset' import { transformVersionResponseToDataset, - transformDatasetModelToUpdateDatasetRequestPayload + transformDatasetModelToUpdateDatasetRequestPayload, + transformPreviewUrlResponseToPreviewUrl } from './transformers/datasetTransformers' +import { PreviewUrl } from '../../domain/models/PreviewUrl' import { DatasetUserPermissions } from '../../domain/models/DatasetUserPermissions' import { transformDatasetUserPermissionsResponseToDatasetUserPermissions } from './transformers/datasetUserPermissionsTransformers' import { DatasetLock } from '../../domain/models/DatasetLock' @@ -67,19 +69,55 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi }) } + public async createPreviewUrl( + datasetId: number | string, + anonymizedAccess?: boolean + ): Promise { + return this.doPost( + this.buildApiEndpoint(this.datasetsResourceName, 'previewUrl', datasetId), + {}, + anonymizedAccess !== undefined ? { anonymizedAccess } : {} + ) + .then((response) => transformPreviewUrlResponseToPreviewUrl(response)) + .catch((error) => { + throw error + }) + } + + public async getPreviewUrl(datasetId: number | string): Promise { + return this.doGet( + this.buildApiEndpoint(this.datasetsResourceName, 'previewUrl', datasetId), + true + ) + .then((response) => transformPreviewUrlResponseToPreviewUrl(response)) + .catch((error) => { + throw error + }) + } + + public async deletePreviewUrl(datasetId: number | string): Promise { + return this.doDelete(this.buildApiEndpoint(this.datasetsResourceName, 'previewUrl', datasetId)) + .then(() => undefined) + .catch((error) => { + throw error + }) + } + public async getDataset( datasetId: number | string, datasetVersionId: string, includeDeaccessioned: boolean, - keepRawFields: boolean + keepRawFields: boolean, + previewUrlToken?: string ): Promise { return this.doGet( this.buildApiEndpoint(this.datasetsResourceName, `versions/${datasetVersionId}`, datasetId), - true, + previewUrlToken === undefined, { includeDeaccessioned: includeDeaccessioned, excludeFiles: true, - returnOwners: true + returnOwners: true, + ...(previewUrlToken && { key: previewUrlToken }) } ) .then((response) => transformVersionResponseToDataset(response, keepRawFields)) diff --git a/src/datasets/infra/repositories/transformers/datasetTransformers.ts b/src/datasets/infra/repositories/transformers/datasetTransformers.ts index 1c1e31d8..69d547f7 100644 --- a/src/datasets/infra/repositories/transformers/datasetTransformers.ts +++ b/src/datasets/infra/repositories/transformers/datasetTransformers.ts @@ -8,6 +8,7 @@ import { DatasetMetadataFieldValue, ANONYMIZED_FIELD_VALUE } from '../../../domain/models/Dataset' +import { PreviewUrl } from '../../../domain/models/PreviewUrl' import { AxiosResponse } from 'axios' import { DatasetPayload, @@ -418,3 +419,12 @@ const transformPayloadToDatasetMetadataSubfieldValue = ( export const transformHtmlToMarkdown = (source: string): string => { return turndownService.turndown(source) } + +export const transformPreviewUrlResponseToPreviewUrl = (response: AxiosResponse): PreviewUrl => { + const data = response.data.data + return { + token: data.token, + link: data.link, + isAnonymizedAccess: data.isAnonymizedAccess ?? false + } +} diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 29f78d6b..16ad4c55 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -21,14 +21,16 @@ export interface IFilesRepository { fileOrderCriteria: FileOrderCriteria, limit?: number, offset?: number, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise getDatasetFileCounts( datasetId: number | string, datasetVersionId: string, includeDeaccessioned: boolean, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise getDatasetFilesTotalDownloadSize( @@ -36,7 +38,8 @@ export interface IFilesRepository { datasetVersionId: string, includeDeaccessioned: boolean, fileDownloadSizeMode: FileDownloadSizeMode, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise getFileDownloadCount(fileId: number | string): Promise @@ -49,7 +52,8 @@ export interface IFilesRepository { fileId: number | string, datasetVersionId: string, returnDatasetVersion: boolean, - includeDeaccessioned: boolean + includeDeaccessioned: boolean, + previewUrlToken?: string ): Promise getFileCitation( diff --git a/src/files/domain/useCases/GetDatasetFileCounts.ts b/src/files/domain/useCases/GetDatasetFileCounts.ts index 17c94ba9..135781a0 100644 --- a/src/files/domain/useCases/GetDatasetFileCounts.ts +++ b/src/files/domain/useCases/GetDatasetFileCounts.ts @@ -18,19 +18,22 @@ export class GetDatasetFileCounts implements UseCase { * @param {string | DatasetNotNumberedVersion} [datasetVersionId=DatasetNotNumberedVersion.LATEST] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. If this parameter is not set, the default value is: DatasetNotNumberedVersion.LATEST * @param {boolean} [includeDeaccessioned=false] - Indicates whether to consider deaccessioned versions in the dataset search or not. The default value is false. * @param {FileSearchCriteria} [fileSearchCriteria] - Supports filtering the files by different file properties (optional). + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise} */ async execute( datasetId: number | string, datasetVersionId: string | DatasetNotNumberedVersion = DatasetNotNumberedVersion.LATEST, includeDeaccessioned = false, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise { return await this.filesRepository.getDatasetFileCounts( datasetId, datasetVersionId, includeDeaccessioned, - fileSearchCriteria + fileSearchCriteria, + previewUrlToken ) } } diff --git a/src/files/domain/useCases/GetDatasetFiles.ts b/src/files/domain/useCases/GetDatasetFiles.ts index 3b2bf86a..b5d9ff3b 100644 --- a/src/files/domain/useCases/GetDatasetFiles.ts +++ b/src/files/domain/useCases/GetDatasetFiles.ts @@ -21,6 +21,7 @@ export class GetDatasetFiles implements UseCase { * @param {number} [offset] - Offset for pagination (optional). * @param {FileSearchCriteria} [fileSearchCriteria] - Supports filtering the files by different file properties (optional). * @param {FileOrderCriteria} [fileOrderCriteria=FileOrderCriteria.NAME_AZ] - Supports ordering the results according to different criteria. If not set, the defalt value is FileOrderCriteria.NAME_AZ. + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise} */ async execute( @@ -30,7 +31,8 @@ export class GetDatasetFiles implements UseCase { limit?: number, offset?: number, fileSearchCriteria?: FileSearchCriteria, - fileOrderCriteria: FileOrderCriteria = FileOrderCriteria.NAME_AZ + fileOrderCriteria: FileOrderCriteria = FileOrderCriteria.NAME_AZ, + previewUrlToken?: string ): Promise { return await this.filesRepository.getDatasetFiles( datasetId, @@ -39,7 +41,8 @@ export class GetDatasetFiles implements UseCase { fileOrderCriteria, limit, offset, - fileSearchCriteria + fileSearchCriteria, + previewUrlToken ) } } diff --git a/src/files/domain/useCases/GetDatasetFilesTotalDownloadSize.ts b/src/files/domain/useCases/GetDatasetFilesTotalDownloadSize.ts index b1d5dec3..1a16594e 100644 --- a/src/files/domain/useCases/GetDatasetFilesTotalDownloadSize.ts +++ b/src/files/domain/useCases/GetDatasetFilesTotalDownloadSize.ts @@ -19,6 +19,7 @@ export class GetDatasetFilesTotalDownloadSize implements UseCase { * @param {FileDownloadSizeMode} [fileDownloadSizeMode=FileDownloadSizeMode.ALL] - Applies a filter mode to the operation to consider only archival sizes, original or both (all). The default value is FileDownloadSizeMode.ALL. * @param {FileSearchCriteria} [fileSearchCriteria] - Supports filtering the files to obtain their combined size by different file properties (optional). * @param {boolean} [includeDeaccessioned=false] - Indicates whether to consider deaccessioned versions in the dataset search or not. The default value is false. + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise} */ async execute( @@ -26,14 +27,16 @@ export class GetDatasetFilesTotalDownloadSize implements UseCase { datasetVersionId: string | DatasetNotNumberedVersion = DatasetNotNumberedVersion.LATEST, fileDownloadSizeMode: FileDownloadSizeMode = FileDownloadSizeMode.ALL, fileSearchCriteria?: FileSearchCriteria, - includeDeaccessioned = false + includeDeaccessioned = false, + previewUrlToken?: string ): Promise { return await this.filesRepository.getDatasetFilesTotalDownloadSize( datasetId, datasetVersionId, includeDeaccessioned, fileDownloadSizeMode, - fileSearchCriteria + fileSearchCriteria, + previewUrlToken ) } } diff --git a/src/files/domain/useCases/GetFile.ts b/src/files/domain/useCases/GetFile.ts index aef9629b..ab56539c 100644 --- a/src/files/domain/useCases/GetFile.ts +++ b/src/files/domain/useCases/GetFile.ts @@ -12,18 +12,21 @@ export class GetFile implements UseCase { * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). * @param {string | DatasetNotNumberedVersion} [datasetVersionId=DatasetNotNumberedVersion.LATEST] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. If this parameter is not set, the default value is: DatasetNotNumberedVersion.LATEST * @param {boolean} [includeDeaccessioned=false] - If true, the file will be returned even if it has been deaccessioned. + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise} */ async execute( fileId: number | string, datasetVersionId: string | DatasetNotNumberedVersion = DatasetNotNumberedVersion.LATEST, - includeDeaccessioned = false + includeDeaccessioned = false, + previewUrlToken?: string ): Promise { return (await this.filesRepository.getFile( fileId, datasetVersionId, false, - includeDeaccessioned + includeDeaccessioned, + previewUrlToken )) as FileModel } } diff --git a/src/files/domain/useCases/GetFileAndDataset.ts b/src/files/domain/useCases/GetFileAndDataset.ts index 1697d1c5..3a859821 100644 --- a/src/files/domain/useCases/GetFileAndDataset.ts +++ b/src/files/domain/useCases/GetFileAndDataset.ts @@ -12,18 +12,21 @@ export class GetFileAndDataset implements UseCase<[FileModel, Dataset]> { * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). * @param {string | DatasetNotNumberedVersion} [datasetVersionId=DatasetNotNumberedVersion.LATEST] - The dataset version identifier, which can be a version-specific numeric string (for example, 1.0) or a DatasetNotNumberedVersion enum value. If this parameter is not set, the default value is: DatasetNotNumberedVersion.LATEST * @param {boolean} [includeDeaccessioned=false] - If true, the file will be returned even if it has been deaccessioned. + * @param {string} [previewUrlToken] - The token identifying a Preview URL, allowing a reviewer without credentials to access an unpublished dataset (optional). * @returns {Promise<[FileModel, Dataset]>} */ async execute( fileId: number | string, datasetVersionId: string | DatasetNotNumberedVersion = DatasetNotNumberedVersion.LATEST, - includeDeaccessioned = false + includeDeaccessioned = false, + previewUrlToken?: string ): Promise<[FileModel, Dataset]> { return (await this.filesRepository.getFile( fileId, datasetVersionId, true, - includeDeaccessioned + includeDeaccessioned, + previewUrlToken )) as [FileModel, Dataset] } } diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index fbff81e6..dc02482f 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -36,6 +36,7 @@ export interface GetFilesQueryParams { categoryName?: string tabularTagName?: string searchText?: string + previewUrlToken?: string } export interface GetFilesTotalDownloadSizeQueryParams { @@ -46,6 +47,7 @@ export interface GetFilesTotalDownloadSizeQueryParams { categoryName?: string tabularTagName?: string searchText?: string + previewUrlToken?: string } export interface UploadedFileRequestBody { @@ -81,7 +83,8 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { fileOrderCriteria: FileOrderCriteria, limit?: number, offset?: number, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise { const queryParams: GetFilesQueryParams = { includeDeaccessioned: includeDeaccessioned, @@ -96,14 +99,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { if (fileSearchCriteria !== undefined) { this.applyFileSearchCriteriaToQueryParams(queryParams, fileSearchCriteria) } + if (previewUrlToken !== undefined) { + queryParams.previewUrlToken = previewUrlToken + } return this.doGet( this.buildApiEndpoint( this.datasetsResourceName, `versions/${datasetVersionId}/files`, datasetId ), - true, - queryParams + previewUrlToken === undefined, + this.toApiQueryParams(queryParams) ) .then((response) => transformFilesResponseToFilesSubset(response)) .catch((error) => { @@ -115,7 +121,8 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { datasetId: string | number, datasetVersionId: string, includeDeaccessioned: boolean, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise { const queryParams: GetFilesQueryParams = { includeDeaccessioned: includeDeaccessioned @@ -123,14 +130,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { if (fileSearchCriteria !== undefined) { this.applyFileSearchCriteriaToQueryParams(queryParams, fileSearchCriteria) } + if (previewUrlToken !== undefined) { + queryParams.previewUrlToken = previewUrlToken + } return this.doGet( this.buildApiEndpoint( this.datasetsResourceName, `versions/${datasetVersionId}/files/counts`, datasetId ), - true, - queryParams + previewUrlToken === undefined, + this.toApiQueryParams(queryParams) ) .then((response) => transformFileCountsResponseToFileCounts(response)) .catch((error) => { @@ -143,7 +153,8 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { datasetVersionId: string, includeDeaccessioned: boolean, fileDownloadSizeMode: FileDownloadSizeMode, - fileSearchCriteria?: FileSearchCriteria + fileSearchCriteria?: FileSearchCriteria, + previewUrlToken?: string ): Promise { const queryParams: GetFilesTotalDownloadSizeQueryParams = { includeDeaccessioned: includeDeaccessioned, @@ -152,14 +163,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { if (fileSearchCriteria !== undefined) { this.applyFileSearchCriteriaToQueryParams(queryParams, fileSearchCriteria) } + if (previewUrlToken !== undefined) { + queryParams.previewUrlToken = previewUrlToken + } return this.doGet( this.buildApiEndpoint( this.datasetsResourceName, `versions/${datasetVersionId}/downloadsize`, datasetId ), - true, - queryParams + previewUrlToken === undefined, + this.toApiQueryParams(queryParams) ) .then((response) => response.data.data.storageSize) .catch((error) => { @@ -198,15 +212,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { fileId: number | string, datasetVersionId: string, returnDatasetVersion: boolean, - includeDeaccessioned: boolean + includeDeaccessioned: boolean, + previewUrlToken?: string ): Promise { return this.doGet( this.buildApiEndpoint(this.filesResourceName, `versions/${datasetVersionId}`, fileId), - true, + previewUrlToken === undefined, { returnDatasetVersion: returnDatasetVersion, returnOwners: true, - includeDeaccessioned: includeDeaccessioned + includeDeaccessioned: includeDeaccessioned, + ...(previewUrlToken && { key: previewUrlToken }) } ) .then((response) => transformFileResponseToFile(response, returnDatasetVersion)) @@ -323,6 +339,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { } } + private toApiQueryParams( + queryParams: GetFilesQueryParams | GetFilesTotalDownloadSizeQueryParams + ): object { + const { previewUrlToken, ...apiQueryParams } = queryParams + + return { + ...apiQueryParams, + ...(previewUrlToken !== undefined && { key: previewUrlToken }) + } + } + public async deleteFile(fileId: number | string): Promise { return this.doDelete(this.buildApiEndpoint(this.filesResourceName, undefined, fileId)) .then(() => undefined) diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 6a67299e..76aabebf 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto' import { DatasetsRepository } from '../../../src/datasets/infra/repositories/DatasetsRepository' import { TestConstants } from '../../testHelpers/TestConstants' import { - createPrivateUrlViaApi, publishDatasetViaApi, waitForNoLocks, deleteUnpublishedDatasetViaApi, @@ -73,6 +72,7 @@ import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepo import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' import { createTestFileUploadDestination } from '../../testHelpers/files/fileUploadDestinationHelper' import { CitationFormat } from '../../../src/datasets/domain/models/CitationFormat' +import { createBuiltInUser } from '../../testHelpers/users/builtinUserApiHelper' const TEST_DIFF_DATASET_DTO: DatasetDTO = { license: { @@ -525,8 +525,8 @@ describe('DatasetsRepository', () => { beforeAll(async () => { testDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) - const response = await createPrivateUrlViaApi(testDatasetIds.numericId) - privateUrlToken = response.data.data.token + const previewUrl = await sut.createPreviewUrl(testDatasetIds.numericId) + privateUrlToken = previewUrl.token }) afterAll(async () => { @@ -558,6 +558,197 @@ describe('DatasetsRepository', () => { ) }) }) + + describe('createPreviewUrl, getPreviewUrl, and deletePreviewUrl', () => { + let lifecycleDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + lifecycleDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + }) + + afterAll(async () => { + await deleteUnpublishedDatasetViaApi(lifecycleDatasetIds.numericId) + }) + + test('should create, retrieve, and delete a Preview URL for a dataset', async () => { + const created = await sut.createPreviewUrl(lifecycleDatasetIds.numericId) + + expect(created.token).toBeTruthy() + expect(created.link).toContain(created.token) + expect(created.isAnonymizedAccess).toBe(false) + + const fetched = await sut.getPreviewUrl(lifecycleDatasetIds.numericId) + expect(fetched.token).toBe(created.token) + expect(fetched.link).toBe(created.link) + + await sut.deletePreviewUrl(lifecycleDatasetIds.numericId) + + await expect(sut.getPreviewUrl(lifecycleDatasetIds.numericId)).rejects.toBeInstanceOf( + ReadError + ) + }) + + test('should return error when getting a Preview URL that was never created', async () => { + const noPreviewUrlDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO + ) + + await expect(sut.getPreviewUrl(noPreviewUrlDatasetIds.numericId)).rejects.toBeInstanceOf( + ReadError + ) + + await deleteUnpublishedDatasetViaApi(noPreviewUrlDatasetIds.numericId) + }) + + test('should return error when deleting a Preview URL that does not exist', async () => { + const noPreviewUrlDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO + ) + + await expect(sut.deletePreviewUrl(noPreviewUrlDatasetIds.numericId)).rejects.toBeInstanceOf( + Error + ) + + await deleteUnpublishedDatasetViaApi(noPreviewUrlDatasetIds.numericId) + }) + + test('should return error when creating a Preview URL for a dataset that does not exist', async () => { + await expect(sut.createPreviewUrl(nonExistentTestDatasetId)).rejects.toBeInstanceOf(Error) + }) + + test('should return error when getting a Preview URL for a dataset that does not exist', async () => { + await expect(sut.getPreviewUrl(nonExistentTestDatasetId)).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when deleting a Preview URL for a dataset that does not exist', async () => { + await expect(sut.deletePreviewUrl(nonExistentTestDatasetId)).rejects.toBeInstanceOf(Error) + }) + + test('should create, retrieve, and delete a Preview URL for a dataset identified by persistent id', async () => { + const persistentIdDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO + ) + + const created = await sut.createPreviewUrl(persistentIdDatasetIds.persistentId) + expect(created.token).toBeTruthy() + + const fetched = await sut.getPreviewUrl(persistentIdDatasetIds.persistentId) + expect(fetched.token).toBe(created.token) + + await sut.deletePreviewUrl(persistentIdDatasetIds.persistentId) + + await expect(sut.getPreviewUrl(persistentIdDatasetIds.persistentId)).rejects.toBeInstanceOf( + ReadError + ) + + await deleteUnpublishedDatasetViaApi(persistentIdDatasetIds.numericId) + }) + + test('should return error when a user without permission to manage the dataset creates a Preview URL', async () => { + const noPermissionDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO + ) + const noPermissionUserApiToken = await createBuiltInUser(`noPermUser${randomUUID()}`) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + noPermissionUserApiToken + ) + + await expect(sut.createPreviewUrl(noPermissionDatasetIds.numericId)).rejects.toBeInstanceOf( + Error + ) + + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await deleteUnpublishedDatasetViaApi(noPermissionDatasetIds.numericId) + }) + }) + + describe('getDataset with a preview URL token', () => { + let previewUrlToken: string + + beforeEach(() => { + previewUrlToken = privateUrlToken + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined) + }) + + afterEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return the draft dataset when accessed unauthenticated with a valid preview URL token', async () => { + const actual = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false, + previewUrlToken + ) + + expect(actual.id).toBe(testDatasetIds.numericId) + }) + + test('should return error when accessed unauthenticated without a preview URL token', async () => { + await expect( + sut.getDataset(testDatasetIds.numericId, DatasetNotNumberedVersion.LATEST, false, false) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when accessed unauthenticated with an invalid preview URL token', async () => { + await expect( + sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false, + 'invalidToken' + ) + ).rejects.toBeInstanceOf(ReadError) + }) + + describe('when accessed by an authenticated user with no permission on the dataset', () => { + let noPermissionUserApiToken: string + + beforeAll(async () => { + noPermissionUserApiToken = await createBuiltInUser(`noPermUser${randomUUID()}`) + }) + + beforeEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + noPermissionUserApiToken + ) + }) + + test('should return error when the user has no permission and does not provide a preview URL token', async () => { + await expect( + sut.getDataset(testDatasetIds.numericId, DatasetNotNumberedVersion.LATEST, false, false) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return the draft dataset when a valid preview URL token is provided, regardless of the caller having their own (permissionless) credentials configured', async () => { + const actual = await sut.getDataset( + testDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + false, + previewUrlToken + ) + + expect(actual.id).toBe(testDatasetIds.numericId) + }) + }) + }) }) describe('getDatasetUserPermissions', () => { diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 889a0846..c5407052 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -64,6 +64,7 @@ describe('FilesRepository', () => { let testFileId: number let testFilePersistentId: string + let previewUrlToken: string beforeAll(async () => { ApiConfig.init( @@ -107,6 +108,11 @@ describe('FilesRepository', () => { ) testFileId = filesSubset.files[0].id testFilePersistentId = filesSubset.files[0].persistentId + + const previewUrl = await sutDataset.createPreviewUrl(testDatasetIds.numericId).catch(() => { + throw new Error('Tests beforeAll(): Error while creating preview URL for test dataset') + }) + previewUrlToken = previewUrl.token }) afterAll(async () => { @@ -257,6 +263,62 @@ describe('FilesRepository', () => { ).rejects.toThrow(errorExpected) }) }) + + describe('with a preview URL token', () => { + beforeEach(() => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined) + }) + + afterEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return the draft dataset files when accessed unauthenticated with a valid preview URL token', async () => { + const actual = await sut.getDatasetFiles( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ, + undefined, + undefined, + undefined, + previewUrlToken + ) + + expect(actual.files).toHaveLength(4) + expect(actual.totalFilesCount).toBe(4) + }) + + test('should return error when accessed unauthenticated without a preview URL token', async () => { + await expect( + sut.getDatasetFiles( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when accessed unauthenticated with an invalid preview URL token', async () => { + await expect( + sut.getDatasetFiles( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ, + undefined, + undefined, + undefined, + 'invalidToken' + ) + ).rejects.toBeInstanceOf(ReadError) + }) + }) }) describe('getDatasetFileCounts', () => { @@ -351,6 +413,50 @@ describe('FilesRepository', () => { expect(actual.perAccessStatus).toEqual(expectedFileCounts.perAccessStatus) expect(actual.perCategoryName).toEqual(expectedFileCounts.perCategoryName) }) + + describe('with a preview URL token', () => { + beforeEach(() => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined) + }) + + afterEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return the draft dataset file counts when accessed unauthenticated with a valid preview URL token', async () => { + const actual = await sut.getDatasetFileCounts( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + undefined, + previewUrlToken + ) + + expect(actual.total).toBe(expectedFileCounts.total) + }) + + test('should return error when accessed unauthenticated without a preview URL token', async () => { + await expect( + sut.getDatasetFileCounts(testDatasetIds.numericId, latestDatasetVersionId, false) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when accessed unauthenticated with an invalid preview URL token', async () => { + await expect( + sut.getDatasetFileCounts( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + undefined, + 'invalidToken' + ) + ).rejects.toBeInstanceOf(ReadError) + }) + }) }) describe('getDatasetFilesTotalDownloadSize', () => { @@ -388,6 +494,56 @@ describe('FilesRepository', () => { ) expect(actual).toBe(expectedTotalDownloadSizeForCriteria) }) + + describe('with a preview URL token', () => { + beforeEach(() => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined) + }) + + afterEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return the draft dataset total download size when accessed unauthenticated with a valid preview URL token', async () => { + const actual = await sut.getDatasetFilesTotalDownloadSize( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileDownloadSizeMode.ORIGINAL, + undefined, + previewUrlToken + ) + expect(actual).toBe(expectedTotalDownloadSize) + }) + + test('should return error when accessed unauthenticated without a preview URL token', async () => { + await expect( + sut.getDatasetFilesTotalDownloadSize( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileDownloadSizeMode.ORIGINAL + ) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when accessed unauthenticated with an invalid preview URL token', async () => { + await expect( + sut.getDatasetFilesTotalDownloadSize( + testDatasetIds.numericId, + latestDatasetVersionId, + false, + FileDownloadSizeMode.ORIGINAL, + undefined, + 'invalidToken' + ) + ).rejects.toBeInstanceOf(ReadError) + }) + }) }) describe('getFileDownloadCount', () => { @@ -512,6 +668,44 @@ describe('FilesRepository', () => { sut.getFile(nonExistentFiledId, DatasetNotNumberedVersion.LATEST, false, false) ).rejects.toThrow(expectedError) }) + + describe('with a preview URL token', () => { + beforeEach(() => { + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, undefined) + }) + + afterEach(() => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + }) + + test('should return the draft file when accessed unauthenticated with a valid preview URL token', async () => { + const actual = (await sut.getFile( + testFileId, + DatasetNotNumberedVersion.LATEST, + false, + false, + previewUrlToken + )) as FileModel + + expect(actual.name).toBe(testTextFile1Name) + }) + + test('should return error when accessed unauthenticated without a preview URL token', async () => { + await expect( + sut.getFile(testFileId, DatasetNotNumberedVersion.LATEST, false, false) + ).rejects.toBeInstanceOf(ReadError) + }) + + test('should return error when accessed unauthenticated with an invalid preview URL token', async () => { + await expect( + sut.getFile(testFileId, DatasetNotNumberedVersion.LATEST, false, false, 'invalidToken') + ).rejects.toBeInstanceOf(ReadError) + }) + }) }) describe('getFile with deaccessioned dataset', () => { diff --git a/test/testHelpers/TestConstants.ts b/test/testHelpers/TestConstants.ts index 192781b2..ccada083 100644 --- a/test/testHelpers/TestConstants.ts +++ b/test/testHelpers/TestConstants.ts @@ -1,7 +1,7 @@ import { DatasetDTO } from '../../src/datasets/domain/dtos/DatasetDTO' export class TestConstants { - static readonly TEST_API_URL = 'http://localhost:8080/api/v1' + static readonly TEST_API_URL = 'http://localhost:8081/api/v1' static readonly BUILTIN_USER_KEY = 'builtInS3kretKey' static readonly TEST_DUMMY_API_KEY = 'dummyApiKey' static readonly TEST_DUMMY_PERSISTENT_ID = 'doi:11.1111/AA1/AA1AAA' diff --git a/test/unit/datasets/CreatePreviewUrl.test.ts b/test/unit/datasets/CreatePreviewUrl.test.ts new file mode 100644 index 00000000..37f4bd58 --- /dev/null +++ b/test/unit/datasets/CreatePreviewUrl.test.ts @@ -0,0 +1,42 @@ +import { CreatePreviewUrl } from '../../../src/datasets/domain/useCases/previewUrl/CreatePreviewUrl' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { PreviewUrl } from '../../../src/datasets/domain/models/PreviewUrl' + +describe('execute', () => { + const testPreviewUrl: PreviewUrl = { + token: 'testToken', + link: 'https://demo.dataverse.org/previewurl.xhtml?token=testToken', + isAnonymizedAccess: false + } + + test('should return the created PreviewUrl on repository success', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.createPreviewUrl = jest.fn().mockResolvedValue(testPreviewUrl) + const sut = new CreatePreviewUrl(datasetsRepositoryStub) + + const actual = await sut.execute(1) + + expect(actual).toEqual(testPreviewUrl) + expect(datasetsRepositoryStub.createPreviewUrl).toHaveBeenCalledWith(1, undefined) + }) + + test('should forward the anonymizedAccess flag to the repository when provided', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.createPreviewUrl = jest.fn().mockResolvedValue(testPreviewUrl) + const sut = new CreatePreviewUrl(datasetsRepositoryStub) + + const actual = await sut.execute(1, true) + + expect(actual).toEqual(testPreviewUrl) + expect(datasetsRepositoryStub.createPreviewUrl).toHaveBeenCalledWith(1, true) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.createPreviewUrl = jest.fn().mockRejectedValue(new ReadError()) + const sut = new CreatePreviewUrl(datasetsRepositoryStub) + + await expect(sut.execute(1)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index 67015187..424a0e5b 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -1,3 +1,4 @@ +import { describe, test, beforeEach, jest } from '@jest/globals' import { DatasetsRepository } from '../../../src/datasets/infra/repositories/DatasetsRepository' import axios from 'axios' import { ReadError } from '../../../src/core/domain/repositories/ReadError' @@ -305,6 +306,31 @@ describe('DatasetsRepository', () => { ) expect(error).toBeInstanceOf(Error) }) + + test('should include the preview URL token as a key query param when provided', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testDatasetVersionSuccessfulResponse) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/versions/${testVersionId}` + const testPreviewUrlToken = 'testToken' + + const actual = await sut.getDataset( + testDatasetModel.id, + testVersionId, + testIncludeDeaccessioned, + false, + testPreviewUrlToken + ) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, { + params: { + includeDeaccessioned: testIncludeDeaccessioned, + excludeFiles: true, + returnOwners: true, + key: testPreviewUrlToken + }, + headers: TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG.headers + }) + expect(actual).toStrictEqual(testDatasetModel) + }) }) describe('by persistent id', () => { test('should return Dataset when providing persistent id, version id, and response is successful', async () => { @@ -465,6 +491,123 @@ describe('DatasetsRepository', () => { }) }) + describe('createPreviewUrl', () => { + const testPreviewUrlResponse = { + data: { + status: 'OK', + data: { + token: testPrivateUrlToken, + link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + isAnonymizedAccess: false + } + } + } + const expectedPreviewUrl = { + token: testPrivateUrlToken, + link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + isAnonymizedAccess: false + } + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/previewUrl` + + test('should return the created PreviewUrl when response is successful', async () => { + jest.spyOn(axios, 'post').mockResolvedValue(testPreviewUrlResponse) + + const actual = await sut.createPreviewUrl(testDatasetModel.id) + + expect(axios.post).toHaveBeenCalledWith( + expectedApiEndpoint, + '{}', + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(expectedPreviewUrl) + }) + + test('should include the anonymizedAccess query param when provided', async () => { + jest.spyOn(axios, 'post').mockResolvedValue(testPreviewUrlResponse) + + await sut.createPreviewUrl(testDatasetModel.id, true) + + expect(axios.post).toHaveBeenCalledWith(expectedApiEndpoint, '{}', { + ...TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY, + params: { anonymizedAccess: true } + }) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'post').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error: WriteError | undefined = undefined + await sut.createPreviewUrl(testDatasetModel.id).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('getPreviewUrl', () => { + const testPreviewUrlResponse = { + data: { + status: 'OK', + data: { + token: testPrivateUrlToken, + link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + isAnonymizedAccess: false + } + } + } + const expectedPreviewUrl = { + token: testPrivateUrlToken, + link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + isAnonymizedAccess: false + } + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/previewUrl` + + test('should return the PreviewUrl when response is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testPreviewUrlResponse) + + const actual = await sut.getPreviewUrl(testDatasetModel.id) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toStrictEqual(expectedPreviewUrl) + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error = undefined as unknown as ReadError + await sut.getPreviewUrl(testDatasetModel.id).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + + describe('deletePreviewUrl', () => { + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/previewUrl` + + test('should return nothing when response is successful', async () => { + jest.spyOn(axios, 'delete').mockResolvedValue(undefined) + + const actual = await sut.deletePreviewUrl(testDatasetModel.id) + + expect(axios.delete).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toBeUndefined() + }) + + test('should return error result on error response', async () => { + jest.spyOn(axios, 'delete').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + let error: WriteError | undefined = undefined + await sut.deletePreviewUrl(testDatasetModel.id).catch((e) => (error = e)) + + expect(error).toBeInstanceOf(Error) + }) + }) + describe('getDatasetUserPermissions', () => { const testDatasetUserPermissions = createDatasetUserPermissionsModel() const testDatasetUserPermissionsResponse = { diff --git a/test/unit/datasets/DeletePreviewUrl.test.ts b/test/unit/datasets/DeletePreviewUrl.test.ts new file mode 100644 index 00000000..3593578e --- /dev/null +++ b/test/unit/datasets/DeletePreviewUrl.test.ts @@ -0,0 +1,24 @@ +import { DeletePreviewUrl } from '../../../src/datasets/domain/useCases/previewUrl/DeletePreviewUrl' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' + +describe('execute', () => { + test('should return nothing on repository success', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.deletePreviewUrl = jest.fn().mockResolvedValue(undefined) + const sut = new DeletePreviewUrl(datasetsRepositoryStub) + + const actual = await sut.execute(1) + + expect(actual).toBeUndefined() + expect(datasetsRepositoryStub.deletePreviewUrl).toHaveBeenCalledWith(1) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.deletePreviewUrl = jest.fn().mockRejectedValue(new WriteError()) + const sut = new DeletePreviewUrl(datasetsRepositoryStub) + + await expect(sut.execute(1)).rejects.toThrow(WriteError) + }) +}) diff --git a/test/unit/datasets/GetDataset.test.ts b/test/unit/datasets/GetDataset.test.ts index 35e9beb1..4a8565d9 100644 --- a/test/unit/datasets/GetDataset.test.ts +++ b/test/unit/datasets/GetDataset.test.ts @@ -2,6 +2,7 @@ import { GetDataset } from '../../../src/datasets/domain/useCases/GetDataset' import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' import { createDatasetModel } from '../../testHelpers/datasets/datasetHelper' import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { DatasetNotNumberedVersion } from '../../../src/datasets/domain/models/DatasetNotNumberedVersion' describe('execute', () => { test('should return dataset on repository success', async () => { @@ -15,6 +16,25 @@ describe('execute', () => { expect(actual).toEqual(testDataset) }) + test('should forward the preview URL token to the repository when provided', async () => { + const testDataset = createDatasetModel() + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getDataset = jest.fn().mockResolvedValue(testDataset) + const testPreviewUrlToken = 'testToken' + const sut = new GetDataset(datasetsRepositoryStub) + + const actual = await sut.execute(1, undefined, undefined, undefined, testPreviewUrlToken) + + expect(actual).toEqual(testDataset) + expect(datasetsRepositoryStub.getDataset).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + false, + false, + testPreviewUrlToken + ) + }) + test('should return error result on repository error', async () => { const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository datasetsRepositoryStub.getDataset = jest.fn().mockRejectedValue(new ReadError()) diff --git a/test/unit/datasets/GetPreviewUrl.test.ts b/test/unit/datasets/GetPreviewUrl.test.ts new file mode 100644 index 00000000..b9b68245 --- /dev/null +++ b/test/unit/datasets/GetPreviewUrl.test.ts @@ -0,0 +1,31 @@ +import { GetPreviewUrl } from '../../../src/datasets/domain/useCases/previewUrl/GetPreviewUrl' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { PreviewUrl } from '../../../src/datasets/domain/models/PreviewUrl' + +describe('execute', () => { + const testPreviewUrl: PreviewUrl = { + token: 'testToken', + link: 'https://demo.dataverse.org/previewurl.xhtml?token=testToken', + isAnonymizedAccess: false + } + + test('should return the PreviewUrl on repository success', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getPreviewUrl = jest.fn().mockResolvedValue(testPreviewUrl) + const sut = new GetPreviewUrl(datasetsRepositoryStub) + + const actual = await sut.execute(1) + + expect(actual).toEqual(testPreviewUrl) + expect(datasetsRepositoryStub.getPreviewUrl).toHaveBeenCalledWith(1) + }) + + test('should return error result on repository error', async () => { + const datasetsRepositoryStub: IDatasetsRepository = {} as IDatasetsRepository + datasetsRepositoryStub.getPreviewUrl = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetPreviewUrl(datasetsRepositoryStub) + + await expect(sut.execute(1)).rejects.toThrow(ReadError) + }) +}) diff --git a/test/unit/files/FilesRepository.test.ts b/test/unit/files/FilesRepository.test.ts index aa33e7e6..0780707b 100644 --- a/test/unit/files/FilesRepository.test.ts +++ b/test/unit/files/FilesRepository.test.ts @@ -55,6 +55,7 @@ describe('FilesRepository', () => { .withContentType(testContentType) .withAccessStatus(FileAccessStatus.PUBLIC) .withTabularTagName(testTabularTagName) + const testPreviewUrlToken = 'testToken' beforeEach(() => { ApiConfig.init( @@ -328,6 +329,31 @@ describe('FilesRepository', () => { expect(actual).toStrictEqual(expectedFiles) }) + test('should include the preview URL token as a key query param when provided', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testFilesSuccessfulResponse) + + const actual = await sut.getDatasetFiles( + testDatasetId, + testDatasetVersionId, + testIncludeDeaccessioned, + testFileOrderCriteria, + undefined, + undefined, + undefined, + testPreviewUrlToken + ) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, { + params: { + includeDeaccessioned: testIncludeDeaccessioned, + orderCriteria: testFileOrderCriteria, + key: testPreviewUrlToken + }, + headers: TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG.headers + }) + expect(actual).toStrictEqual(expectedFiles) + }) + test('should return error result on error response', async () => { jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) @@ -502,6 +528,24 @@ describe('FilesRepository', () => { expect(actual).toStrictEqual(expectedCount) }) + test('should include the preview URL token as a key query param when provided', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testFileCountsSuccessfulResponse) + + const actual = await sut.getDatasetFileCounts( + testDatasetId, + testDatasetVersionId, + testIncludeDeaccessioned, + undefined, + testPreviewUrlToken + ) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, { + params: { includeDeaccessioned: testIncludeDeaccessioned, key: testPreviewUrlToken }, + headers: TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG.headers + }) + expect(actual).toStrictEqual(expectedCount) + }) + test('should return error result on error response', async () => { jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) @@ -654,6 +698,29 @@ describe('FilesRepository', () => { expect(actual).toStrictEqual(expectedSize) }) + test('should include the preview URL token as a key query param when provided', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testFilesTotalDownloadSizeSuccessfulResponse) + + const actual = await sut.getDatasetFilesTotalDownloadSize( + testDatasetId, + testDatasetVersionId, + testIncludeDeaccessioned, + testFileDownloadSizeMode, + undefined, + testPreviewUrlToken + ) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, { + params: { + mode: FileDownloadSizeMode.ARCHIVAL.toString(), + includeDeaccessioned: testIncludeDeaccessioned, + key: testPreviewUrlToken + }, + headers: TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG.headers + }) + expect(actual).toStrictEqual(expectedSize) + }) + test('should return error result on error response', async () => { jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) @@ -1066,6 +1133,25 @@ describe('FilesRepository', () => { sut.getFile(testFile.id, DatasetNotNumberedVersion.LATEST, false, false) ).rejects.toThrow(ReadError) }) + + test('should include the preview URL token as a key query param when provided', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(testGetFileResponse) + const testPreviewUrlToken = 'testToken' + + const actual = await sut.getFile( + testFile.id, + DatasetNotNumberedVersion.LATEST, + false, + false, + testPreviewUrlToken + ) + + expect(axios.get).toHaveBeenCalledWith(expectedApiEndpoint, { + params: { ...expectedRequestParams, key: testPreviewUrlToken }, + headers: TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG.headers + }) + expect(actual).toEqual(createFileModel()) + }) }) describe('by persistent id', () => { diff --git a/test/unit/files/GetDatasetFileCounts.test.ts b/test/unit/files/GetDatasetFileCounts.test.ts index 56308301..cb8dac97 100644 --- a/test/unit/files/GetDatasetFileCounts.test.ts +++ b/test/unit/files/GetDatasetFileCounts.test.ts @@ -20,10 +20,37 @@ describe('execute', () => { 1, DatasetNotNumberedVersion.LATEST, false, + undefined, undefined ) }) + test('should forward the preview URL token to the repository when provided', async () => { + const testFileCounts: FileCounts = createFileCountsModel() + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.getDatasetFileCounts = jest.fn().mockResolvedValue(testFileCounts) + const testPreviewUrlToken = 'testToken' + + const sut = new GetDatasetFileCounts(filesRepositoryStub) + + const actual = await sut.execute( + 1, + DatasetNotNumberedVersion.LATEST, + false, + undefined, + testPreviewUrlToken + ) + + expect(actual).toEqual(testFileCounts) + expect(filesRepositoryStub.getDatasetFileCounts).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + false, + undefined, + testPreviewUrlToken + ) + }) + test('should return error result on repository error', async () => { const filesRepositoryStub: IFilesRepository = {} as IFilesRepository filesRepositoryStub.getDatasetFileCounts = jest.fn().mockRejectedValue(new ReadError()) diff --git a/test/unit/files/GetDatasetFiles.test.ts b/test/unit/files/GetDatasetFiles.test.ts index fa850a5f..2355481c 100644 --- a/test/unit/files/GetDatasetFiles.test.ts +++ b/test/unit/files/GetDatasetFiles.test.ts @@ -24,10 +24,43 @@ describe('execute', () => { FileOrderCriteria.NAME_AZ, undefined, undefined, + undefined, undefined ) }) + test('should forward the preview URL token to the repository when provided', async () => { + const testFiles: FileModel[] = [createFileModel()] + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.getDatasetFiles = jest.fn().mockResolvedValue(testFiles) + const testPreviewUrlToken = 'testToken' + + const sut = new GetDatasetFiles(filesRepositoryStub) + + const actual = await sut.execute( + 1, + DatasetNotNumberedVersion.LATEST, + false, + undefined, + undefined, + undefined, + FileOrderCriteria.NAME_AZ, + testPreviewUrlToken + ) + + expect(actual).toEqual(testFiles) + expect(filesRepositoryStub.getDatasetFiles).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ, + undefined, + undefined, + undefined, + testPreviewUrlToken + ) + }) + test('should return error result on repository error', async () => { const filesRepositoryStub: IFilesRepository = {} as IFilesRepository filesRepositoryStub.getDatasetFiles = jest.fn().mockRejectedValue(new ReadError()) diff --git a/test/unit/files/GetDatasetFilesTotalDownloadSize.test.ts b/test/unit/files/GetDatasetFilesTotalDownloadSize.test.ts index f057cff8..9bdf1a3e 100644 --- a/test/unit/files/GetDatasetFilesTotalDownloadSize.test.ts +++ b/test/unit/files/GetDatasetFilesTotalDownloadSize.test.ts @@ -27,6 +27,7 @@ describe('execute', () => { DatasetNotNumberedVersion.LATEST, false, FileDownloadSizeMode.ALL, + undefined, undefined ) }) @@ -58,7 +59,36 @@ describe('execute', () => { testVersionId, false, FileDownloadSizeMode.ARCHIVAL, - testFileSearchCriteria + testFileSearchCriteria, + undefined + ) + }) + + test('should forward the preview URL token to the repository when provided', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.getDatasetFilesTotalDownloadSize = jest + .fn() + .mockResolvedValue(testDatasetTotalDownloadSize) + const sut = new GetDatasetFilesTotalDownloadSize(filesRepositoryStub) + const testPreviewUrlToken = 'testToken' + + const actual = await sut.execute( + 1, + DatasetNotNumberedVersion.LATEST, + FileDownloadSizeMode.ALL, + undefined, + false, + testPreviewUrlToken + ) + + expect(actual).toEqual(testDatasetTotalDownloadSize) + expect(filesRepositoryStub.getDatasetFilesTotalDownloadSize).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + false, + FileDownloadSizeMode.ALL, + undefined, + testPreviewUrlToken ) }) diff --git a/test/unit/files/GetFile.test.ts b/test/unit/files/GetFile.test.ts index c93b6ec2..03f967fd 100644 --- a/test/unit/files/GetFile.test.ts +++ b/test/unit/files/GetFile.test.ts @@ -17,7 +17,8 @@ describe('execute', () => { 1, DatasetNotNumberedVersion.LATEST, false, - false + false, + undefined ) }) @@ -35,7 +36,8 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', DatasetNotNumberedVersion.LATEST, false, - false + false, + undefined ) }) @@ -53,7 +55,8 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', '2.0', false, - false + false, + undefined ) }) @@ -75,7 +78,33 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', DatasetNotNumberedVersion.LATEST, false, - true + true, + undefined + ) + }) + + test('should forward the preview URL token to the repository when provided', async () => { + const testFile = createFileModel() + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.getFile = jest.fn().mockResolvedValue(testFile) + const testPreviewUrlToken = 'testToken' + + const sut = new GetFile(filesRepositoryStub) + + const actual = await sut.execute( + 1, + DatasetNotNumberedVersion.LATEST, + false, + testPreviewUrlToken + ) + + expect(actual).toEqual(testFile) + expect(filesRepositoryStub.getFile).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + false, + false, + testPreviewUrlToken ) }) diff --git a/test/unit/files/GetFileAndDataset.test.ts b/test/unit/files/GetFileAndDataset.test.ts index be70b3a0..8c74debe 100644 --- a/test/unit/files/GetFileAndDataset.test.ts +++ b/test/unit/files/GetFileAndDataset.test.ts @@ -22,7 +22,8 @@ describe('execute', () => { 1, DatasetNotNumberedVersion.LATEST, true, - false + false, + undefined ) }) @@ -39,7 +40,8 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', DatasetNotNumberedVersion.LATEST, true, - false + false, + undefined ) }) @@ -56,7 +58,8 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', '2.0', true, - false + false, + undefined ) }) @@ -77,7 +80,32 @@ describe('execute', () => { 'doi:10.5072/FK2/J8SJZB', DatasetNotNumberedVersion.LATEST, true, - true + true, + undefined + ) + }) + + test('should forward the preview URL token to the repository when provided', async () => { + const filesRepositoryStub = {} + filesRepositoryStub.getFile = jest.fn().mockResolvedValue(testTuple) + const sut = new GetFileAndDataset(filesRepositoryStub) + const testPreviewUrlToken = 'testToken' + + const actual = await sut.execute( + 1, + DatasetNotNumberedVersion.LATEST, + false, + testPreviewUrlToken + ) + + expect(actual[0]).toEqual(testFile) + expect(actual[1]).toEqual(testDataset) + expect(filesRepositoryStub.getFile).toHaveBeenCalledWith( + 1, + DatasetNotNumberedVersion.LATEST, + true, + false, + testPreviewUrlToken ) }) diff --git a/tsconfig.tests.json b/tsconfig.tests.json index fd0ea25b..13684d5d 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "types": ["jest", "node"] }, "include": [ "src/**/*", From 848bac7c4c9bcb78a79d3362e5c24c2e2872568b Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Mon, 3 Aug 2026 16:53:05 -0400 Subject: [PATCH 2/3] fix: chore fix --- test/testHelpers/TestConstants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/testHelpers/TestConstants.ts b/test/testHelpers/TestConstants.ts index ccada083..192781b2 100644 --- a/test/testHelpers/TestConstants.ts +++ b/test/testHelpers/TestConstants.ts @@ -1,7 +1,7 @@ import { DatasetDTO } from '../../src/datasets/domain/dtos/DatasetDTO' export class TestConstants { - static readonly TEST_API_URL = 'http://localhost:8081/api/v1' + static readonly TEST_API_URL = 'http://localhost:8080/api/v1' static readonly BUILTIN_USER_KEY = 'builtInS3kretKey' static readonly TEST_DUMMY_API_KEY = 'dummyApiKey' static readonly TEST_DUMMY_PERSISTENT_ID = 'doi:11.1111/AA1/AA1AAA' From f63b27eaed7f1eadc8ef073ab91a0968d238c917 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Tue, 4 Aug 2026 09:45:33 -0400 Subject: [PATCH 3/3] fix: documents --- CHANGELOG.md | 4 ++-- docs/useCases.md | 8 +------- test/unit/datasets/CreatePreviewUrl.test.ts | 2 +- test/unit/datasets/DatasetsRepository.test.ts | 8 ++++---- test/unit/datasets/GetPreviewUrl.test.ts | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cdd407c..2af4f6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Datasets: Added `exportDatasetMetadata` use case, repository method, and `ExportedDatasetMetadata` response type to support exporting dataset metadata by numeric id or persistent id through Dataverse endpoint `GET /datasets/export`. - Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. - Collections: Added theme information when retrieving a collection using `getCollection`. -- Datasets/Files: Added optional Preview URL (`previewUrlToken`) support to `getDataset`, `getDatasetFiles`, `getDatasetFileCounts`, `getDatasetFilesTotalDownloadSize`, `getFile`, and `getFileAndDataset`, so a reviewer using a Preview URL can access an unpublished dataset's metadata and files without needing to log in. When a `previewUrlToken` is provided, it takes priority over any other credentials the caller may have configured (e.g. an already-logged-in user's session), so the Preview URL reliably grants access on its own. -- Datasets: Added `createPreviewUrl`, `getPreviewUrl`, and `deletePreviewUrl` use cases and repository methods to support Dataverse endpoint `/datasets/{id}/previewUrl`, for creating, retrieving, and deleting a dataset's Preview URL. +- Datasets/Files: Added optional Preview URL (`previewUrlToken`) support to `getDataset`, `getDatasetFiles`, `getDatasetFileCounts`, `getDatasetFilesTotalDownloadSize`, `getFile`, and `getFileAndDataset`. +- Datasets: Added `createPreviewUrl`, `getPreviewUrl`, and `deletePreviewUrl` use cases and repository methods to support Dataverse endpoint `/datasets/{id}/previewUrl`. ### Changed diff --git a/docs/useCases.md b/docs/useCases.md index 3e29991f..71765efd 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -1919,11 +1919,9 @@ _See [use case](../src/datasets/domain/useCases/previewUrl/GetPreviewUrl.ts) imp The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. -Requires permission to manage the dataset's permissions. Throws an error if no Preview URL exists yet for the dataset; see [Create a Preview URL for a Dataset](#create-a-preview-url-for-a-dataset). - #### Create a Preview URL for a Dataset -Creates a Preview URL for the given dataset, returning a [PreviewUrl](../src/datasets/domain/models/PreviewUrl.ts) instance. The token in the returned `PreviewUrl` allows a reviewer without credentials to access the dataset's latest (unpublished) version — see the `previewUrlToken` parameter on the read use cases above (for example, [Get a Dataset](#get-a-dataset)). +Creates a Preview URL for the given dataset, returning a [PreviewUrl](../src/datasets/domain/models/PreviewUrl.ts) instance. ##### Example call: @@ -1947,8 +1945,6 @@ The `datasetId` parameter can be a string, for persistent identifiers, or a numb There is an optional second parameter called `anonymizedAccess`. If set to `true`, and Anonymized Access has been enabled on the installation, the created Preview URL will only allow an anonymized view of the dataset. -Requires permission to manage the dataset's permissions. - #### Delete a Preview URL from a Dataset Deletes the Preview URL for the given dataset, if one exists. @@ -1973,8 +1969,6 @@ _See [use case](../src/datasets/domain/useCases/previewUrl/DeletePreviewUrl.ts) The `datasetId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. -Requires permission to manage the dataset's permissions. Throws an error if no Preview URL exists for the dataset. - ## Files ### Files read use cases diff --git a/test/unit/datasets/CreatePreviewUrl.test.ts b/test/unit/datasets/CreatePreviewUrl.test.ts index 37f4bd58..148962f3 100644 --- a/test/unit/datasets/CreatePreviewUrl.test.ts +++ b/test/unit/datasets/CreatePreviewUrl.test.ts @@ -6,7 +6,7 @@ import { PreviewUrl } from '../../../src/datasets/domain/models/PreviewUrl' describe('execute', () => { const testPreviewUrl: PreviewUrl = { token: 'testToken', - link: 'https://demo.dataverse.org/previewurl.xhtml?token=testToken', + link: 'http://dataverse.com/previewurl.xhtml?token=testToken', isAnonymizedAccess: false } diff --git a/test/unit/datasets/DatasetsRepository.test.ts b/test/unit/datasets/DatasetsRepository.test.ts index 424a0e5b..4323a32d 100644 --- a/test/unit/datasets/DatasetsRepository.test.ts +++ b/test/unit/datasets/DatasetsRepository.test.ts @@ -497,14 +497,14 @@ describe('DatasetsRepository', () => { status: 'OK', data: { token: testPrivateUrlToken, - link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + link: `http://dataverse.com/previewurl.xhtml?token=${testPrivateUrlToken}`, isAnonymizedAccess: false } } } const expectedPreviewUrl = { token: testPrivateUrlToken, - link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + link: `http://dataverse.com/previewurl.xhtml?token=${testPrivateUrlToken}`, isAnonymizedAccess: false } const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/previewUrl` @@ -549,14 +549,14 @@ describe('DatasetsRepository', () => { status: 'OK', data: { token: testPrivateUrlToken, - link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + link: `http://dataverse.com/previewurl.xhtml?token=${testPrivateUrlToken}`, isAnonymizedAccess: false } } } const expectedPreviewUrl = { token: testPrivateUrlToken, - link: `https://demo.dataverse.org/previewurl.xhtml?token=${testPrivateUrlToken}`, + link: `http://dataverse.com/previewurl.xhtml?token=${testPrivateUrlToken}`, isAnonymizedAccess: false } const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/datasets/${testDatasetModel.id}/previewUrl` diff --git a/test/unit/datasets/GetPreviewUrl.test.ts b/test/unit/datasets/GetPreviewUrl.test.ts index b9b68245..658653e4 100644 --- a/test/unit/datasets/GetPreviewUrl.test.ts +++ b/test/unit/datasets/GetPreviewUrl.test.ts @@ -6,7 +6,7 @@ import { PreviewUrl } from '../../../src/datasets/domain/models/PreviewUrl' describe('execute', () => { const testPreviewUrl: PreviewUrl = { token: 'testToken', - link: 'https://demo.dataverse.org/previewurl.xhtml?token=testToken', + link: 'http://dataverse.com/previewurl.xhtml?token=testToken', isAnonymizedAccess: false }