diff --git a/docs/useCases.md b/docs/useCases.md index fa726a78..6a5be49d 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -51,6 +51,7 @@ The different use cases currently available in the package are classified below, - [Files write use cases](#files-write-use-cases) - [File Uploading Use Cases](#file-uploading-use-cases) - [Delete a File](#delete-a-file) + - [Restrict or Unrestrict a File](#restrict-or-unrestrict-a-file) - [Metadata Blocks](#metadata-blocks) - [Metadata Blocks read use cases](#metadata-blocks-read-use-cases) - [Get All Facetable Metadata Fields](#get-all-facetable-metadata-fields) @@ -1264,6 +1265,28 @@ Note that the behavior of deleting files depends on if the dataset has ever been - If the dataset has published, the file is deleted from the draft (and future published versions). - If the dataset has published, the deleted file can still be downloaded because it was part of a published version. +#### Restrict or Unrestrict a File + +Restrict or unrestrict an existing file. + +##### Example call: + +```typescript +import { restrictFile } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId = 12345 + +restrictFile.execute(fileId, true) + +/* ... */ +``` + +_See [use case](../src/files/domain/useCases/RestrictFile.ts) implementation_. + +The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + ## Metadata Blocks ### Metadata Blocks read use cases diff --git a/src/core/infra/repositories/ApiRepository.ts b/src/core/infra/repositories/ApiRepository.ts index 8ac423db..7a70c17c 100644 --- a/src/core/infra/repositories/ApiRepository.ts +++ b/src/core/infra/repositories/ApiRepository.ts @@ -20,7 +20,7 @@ export abstract class ApiRepository { public async doPost( apiEndpoint: string, - data: string | object, + data: string | object | boolean, queryParams: object = {}, contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON ): Promise { @@ -29,7 +29,7 @@ export abstract class ApiRepository { public async doPut( apiEndpoint: string, - data: string | object, + data: string | object | boolean, queryParams: object = {}, contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON ): Promise { @@ -70,12 +70,18 @@ export abstract class ApiRepository { private async doRequest( method: 'post' | 'put', apiEndpoint: string, - data: string | object, + data: string | object | boolean, queryParams: object = {}, contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON ): Promise { - const requestData = - contentType == ApiConstants.CONTENT_TYPE_APPLICATION_JSON ? JSON.stringify(data) : data + let requestData = data + + if (contentType === ApiConstants.CONTENT_TYPE_APPLICATION_JSON) { + if (typeof data === 'object' || typeof data === 'boolean') { + requestData = JSON.stringify(data) + } + } + const requestUrl = buildRequestUrl(apiEndpoint) const requestConfig = buildRequestConfig(true, queryParams, contentType) diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 8365374e..775cf18a 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -61,4 +61,6 @@ export interface IFilesRepository { ): Promise deleteFile(fileId: number | string): Promise + + restrictFile(fileId: number | string, restrict: boolean): Promise } diff --git a/src/files/domain/useCases/RestrictFile.ts b/src/files/domain/useCases/RestrictFile.ts new file mode 100644 index 00000000..c01d66b7 --- /dev/null +++ b/src/files/domain/useCases/RestrictFile.ts @@ -0,0 +1,18 @@ +import { IFilesRepository } from '../repositories/IFilesRepository' +import { UseCase } from '../../../core/domain/useCases/UseCase' + +export class RestrictFile implements UseCase { + constructor(private readonly filesRepository: IFilesRepository) {} + + /** + * Restrict or unrestrict an existing file. + * More detailed information about the file restriction behavior can be found in https://guides.dataverse.org/en/latest/api/native-api.html#restrict-files + * + * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {boolean} [restrict] - A boolean value that indicates whether the file should be restricted or unrestricted. + * @returns {Promise} -This method does not return anything upon successful completion. + */ + async execute(fileId: number | string, restrict: boolean): Promise { + return await this.filesRepository.restrictFile(fileId, restrict) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index 0d847852..3809b4cf 100644 --- a/src/files/index.ts +++ b/src/files/index.ts @@ -12,6 +12,7 @@ import { UploadFile } from './domain/useCases/UploadFile' import { DirectUploadClient } from './infra/clients/DirectUploadClient' import { AddUploadedFilesToDataset } from './domain/useCases/AddUploadedFilesToDataset' import { DeleteFile } from './domain/useCases/DeleteFile' +import { RestrictFile } from './domain/useCases/RestrictFile' const filesRepository = new FilesRepository() const directUploadClient = new DirectUploadClient(filesRepository) @@ -28,6 +29,7 @@ const getFileCitation = new GetFileCitation(filesRepository) const uploadFile = new UploadFile(directUploadClient) const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository) const deleteFile = new DeleteFile(filesRepository) +const restrictFile = new RestrictFile(filesRepository) export { getDatasetFiles, @@ -41,7 +43,8 @@ export { getFileCitation, uploadFile, addUploadedFilesToDataset, - deleteFile + deleteFile, + restrictFile } export { FileModel as File, FileEmbargo, FileChecksum } from './domain/models/FileModel' diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index 9aebe720..76e4605b 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -301,4 +301,12 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { throw error }) } + + public async restrictFile(fileId: number | string, restrict: boolean): Promise { + return this.doPut(this.buildApiEndpoint(this.filesResourceName, 'restrict', fileId), restrict) + .then(() => undefined) + .catch((error) => { + throw error + }) + } } diff --git a/test/functional/collections/GetCollectionItems.test.ts b/test/functional/collections/GetCollectionItems.test.ts index 15e2814f..96832fef 100644 --- a/test/functional/collections/GetCollectionItems.test.ts +++ b/test/functional/collections/GetCollectionItems.test.ts @@ -61,8 +61,8 @@ describe('execute', () => { try { const actual = await getCollectionItems.execute(testCollectionAlias) - const actualFilePreview = actual.items[0] as FilePreview - const actualDatasetPreview = actual.items[1] as DatasetPreview + const actualFilePreview = actual.items[1] as FilePreview + const actualDatasetPreview = actual.items[0] as DatasetPreview expect(actualFilePreview.name).toBe('test-file-1.txt') expect(actualDatasetPreview.title).toBe('Dataset created using the createDataset use case') diff --git a/test/functional/files/RestrictFile.test.ts b/test/functional/files/RestrictFile.test.ts new file mode 100644 index 00000000..39cd7d52 --- /dev/null +++ b/test/functional/files/RestrictFile.test.ts @@ -0,0 +1,108 @@ +import { + ApiConfig, + createDataset, + CreatedDatasetIdentifiers, + restrictFile, + getDatasetFiles, + WriteError +} from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + createCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper' +import { uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('execute', () => { + const testCollectionAlias = 'restrictFileFunctionalTest' + let testDatasetIds: CreatedDatasetIdentifiers + const testTextFile1Name = 'test-file-1.txt' + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await createCollectionViaApi(testCollectionAlias) + + try { + testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + } catch (error) { + throw new Error('Tests beforeAll(): Error while creating test dataset') + } + await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name).catch(() => { + throw new Error(`Tests beforeAll(): Error while uploading file ${testTextFile1Name}`) + }) + }) + + afterAll(async () => { + try { + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test dataset') + } + try { + await deleteCollectionViaApi(testCollectionAlias) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test collection') + } + }) + + test('should successfully restrict a file', async () => { + try { + const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId) + + await restrictFile.execute(datasetFiles.files[0].id, true) + } catch (error) { + throw new Error('File should be deleted') + } finally { + const datasetFilesAfterRestriction = await getDatasetFiles.execute(testDatasetIds.numericId) + + expect(datasetFilesAfterRestriction.files[0].restricted).toEqual(true) + + // Unrestrict the file for the next test + await restrictFile.execute(datasetFilesAfterRestriction.files[0].id, false) + } + }) + + test('should succesfully unrestrict a file', async () => { + try { + const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId) + + await restrictFile.execute(datasetFiles.files[0].id, true) + + await restrictFile.execute(datasetFiles.files[0].id, false) + } catch (error) { + throw new Error('File should be deleted') + } finally { + const datasetFilesAfterRestriction = await getDatasetFiles.execute(testDatasetIds.numericId) + + expect(datasetFilesAfterRestriction.files[0].restricted).toEqual(false) + } + }) + + test('should throw an error when the file id does not exist', async () => { + expect.assertions(2) + let writeError: WriteError | undefined = undefined + const nonExistentFileId = 5 + + try { + await restrictFile.execute(nonExistentFileId, true) + throw new Error('Use case should throw an error') + } catch (error) { + writeError = error as WriteError + } finally { + expect(writeError).toBeInstanceOf(WriteError) + + expect(writeError?.message).toEqual( + `There was an error when writing the resource. Reason was: [400] Could not find datafile with id ${nonExistentFileId}` + ) + } + }) +}) diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index b8f43967..3fb13607 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -339,8 +339,8 @@ describe('CollectionsRepository', () => { await new Promise((resolve) => setTimeout(resolve, 5000)) let actual = await sut.getCollectionItems(testCollectionAlias) - const actualFilePreview = actual.items[0] as FilePreview - const actualDatasetPreview = actual.items[1] as DatasetPreview + const actualFilePreview = actual.items[1] as FilePreview + const actualDatasetPreview = actual.items[0] as DatasetPreview const actualCollectionPreview = actual.items[2] as CollectionPreview const expectedFileMd5 = '68b22040025784da775f55cfcb6dee2e' @@ -473,7 +473,7 @@ describe('CollectionsRepository', () => { // Test limit and offset actual = await sut.getCollectionItems(testCollectionAlias, 1, 1) - expect((actual.items[0] as DatasetPreview).persistentId).toBe(testDatasetIds.persistentId) + expect((actual.items[0] as FilePreview).name).toBe(expectedFileName) expect(actual.items.length).toBe(1) expect(actual.totalItemCount).toBe(3) @@ -683,8 +683,8 @@ describe('CollectionsRepository', () => { ) expect(actual.items.length).toBe(3) expect(actual.totalItemCount).toBe(3) - expect((actual.items[0] as FilePreview).type).toBe(CollectionItemType.FILE) - expect((actual.items[1] as DatasetPreview).type).toBe(CollectionItemType.DATASET) + expect((actual.items[0] as DatasetPreview).type).toBe(CollectionItemType.DATASET) + expect((actual.items[1] as FilePreview).type).toBe(CollectionItemType.FILE) expect((actual.items[2] as CollectionPreview).type).toBe(CollectionItemType.COLLECTION) expect(actual.countPerObjectType.dataverses).toBe(1) expect(actual.countPerObjectType.datasets).toBe(1) diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 7aff8c49..12ee137d 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -725,4 +725,133 @@ describe('FilesRepository', () => { await expect(sut.deleteFile(nonExistentFiledId)).rejects.toThrow(expectedError) }) }) + + describe('restrictFile', () => { + let restrictFileDatasetIds: CreatedDatasetIdentifiers + const testTextFile1Name = 'test-file-1.txt' + + const setFileToRestricted = async (fileId: number) => { + await sut.restrictFile(fileId, true) + } + + const setFileToUnrestricted = async (fileId: number) => { + await sut.restrictFile(fileId, false) + } + + beforeEach(async () => { + try { + restrictFileDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + } catch (error) { + throw new Error('Tests beforeEach(): Error while creating test dataset') + } + await uploadFileViaApi(restrictFileDatasetIds.numericId, testTextFile1Name).catch(() => { + throw new Error(`Tests beforeEach(): Error while uploading file ${testTextFile1Name}`) + }) + }) + + afterEach(async () => { + await deleteUnpublishedDatasetViaApi(restrictFileDatasetIds.numericId) + }) + + test('should successfully restrict a file', async () => { + const datasetFiles = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + expect(datasetFiles.files[0].restricted).toEqual(false) + + await setFileToRestricted(datasetFiles.files[0].id) + + const datasetFilesAfterRestrict = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + expect(datasetFilesAfterRestrict.files[0].restricted).toEqual(true) + + // Unrestrict the file Just in case to avoid conflicts with other tests + await setFileToUnrestricted(datasetFiles.files[0].id) + }) + + test('should successfully unrestrict a file', async () => { + const datasetFiles = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + expect(datasetFiles.files[0].restricted).toEqual(false) + + await setFileToRestricted(datasetFiles.files[0].id) + + const datasetFilesAfterRestrict = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + expect(datasetFilesAfterRestrict.files[0].restricted).toEqual(true) + + await setFileToUnrestricted(datasetFiles.files[0].id) + + const datasetFilesAfterUnrestrict = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + expect(datasetFilesAfterUnrestrict.files[0].restricted).toEqual(false) + }) + + test('should return error when file was already restricted', async () => { + const datasetFiles = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + await setFileToRestricted(datasetFiles.files[0].id) + + const expectedError = new WriteError( + `[400] Problem trying to update restriction status on ${testTextFile1Name}: File ${testTextFile1Name} is already restricted` + ) + + await expect(setFileToRestricted(datasetFiles.files[0].id)).rejects.toThrow(expectedError) + + // Unrestrict the file Just in case to avoid conflicts with other tests + await setFileToUnrestricted(datasetFiles.files[0].id) + }) + + test('should return error when files was already unrestricted', async () => { + const datasetFiles = await sut.getDatasetFiles( + restrictFileDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + false, + FileOrderCriteria.NAME_AZ + ) + + const expectedError = new WriteError( + `[400] Problem trying to update restriction status on ${testTextFile1Name}: File ${testTextFile1Name} is already unrestricted` + ) + + await expect(setFileToUnrestricted(datasetFiles.files[0].id)).rejects.toThrow(expectedError) + }) + + test('should return error when file does not exist', async () => { + const expectedError = new WriteError( + `[400] Could not find datafile with id ${nonExistentFiledId}` + ) + + await expect(setFileToRestricted(nonExistentFiledId)).rejects.toThrow(expectedError) + }) + }) }) diff --git a/test/unit/auth/AuthRepository.test.ts b/test/unit/auth/AuthRepository.test.ts index e78aa9fc..97da3367 100644 --- a/test/unit/auth/AuthRepository.test.ts +++ b/test/unit/auth/AuthRepository.test.ts @@ -35,7 +35,7 @@ describe('logout', () => { expect(axios.post).toHaveBeenCalledWith( expectedApiEndpoint, - JSON.stringify(''), + '', TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY ) @@ -46,7 +46,7 @@ describe('logout', () => { expect(axios.post).toHaveBeenCalledWith( expectedApiEndpoint, - JSON.stringify(''), + '', TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE ) }) @@ -59,7 +59,7 @@ describe('logout', () => { expect(axios.post).toHaveBeenCalledWith( `${TestConstants.TEST_API_URL}/logout`, - JSON.stringify(''), + '', TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY ) expect(error).toBeInstanceOf(Error) diff --git a/test/unit/files/RestrictFile.test.ts b/test/unit/files/RestrictFile.test.ts new file mode 100644 index 00000000..b240809e --- /dev/null +++ b/test/unit/files/RestrictFile.test.ts @@ -0,0 +1,25 @@ +import { WriteError } from '../../../src' +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { RestrictFile } from '../../../src/files/domain/useCases/RestrictFile' + +describe('execute', () => { + test('should return undefined when repository call is successful', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.restrictFile = jest.fn().mockResolvedValue(undefined) + + const sut = new RestrictFile(filesRepositoryStub) + + const actual = await sut.execute(1, true) + + expect(actual).toEqual(undefined) + }) + + test('should return error result on repository error', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.restrictFile = jest.fn().mockRejectedValue(new WriteError()) + + const sut = new RestrictFile(filesRepositoryStub) + + await expect(sut.execute(1, true)).rejects.toThrow(WriteError) + }) +})