diff --git a/docs/useCases.md b/docs/useCases.md index 6a221419..e615a266 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -52,6 +52,7 @@ The different use cases currently available in the package are classified below, - [Get the size of Downloading all the files of a Dataset Version](#get-the-size-of-downloading-all-the-files-of-a-dataset-version) - [Get User Permissions on a File](#get-user-permissions-on-a-file) - [List Files in a Dataset](#list-files-in-a-dataset) + - [Is File Deleted](#is-file-deleted) - [Files write use cases](#files-write-use-cases) - [File Uploading Use Cases](#file-uploading-use-cases) - [Delete a File](#delete-a-file) @@ -1571,6 +1572,30 @@ If restrict is false then enableAccessRequest and termsOfAccess are ignored If restrict is true and enableAccessRequest is false then termsOfAccess is required. The enableAccessRequest and termsOfAccess are applied to the Draft version of the Dataset and affect all of the restricted files in said Draft version. +#### Is File Deleted + +Check if the file has been deleted, return a boolean. + +##### Example call: + +```typescript +import { isFileDeleted } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId = 12345 + +await isFileDeleted.execute(fileId).then((isDeleted: boolean) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/files/domain/useCases/isFileDeleted.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/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index fa473d71..83596db3 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -84,4 +84,6 @@ export interface IFilesRepository { categories: string[], replace?: boolean ): Promise + + isFileDeleted(fileId: number | string): Promise } diff --git a/src/files/domain/useCases/IsFileDeleted.ts b/src/files/domain/useCases/IsFileDeleted.ts new file mode 100644 index 00000000..58bbc45f --- /dev/null +++ b/src/files/domain/useCases/IsFileDeleted.ts @@ -0,0 +1,16 @@ +import { IFilesRepository } from '../repositories/IFilesRepository' +import { UseCase } from '../../../core/domain/useCases/UseCase' + +export class IsFileDeleted implements UseCase { + constructor(private readonly filesRepository: IFilesRepository) {} + + /** + * Returns a boolean, indicating whether the file has been deleted or not. + * + * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise} - A boolean indicating whether the file has been deleted or not. + */ + async execute(fileId: number | string): Promise { + return await this.filesRepository.isFileDeleted(fileId) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index aac93272..47b86cd6 100644 --- a/src/files/index.ts +++ b/src/files/index.ts @@ -17,6 +17,7 @@ import { RestrictFile } from './domain/useCases/RestrictFile' import { UpdateFileMetadata } from './domain/useCases/UpdateFileMetadata' import { UpdateFileTabularTags } from './domain/useCases/UpdateFileTabularTags' import { UpdateFileCategories } from './domain/useCases/UpdateFileCategories' +import { IsFileDeleted } from './domain/useCases/IsFileDeleted' const filesRepository = new FilesRepository() const directUploadClient = new DirectUploadClient(filesRepository) @@ -38,6 +39,7 @@ const restrictFile = new RestrictFile(filesRepository) const updateFileMetadata = new UpdateFileMetadata(filesRepository) const updateFileTabularTags = new UpdateFileTabularTags(filesRepository) const updateFileCategories = new UpdateFileCategories(filesRepository) +const isFileDeleted = new IsFileDeleted(filesRepository) export { getDatasetFiles, @@ -56,7 +58,8 @@ export { updateFileMetadata, updateFileTabularTags, updateFileCategories, - replaceFile + replaceFile, + isFileDeleted } 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 41c49b38..2fcf308e 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -415,4 +415,12 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { throw error }) } + + public async isFileDeleted(fileId: number | string): Promise { + return this.doGet(this.buildApiEndpoint(this.filesResourceName, 'hasBeenDeleted', fileId), true) + .then((response) => response.data.data) + .catch((error) => { + throw error + }) + } } diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 2b0b2655..85187168 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto' import { FilesRepository } from '../../../src/files/infra/repositories/FilesRepository' import { ApiConfig, @@ -39,10 +40,12 @@ import { import { createCollectionViaApi, deleteCollectionViaApi, + publishCollectionViaApi, setStorageDriverViaApi } from '../../testHelpers/collections/collectionHelper' import { RestrictFileDTO } from '../../../src/files/domain/dtos/RestrictFileDTO' import { DatasetsRepository } from '../../../src/datasets/infra/repositories/DatasetsRepository' +import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' describe('FilesRepository', () => { const sut: FilesRepository = new FilesRepository() @@ -840,6 +843,169 @@ describe('FilesRepository', () => { }) }) + describe('isFileDeleted', () => { + const testTextFile1Name = 'test-file-1.txt' + const testTextFile2Name = 'test-file-2.txt' + const testCollectionAlias = 'isFileDeletedTestCollection' + + let deleFileTestDatasetIds: CreatedDatasetIdentifiers + let fileId: number + let singlepartFile: File + + const createTestFileUploadDestination = async (file: File, datasetId: number) => { + const destination = await sut.getFileUploadDestination(datasetId, file) + destination.urls = destination.urls.map((url) => url.replace('localstack', 'localhost')) + return destination + } + + const calculateBlobChecksum = (blob: Buffer): string => { + return crypto.createHash('md5').update(blob).digest('hex') + } + + beforeAll(async () => { + await createCollectionViaApi(testCollectionAlias) + await setStorageDriverViaApi(testCollectionAlias, 'LocalStack') + await publishCollectionViaApi(testCollectionAlias) + + deleFileTestDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + + singlepartFile = await createSinglepartFileBlob() + + await uploadFileViaApi(deleFileTestDatasetIds.numericId, testTextFile1Name) + + const datasetFiles = await sut.getDatasetFiles( + deleFileTestDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + fileId = datasetFiles.files[0].id + }) + + describe('Basic deletion scenarios', () => { + test('should return False if a file has not been deleted', async () => { + const hasBeenDeleted = await sut.isFileDeleted(fileId) + expect(hasBeenDeleted).toBe(false) + }) + + test('should return error if the dataset is unpublished and the file has been deleted', async () => { + await sut.deleteFile(fileId) + + const expectedError = new ReadError(`[404] File with ID ${nonExistentFiledId} not found.`) + await expect(sut.isFileDeleted(nonExistentFiledId)).rejects.toThrow(expectedError) + }) + + test('should return correctly when the file has or has not been deleted, in a published dataset', async () => { + await uploadFileViaApi(deleFileTestDatasetIds.numericId, testTextFile1Name) + await publishDatasetViaApi(deleFileTestDatasetIds.numericId) + await waitForNoLocks(deleFileTestDatasetIds.numericId, 10) + + const datasetFiles = await sut.getDatasetFiles( + deleFileTestDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + fileId = datasetFiles.files[0].id + + const fileHasNotBeenDeleted = await sut.isFileDeleted(fileId) + expect(fileHasNotBeenDeleted).toBe(false) + + await sut.deleteFile(fileId) + + const fileHasBeenDeleted = await sut.isFileDeleted(fileId) + expect(fileHasBeenDeleted).toBe(true) + }) + + test('should return error when file does not exist', async () => { + const expectedError = new ReadError(`[404] File with ID ${nonExistentFiledId} not found.`) + await expect(sut.isFileDeleted(nonExistentFiledId)).rejects.toThrow(expectedError) + }) + }) + + describe('File replacement scenario', () => { + test('should return True when file has been replaced', async () => { + const directUploadSut = new DirectUploadClient(sut) + const progressMock = jest.fn() + const abortController = new AbortController() + + // Upload original file + const originalBuffer = Buffer.from(await singlepartFile.arrayBuffer()) + const originalDestination = await createTestFileUploadDestination( + singlepartFile, + deleFileTestDatasetIds.numericId + ) + + const originalStorageId = await directUploadSut.uploadFile( + deleFileTestDatasetIds.numericId, + singlepartFile, + progressMock, + abortController, + originalDestination + ) + + const originalUploadedFileDTO = { + fileName: singlepartFile.name, + storageId: originalStorageId, + checksumType: 'md5', + checksumValue: calculateBlobChecksum(originalBuffer), + mimeType: singlepartFile.type + } + + await sut.addUploadedFilesToDataset(deleFileTestDatasetIds.numericId, [ + originalUploadedFileDTO + ]) + + const originalFileId = ( + await sut.getDatasetFiles( + deleFileTestDatasetIds.numericId, + DatasetNotNumberedVersion.LATEST, + true, + FileOrderCriteria.NAME_AZ + ) + ).files[0].id + + // Create and upload replacement file + const newFileBlob = await createSinglepartFileBlob(testTextFile2Name, 2000) + const newBuffer = Buffer.from(await newFileBlob.arrayBuffer()) + const newDestination = await createTestFileUploadDestination( + newFileBlob, + deleFileTestDatasetIds.numericId + ) + + const newStorageId = await directUploadSut.uploadFile( + deleFileTestDatasetIds.numericId, + newFileBlob, + progressMock, + abortController, + newDestination + ) + + const newUploadedFileDTO = { + fileName: newFileBlob.name, + storageId: newStorageId, + checksumType: 'md5', + checksumValue: calculateBlobChecksum(newBuffer), + mimeType: newFileBlob.type + } + + await publishDatasetViaApi(deleFileTestDatasetIds.numericId) + await waitForNoLocks(deleFileTestDatasetIds.numericId, 10) + + await sut.replaceFile(originalFileId, newUploadedFileDTO) + + const isDeleted = await sut.isFileDeleted(originalFileId) + expect(isDeleted).toBe(true) + + await deletePublishedDatasetViaApi(deleFileTestDatasetIds.persistentId) + await deleteCollectionViaApi(testCollectionAlias) + }) + }) + }) + describe('restrictFile', () => { let restrictFileDatasetIds: CreatedDatasetIdentifiers const testTextFile1Name = 'test-file-1.txt' diff --git a/test/unit/files/IsFileDeleted.test.ts b/test/unit/files/IsFileDeleted.test.ts new file mode 100644 index 00000000..d15ad51a --- /dev/null +++ b/test/unit/files/IsFileDeleted.test.ts @@ -0,0 +1,33 @@ +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { IsFileDeleted } from '../../../src/files/domain/useCases/IsFileDeleted' +import { ReadError } from '../../../src' + +describe('execute', () => { + test('should return true when file has been deleted', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.isFileDeleted = jest.fn().mockResolvedValue(true) + const sut = new IsFileDeleted(filesRepositoryStub) + + const result = await sut.execute(1) + + expect(result).toBe(true) + }) + + test('should return false when file has not been deleted', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.isFileDeleted = jest.fn().mockResolvedValue(false) + const sut = new IsFileDeleted(filesRepositoryStub) + + const result = await sut.execute(1) + + expect(result).toBe(false) + }) + + test('should return error result on repository error', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.isFileDeleted = jest.fn().mockRejectedValue(new ReadError()) + const sut = new IsFileDeleted(filesRepositoryStub) + + await expect(sut.execute(1)).rejects.toThrow(ReadError) + }) +})