diff --git a/docs/useCases.md b/docs/useCases.md index e615a266..edc4f407 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -53,6 +53,7 @@ The different use cases currently available in the package are classified below, - [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) + - [Get File Version Summaries](#get-file-version-summaries) - [Files write use cases](#files-write-use-cases) - [File Uploading Use Cases](#file-uploading-use-cases) - [Delete a File](#delete-a-file) @@ -1596,6 +1597,28 @@ _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. +#### Get File Version Summaries + +Get the file versions summaries, return a list of summaries for each version + +##### Example call: + +```typescript +import { getFileVersionSummaries } from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId = 1 + +getFileVersionSummaries.execute(fileId).then((fileVersionSummaries: fileVersionSummaryInfo[]) => { + /* ... */ +}) + +/* ... */ +``` + +_See [use case](../src/files/domain/useCases/GetFileVersionSummaries.ts) implementation_. + ## Metadata Blocks ### Metadata Blocks read use cases diff --git a/src/files/domain/models/FileVersionSummaryInfo.ts b/src/files/domain/models/FileVersionSummaryInfo.ts new file mode 100644 index 00000000..2ee0a7ba --- /dev/null +++ b/src/files/domain/models/FileVersionSummaryInfo.ts @@ -0,0 +1,27 @@ +import { DatasetVersionState } from '../../../datasets/domain/models/Dataset' + +export interface FileVersionSummaryInfo { + datasetVersion: string + contributors?: string + publishedDate?: string + fileDifferenceSummary?: FileDifferenceSummary + versionState?: DatasetVersionState + datafileId: number + persistentId?: string + versionNote?: string +} + +export type FileDifferenceSummary = { + file?: FileChangeType + fileAccess?: 'Restricted' | 'Unrestricted' + fileMetadata?: FileMetadataChange[] + deaccessionedReason?: string + fileTags?: { [key in FileChangeType]?: number } +} + +export type FileChangeType = 'Added' | 'Deleted' | 'Replaced' | 'Changed' + +export interface FileMetadataChange { + name: string + action: FileChangeType +} diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 83596db3..a06782f5 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -10,6 +10,7 @@ import { FileUploadDestination } from '../models/FileUploadDestination' import { UploadedFileDTO } from '../dtos/UploadedFileDTO' import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO' import { RestrictFileDTO } from '../dtos/RestrictFileDTO' +import { FileVersionSummaryInfo } from '../models/FileVersionSummaryInfo' export interface IFilesRepository { getDatasetFiles( @@ -85,5 +86,7 @@ export interface IFilesRepository { replace?: boolean ): Promise + getFileVersionSummaries(fileId: number | string): Promise + isFileDeleted(fileId: number | string): Promise } diff --git a/src/files/domain/useCases/GetFileVersionSummaries.ts b/src/files/domain/useCases/GetFileVersionSummaries.ts new file mode 100644 index 00000000..c8bafb50 --- /dev/null +++ b/src/files/domain/useCases/GetFileVersionSummaries.ts @@ -0,0 +1,21 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { FileVersionSummaryInfo } from '../models/FileVersionSummaryInfo' +import { IFilesRepository } from '../repositories/IFilesRepository' + +export class GetFileVersionSummaries implements UseCase { + private filesRepository: IFilesRepository + + constructor(filesRepository: IFilesRepository) { + this.filesRepository = filesRepository + } + + /** + * Returns a list of versions for a given file including a summary of differences between consecutive versions + * + * @param {number | string} [fileId] - The file identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @returns {Promise} - An array of FileVersionSummaryInfo. + */ + async execute(fileId: number | string): Promise { + return await this.filesRepository.getFileVersionSummaries(fileId) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index 47b86cd6..b13bfc53 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 { GetFileVersionSummaries } from './domain/useCases/GetFileVersionSummaries' import { IsFileDeleted } from './domain/useCases/IsFileDeleted' const filesRepository = new FilesRepository() @@ -39,6 +40,7 @@ const restrictFile = new RestrictFile(filesRepository) const updateFileMetadata = new UpdateFileMetadata(filesRepository) const updateFileTabularTags = new UpdateFileTabularTags(filesRepository) const updateFileCategories = new UpdateFileCategories(filesRepository) +const getFileVersionSummaries = new GetFileVersionSummaries(filesRepository) const isFileDeleted = new IsFileDeleted(filesRepository) export { @@ -59,6 +61,7 @@ export { updateFileTabularTags, updateFileCategories, replaceFile, + getFileVersionSummaries, isFileDeleted } diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index 2fcf308e..f657a9a7 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -22,6 +22,8 @@ import { UploadedFileDTO } from '../../domain/dtos/UploadedFileDTO' import { UpdateFileMetadataDTO } from '../../domain/dtos/UpdateFileMetadataDTO' import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' import { RestrictFileDTO } from '../../domain/dtos/RestrictFileDTO' +import { FileVersionSummaryInfo } from '../../domain/models/FileVersionSummaryInfo' +import { transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo } from './transformers/fileVersionSummaryInfoTransformers' export interface GetFilesQueryParams { includeDeaccessioned: boolean @@ -416,6 +418,17 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { }) } + public async getFileVersionSummaries(fileId: number | string): Promise { + return this.doGet( + this.buildApiEndpoint(this.filesResourceName, 'versionDifferences', fileId), + true + ) + .then((response) => transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo(response)) + .catch((error) => { + throw error + }) + } + public async isFileDeleted(fileId: number | string): Promise { return this.doGet(this.buildApiEndpoint(this.filesResourceName, 'hasBeenDeleted', fileId), true) .then((response) => response.data.data) diff --git a/src/files/infra/repositories/transformers/fileVersionSummaryInfoTransformers.ts b/src/files/infra/repositories/transformers/fileVersionSummaryInfoTransformers.ts new file mode 100644 index 00000000..46eb4049 --- /dev/null +++ b/src/files/infra/repositories/transformers/fileVersionSummaryInfoTransformers.ts @@ -0,0 +1,57 @@ +import { AxiosResponse } from 'axios' +import { + FileVersionSummaryInfo, + FileMetadataChange, + FileDifferenceSummary +} from '../../../domain/models/FileVersionSummaryInfo' +import { DatasetVersionState } from '../../../../datasets/domain/models/Dataset' + +export interface FileVersionSummaryInfoPayload { + datasetVersion: string + contributors?: string + publishedDate?: string + fileDifferenceSummary?: { + file?: string + FileAccess?: string + FileMetadata?: FileMetadataChange[] + deaccessionedReason?: string + FileTags?: { + Added?: number + Deleted?: number + Changed?: number + } + } + versionState?: DatasetVersionState + datafileId: number + persistentId?: string + versionNote?: string +} + +export const transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo = ( + response: AxiosResponse +): FileVersionSummaryInfo[] => { + const payload = response.data.data + + return payload.map((item: FileVersionSummaryInfoPayload): FileVersionSummaryInfo => { + const summary = item.fileDifferenceSummary || {} + + const fileDifferenceSummary: FileDifferenceSummary = { + ...(summary.file && { file: summary.file }), + ...(summary.FileAccess && { fileAccess: summary.FileAccess }), + ...(summary.FileMetadata && { fileMetadata: summary.FileMetadata }), + ...(summary.deaccessionedReason && { deaccessionedReason: summary.deaccessionedReason }), + ...(summary.FileTags && { fileTags: summary.FileTags }) + } as FileDifferenceSummary + + return { + datasetVersion: item.datasetVersion, + contributors: item.contributors, + publishedDate: item.publishedDate, + fileDifferenceSummary: fileDifferenceSummary, + versionState: item.versionState, + datafileId: item.datafileId, + persistentId: item.persistentId, + versionNote: item.versionNote + } + }) +} diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 108ad117..c5c93dcd 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -94,7 +94,7 @@ describe('DatasetsRepository', () => { const testCollectionAlias = 'datasetsRepositoryTestCollection' const sut: DatasetsRepository = new DatasetsRepository() - const nonExistentTestDatasetId = 100 + const nonExistentTestDatasetId = 1000 const filesRepositorySut = new FilesRepository() const directUploadSut: DirectUploadClient = new DirectUploadClient(filesRepositorySut) diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 85187168..89e3ca64 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -45,6 +45,8 @@ import { } from '../../testHelpers/collections/collectionHelper' import { RestrictFileDTO } from '../../../src/files/domain/dtos/RestrictFileDTO' import { DatasetsRepository } from '../../../src/datasets/infra/repositories/DatasetsRepository' +import { FileVersionSummaryInfo } from '../../../src/files/domain/models/FileVersionSummaryInfo' +import { DatasetVersionState } from '../../../src/datasets' import { DirectUploadClient } from '../../../src/files/infra/clients/DirectUploadClient' describe('FilesRepository', () => { @@ -764,6 +766,166 @@ describe('FilesRepository', () => { }) }) + describe('getFileVersionSummaries', () => { + let fileTestDatasetIds: CreatedDatasetIdentifiers + const testTextFile1Name = 'test-file-1.txt' + + beforeEach(async () => { + try { + fileTestDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + } catch (error) { + throw new Error('Tests beforeEach(): Error while creating test dataset') + } + await uploadFileViaApi(fileTestDatasetIds.numericId, testTextFile1Name).catch(() => { + throw new Error(`Tests beforeEach(): Error while uploading file ${testTextFile1Name}`) + }) + }) + + test('should return file version summaries when file draft exists', async () => { + const currentTestFilesSubset = await sut.getDatasetFiles( + fileTestDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + const testFile = currentTestFilesSubset.files[0] + const actual = await sut.getFileVersionSummaries(testFile.id) + + const fileSummmaries: FileVersionSummaryInfo = { + datasetVersion: 'DRAFT', + versionState: DatasetVersionState.DRAFT, + contributors: 'Dataverse Admin', + datafileId: testFile.id, + persistentId: testFile.persistentId, + fileDifferenceSummary: { file: 'Added' } + } + + expect(actual).toHaveLength(1) + expect(actual[0]).toEqual(fileSummmaries) + deleteUnpublishedDatasetViaApi(fileTestDatasetIds.numericId) + }) + + test('should return file version summaries when dataset is deaccessioned', async () => { + await publishDatasetViaApi(fileTestDatasetIds.numericId).catch(() => { + throw new Error('Error while publishing test Dataset') + }) + + await waitForNoLocks(fileTestDatasetIds.numericId, 10).catch(() => { + throw new Error('Error while waiting for no locks') + }) + + const datasetFiles = await sut.getDatasetFiles( + fileTestDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + const testFile = datasetFiles.files[0] + const publishedFileVersionSummariesActual = await sut.getFileVersionSummaries(testFile.id) + + const publishedFileVersionSummmaries: FileVersionSummaryInfo = { + datasetVersion: '1.0', + publishedDate: publishedFileVersionSummariesActual[0].publishedDate, + versionState: DatasetVersionState.RELEASED, + contributors: 'Dataverse Admin', + datafileId: testFile.id, + persistentId: testFile.persistentId, + fileDifferenceSummary: { file: 'Added' } + } + + expect(publishedFileVersionSummariesActual).toHaveLength(1) + expect(publishedFileVersionSummariesActual[0]).toEqual(publishedFileVersionSummmaries) + + await deaccessionDatasetViaApi(fileTestDatasetIds.numericId, '1.0').catch(() => { + throw new Error('Error while deaccessioning test Dataset') + }) + + const actual = await sut.getFileVersionSummaries(testFile.id) + + const fileSummmaries: FileVersionSummaryInfo = { + datasetVersion: '1.0', + publishedDate: publishedFileVersionSummariesActual[0].publishedDate, + versionState: DatasetVersionState.DEACCESSIONED, + contributors: 'Dataverse Admin', + datafileId: testFile.id, + persistentId: testFile.persistentId, + fileDifferenceSummary: { + deaccessionedReason: 'Test reason.', + file: 'Added' + } + } + + expect(actual).toHaveLength(1) + expect(actual[0]).toEqual(fileSummmaries) + deletePublishedDatasetViaApi(fileTestDatasetIds.persistentId) + }) + + test('should return file version summaries when file is updated', async () => { + await publishDatasetViaApi(fileTestDatasetIds.numericId).catch(() => { + throw new Error('Error while publishing test Dataset') + }) + + await waitForNoLocks(fileTestDatasetIds.numericId, 10).catch(() => { + throw new Error('Error while waiting for no locks') + }) + + const datasetFiles = await sut.getDatasetFiles( + fileTestDatasetIds.numericId, + latestDatasetVersionId, + false, + FileOrderCriteria.NAME_AZ + ) + const testFile = datasetFiles.files[0] + const actual = await sut.getFileVersionSummaries(testFile.id) + + expect(actual).toHaveLength(1) + + await sut.updateFileMetadata(testFile.id, { + description: 'My description test.', + categories: ['Data', 'Test'], + label: 'myfile.txt', + directoryLabel: 'mydir', + restrict: true + }) + const updatedFileVersionSummariesActual = await sut.getFileVersionSummaries(testFile.id) + const updatedFileVersionSummaries: FileVersionSummaryInfo = { + datasetVersion: 'DRAFT', + versionState: DatasetVersionState.DRAFT, + contributors: 'Dataverse Admin', + datafileId: testFile.id, + persistentId: testFile.persistentId, + publishedDate: actual[0].publishedDate, + versionNote: undefined, + fileDifferenceSummary: { + fileMetadata: [ + { + name: 'File Name', + action: 'Changed' + }, + { + name: 'Description', + action: 'Changed' + } + ], + fileTags: { + Added: 2 + }, + fileAccess: 'Restricted' + } + } + + expect(updatedFileVersionSummariesActual).toHaveLength(2) + expect(updatedFileVersionSummariesActual[0]).toEqual(updatedFileVersionSummaries) + deletePublishedDatasetViaApi(fileTestDatasetIds.persistentId) + }) + + test('should return error when file does not exist', async () => { + const expectedError = new ReadError(`[404] File with ID ${nonExistentFiledId} not found.`) + + await expect(sut.getFileVersionSummaries(nonExistentFiledId)).rejects.toThrow(expectedError) + }) + }) + describe('deleteFile', () => { let deleFileTestDatasetIds: CreatedDatasetIdentifiers const testTextFile1Name = 'test-file-1.txt' diff --git a/test/unit/files/GetFileVersionSummaries.test.ts b/test/unit/files/GetFileVersionSummaries.test.ts new file mode 100644 index 00000000..d8352603 --- /dev/null +++ b/test/unit/files/GetFileVersionSummaries.test.ts @@ -0,0 +1,39 @@ +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { ReadError } from '../../../src' +import { GetFileVersionSummaries } from '../../../src/files/domain/useCases/GetFileVersionSummaries' +import { FileVersionSummaryInfo } from '../../../src/files/domain/models/FileVersionSummaryInfo' + +describe('execute', () => { + test('should return file on repository success when passing numeric id', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + const fileVersionSummaries: FileVersionSummaryInfo[] = [ + { + datasetVersion: '1.0', + contributors: 'John Doe', + publishedDate: '2023-01-01', + fileDifferenceSummary: { + fileMetadata: [ + { + name: 'file.txt', + action: 'Added' + } + ] + }, + datafileId: 1 + } + ] + filesRepositoryStub.getFileVersionSummaries = jest.fn().mockResolvedValue(fileVersionSummaries) + + const sut = new GetFileVersionSummaries(filesRepositoryStub) + const actualFileVersionSummaries = await sut.execute(1) + expect(actualFileVersionSummaries).toEqual(fileVersionSummaries) + }) + + test('should return error result on repository error', async () => { + const filesRepositoryStub: IFilesRepository = {} as IFilesRepository + filesRepositoryStub.getFileVersionSummaries = jest.fn().mockRejectedValue(new ReadError()) + const sut = new GetFileVersionSummaries(filesRepositoryStub) + + await expect(sut.execute(1)).rejects.toThrow(ReadError) + }) +})