diff --git a/docs/useCases.md b/docs/useCases.md index 6d68e86c..66312def 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -1284,6 +1284,33 @@ The following error might arise from the `AddUploadedFileToDataset` use case: - AddUploadedFileToDatasetError: This error indicates that there was an error while adding the uploaded file to the dataset. +#### Update File Metadata + +Updates Metadata of a File. + +###### Example call: + +```typescript +import { updateFileMetadata } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId: number | string = 123 +const updateFileMetadataDTO = { + description: 'My description bbb.', + categories: ['Data'], + restrict: false +} + +await updateFileMetadata.execute(fileId, updateFileMetadataDTO).then((fileId) => { + console.log(`File updated successfully with file ID: ${fileId}`) +}) +``` + +_See [use case](../src/files/domain/useCases/UpdateFileMetadata.ts) implementation_. + +The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + #### Delete a File Deletes a File. diff --git a/src/files/domain/dtos/UpdateFileMetadataDTO.ts b/src/files/domain/dtos/UpdateFileMetadataDTO.ts new file mode 100644 index 00000000..f26798f4 --- /dev/null +++ b/src/files/domain/dtos/UpdateFileMetadataDTO.ts @@ -0,0 +1,7 @@ +export interface UpdateFileMetadataDTO { + description?: string + prevFreeform?: string + categories?: string[] + dataFileTags?: string[] + restrict?: boolean +} diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 42609380..eb7d7d67 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -8,6 +8,7 @@ import { FileModel } from '../models/FileModel' import { Dataset } from '../../../datasets' import { FileUploadDestination } from '../models/FileUploadDestination' import { UploadedFileDTO } from '../dtos/UploadedFileDTO' +import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO' export interface IFilesRepository { getDatasetFiles( @@ -65,4 +66,8 @@ export interface IFilesRepository { replaceFile(fileId: number | string, uploadedFileDTO: UploadedFileDTO): Promise restrictFile(fileId: number | string, restrict: boolean): Promise + updateFileMetadata( + fileId: number | string, + updateFileMetadataDTO: UpdateFileMetadataDTO + ): Promise } diff --git a/src/files/domain/useCases/UpdateFileMetadata.ts b/src/files/domain/useCases/UpdateFileMetadata.ts new file mode 100644 index 00000000..f06c96c3 --- /dev/null +++ b/src/files/domain/useCases/UpdateFileMetadata.ts @@ -0,0 +1,26 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IFilesRepository } from '../repositories/IFilesRepository' +import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO' + +export class UpdateFileMetadata implements UseCase { + private filesRepository: IFilesRepository + + constructor(filesRepository: IFilesRepository) { + this.filesRepository = filesRepository + } + + /** + * Updates the metadata for a particular File. + * More detailed information about updating a file's metadata behavior can be found in https://guides.dataverse.org/en/latest/api/native-api.html#updating-file-metadata + * + * @param {number | string} [fileId] - The file identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {UpdateFileMetadataDTO} [updateFileMetadataDTO] - The DTO containing the metadata updates. + * @returns {Promise} + */ + async execute( + fileId: number | string, + updateFileMetadataDTO: UpdateFileMetadataDTO + ): Promise { + await this.filesRepository.updateFileMetadata(fileId, updateFileMetadataDTO) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index 16c81776..69ce71e2 100644 --- a/src/files/index.ts +++ b/src/files/index.ts @@ -14,6 +14,7 @@ import { AddUploadedFilesToDataset } from './domain/useCases/AddUploadedFilesToD import { DeleteFile } from './domain/useCases/DeleteFile' import { ReplaceFile } from './domain/useCases/ReplaceFile' import { RestrictFile } from './domain/useCases/RestrictFile' +import { UpdateFileMetadata } from './domain/useCases/UpdateFileMetadata' const filesRepository = new FilesRepository() const directUploadClient = new DirectUploadClient(filesRepository) @@ -32,6 +33,7 @@ const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository) const deleteFile = new DeleteFile(filesRepository) const replaceFile = new ReplaceFile(filesRepository) const restrictFile = new RestrictFile(filesRepository) +const updateFileMetadata = new UpdateFileMetadata(filesRepository) export { getDatasetFiles, @@ -46,8 +48,9 @@ export { uploadFile, addUploadedFilesToDataset, deleteFile, - replaceFile, - restrictFile + restrictFile, + updateFileMetadata, + replaceFile } export { FileModel as File, FileEmbargo, FileChecksum } from './domain/models/FileModel' @@ -77,3 +80,4 @@ export { FileDownloadSizeMode } from './domain/models/FileDownloadSizeMode' export { FilesSubset } from './domain/models/FilesSubset' export { FilePreview, FilePreviewChecksum } from './domain/models/FilePreview' export { UploadedFileDTO } from './domain/dtos/UploadedFileDTO' +export { UpdateFileMetadataDTO } from './domain/dtos/UpdateFileMetadataDTO' diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index 1b712445..87725b53 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -18,6 +18,7 @@ import { Dataset } from '../../../datasets' import { FileUploadDestination } from '../../domain/models/FileUploadDestination' import { transformUploadDestinationsResponseToUploadDestination } from './transformers/fileUploadDestinationsTransformers' import { UploadedFileDTO } from '../../domain/dtos/UploadedFileDTO' +import { UpdateFileMetadataDTO } from '../../domain/dtos/UpdateFileMetadataDTO' import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' export interface GetFilesQueryParams { @@ -344,4 +345,23 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { throw error }) } + + public async updateFileMetadata( + fileId: string | number, + updateFileMetadata: UpdateFileMetadataDTO + ): Promise { + const formData = new FormData() + formData.append('jsonData', JSON.stringify(updateFileMetadata)) + + return this.doPost( + this.buildApiEndpoint(this.filesResourceName, `${fileId}/metadata`), + formData, + {}, + ApiConstants.CONTENT_TYPE_MULTIPART_FORM_DATA + ) + .then(() => undefined) + .catch((error) => { + throw error + }) + } } diff --git a/test/functional/files/UpdateFileMetadata.test.ts b/test/functional/files/UpdateFileMetadata.test.ts new file mode 100644 index 00000000..7944b79e --- /dev/null +++ b/test/functional/files/UpdateFileMetadata.test.ts @@ -0,0 +1,104 @@ +import { + ApiConfig, + createDataset, + CreatedDatasetIdentifiers, + WriteError, + updateFileMetadata, + getFile, + DatasetNotNumberedVersion, + getDatasetFiles +} 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' +import { UpdateFileMetadataDTO } from '../../../src/files/domain/dtos/UpdateFileMetadataDTO' +import { FileModel } from '../../../src/files/domain/models/FileModel' + +describe('execute', () => { + const testCollectionAlias = 'updateFileMetadatFunctionalTest' + let testDatasetIds: CreatedDatasetIdentifiers + const testTextFile1Name = 'test-file-1.txt' + const metadataUpdate: UpdateFileMetadataDTO = { + description: 'This is a test file', + categories: ['file'], + restrict: true + } + + 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 update metadata of a file', async () => { + const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId) + const fileId = datasetFiles.files[0].id + + try { + await updateFileMetadata.execute(fileId, metadataUpdate) + } catch (error) { + throw new Error('File metadata should be updated') + } finally { + const fileInfo: FileModel = (await getFile.execute( + fileId, + DatasetNotNumberedVersion.LATEST + )) as FileModel + + expect(fileInfo.description).toEqual(metadataUpdate.description) + expect(fileInfo.categories).toEqual(metadataUpdate.categories) + expect(fileInfo.restricted).toEqual(metadataUpdate.restrict) + } + }) + + test('should throw an error when the file id does not exist', async () => { + let writeError: WriteError | undefined = undefined + const nonExistentFileId = 5 + + try { + await updateFileMetadata.execute(nonExistentFileId, metadataUpdate) + 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] Error attempting get the requested data file.` + ) + } + }) +}) diff --git a/test/functional/users/DeleteCurrentApiToken.test.ts b/test/functional/users/DeleteCurrentApiToken.test.ts index 2cc0cc39..2f69ba91 100644 --- a/test/functional/users/DeleteCurrentApiToken.test.ts +++ b/test/functional/users/DeleteCurrentApiToken.test.ts @@ -24,7 +24,6 @@ describe('execute', () => { const testApiToken = await createApiTokenViaApi('deleteCurrentApiTokenFTUser') ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.API_KEY, testApiToken) await deleteCurrentApiToken.execute() - // Since the token has been deleted, the next call using it should return a WriteError await expect(deleteCurrentApiToken.execute()).rejects.toBeInstanceOf(WriteError) }) }) diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 12ee137d..262e1d9f 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -647,6 +647,43 @@ describe('FilesRepository', () => { }) }) + describe('updateFileMetadata', () => { + test('should update file metadata when file exists', async () => { + const testFileMetadata = { + description: 'My description test.', + categories: ['Data'], + restrict: false + } + + const actual = await sut.updateFileMetadata(testFileId, testFileMetadata) + + expect(actual).toBeUndefined() + + const fileInfo: FileModel = (await sut.getFile( + testFileId, + DatasetNotNumberedVersion.LATEST, + false + )) as FileModel + + expect(fileInfo.description).toBe(testFileMetadata.description) + expect(fileInfo.categories).toEqual(testFileMetadata.categories) + expect(fileInfo.restricted).toBe(testFileMetadata.restrict) + }) + + test('should return error when file does not exist', async () => { + const testFileMetadata = { + description: 'My description test.', + categories: ['Data'], + restrict: false + } + const errorExpected = new WriteError(`[400] Error attempting get the requested data file.`) + + await expect(sut.updateFileMetadata(nonExistentFiledId, testFileMetadata)).rejects.toThrow( + errorExpected + ) + }) + }) + describe('deleteFile', () => { let deleFileTestDatasetIds: CreatedDatasetIdentifiers const testTextFile1Name = 'test-file-1.txt' diff --git a/test/testHelpers/files/filesHelper.ts b/test/testHelpers/files/filesHelper.ts index 62a5f890..9de72830 100644 --- a/test/testHelpers/files/filesHelper.ts +++ b/test/testHelpers/files/filesHelper.ts @@ -237,6 +237,22 @@ export const updateFileTabularTags = async ( ) } +export const getFileMetadata = async (fileId: number): Promise => { + return await axios.get(`${TestConstants.TEST_API_URL}/files/${fileId}/metadata`, { + headers: { + 'X-Dataverse-Key': process.env.TEST_API_KEY + } + }) +} + +export const createFileMetadataWithCategories = (): FileMetadata => { + return { + categories: ['category1', 'category2'], + description: 'description', + directoryLabel: 'directoryLabel' + } +} + export const calculateBlobChecksum = (blob: Buffer, checksumAlgorithm: string): string => { const hash = crypto.createHash(checksumAlgorithm) hash.update(blob) diff --git a/test/unit/files/UpdateFileMetadata.test.ts b/test/unit/files/UpdateFileMetadata.test.ts new file mode 100644 index 00000000..41255e48 --- /dev/null +++ b/test/unit/files/UpdateFileMetadata.test.ts @@ -0,0 +1,46 @@ +import { UpdateFileMetadata } from '../../../src/files/domain/useCases/UpdateFileMetadata' +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { WriteError } from '../../../src/core/domain/repositories/WriteError' +import { createFileMetadataWithCategories } from '../../testHelpers/files/filesHelper' + +describe('UpdateFileMetadata', () => { + const testFileMetadata = createFileMetadataWithCategories() + test('should updated file metadata with correct parameters and id', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.updateFileMetadata = jest.fn().mockResolvedValue(testFileMetadata) + + const sut = new UpdateFileMetadata(filesRepositoryStub) + + await sut.execute(1, testFileMetadata) + + expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(1, testFileMetadata) + expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledTimes(1) + }) + + test('should return the updated file metadata with correct parameters and persisten Id', async () => { + const filesRepositoryStub: IFilesRepository = { + updateFileMetadata: jest.fn().mockResolvedValue(testFileMetadata) + } as unknown as IFilesRepository + + const sut = new UpdateFileMetadata(filesRepositoryStub) + + await sut.execute('doi:10.5072/FK2/HC6KTB', testFileMetadata) + + expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith( + 'doi:10.5072/FK2/HC6KTB', + testFileMetadata + ) + expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledTimes(1) + }) + + test('should throw an error if the repository throws an error', async () => { + const filesRepositoryStub: IFilesRepository = { + updateFileMetadata: jest.fn().mockRejectedValue(new WriteError()) + } as unknown as IFilesRepository + + const sut = new UpdateFileMetadata(filesRepositoryStub) + + await expect(sut.execute(1, testFileMetadata)).rejects.toThrow(WriteError) + expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(1, testFileMetadata) + }) +})