diff --git a/doc/release-notes/11855-version-summaries-pagination-and-perfomance.md b/doc/release-notes/11855-version-summaries-pagination-and-perfomance.md new file mode 100644 index 00000000000..ca867ba9697 --- /dev/null +++ b/doc/release-notes/11855-version-summaries-pagination-and-perfomance.md @@ -0,0 +1,33 @@ +### Pagination for API Version Summaries + +We've added pagination support to the following API endpoints: + +- File Version Differences: api/files/{id}/versionDifferences + +- Dataset Version Summaries: api/datasets/:persistentId/versions/compareSummary + +You can now use two new query parameters to control the results: + +- **limit**: An integer specifying the maximum number of results to return per page. + +- **offset**: An integer specifying the number of results to skip before starting to return items. This is used to + navigate to different pages. + +### Performance enhancements for API Version Summaries + +In addition to adding pagination, we've significantly improved the performance of these endpoints by implementing more +efficient database queries. + +These changes address performance bottlenecks that were previously encountered, especially with datasets or files +containing a large number of versions. + +### Fixes for File Version Summaries API + +The implementation for file version summaries was unreliable, leading to exceptions and functional inconsistencies, as +documented in issue #11561. This functionality has been reviewed and fixed to ensure correctness and stability. + +### Related issues and PRs + +- https://github.com/IQSS/dataverse/issues/11855 +- https://github.com/IQSS/dataverse/pull/11859 +- https://github.com/IQSS/dataverse/issues/11561 \ No newline at end of file diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index a12dfa151c9..ada3ae5a8af 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -2143,14 +2143,26 @@ be available to users who have permission to view unpublished drafts. The api to export SERVER_URL=https://demo.dataverse.org export PERSISTENT_IDENTIFIER=doi:10.5072/FK2/BCCP9Z - curl -H "X-Dataverse-key: $API_TOKEN" -X PUT "$SERVER_URL/api/datasets/:persistentId/versions/compareSummary?persistentId=$PERSISTENT_IDENTIFIER" + curl -H "X-Dataverse-key: $API_TOKEN" -X GET "$SERVER_URL/api/datasets/:persistentId/versions/compareSummary?persistentId=$PERSISTENT_IDENTIFIER" The fully expanded example above (without environment variables) looks like this: .. code-block:: bash - curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/:persistentId/versions/compareSummary?persistentId=doi:10.5072/FK2/BCCP9Z" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X GET "https://demo.dataverse.org/api/datasets/:persistentId/versions/compareSummary?persistentId=doi:10.5072/FK2/BCCP9Z" +You can control pagination of the results using the following optional query parameters. + +* ``limit``: The maximum number of version differences to return. +* ``offset``: The number of version differences to skip from the beginning of the list. Used for retrieving subsequent pages of results. + +To aid in pagination the JSON response also includes the total number of rows (totalCount) available. + +For example, to get the second page of results, with 2 items per page, you would use ``limit=2`` and ``offset=2`` (skipping the first two results). + +.. code-block:: bash + + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X GET "https://demo.dataverse.org/api/datasets/:persistentId/versions/compareSummary?persistentId=doi:10.5072/FK2/BCCP9Z&limit=2&offset=2" Update Metadata For a Dataset ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -4322,8 +4334,21 @@ The fully expanded example above (without environment variables) looks like this .. code-block:: bash - curl -X GET "https://demo.dataverse.org/api/files/1234/versionDifferences" - curl -X GET "https://demo.dataverse.org/api/files/:persistentId/versionDifferences?persistentId=doi:10.5072/FK2/J8SJZB" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X GET "https://demo.dataverse.org/api/files/1234/versionDifferences" + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X GET "https://demo.dataverse.org/api/files/:persistentId/versionDifferences?persistentId=doi:10.5072/FK2/J8SJZB" + +You can control pagination of the results using the following optional query parameters. + +* ``limit``: The maximum number of version differences to return. +* ``offset``: The number of version differences to skip from the beginning of the list. Used for retrieving subsequent pages of results. + +To aid in pagination the JSON response also includes the total number of rows (totalCount) available. + +For example, to get the second page of results, with 2 items per page, you would use ``limit=2`` and ``offset=2`` (skipping the first two results). + +.. code-block:: bash + + curl -H "X-Dataverse-key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X GET "https://demo.dataverse.org/api/files/1234/versionDifferences?limit=2&offset=2" Adding Files ~~~~~~~~~~~~ diff --git a/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java index 937f5693511..271cdcafd11 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataFileServiceBean.java @@ -28,18 +28,18 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; + import jakarta.ejb.EJB; import jakarta.ejb.Stateless; import jakarta.ejb.TransactionAttribute; import jakarta.ejb.TransactionAttributeType; import jakarta.inject.Named; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; -import jakarta.persistence.TypedQuery; +import jakarta.persistence.*; +import jakarta.persistence.criteria.*; /** * @@ -376,6 +376,133 @@ public FileMetadata findFileMetadataByDatasetVersionIdAndDataFileId(Long dataset } } + /** + * Finds the complete history of a file's presence across all dataset versions. + *

+ * This method returns a {@link VersionedFileMetadata} entry for every version + * of the specified dataset. If a version does not contain the file, the + * {@code fileMetadata} field in the corresponding DTO will be {@code null}. + * It correctly handles file replacements by searching for all files sharing the + * same {@code rootDataFileId}. + * + * @param datasetId The ID of the parent dataset. + * @param dataFile The DataFile entity to find the history for. + * @param canViewUnpublishedVersions A boolean indicating if the user has permission to view non-released versions. + * @param limit (Optional) The maximum number of results to return. + * @param offset (Optional) The starting point of the result list. + * @return A chronologically sorted, paginated list of the file's version history, including versions where the file is absent. + */ + public List findFileMetadataHistory(Long datasetId, + DataFile dataFile, + boolean canViewUnpublishedVersions, + Integer limit, + Integer offset) { + if (dataFile == null) { + return Collections.emptyList(); + } + + // Query 1: Get the paginated list of relevant DatasetVersions + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery versionQuery = cb.createQuery(DatasetVersion.class); + Root versionRoot = versionQuery.from(DatasetVersion.class); + + List versionPredicates = new ArrayList<>(); + versionPredicates.add(cb.equal(versionRoot.join("dataset").get("id"), datasetId)); + if (!canViewUnpublishedVersions) { + versionPredicates.add(versionRoot.get("versionState").in( + VersionState.RELEASED, VersionState.DEACCESSIONED)); + } + versionQuery.where(versionPredicates.toArray(new Predicate[0])); + versionQuery.orderBy( + cb.desc(versionRoot.get("versionNumber")), + cb.desc(versionRoot.get("minorVersionNumber")) + ); + + TypedQuery typedVersionQuery = em.createQuery(versionQuery); + if (limit != null) { + typedVersionQuery.setMaxResults(limit); + } + if (offset != null) { + typedVersionQuery.setFirstResult(offset); + } + List datasetVersions = typedVersionQuery.getResultList(); + + if (datasetVersions.isEmpty()) { + return Collections.emptyList(); + } + + // Query 2: Get all FileMetadata for this file's history in this dataset + CriteriaQuery fmQuery = cb.createQuery(FileMetadata.class); + Root fmRoot = fmQuery.from(FileMetadata.class); + + List fmPredicates = new ArrayList<>(); + fmPredicates.add(cb.equal(fmRoot.get("datasetVersion").get("dataset").get("id"), datasetId)); + + // Find the file by its entire lineage + if (dataFile.getRootDataFileId() < 0) { + fmPredicates.add(cb.equal(fmRoot.get("dataFile").get("id"), dataFile.getId())); + } else { + fmPredicates.add(cb.equal(fmRoot.get("dataFile").get("rootDataFileId"), dataFile.getRootDataFileId())); + } + fmQuery.where(fmPredicates.toArray(new Predicate[0])); + + List fileHistory = em.createQuery(fmQuery).getResultList(); + + // Combine results + Map fmMap = fileHistory.stream() + .collect(Collectors.toMap( + fm -> fm.getDatasetVersion().getId(), + Function.identity() + )); + + // Create the final list, looking up the FileMetadata for each version + return datasetVersions.stream() + .map(version -> new VersionedFileMetadata( + version, + fmMap.get(version.getId()) // This will be null if no entry exists for that version ID + )) + .collect(Collectors.toList()); + } + + /** + * Finds the FileMetadata for a given file in the version immediately preceding a specified version. + * + * @param fileMetadata The FileMetadata instance from the current version, used to identify the file's lineage. + * @return The FileMetadata from the immediately prior version, or {@code null} if this is the first version of the file. + */ + public FileMetadata getPreviousFileMetadata(FileMetadata fileMetadata) { + if (fileMetadata == null || fileMetadata.getDataFile() == null) { + return null; + } + + // 1. Get the ID of the file that was replaced. + Long previousId = fileMetadata.getDataFile().getPreviousDataFileId(); + + // If there's no previous ID, this is the first version of the file. + if (previousId == null) { + return null; + } + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(FileMetadata.class); + Root fileMetadataRoot = cq.from(FileMetadata.class); + + // 2. Join FileMetadata to DataFile to access the ID. + Join dataFileJoin = fileMetadataRoot.join("dataFile"); + + // 3. Find the FileMetadata whose DataFile ID matches the previousId. + cq.where(cb.equal(dataFileJoin.get("id"), previousId)); + + // --- Execution --- + TypedQuery query = em.createQuery(cq); + try { + return query.getSingleResult(); + } catch (NoResultException e) { + // If no result is found, return null. + return null; + } + } + public FileMetadata findMostRecentVersionFileIsIn(DataFile file) { if (file == null) { return null; diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java b/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java index 14ddf65a833..cfd4c886bf3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java @@ -73,7 +73,11 @@ @NamedQuery(name = "DatasetVersion.findById", query = "SELECT o FROM DatasetVersion o LEFT JOIN FETCH o.fileMetadatas WHERE o.id=:id"), @NamedQuery(name = "DatasetVersion.findByDataset", - query = "SELECT o FROM DatasetVersion o WHERE o.dataset.id=:datasetId ORDER BY o.versionNumber DESC, o.minorVersionNumber DESC"), + query = "SELECT o FROM DatasetVersion o WHERE o.dataset.id=:datasetId ORDER BY o.versionNumber DESC, o.minorVersionNumber DESC"), + @NamedQuery(name = "DatasetVersion.findByDesiredStatesAndDataset", + query = "SELECT o FROM DatasetVersion o " + + "WHERE o.dataset.id = :datasetId AND o.versionState IN :states " + + "ORDER BY o.versionNumber DESC, o.minorVersionNumber DESC"), @NamedQuery(name = "DatasetVersion.findReleasedByDataset", query = "SELECT o FROM DatasetVersion o WHERE o.dataset.id=:datasetId AND o.versionState=edu.harvard.iq.dataverse.DatasetVersion.VersionState.RELEASED ORDER BY o.versionNumber DESC, o.minorVersionNumber DESC")/*, @NamedQuery(name = "DatasetVersion.findVersionElements", diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetVersionServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DatasetVersionServiceBean.java index 7e9b778c6f3..60df1fd3dfd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetVersionServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetVersionServiceBean.java @@ -36,6 +36,10 @@ import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import org.apache.commons.lang3.StringUtils; /** @@ -171,41 +175,66 @@ public DatasetVersion findDeep(Object pk) { .setHint("eclipselink.left-join-fetch", "o.fileMetadatas.dataFile.dataFileTags") .getSingleResult(); } - + /** - * Performs the same database lookup as the one behind Dataset.getVersions(). - * Additionally, provides the arguments for selecting a partial list of - * (length-offset) versions for pagination, plus the ability to pre-select - * only the publicly-viewable versions. - * It is recommended that individual software components utilize the - * ListVersionsCommand, instead of calling this service method directly. - * @param datasetId - * @param offset for pagination through long lists of versions - * @param length for pagination through long lists of versions - * @param includeUnpublished retrieves all the versions, including drafts and deaccessioned. - * @return (partial) list of versions + * Performs the same database lookup as the one behind {@code Dataset.getVersions()}. + *

+ * Additionally, supports: + *

+ *

+ * It is recommended that individual software components utilize + * {@link edu.harvard.iq.dataverse.engine.command.impl.ListVersionsCommand}, + * instead of calling this service method directly. + * + * @param datasetId the dataset identifier + * @param offset pagination offset (nullable) + * @param length pagination length (nullable) + * @param includeAllVersions if {@code true}, retrieves all versions (drafts, released, and deaccessioned) + * @param includeDeaccessioned if {@code true}, includes deaccessioned versions + * when {@code includeAll} is {@code false} + * @return a (possibly partial) list of dataset versions */ - public List findVersions(Long datasetId, Integer offset, Integer length, boolean includeUnpublished) { - TypedQuery query; - if (includeUnpublished) { + public List findVersions(Long datasetId, + Integer offset, + Integer length, + boolean includeAllVersions, + boolean includeDeaccessioned) { + TypedQuery query; + + if (includeAllVersions) { query = em.createNamedQuery("DatasetVersion.findByDataset", DatasetVersion.class); + } else if (includeDeaccessioned) { + query = em.createNamedQuery("DatasetVersion.findByDesiredStatesAndDataset", DatasetVersion.class); + query.setParameter("states", List.of( + VersionState.RELEASED, + VersionState.DEACCESSIONED + )); } else { - query = em.createNamedQuery("DatasetVersion.findReleasedByDataset", DatasetVersion.class) - .setParameter("datasetId", datasetId); + query = em.createNamedQuery("DatasetVersion.findReleasedByDataset", DatasetVersion.class); } - + query.setParameter("datasetId", datasetId); - + if (offset != null) { query.setFirstResult(offset); } if (length != null) { query.setMaxResults(length); } - + return query.getResultList(); } - + + public List findVersions(Long datasetId, + Integer offset, + Integer length, + boolean includeAllVersions) { + return findVersions(datasetId, offset, length, includeAllVersions, false); + } + public DatasetVersion findByFriendlyVersionNumber(Long datasetId, String friendlyVersionNumber) { Long majorVersionNumber = null; Long minorVersionNumber = null; @@ -1259,5 +1288,49 @@ public List getUnarchivedDatasetVersions(){ logger.log(Level.WARNING, "EJBException exception: {0}", e.getMessage()); return null; } - } // end getUnarchivedDatasetVersions -} // end class + } + + /** + * Calculates the total number of versions for a specified dataset. + *

+ * This method provides a flexible way to count dataset versions. It can either + * return a total count of all versions or restrict the count to only those + * that are publicly visible (i.e., {@code RELEASED} or {@code DEACCESSIONED}). + * This is particularly useful for displaying different counts to users with + * different permission levels. + * + * @param datasetId The unique identifier of the dataset for which to count versions. + * Must not be {@code null}. + * @param canViewUnpublishedVersions A boolean flag that controls the scope of the count: + *

+ * @return A {@code Long} representing the total count of matching dataset versions. + * This will be {@code 0L} if the dataset has no versions or does not exist. + */ + public Long getDatasetVersionCount(Long datasetId, boolean canViewUnpublishedVersions) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root versionRoot = cq.from(DatasetVersion.class); + + List predicates = new ArrayList<>(); + + // Add the primary predicate to filter by the dataset's ID. + predicates.add(cb.equal(versionRoot.join("dataset").get("id"), datasetId)); + + // Conditionally add a predicate to filter for public versions only. + if (!canViewUnpublishedVersions) { + predicates.add(versionRoot.get("versionState").in( + VersionState.RELEASED, VersionState.DEACCESSIONED)); + } + + cq.select(cb.count(versionRoot)) + .where(predicates.toArray(new Predicate[0])); + + return em.createQuery(cq).getSingleResult(); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/FileMetadataVersionsHelper.java b/src/main/java/edu/harvard/iq/dataverse/FileMetadataVersionsHelper.java index 6778794dea6..4d408a72c8c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/FileMetadataVersionsHelper.java +++ b/src/main/java/edu/harvard/iq/dataverse/FileMetadataVersionsHelper.java @@ -2,21 +2,13 @@ import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -import edu.harvard.iq.dataverse.util.StringUtil; import jakarta.ejb.EJB; import jakarta.ejb.Stateless; -import jakarta.json.*; import java.util.*; -import java.util.logging.Logger; -import java.util.stream.Collectors; - -import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; @Stateless public class FileMetadataVersionsHelper { - private static final Logger logger = Logger.getLogger(FileMetadataVersionsHelper.class.getCanonicalName()); - @EJB DataFileServiceBean datafileService; @EJB @@ -24,9 +16,6 @@ public class FileMetadataVersionsHelper { @EJB PermissionServiceBean permissionService; - // Groups that are single element groups and therefore not arrays. - private static final List SINGLE_ELEMENT_GROUPS = List.of("File Access"); - public List loadFileVersionList(DataverseRequest req, FileMetadata fileMetadata) { List allfiles = allRelatedFiles(fileMetadata); List retList = new ArrayList<>(); @@ -61,93 +50,6 @@ public List loadFileVersionList(DataverseRequest req, FileMetadata return retList; } - public JsonObjectBuilder jsonDataFileVersions(FileMetadata fileMetadata) { - JsonObjectBuilder job = jsonObjectBuilder(); - if (fileMetadata.getDatasetVersion() != null) { - job.add("datasetVersion", fileMetadata.getDatasetVersion().getFriendlyVersionNumber()); - if (fileMetadata.getDatasetVersion().getVersionNumber() != null) { - job - .add("versionNumber", fileMetadata.getDatasetVersion().getVersionNumber()) - .add("versionMinorNumber", fileMetadata.getDatasetVersion().getMinorVersionNumber()); - } - - job - .add("isDraft", fileMetadata.getDatasetVersion().isDraft()) - .add("isReleased", fileMetadata.getDatasetVersion().isReleased()) - .add("isDeaccessioned", fileMetadata.getDatasetVersion().isDeaccessioned()) - .add("versionState", fileMetadata.getDatasetVersion().getVersionState().name()) - .add("summary", fileMetadata.getDatasetVersion().getVersionNote()) - .add("contributors", fileMetadata.getContributorNames()) - .add("publishedDate", fileMetadata.getDataFile().getPublicationDate() != null ? fileMetadata.getDataFile().getPublicationDate().toString() : null) - ; - } - if (fileMetadata.getDataFile() != null) { - job.add("datafileId", fileMetadata.getDataFile().getId()); - job.add("persistentId", (fileMetadata.getDataFile().getGlobalId() != null ? fileMetadata.getDataFile().getGlobalId().asString() : "")); - } - FileVersionDifference fvd = fileMetadata.getFileVersionDifference(); - if (fvd != null) { - List groups = fvd.getDifferenceSummaryGroups(); - JsonObjectBuilder fileDifferenceSummary = jsonObjectBuilder() - .add("versionNote", fileMetadata.getDatasetVersion().getVersionNote()) - .add("deaccessionedReason", fileMetadata.getDatasetVersion().getDeaccessionNote()) - .add("file", getFileAction(fvd.getOriginalFileMetadata(), fvd.getNewFileMetadata())); - - if (groups != null && !groups.isEmpty()) { - List sortedGroups = groups.stream() - .sorted(Comparator.comparing(FileVersionDifference.FileDifferenceSummaryGroup::getName)) - .collect(Collectors.toList()); - String groupName = null; - final JsonArrayBuilder groupsArrayBuilder = Json.createArrayBuilder(); - final JsonObjectBuilder groupsObjectBuilder = jsonObjectBuilder(); - Map itemCounts = new HashMap<>(); - - for (FileVersionDifference.FileDifferenceSummaryGroup group : sortedGroups) { - if (!StringUtil.isEmpty(group.getName())) { - // if the group name changed then add its data to the fileDifferenceSummary and reset list for next group - if (groupName != null && groupName.compareTo(group.getName()) != 0) { - addJsonGroupObject(fileDifferenceSummary, groupName, groupsObjectBuilder.build(), groupsArrayBuilder.build(), itemCounts); - // Note: groupsArrayBuilder.build() also clears the data within it - itemCounts.clear(); - } - groupName = group.getName(); - - group.getFileDifferenceSummaryItems().forEach(item -> { - JsonObjectBuilder itemObjectBuilder = jsonObjectBuilder(); - if (item.getName().isEmpty()) { - // 'groupName': {'Added'=#, 'Changed'=#, ...} - // accumulate the counts since we can't make a separate array item - itemCounts.merge("Added", item.getAdded(), Integer::sum); - itemCounts.merge("Changed", item.getChanged(), Integer::sum); - itemCounts.merge("Deleted", item.getDeleted(), Integer::sum); - itemCounts.merge("Replaced", item.getReplaced(), Integer::sum); - } else if (SINGLE_ELEMENT_GROUPS.contains(group.getName())) { - // 'groupName': 'getNameValue' - groupsObjectBuilder.add(group.getName(), group.getFileDifferenceSummaryItems().get(0).getName()); - } else { - // 'groupName': [{name='', action=''}, {name='', action=''}] - String action = item.getAdded() > 0 ? "Added" : item.getChanged() > 0 ? "Changed" : - item.getDeleted() > 0 ? "Deleted" : item.getReplaced() > 0 ? "Replaced" : ""; - itemObjectBuilder.add("name", item.getName()); - if (!action.isEmpty()) { - itemObjectBuilder.add("action", action); - } - groupsArrayBuilder.add(itemObjectBuilder.build()); - } - }); - } - } - // process last group - addJsonGroupObject(fileDifferenceSummary, groupName, groupsObjectBuilder.build(), groupsArrayBuilder.build(), itemCounts); - } - JsonObject fileDifferenceSummaryObject = fileDifferenceSummary.build(); - if (!fileDifferenceSummaryObject.isEmpty()) { - job.add("fileDifferenceSummary", fileDifferenceSummaryObject); - } - } - return job; - } - //TODO: this could use some refactoring to cut down on the number of for loops! private FileMetadata getPreviousFileMetadata(FileMetadata fileMetadata, FileMetadata fmdIn){ @@ -227,41 +129,4 @@ private List allRelatedFiles(FileMetadata fileMetadata) { } return dataFiles; } - - private String getFileAction(FileMetadata originalFileMetadata, FileMetadata newFileMetadata) { - if (newFileMetadata.getDataFile() != null && originalFileMetadata == null) { - return "Added"; - } else if (newFileMetadata.getDataFile() == null && originalFileMetadata != null) { - return "Deleted"; - } else if (originalFileMetadata != null && - newFileMetadata.getDataFile() != null && originalFileMetadata.getDataFile() != null &&!originalFileMetadata.getDataFile().equals(newFileMetadata.getDataFile())) { - return "Replaced"; - } else { - return null; - } - } - - private void addJsonGroupObject(JsonObjectBuilder jsonObjectBuilder, String key, JsonObject jsonObjectValue, JsonArray jsonArrayValue, Map itemCounts) { - if (key != null && !key.isEmpty()) { - String sanitizedKey = key.replaceAll("\\s+", ""); - if (itemCounts.isEmpty()) { - if (jsonArrayValue.isEmpty()) { - // add the object - jsonObjectBuilder.add(sanitizedKey, jsonObjectValue.getValue("/"+key)); - } else { - // add the array - jsonObjectBuilder.add(sanitizedKey, jsonArrayValue); - } - } else { - // add the accumulated totals - JsonObjectBuilder accumulatedTotalsObjectBuilder = jsonObjectBuilder(); - itemCounts.forEach((k, v) -> { - if (v != 0) { - accumulatedTotalsObjectBuilder.add(k, v); - } - }); - jsonObjectBuilder.add(sanitizedKey, accumulatedTotalsObjectBuilder.build()); - } - } - } } diff --git a/src/main/java/edu/harvard/iq/dataverse/VersionedFileMetadata.java b/src/main/java/edu/harvard/iq/dataverse/VersionedFileMetadata.java new file mode 100644 index 00000000000..91478d536f6 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/VersionedFileMetadata.java @@ -0,0 +1,25 @@ +package edu.harvard.iq.dataverse; + +/** + * A Data Transfer Object (DTO) to hold a DatasetVersion and its + * corresponding FileMetadata for a specific file. The fileMetadata + * field can be null if the file does not exist in that version. + */ +public class VersionedFileMetadata { + private final DatasetVersion datasetVersion; + private final FileMetadata fileMetadata; // This can be null + + public VersionedFileMetadata(DatasetVersion datasetVersion, FileMetadata fileMetadata) { + this.datasetVersion = datasetVersion; + this.fileMetadata = fileMetadata; + } + + // Add getters for both fields + public DatasetVersion getDatasetVersion() { + return datasetVersion; + } + + public FileMetadata getFileMetadata() { + return fileMetadata; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 1ada5f78b01..3fd490c38bc 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -2,7 +2,6 @@ import edu.harvard.iq.dataverse.*; import edu.harvard.iq.dataverse.DatasetLock.Reason; -import edu.harvard.iq.dataverse.DatasetVersion.VersionState; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.api.dto.RoleAssignmentDTO; @@ -17,6 +16,7 @@ import edu.harvard.iq.dataverse.batch.jobs.importer.ImportMode; import edu.harvard.iq.dataverse.dataaccess.*; import edu.harvard.iq.dataverse.datacapturemodule.DataCaptureModuleUtil; +import edu.harvard.iq.dataverse.datasetversionsummaries.DatasetVersionSummary; import software.amazon.awssdk.services.s3.model.CompletedPart; import edu.harvard.iq.dataverse.datacapturemodule.ScriptRequestResponse; import edu.harvard.iq.dataverse.dataset.*; @@ -3138,76 +3138,21 @@ public Response getCompareVersions(@Context ContainerRequestContext crc, @PathPa @GET @AuthRequired @Path("{id}/versions/compareSummary") - public Response getCompareVersionsSummary(@Context ContainerRequestContext crc, @PathParam("id") String id, - @Context UriInfo uriInfo, @Context HttpHeaders headers) { - try { - Dataset dataset = findDatasetOrDie(id); - User user = getRequestUser(crc); - JsonArrayBuilder differenceSummaries = Json.createArrayBuilder(); - - for (DatasetVersion dv : dataset.getVersions()) { - //only get summaries of draft is user may view unpublished - - if (dv.isPublished() ||dv.isDeaccessioned() || permissionService.hasPermissionsFor(user, dv.getDataset(), - EnumSet.of(Permission.ViewUnpublishedDataset))) { - - JsonObjectBuilder versionBuilder = new NullSafeJsonBuilder(); - versionBuilder.add("id", dv.getId()); - versionBuilder.add("versionNumber", dv.getFriendlyVersionNumber()); - DatasetVersionDifference dvdiff = dv.getDefaultVersionDifference(); - if (dvdiff == null) { - if (dv.isReleased()) { - if (dv.getPriorVersionState() == null) { - versionBuilder.add("summary", "firstPublished"); - } - if (dv.getPriorVersionState() != null && dv.getPriorVersionState().equals(VersionState.DEACCESSIONED)) { - versionBuilder.add("summary", "previousVersionDeaccessioned"); - } - } - if (dv.isDraft()) { - if (dv.getPriorVersionState() == null) { - versionBuilder.add("summary", "firstDraft"); - } - if (dv.getPriorVersionState() != null && dv.getPriorVersionState().equals(VersionState.DEACCESSIONED)) { - versionBuilder.add("summary", "previousVersionDeaccessioned"); - } - } - if (dv.isDeaccessioned()) { - versionBuilder.add("summary", getDeaccessionJson(dv)); - } - - } else { - versionBuilder.add("summary", dvdiff.getSummaryDifferenceAsJson()); - } - versionBuilder.add("versionNote", dv.getVersionNote()); - versionBuilder.add("contributors", datasetversionService.getContributorsNames(dv)); - versionBuilder.add("publishedOn", !dv.isDraft() ? dv.getPublicationDateAsString() : ""); - differenceSummaries.add(versionBuilder); - } + public Response getCompareVersionsSummary(@Context ContainerRequestContext crc, + @PathParam("id") String id, + @QueryParam("limit") Integer limit, + @QueryParam("offset") Integer offset) { + return response(req -> { + try { + Dataset dataset = findDatasetOrDie(id); + List versionSummaries = execCommand(new GetDatasetVersionSummariesCommand(req, dataset, limit, offset)); + JsonArrayBuilder versionSummariesArrayBuilder = jsonDatasetVersionSummaries(versionSummaries); + long datasetVersionTotalCount = execCommand(new GetDatasetVersionCountCommand(req, dataset)); + return ok(versionSummariesArrayBuilder, datasetVersionTotalCount); + } catch (WrappedResponse wr) { + return wr.getResponse(); } - return ok(differenceSummaries); - } catch (WrappedResponse wr) { - return wr.getResponse(); - } - } - - private JsonObject getDeaccessionJson(DatasetVersion dv) { - - JsonObjectBuilder compositionBuilder = Json.createObjectBuilder(); - - if (dv.getDeaccessionNote() != null && !dv.getDeaccessionNote().isEmpty()) { - compositionBuilder.add("reason", dv.getDeaccessionNote()); - } - - if (dv.getDeaccessionLink() != null && !dv.getDeaccessionLink().isEmpty()) { - compositionBuilder.add("url", dv.getDeaccessionLink()); - } - - JsonObject json = Json.createObjectBuilder() - .add("deaccessioned", compositionBuilder) - .build(); - - return json; + }, getRequestUser(crc)); } private static Set getDatasetFilenames(Dataset dataset) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Files.java b/src/main/java/edu/harvard/iq/dataverse/api/Files.java index 4ea9087b325..ea3035333b9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Files.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Files.java @@ -35,7 +35,7 @@ import static edu.harvard.iq.dataverse.api.ApiConstants.*; import static edu.harvard.iq.dataverse.api.Datasets.handleVersion; -import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; + import edu.harvard.iq.dataverse.util.json.JsonUtil; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; @@ -61,7 +61,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import static edu.harvard.iq.dataverse.util.json.JsonPrinter.jsonDT; +import static edu.harvard.iq.dataverse.util.json.JsonPrinter.*; import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; import static jakarta.ws.rs.core.Response.Status.FORBIDDEN; @@ -104,8 +104,6 @@ public class Files extends AbstractApiBean { GuestbookResponseServiceBean guestbookResponseService; @Inject DataFileServiceBean dataFileServiceBean; - @Inject - FileMetadataVersionsHelper fileMetadataVersionsHelper; private static final Logger logger = Logger.getLogger(Files.class.getName()); @@ -1174,7 +1172,10 @@ public Response getFileCitationByVersion(@Context ContainerRequestContext crc, @ @AuthRequired @Path("{id}/versionDifferences") @Produces(MediaType.APPLICATION_JSON) - public Response getFileVersionsList(@Context ContainerRequestContext crc, @PathParam("id") String fileIdOrPersistentId) { + public Response getFileVersionsList(@Context ContainerRequestContext crc, + @PathParam("id") String fileIdOrPersistentId, + @QueryParam("limit") Integer limit, + @QueryParam("offset") Integer offset) { try { DataverseRequest req = createDataverseRequest(getRequestUser(crc)); final DataFile df = execCommand(new GetDataFileCommand(req, findDataFileOrDie(fileIdOrPersistentId))); @@ -1182,16 +1183,10 @@ public Response getFileVersionsList(@Context ContainerRequestContext crc, @PathP if (fm == null) { return notFound(BundleUtil.getStringFromBundle("files.api.fileNotFound")); } - List fileMetadataList = fileMetadataVersionsHelper.loadFileVersionList(req, fm); - JsonArrayBuilder jab = Json.createArrayBuilder(); - for (FileMetadata fileMetadata : fileMetadataList) { - jab.add(fileMetadataVersionsHelper.jsonDataFileVersions(fileMetadata).build()); - } - return Response.ok() - .entity(Json.createObjectBuilder() - .add("status", STATUS_OK) - .add("data", jab.build()).build() - ).build(); + List versionDifferences = execCommand(new GetFileVersionDifferencesCommand(req, fm, limit, offset)); + JsonArrayBuilder versionDifferencesArrayBuilder = jsonFileVersionSummaries(versionDifferences); + long datasetVersionTotalCount = execCommand(new GetDatasetVersionCountCommand(req, fm.getDatasetVersion().getDataset())); + return ok(versionDifferencesArrayBuilder, datasetVersionTotalCount); } catch (WrappedResponse ex) { return ex.getResponse(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummary.java b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummary.java new file mode 100644 index 00000000000..06da005166f --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummary.java @@ -0,0 +1,73 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DatasetVersion.VersionState; +import edu.harvard.iq.dataverse.datasetversionsummaries.DatasetVersionSummaryContentSimple.Content; + +import java.util.Optional; + +/** + * An immutable data carrier representing a summary of a DatasetVersion. + */ +public record DatasetVersionSummary( + Long id, + String versionNumber, + String versionNote, + String contributorNames, + String publicationDate, + DatasetVersionSummaryContent content +) { + + /** + * Creates a DatasetVersionSummary from a DatasetVersion entity. + *

+ * This factory method is the preferred way to create summaries, as it encapsulates + * all the logic for determining the correct summary content. + * + * @param datasetVersion The entity to convert. + * @return An {@link Optional} containing the summary, or an empty Optional if the input is null. + */ + public static Optional from(DatasetVersion datasetVersion) { + return Optional.ofNullable(datasetVersion).map(version -> new DatasetVersionSummary( + version.getId(), + version.getFriendlyVersionNumber(), + version.getVersionNote(), + version.getContributorNames(), + version.getPublicationDateAsString(), + determineContent(version) + )); + } + + /** + * Determines the appropriate summary content based on the version's state. + * The logic is prioritized to handle the most specific cases first. + * + * @return The determined {@link DatasetVersionSummaryContent}, or {@code null} if the version + * state does not match any known summary condition. + */ + private static DatasetVersionSummaryContent determineContent(DatasetVersion version) { + // Priority 1: The version has explicit differences calculated. + if (version.getDefaultVersionDifference() != null) { + return new DatasetVersionSummaryContentDifferences(version.getDefaultVersionDifference()); + } + + // Priority 2: Deaccessioned status is a critical override. + if (version.isDeaccessioned()) { + return new DatasetVersionSummaryContentDeaccessioned(version.getDeaccessionNote(), version.getDeaccessionLink()); + } + + // Priority 3: Standard draft or released versions. + if (version.isReleased() || version.isDraft()) { + boolean isFirstVersion = version.getPriorVersionState() == null; + if (isFirstVersion) { + return new DatasetVersionSummaryContentSimple(version.isReleased() ? Content.firstPublished : Content.firstDraft); + } + + if (version.getPriorVersionState() == VersionState.DEACCESSIONED) { + return new DatasetVersionSummaryContentSimple(Content.previousVersionDeaccessioned); + } + } + + return null; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContent.java b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContent.java new file mode 100644 index 00000000000..653c13aa970 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContent.java @@ -0,0 +1,4 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +public abstract class DatasetVersionSummaryContent { +} diff --git a/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDeaccessioned.java b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDeaccessioned.java new file mode 100644 index 00000000000..6a6ea3fc496 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDeaccessioned.java @@ -0,0 +1,20 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +public class DatasetVersionSummaryContentDeaccessioned extends DatasetVersionSummaryContent { + + private final String deaccessionNote; + private final String deaccessionLink; + + public DatasetVersionSummaryContentDeaccessioned(String deaccessionNote, String deaccessionLink) { + this.deaccessionNote = deaccessionNote; + this.deaccessionLink = deaccessionLink; + } + + public String getDeaccessionNote() { + return deaccessionNote; + } + + public String getDeaccessionLink() { + return deaccessionLink; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDifferences.java b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDifferences.java new file mode 100644 index 00000000000..57add5511da --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentDifferences.java @@ -0,0 +1,16 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +import edu.harvard.iq.dataverse.DatasetVersionDifference; + +public class DatasetVersionSummaryContentDifferences extends DatasetVersionSummaryContent { + + private final DatasetVersionDifference datasetVersionDifference; + + public DatasetVersionSummaryContentDifferences(DatasetVersionDifference datasetVersionDifference) { + this.datasetVersionDifference = datasetVersionDifference; + } + + public DatasetVersionDifference getDatasetVersionDifference() { + return datasetVersionDifference; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentSimple.java b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentSimple.java new file mode 100644 index 00000000000..5d1075b41e6 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryContentSimple.java @@ -0,0 +1,20 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +public class DatasetVersionSummaryContentSimple extends DatasetVersionSummaryContent { + + private final Content value; + + public DatasetVersionSummaryContentSimple(Content value) { + this.value = value; + } + + public Content getValue() { + return value; + } + + public enum Content { + firstPublished, + previousVersionDeaccessioned, + firstDraft + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractPaginatedCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractPaginatedCommand.java new file mode 100644 index 00000000000..0a9f40d7ec2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/AbstractPaginatedCommand.java @@ -0,0 +1,63 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.DvObject; +import edu.harvard.iq.dataverse.engine.command.AbstractCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidCommandArgumentsException; +import edu.harvard.iq.dataverse.util.BundleUtil; + +import java.util.List; + +/** + * An abstract command for operations that support pagination. + * It handles the common logic for 'limit' and 'offset' parameters, + * including validation. + * + * @param The return type of the command's result. + */ +public abstract class AbstractPaginatedCommand extends AbstractCommand { + + protected final Integer limit; + protected final Integer offset; + + public AbstractPaginatedCommand(DataverseRequest request, DvObject dvObject, Integer limit, Integer offset) { + super(request, dvObject); + this.limit = limit; + this.offset = offset; + } + + @Override + public T execute(CommandContext ctxt) throws CommandException { + validatePaginationParameter(limit, "limit"); + validatePaginationParameter(offset, "offset"); + return executeCommand(ctxt); + } + + /** + * The core logic of the command, executed after pagination parameters have been validated. + * Subclasses must implement this method. + * + * @param ctxt The command context. + * @return The result of the command. + * @throws CommandException if an error occurs during command execution. + */ + public abstract T executeCommand(CommandContext ctxt) throws CommandException; + + /** + * Validates that a given pagination parameter is not negative. + * + * @param value The parameter's value. + * @param name The parameter's name, used for the error message. + * @throws InvalidCommandArgumentsException if the value is negative. + */ + private void validatePaginationParameter(Integer value, String name) throws InvalidCommandArgumentsException { + if (value != null && value < 0) { + throw new InvalidCommandArgumentsException( + BundleUtil.getStringFromBundle("abstractPaginatedCommand.errors.negativePaginationParam", List.of(name)), + this + ); + } + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommand.java new file mode 100644 index 00000000000..752f316783e --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommand.java @@ -0,0 +1,42 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.AbstractCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; + +import java.util.EnumSet; + +/** + * Retrieves the version count for a single dataset. + * Depending on user permissions, unpublished versions will be counted or not. + */ +@RequiredPermissions({}) +public class GetDatasetVersionCountCommand extends AbstractCommand { + + private final Dataset dataset; + + public GetDatasetVersionCountCommand(DataverseRequest request, Dataset dataset) { + super(request, dataset); + this.dataset = dataset; + } + + @Override + public Long execute(CommandContext ctxt) throws CommandException { + return ctxt.datasetVersion().getDatasetVersionCount(dataset.getId(), canViewUnpublishedVersions(ctxt, dataset)); + } + + /** + * Determines if the user can view non-released versions of the dataset. + */ + private boolean canViewUnpublishedVersions(CommandContext ctxt, Dataset dataset) { + return ctxt.permissions().hasPermissionsFor( + getRequest().getUser(), + dataset, + EnumSet.of(Permission.ViewUnpublishedDataset) + ); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommand.java new file mode 100644 index 00000000000..ab716658eff --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommand.java @@ -0,0 +1,65 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.datasetversionsummaries.DatasetVersionSummary; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; + +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; + +/** + * A command that retrieves a paginated list of {@link DatasetVersionSummary} for the versions of a given {@link Dataset}. + **/ +@RequiredPermissions({}) +public class GetDatasetVersionSummariesCommand extends AbstractPaginatedCommand> { + + private final Dataset dataset; + + public GetDatasetVersionSummariesCommand(DataverseRequest request, Dataset dataset, Integer limit, Integer offset) { + super(request, dataset, limit, offset); + this.dataset = dataset; + } + + /** + * Executes the command to retrieve a paginated list of dataset version summaries. + *

+ * This method first checks if the user has permission to view unpublished + * versions of the dataset. It then fetches the appropriate {@link DatasetVersion}s, + * respecting pagination parameters (limit and offset). Each version is then + * enriched with its contributor names before being converted into a + * {@link DatasetVersionSummary}. + * + * @param ctxt The command context. + * @return A list of {@link DatasetVersionSummary} objects. + */ + @Override + public List executeCommand(CommandContext ctxt) { + boolean canViewUnpublished = ctxt.permissions().hasPermissionsFor( + getRequest().getUser(), + dataset, + EnumSet.of(Permission.ViewUnpublishedDataset) + ); + + List versions = ctxt.datasetVersion().findVersions( + dataset.getId(), + offset, + limit, + canViewUnpublished, + true + ); + + for (DatasetVersion version : versions) { + version.setContributorNames(ctxt.datasetVersion().getContributorsNames(version)); + } + + return versions.stream() + .map(DatasetVersionSummary::from) + .flatMap(Optional::stream) + .toList(); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommand.java new file mode 100644 index 00000000000..459ab1095e0 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommand.java @@ -0,0 +1,136 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.FileVersionDifference; +import edu.harvard.iq.dataverse.VersionedFileMetadata; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; + +/** + * Retrieves the version history for a single file, highlighting the + * differences between each version. This command is pagination-aware. + */ +@RequiredPermissions({}) +public class GetFileVersionDifferencesCommand extends AbstractPaginatedCommand> { + + private final FileMetadata fileMetadata; + + public GetFileVersionDifferencesCommand(DataverseRequest request, FileMetadata fileMetadata, Integer limit, Integer offset) { + super(request, fileMetadata.getDataFile(), limit, offset); + this.fileMetadata = Objects.requireNonNull(fileMetadata); + } + + /** + * Executes the command to generate a list of file version differences. + *

+ * This method processes a paginated list of file history. + * It pairs each version with its successor from the list. + * For the last item on the page, it performs a single query to find its true predecessor, handling the pagination case. + * + * @param ctxt The command context. + * @return A list of {@link FileVersionDifference} objects. + */ + @Override + public List executeCommand(CommandContext ctxt) { + List fileHistory = findFileHistory(ctxt); + if (fileHistory.isEmpty()) { + return Collections.emptyList(); + } + + List differences = new ArrayList<>(); + + // Process all but the last item, using the next item in the list as the predecessor. + for (int i = 0; i < fileHistory.size() - 1; i++) { + VersionedFileMetadata current = fileHistory.get(i); + FileMetadata previous = fileHistory.get(i + 1).getFileMetadata(); + differences.add(buildDifference(ctxt, current, previous)); + } + + // Handle the last item on the page separately. Its predecessor may not be in the current + // list, so we must query the database for it. + VersionedFileMetadata lastItemOnPage = fileHistory.get(fileHistory.size() - 1); + FileMetadata predecessor = findPredecessorFor(ctxt, lastItemOnPage); + differences.add(buildDifference(ctxt, lastItemOnPage, predecessor)); + + return differences; + } + + /** + * Constructs a {@link FileVersionDifference} from a version's metadata and its predecessor. + * + * @param ctxt The command context. + * @param currentVersionMetadata The metadata for the current version in history. + * @param previousFileMetadata The metadata for the preceding version (can be null). + * @return A new {@link FileVersionDifference} object. + */ + private FileVersionDifference buildDifference(CommandContext ctxt, VersionedFileMetadata currentVersionMetadata, FileMetadata previousFileMetadata) { + FileMetadata currentFileMetadata = currentVersionMetadata.getFileMetadata(); + + if (currentFileMetadata != null) { + return createDifferenceForExistingFile(ctxt, currentFileMetadata, previousFileMetadata); + } else { + return createDifferenceForMissingFile(currentVersionMetadata.getDatasetVersion(), previousFileMetadata); + } + } + + /** + * Creates a difference object for a version where the file exists. + * As a side effect, this method also enriches the metadata with contributor names. + */ + private FileVersionDifference createDifferenceForExistingFile(CommandContext ctxt, FileMetadata current, FileMetadata previous) { + current.setContributorNames(ctxt.datasetVersion().getContributorsNames(current.getDatasetVersion())); + return new FileVersionDifference(current, previous, true); + } + + /** + * Creates a placeholder difference object for a version where the file was absent. + */ + private FileVersionDifference createDifferenceForMissingFile(DatasetVersion version, FileMetadata previous) { + FileMetadata placeholder = new FileMetadata(); + placeholder.setDatasetVersion(version); + placeholder.setDataFile(null); // Explicitly null to indicate absence. + + FileVersionDifference difference = new FileVersionDifference(placeholder, previous, true); + placeholder.setFileVersionDifference(difference); + + return difference; + } + + /** + * Fetches the file's version history, respecting user permissions. + */ + private List findFileHistory(CommandContext ctxt) { + Dataset dataset = fileMetadata.getDatasetVersion().getDataset(); + boolean canViewUnpublished = canViewUnpublishedVersions(ctxt, dataset); + return ctxt.files().findFileMetadataHistory(dataset.getId(), fileMetadata.getDataFile(), canViewUnpublished, limit, offset); + } + + /** + * Determines if the user can view non-released versions of the dataset. + */ + private boolean canViewUnpublishedVersions(CommandContext ctxt, Dataset dataset) { + return ctxt.permissions().hasPermissionsFor( + getRequest().getUser(), + dataset, + EnumSet.of(Permission.ViewUnpublishedDataset) + ); + } + + /** + * Finds the chronological predecessor for a given {@link VersionedFileMetadata}. + */ + private FileMetadata findPredecessorFor(CommandContext ctxt, VersionedFileMetadata versionedMetadata) { + FileMetadata current = versionedMetadata.getFileMetadata(); + return (current != null) ? ctxt.files().getPreviousFileMetadata(current) : null; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinter.java new file mode 100644 index 00000000000..44941203fc6 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinter.java @@ -0,0 +1,265 @@ +package edu.harvard.iq.dataverse.util.json; + +import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.FileVersionDifference; +import edu.harvard.iq.dataverse.util.StringUtil; +import jakarta.json.*; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; + +/** + * Utility class responsible for producing JSON representations of {@link edu.harvard.iq.dataverse.FileVersionDifference} objects. + */ +public class FileVersionDifferenceJsonPrinter { + + private static final String ACTION_ADDED = "Added"; + private static final String ACTION_CHANGED = "Changed"; + private static final String ACTION_DELETED = "Deleted"; + private static final String ACTION_REPLACED = "Replaced"; + private static final String GROUP_FILE_ACCESS = "File Access"; + + /** + * Creates a JSON representation of a FileVersionDifference. + * + * @param fileVersionDifference The object to serialize. + * @return A JsonObjectBuilder containing the serialized data. + */ + public static JsonObjectBuilder jsonFileVersionDifference(FileVersionDifference fileVersionDifference) { + JsonObjectBuilder jsonBuilder = jsonObjectBuilder(); + FileMetadata newFileMetadata = fileVersionDifference.getNewFileMetadata(); + + addDatasetVersionDetails(jsonBuilder, newFileMetadata); + addDataFileDetails(jsonBuilder, newFileMetadata); + addFileDifferenceSummary(jsonBuilder, fileVersionDifference); + + return jsonBuilder; + } + + /** + * Adds details from the DatasetVersion to the JSON object. + */ + private static void addDatasetVersionDetails(JsonObjectBuilder jsonBuilder, FileMetadata fileMetadata) { + DatasetVersion datasetVersion = fileMetadata.getDatasetVersion(); + if (datasetVersion == null) { + return; + } + jsonBuilder.add("datasetVersion", datasetVersion.getFriendlyVersionNumber()); + + Long versionNumber = datasetVersion.getVersionNumber(); + if (versionNumber != null) { + jsonBuilder + .add("versionNumber", versionNumber) + .add("versionMinorNumber", datasetVersion.getMinorVersionNumber()); + } + + DatasetVersion.VersionState versionState = datasetVersion.getVersionState(); + if (versionState != null) { + jsonBuilder.add("versionState", datasetVersion.getVersionState().name()); + } + + jsonBuilder + .add("isDraft", datasetVersion.isDraft()) + .add("isReleased", datasetVersion.isReleased()) + .add("isDeaccessioned", datasetVersion.isDeaccessioned()) + .add("summary", datasetVersion.getVersionNote()) + .add("contributors", fileMetadata.getContributorNames()) + .add("publishedDate", fileMetadata.getDatasetVersion().getPublicationDateAsString()); + } + + /** + * Adds details from the DataFile to the JSON object. + */ + private static void addDataFileDetails(JsonObjectBuilder jsonBuilder, FileMetadata fileMetadata) { + DataFile dataFile = fileMetadata.getDataFile(); + if (dataFile == null) { + return; + } + jsonBuilder.add("datafileId", dataFile.getId()); + jsonBuilder.add("persistentId", dataFile.getGlobalId() != null ? dataFile.getGlobalId().asString() : ""); + } + + /** + * Adds the file difference summary, which contains the core change details. + */ + private static void addFileDifferenceSummary(JsonObjectBuilder jsonBuilder, FileVersionDifference fileVersionDifference) { + FileMetadata newFileMetadata = fileVersionDifference.getNewFileMetadata(); + List groups = fileVersionDifference.getDifferenceSummaryGroups(); + + JsonObjectBuilder summaryBuilder = jsonObjectBuilder() + .add("versionNote", newFileMetadata.getDatasetVersion().getVersionNote()) + .add("deaccessionedReason", newFileMetadata.getDatasetVersion().getDeaccessionNote()) + .add("file", getFileAction(fileVersionDifference.getOriginalFileMetadata(), newFileMetadata)); + + if (groups != null && !groups.isEmpty()) { + processDifferenceGroups(summaryBuilder, groups); + } + + JsonObject summaryObject = summaryBuilder.build(); + if (!summaryObject.isEmpty()) { + jsonBuilder.add("fileDifferenceSummary", summaryObject); + } + } + + /** + * Processes and adds the categorized difference groups to the summary. + */ + private static void processDifferenceGroups(JsonObjectBuilder summaryBuilder, List groups) { + List sortedGroups = groups.stream() + .filter(g -> !StringUtil.isEmpty(g.getName())) + .sorted(Comparator.comparing(FileVersionDifference.FileDifferenceSummaryGroup::getName)) + .toList(); + + if (sortedGroups.isEmpty()) { + return; + } + + String currentGroupName = sortedGroups.get(0).getName(); + GroupDataAccumulator accumulator = new GroupDataAccumulator(); + + for (FileVersionDifference.FileDifferenceSummaryGroup group : sortedGroups) { + // When the group name changes, build and add the JSON for the previous group. + if (!Objects.equals(currentGroupName, group.getName())) { + addGroupDataToJson(summaryBuilder, currentGroupName, accumulator); + accumulator.reset(); + currentGroupName = group.getName(); + } + + // Accumulate data for the current group. + group.getFileDifferenceSummaryItems().forEach(item -> processSummaryItem(group, item, accumulator)); + } + // Add the last processed group. + addGroupDataToJson(summaryBuilder, currentGroupName, accumulator); + } + + /** + * Determines how to process a summary item based on its structure and content. + */ + private static void processSummaryItem(FileVersionDifference.FileDifferenceSummaryGroup group, FileVersionDifference.FileDifferenceSummaryItem item, GroupDataAccumulator accumulator) { + if (item.getName().isEmpty()) { + // Case 1: The item represents simple counts (Added, Changed, etc.) for the group. + accumulator.mergeCounts(item); + } else if (GROUP_FILE_ACCESS.equals(group.getName())) { + // Case 2: Special handling for "File Access", which is a single name/value pair. + accumulator.setNameValue(group.getName(), item.getName()); + } else { + // Case 3: The item is a named entity with an associated action. + accumulator.addListItem(item); + } + } + + /** + * Builds the final JSON structure for a completed group and adds it to the parent builder. + */ + private static void addGroupDataToJson(JsonObjectBuilder parentBuilder, String groupName, GroupDataAccumulator accumulator) { + if (groupName == null || groupName.isEmpty()) { + return; + } + String sanitizedKey = groupName.replaceAll("\\s+", ""); + + if (!accumulator.getItemCounts().isEmpty()) { + parentBuilder.add(sanitizedKey, buildCountsObject(accumulator.getItemCounts())); + } else { + JsonArray listItemsArray = accumulator.getListItems().build(); + if (!listItemsArray.isEmpty()) { + parentBuilder.add(sanitizedKey, listItemsArray); + } else if (accumulator.getNameValue() != null) { + parentBuilder.add(sanitizedKey, accumulator.getNameValue().getValue("/" + groupName)); + } + } + } + + /** + * Builds a JSON object from the accumulated action counts. + */ + private static JsonObject buildCountsObject(Map itemCounts) { + JsonObjectBuilder countsBuilder = jsonObjectBuilder(); + itemCounts.forEach((action, count) -> { + if (count != 0) { + countsBuilder.add(action, count); + } + }); + return countsBuilder.build(); + } + + /** + * Determines the overall action performed on the file itself (Added, Deleted, Replaced). + */ + private static String getFileAction(FileMetadata originalFileMetadata, FileMetadata newFileMetadata) { + boolean newFileExists = newFileMetadata.getDataFile() != null; + boolean originalFileExists = originalFileMetadata != null; + + if (newFileExists && !originalFileExists) { + return ACTION_ADDED; + } + if (!newFileExists && originalFileExists) { + return ACTION_DELETED; + } + if (originalFileExists && !originalFileMetadata.getDataFile().equals(newFileMetadata.getDataFile())) { + return ACTION_REPLACED; + } + return null; + } + + /** + * An inner helper class to hold the state of the JSON builders and counters for a single group while iterating. + */ + private static class GroupDataAccumulator { + private JsonObject nameValue; + private JsonArrayBuilder listItems = Json.createArrayBuilder(); + private Map itemCounts = new HashMap<>(); + + void mergeCounts(FileVersionDifference.FileDifferenceSummaryItem item) { + itemCounts.merge(ACTION_ADDED, item.getAdded(), Integer::sum); + itemCounts.merge(ACTION_CHANGED, item.getChanged(), Integer::sum); + itemCounts.merge(ACTION_DELETED, item.getDeleted(), Integer::sum); + itemCounts.merge(ACTION_REPLACED, item.getReplaced(), Integer::sum); + } + + void setNameValue(String groupName, String value) { + this.nameValue = jsonObjectBuilder().add(groupName, value).build(); + } + + void addListItem(FileVersionDifference.FileDifferenceSummaryItem item) { + String action = getActionFromItem(item); + JsonObjectBuilder itemObjectBuilder = jsonObjectBuilder().add("name", item.getName()); + if (!action.isEmpty()) { + itemObjectBuilder.add("action", action); + } + listItems.add(itemObjectBuilder.build()); + } + + private String getActionFromItem(FileVersionDifference.FileDifferenceSummaryItem item) { + if (item.getAdded() > 0) return ACTION_ADDED; + if (item.getChanged() > 0) return ACTION_CHANGED; + if (item.getDeleted() > 0) return ACTION_DELETED; + if (item.getReplaced() > 0) return ACTION_REPLACED; + return ""; + } + + void reset() { + nameValue = null; + listItems = Json.createArrayBuilder(); + itemCounts.clear(); + } + + JsonObject getNameValue() { + return nameValue; + } + + JsonArrayBuilder getListItems() { + return listItems; + } + + Map getItemCounts() { + return itemCounts; + } + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index 8b0ea201aa3..4cc392ac976 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -19,6 +19,7 @@ import edu.harvard.iq.dataverse.dataaccess.DataAccess; import edu.harvard.iq.dataverse.dataset.DatasetType; import edu.harvard.iq.dataverse.dataset.DatasetUtil; +import edu.harvard.iq.dataverse.datasetversionsummaries.*; import edu.harvard.iq.dataverse.datavariable.CategoryMetadata; import edu.harvard.iq.dataverse.datavariable.DataVariable; import edu.harvard.iq.dataverse.datavariable.SummaryStatistic; @@ -34,6 +35,8 @@ import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.DatasetFieldWalker; + +import static edu.harvard.iq.dataverse.util.json.FileVersionDifferenceJsonPrinter.jsonFileVersionDifference; import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; import edu.harvard.iq.dataverse.util.MailUtil; @@ -42,6 +45,7 @@ import java.io.IOException; import java.util.*; + import jakarta.json.Json; import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObjectBuilder; @@ -84,7 +88,7 @@ public class JsonPrinter { @EJB static InAppNotificationsJsonPrinter inAppNotificationsJsonPrinter; - + public static void injectSettingsService(SettingsServiceBean ssb, DatasetFieldServiceBean dfsb, DataverseFieldTypeInputLevelServiceBean dfils, @@ -1715,4 +1719,56 @@ public static JsonArrayBuilder json(List notifications, Authen return notificationsArray; } + + public static JsonArrayBuilder jsonDatasetVersionSummaries(List summaries) { + JsonArrayBuilder arrayBuilder = Json.createArrayBuilder(); + summaries.stream() + .filter(Objects::nonNull) + .map(JsonPrinter::json) + .forEach(arrayBuilder::add); + + return arrayBuilder; + } + + private static JsonObjectBuilder json(DatasetVersionSummary summary) { + JsonObjectBuilder jsonObjectBuilder = jsonObjectBuilder() + .add("id", summary.id()) + .add("versionNumber", summary.versionNumber()) + .add("versionNote", summary.versionNote()) + .add("contributors", summary.contributorNames()) + .add("publishedOn", summary.publicationDate()); + + DatasetVersionSummaryContent content = summary.content(); + if (content instanceof DatasetVersionSummaryContentDeaccessioned deaccessioned) { + JsonObject deaccessionedDetailsJsonObject = jsonObjectBuilder() + .add("reason", deaccessioned.getDeaccessionNote()) + .add("url", deaccessioned.getDeaccessionLink()) + .build(); + JsonObject deaccessionedJsonObject = jsonObjectBuilder() + .add("deaccessioned", deaccessionedDetailsJsonObject) + .build(); + jsonObjectBuilder.add("summary", deaccessionedJsonObject + ); + } else if (content instanceof DatasetVersionSummaryContentSimple simple) { + jsonObjectBuilder.add("summary", simple.getValue().name()); + + } else if (content instanceof DatasetVersionSummaryContentDifferences differences) { + jsonObjectBuilder.add("summary", differences + .getDatasetVersionDifference() + .getSummaryDifferenceAsJson() + .build()); + } + + return jsonObjectBuilder; + } + + public static JsonArrayBuilder jsonFileVersionSummaries(List differences) { + JsonArrayBuilder arrayBuilder = Json.createArrayBuilder(); + differences.stream() + .filter(Objects::nonNull) + .map(diff -> jsonFileVersionDifference(diff).build()) + .forEach(arrayBuilder::add); + + return arrayBuilder; + } } diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index a07546284e0..1016bb96788 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -3236,3 +3236,6 @@ roleAssigneeServiceBean.error.dataverseRequestCannotBeNull=DataverseRequest cann #GetUserPermittedCollectionsCommand.java getUserPermittedCollectionsCommand.errors.userNotFound=User not found. getUserPermittedCollectionsCommand.errors.permissionNotValid=Permission not valid. + +#AbstractPaginatedCommand.java +abstractPaginatedCommand.errors.negativePaginationParam=The {0} parameter cannot be negative. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 16552869a80..62e8dfdc59e 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -6428,11 +6428,8 @@ public void testCompareDatasetVersionsAPI() throws InterruptedException { compareResponse = UtilIT.compareDatasetVersions(datasetPersistentId, ":latest-published", ":draft", apiToken, true); compareResponse.prettyPrint(); compareResponse.then().assertThat().statusCode(OK.getStatusCode()); - - - } - + @Test public void testSummaryDatasetVersionsDifferencesAPI() throws InterruptedException { @@ -6531,16 +6528,19 @@ public void testSummaryDatasetVersionsDifferencesAPI() throws InterruptedExcepti Response updateTerms = UtilIT.updateDatasetJsonLDMetadata(datasetId, apiToken, jsonLDTerms, true); updateTerms.then().assertThat() .statusCode(OK.getStatusCode()); - - + Response compareResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, apiToken); - compareResponse.prettyPrint(); + compareResponse.prettyPrint(); compareResponse.then().assertThat() + .body("totalCount", is(2)) + .body("data.size()", is(2)) .body("data[1].versionNumber", equalTo("1.0")) + .body("data[1].contributors", notNullValue()) .body("data[1].summary", equalTo("firstPublished")) .body("data[0].versionNumber", equalTo("DRAFT")) + .body("data[0].contributors", notNullValue()) .body("data[0].summary.'Citation Metadata'.Author.added", equalTo(2)) .body("data[0].summary.'Citation Metadata'.Subject.added", equalTo(2)) .body("data[0].summary.'Additional Citation Metadata'.changed", equalTo(0)) @@ -6552,32 +6552,87 @@ public void testSummaryDatasetVersionsDifferencesAPI() throws InterruptedExcepti .statusCode(OK.getStatusCode()); //user with no privileges will only see the published version - + Response createUsernoPriv = UtilIT.createRandomUser(); assertEquals(200, createUsernoPriv.getStatusCode()); String apiTokenNoPriv = UtilIT.getApiTokenFromResponse(createUsernoPriv); - + Response compareResponse2 = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, apiTokenNoPriv); compareResponse2.prettyPrint(); compareResponse2.then().assertThat() + .body("totalCount", is(1)) + .body("data.size()", is(1)) .body("data[0].versionNumber", CoreMatchers.equalTo("1.0")) .body("data[0].summary", CoreMatchers.equalTo("firstPublished")) .statusCode(OK.getStatusCode()); - + Response deaccessionDatasetResponse = UtilIT.deaccessionDataset(datasetId, DS_VERSION_LATEST_PUBLISHED, "Test deaccession reason.", null, apiToken); deaccessionDatasetResponse.then().assertThat().statusCode(OK.getStatusCode()); - + compareResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, apiToken); - compareResponse.prettyPrint(); - + compareResponse.prettyPrint(); + compareResponse.then().assertThat() + .body("totalCount", is(2)) + .body("data.size()", is(2)) .body("data[1].versionNumber", equalTo("1.0")) .body("data[1].summary.deaccessioned.reason", equalTo("Test deaccession reason.")) .body("data[0].versionNumber", equalTo("DRAFT")) .body("data[0].summary.", equalTo("previousVersionDeaccessioned")) .statusCode(OK.getStatusCode()); - - + + // Pagination tests + + // Publish the current DRAFT as a minor version to create version 2.0 + // This will give us more versions to test pagination against. + + publishDataset = UtilIT.publishDatasetViaNativeApi(datasetPersistentId, "major", apiToken); + publishDataset.then().assertThat().statusCode(OK.getStatusCode()); + + // Make one file change to create a new DRAFT on top of version 2.0 + pathToJsonFilePostPub = "doc/sphinx-guides/source/_static/api/dataset-add-metadata-after-pub.json"; + addDataToPublishedVersion = UtilIT.addDatasetMetadataViaNative(datasetPersistentId, pathToJsonFilePostPub, apiToken); + addDataToPublishedVersion.then().assertThat().statusCode(OK.getStatusCode()); + + // Test pagination: No pagination. Should return all 3 version differences (DRAFT, 2.0, 1.0). + Response compareAllResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, apiToken); + compareAllResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.size()", is(3)) + .body("totalCount", is(3)) + .body("data[0].versionNumber", equalTo("DRAFT")) + .body("data[1].versionNumber", equalTo("2.0")) + .body("data[2].versionNumber", equalTo("1.0")); + + // Test pagination: Use limit=1. Should return only the latest difference (DRAFT). + Response compareLimitResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, 1, null, apiToken); + compareLimitResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.size()", is(1)) + .body("totalCount", is(3)) + .body("data[0].versionNumber", equalTo("DRAFT")); + + // Test pagination: Use limit=1 and offset=1. Should skip the first result and return the second (2.0). + Response compareOffsetResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, 1, 1, apiToken); + compareOffsetResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.size()", is(1)) + .body("totalCount", is(3)) + .body("data[0].versionNumber", equalTo("2.0")); + + // Test invalid pagination: limit=-1 (should return a bad request error) + compareAllResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, -1, 1, apiToken); + compareAllResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("status", equalTo("ERROR")) + .body("message", containsString("The limit parameter cannot be negative.")); + + // Test invalid pagination: offset=-1 (should return a bad request error) + compareAllResponse = UtilIT.summaryDatasetVersionDifferences(datasetPersistentId, 1, -1, apiToken); + compareAllResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("status", equalTo("ERROR")) + .body("message", containsString("The offset parameter cannot be negative.")); } @Test diff --git a/src/test/java/edu/harvard/iq/dataverse/api/FilesIT.java b/src/test/java/edu/harvard/iq/dataverse/api/FilesIT.java index 38d89f782dd..175d93b57a6 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/FilesIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/FilesIT.java @@ -1448,7 +1448,7 @@ public void testDataSizeInDataverse() throws InterruptedException { } @Test - public void GetFileVersionDifferences() { + public void getFileVersionDifferences() { // Create superuser and regular user Response createUser = UtilIT.createRandomUser(); String superUserUsername = UtilIT.getUsernameFromResponse(createUser); @@ -1461,7 +1461,8 @@ public void GetFileVersionDifferences() { // Create dataverse and dataset. Upload 1 file String dataverseAlias = createDataverseGetAlias(superUserApiToken); UtilIT.publishDataverseViaNativeApi(dataverseAlias, superUserApiToken); - Integer datasetId = createDatasetGetId(dataverseAlias, superUserApiToken); + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(dataverseAlias, superUserApiToken); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse); String pathToFile = "scripts/search/data/binary/trees.png"; Response addResponse = UtilIT.uploadFileViaNative(datasetId.toString(), pathToFile, superUserApiToken); addResponse.prettyPrint(); @@ -1472,6 +1473,8 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(1)) + .body("data.size()", is(1)) .body("data[0].datasetVersion", equalTo("DRAFT")) .body("data[0].fileDifferenceSummary.file", equalTo("Added")) .statusCode(OK.getStatusCode()); @@ -1492,6 +1495,8 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(1)) + .body("data.size()", is(1)) .body("data[0].datasetVersion", equalTo("1.0")) .body("data[0].fileDifferenceSummary.file", equalTo("Added")) .statusCode(OK.getStatusCode()); @@ -1506,6 +1511,8 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(1)) + .body("data.size()", is(1)) .body("data[0].datasetVersion", equalTo("1.0")) .body("data[0].fileDifferenceSummary.file", equalTo("Added")) .statusCode(OK.getStatusCode()); @@ -1520,11 +1527,70 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(2)) .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.file", equalTo(null)) .body("data[1].datasetVersion", equalTo("1.0")) .body("data[1].fileDifferenceSummary.file", equalTo("Added")) .statusCode(OK.getStatusCode()); + // Test pagination: limit=1, offset=0 (should get the first item: DRAFT) + Response paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, 1, 0); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(1)) + .body("data[0].datasetVersion", equalTo("DRAFT")); + + // Test pagination: limit=1, offset=1 (should skip 1 and get the second item: 1.0) + paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, 1, 1); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(1)) + .body("data[0].datasetVersion", equalTo("1.0")); + + // Test pagination: limit=1, offset=2 (out of bounds, should get an empty list) + paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, 1, 2); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(0)); + + // Test pagination: limit=2, offset=0 (should get both items) + paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, 2, 0); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(2)) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[1].datasetVersion", equalTo("1.0")); + + // Test invalid pagination: limit=-1 (should return a bad request error) + paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, -1, 0); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("status", equalTo("ERROR")) + .body("message", containsString("The limit parameter cannot be negative.")); + + // Test invalid pagination: offset=-1 (should return a bad request error) + paginatedResponse = UtilIT.getFileVersionDifferences(dataFileId, regularApiToken, 1, -1); + paginatedResponse.prettyPrint(); + paginatedResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("status", equalTo("ERROR")) + .body("message", containsString("The offset parameter cannot be negative.")); + // test replace file pathToFile = "src/test/resources/images/coffeeshop.png"; Response replaceFileResponse = UtilIT.replaceFile(dataFileId, pathToFile, superUserApiToken); @@ -1536,6 +1602,8 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(2)) .body("data[0].datasetVersion", equalTo("DRAFT")) .body("data[0].fileDifferenceSummary.file", equalTo("Replaced")) .body("data[0].datafileId", equalTo(Integer.parseInt(replacedDataFileId))) @@ -1562,11 +1630,161 @@ public void GetFileVersionDifferences() { getFileDataResponse.prettyPrint(); getFileDataResponse.then().assertThat() .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(2)) .body("data[1].datasetVersion", equalTo("1.0")) .body("data[1].fileDifferenceSummary.deaccessionedReason", equalTo("Test reason")) .body("data[1].fileDifferenceSummary.file", equalTo("Added")) .statusCode(OK.getStatusCode()); + + // Test when the file was not present on previous versions + + pathToFile = "src/test/resources/images/coffeeshop.png"; + addResponse = UtilIT.uploadFileViaNative(datasetId.toString(), pathToFile, superUserApiToken); + addResponse.then().assertThat().statusCode(OK.getStatusCode()); + dataFileId = addResponse.getBody().jsonPath().getString("data.files[0].dataFile.id"); + + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(2)) + .body("data.size()", is(2)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].versionState", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.file", equalTo("Added")) + .body("data[0].isDraft", equalTo(true)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", equalTo("")) + // Since the file was not present on the previous version, datafileId should be null + .body("data[1].datafileId", equalTo(null)) + .body("data[1].datasetVersion", equalTo("1.0")) + .body("data[1].versionState", equalTo("DEACCESSIONED")) + .body("data[1].fileDifferenceSummary.deaccessionedReason", equalTo("Test reason")) + // No differences to report at this stage since we are requesting differences for a newly added file + .body("data[1].fileDifferenceSummary.file", equalTo(null)) + .body("data[1].isDraft", equalTo(false)) + .body("data[1].isDeaccessioned", equalTo(true)) + .body("data[1].publishedDate", not(equalTo(""))); + + // Test when no changes were made to the file in a new version + + // First, publish the current DRAFT version + UtilIT.publishDatasetViaNativeApi(datasetId, "major", superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + + // Second, generate a new version by updating the dataset title, and publish it + String newTitle = "I am changing the title"; + String datasetPersistentId = UtilIT.getDatasetPersistentIdFromResponse(createDatasetResponse); + UtilIT.updateDatasetTitleViaSword(datasetPersistentId, newTitle, superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + UtilIT.publishDatasetViaNativeApi(datasetId, "major", superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.prettyPrint(); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(3)) + .body("data.size()", is(3)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("3.0")) + .body("data[0].versionState", equalTo("RELEASED")) + .body("data[0].fileDifferenceSummary", equalTo(null)) + .body("data[0].isDraft", equalTo(false)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", not(equalTo(""))) + // Since the file was already present in the previous version, datafileId should be present + .body("data[1].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[1].datasetVersion", equalTo("2.0")) + .body("data[1].versionState", equalTo("RELEASED")) + .body("data[1].fileDifferenceSummary.file", equalTo("Added")) + .body("data[1].isDraft", equalTo(false)) + .body("data[1].isDeaccessioned", equalTo(false)) + .body("data[1].publishedDate", not(equalTo(""))) + .body("data[2].fileDifferenceSummary.file", equalTo(null)) + .body("data[2].isDraft", equalTo(false)) + // Since the file was not present in this version, datafileId should be null + .body("data[2].datafileId", equalTo(null)) + .body("data[2].isDeaccessioned", equalTo(true)) + .body("data[2].publishedDate", not(equalTo(""))); + + // Test FileAccess restricted + + UtilIT.allowAccessRequests(datasetPersistentId, true, superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + + UtilIT.restrictFile(dataFileId, true, superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(4)) + .body("data.size()", is(4)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].versionState", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.FileAccess", equalTo("Restricted")) + .body("data[0].isDraft", equalTo(true)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", equalTo("")); + + // Test FileAccess unrestricted + + UtilIT.publishDatasetViaNativeApi(datasetId, "major", superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + + UtilIT.restrictFile(dataFileId, false, superUserApiToken).then().assertThat().statusCode(OK.getStatusCode()); + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(5)) + .body("data.size()", is(5)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].versionState", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.FileAccess", equalTo("Unrestricted")) + .body("data[0].isDraft", equalTo(true)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", equalTo("")); + + // Test FileMetadata update + + JsonObjectBuilder updateFileMetadata = Json.createObjectBuilder() + .add("label", "new_name.png"); + UtilIT.updateFileMetadata(dataFileId, updateFileMetadata.build().toString(), superUserApiToken).then().statusCode(OK.getStatusCode()); + + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(5)) + .body("data.size()", is(5)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].versionState", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.FileAccess", equalTo("Unrestricted")) + .body("data[0].fileDifferenceSummary.FileMetadata[0].name", equalTo("File Name")) + .body("data[0].fileDifferenceSummary.FileMetadata[0].action", equalTo("Changed")) + .body("data[0].isDraft", equalTo(true)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", equalTo("")); + + // Test FileTags update + + Response setFileCategoriesResponse = UtilIT.setFileCategories(dataFileId, superUserApiToken, List.of("Category")); + setFileCategoriesResponse.then().assertThat().statusCode(OK.getStatusCode()); + + getFileDataResponse = UtilIT.getFileVersionDifferences(dataFileId, superUserApiToken); + getFileDataResponse.then().assertThat() + .body("status", equalTo("OK")) + .body("totalCount", is(5)) + .body("data.size()", is(5)) + .body("data[0].datafileId", equalTo(Integer.parseInt(dataFileId))) + .body("data[0].datasetVersion", equalTo("DRAFT")) + .body("data[0].versionState", equalTo("DRAFT")) + .body("data[0].fileDifferenceSummary.FileAccess", equalTo("Unrestricted")) + .body("data[0].fileDifferenceSummary.FileMetadata[0].name", equalTo("File Name")) + .body("data[0].fileDifferenceSummary.FileMetadata[0].action", equalTo("Changed")) + .body("data[0].fileDifferenceSummary.FileTags.Added", equalTo(1)) + .body("data[0].isDraft", equalTo(true)) + .body("data[0].isDeaccessioned", equalTo(false)) + .body("data[0].publishedDate", equalTo("")); } + @Test public void testGetFileInfo() { Response createUser = UtilIT.createRandomUser(); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index c11f66aa749..e80fabc0137 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1339,9 +1339,21 @@ static Response getFileData(String fileId, String apiToken, String datasetVersio } static Response getFileVersionDifferences(String fileId, String apiToken) { - return given() - .header(API_TOKEN_HTTP_HEADER, apiToken) - .get("/api/files/" + fileId + "/versionDifferences"); + return getFileVersionDifferences(fileId, apiToken, null, null); + } + + static Response getFileVersionDifferences(String fileId, String apiToken, Integer limit, Integer offset) { + RequestSpecification request = given() + .header(API_TOKEN_HTTP_HEADER, apiToken); + + if (limit != null) { + request.queryParam("limit", limit); + } + if (offset != null) { + request.queryParam("offset", offset); + } + + return request.get("/api/files/" + fileId + "/versionDifferences"); } static Response testIngest(String fileName, String fileType) { @@ -1787,14 +1799,21 @@ static Response compareDatasetVersions(String persistentId, String versionNumber + "&includeDeaccessioned=" + includeDeaccessioned); } + + static Response summaryDatasetVersionDifferences(String persistentId, String apiToken) { + return summaryDatasetVersionDifferences(persistentId, null, null, apiToken); + } - static Response summaryDatasetVersionDifferences(String persistentId, String apiToken) { + static Response summaryDatasetVersionDifferences(String persistentId, Integer limit, Integer offset, String apiToken) { return given() .header(API_TOKEN_HTTP_HEADER, apiToken) + .queryParam("limit", limit) + .queryParam("offset", offset) .get("/api/datasets/:persistentId/versions/compareSummary" + "?persistentId=" + persistentId); } + static Response getDatasetWithOwners(String persistentId, String apiToken, boolean returnOwners) { return given() .header(API_TOKEN_HTTP_HEADER, apiToken) diff --git a/src/test/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryTest.java b/src/test/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryTest.java new file mode 100644 index 00000000000..430c945591c --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/datasetversionsummaries/DatasetVersionSummaryTest.java @@ -0,0 +1,195 @@ +package edu.harvard.iq.dataverse.datasetversionsummaries; + +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DatasetVersion.VersionState; +import edu.harvard.iq.dataverse.DatasetVersionDifference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the {@link DatasetVersionSummary} record and its factory methods. + */ +@ExtendWith(MockitoExtension.class) +class DatasetVersionSummaryTest { + + @Mock + private DatasetVersion versionMock; + + @Mock + private DatasetVersionDifference differenceMock; + + @Test + @DisplayName("from() should return an empty Optional when the input DatasetVersion is null") + void from_whenVersionIsNull_shouldReturnEmptyOptional() { + // Act + Optional result = DatasetVersionSummary.from(null); + + // Assert + assertThat(result).isNotPresent(); + } + + @Test + @DisplayName("from() should map all basic properties from the DatasetVersion correctly") + void from_whenVersionIsNotNull_shouldMapPropertiesCorrectly() { + // Arrange + when(versionMock.getId()).thenReturn(42L); + when(versionMock.getFriendlyVersionNumber()).thenReturn("V1.0"); + when(versionMock.getVersionNote()).thenReturn("Initial version note."); + when(versionMock.getContributorNames()).thenReturn("Contributor One, Contributor Two"); + when(versionMock.getPublicationDateAsString()).thenReturn("2025-10-02"); + when(versionMock.isReleased()).thenReturn(true); + when(versionMock.getPriorVersionState()).thenReturn(null); + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + DatasetVersionSummary summary = summaryOpt.get(); + + assertThat(summary.id()).isEqualTo(42L); + assertThat(summary.versionNumber()).isEqualTo("V1.0"); + assertThat(summary.versionNote()).isEqualTo("Initial version note."); + assertThat(summary.contributorNames()).isEqualTo("Contributor One, Contributor Two"); + assertThat(summary.publicationDate()).isEqualTo("2025-10-02"); + } + + @Test + @DisplayName("Content should be 'Differences' if a version difference is present (highest priority)") + void from_whenVersionHasDifferences_shouldCreateDifferencesContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(differenceMock); + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()).isInstanceOf(DatasetVersionSummaryContentDifferences.class); + DatasetVersionSummaryContentDifferences content = (DatasetVersionSummaryContentDifferences) summaryOpt.get().content(); + assertThat(content.getDatasetVersionDifference()).isSameAs(differenceMock); + } + + @Test + @DisplayName("Content should be 'Deaccessioned' if the version is deaccessioned") + void from_whenVersionIsDeaccessioned_shouldCreateDeaccessionedContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); // No differences + when(versionMock.isDeaccessioned()).thenReturn(true); + when(versionMock.getDeaccessionNote()).thenReturn("Reason for deaccession."); + when(versionMock.getDeaccessionLink()).thenReturn("http://example.com/deaccession"); + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + DatasetVersionSummaryContentDeaccessioned content = (DatasetVersionSummaryContentDeaccessioned) summaryOpt.get().content(); + + assertThat(content.getDeaccessionNote()).isEqualTo("Reason for deaccession."); + assertThat(content.getDeaccessionLink()).isEqualTo("http://example.com/deaccession"); + } + + @Test + @DisplayName("Content should be 'firstPublished' for the first released version") + void from_whenIsFirstPublishedVersion_shouldCreateFirstPublishedContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); + when(versionMock.isDeaccessioned()).thenReturn(false); + when(versionMock.isReleased()).thenReturn(true); + when(versionMock.getPriorVersionState()).thenReturn(null); // This makes it the "first" version + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()) + .isInstanceOf(DatasetVersionSummaryContentSimple.class) + .extracting("value") + .isEqualTo(DatasetVersionSummaryContentSimple.Content.firstPublished); + } + + @Test + @DisplayName("Content should be 'firstDraft' for the first draft version") + void from_whenIsFirstDraftVersion_shouldCreateFirstDraftContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); + when(versionMock.isDeaccessioned()).thenReturn(false); + when(versionMock.isDraft()).thenReturn(true); + when(versionMock.getPriorVersionState()).thenReturn(null); // This makes it the "first" version + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()) + .isInstanceOf(DatasetVersionSummaryContentSimple.class) + .extracting("value") + .isEqualTo(DatasetVersionSummaryContentSimple.Content.firstDraft); + } + + @Test + @DisplayName("Content should be 'previousVersionDeaccessioned' if the prior version was deaccessioned") + void from_whenPriorVersionWasDeaccessioned_shouldCreateAppropriateContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); + when(versionMock.isDeaccessioned()).thenReturn(false); + when(versionMock.isReleased()).thenReturn(true); + when(versionMock.getPriorVersionState()).thenReturn(VersionState.DEACCESSIONED); + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()) + .isInstanceOf(DatasetVersionSummaryContentSimple.class) + .extracting("value") + .isEqualTo(DatasetVersionSummaryContentSimple.Content.previousVersionDeaccessioned); + } + + @Test + @DisplayName("Content should be null for a standard released version that is not the first") + void from_whenIsStandardUpdate_shouldHaveNullContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); + when(versionMock.isDeaccessioned()).thenReturn(false); + when(versionMock.isReleased()).thenReturn(true); + when(versionMock.getPriorVersionState()).thenReturn(VersionState.RELEASED); // Prior version exists + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()).isNull(); + } + + @Test + @DisplayName("Content should be null for an unhandled version state like ARCHIVED") + void from_whenStateIsUnhandled_shouldHaveNullContent() { + // Arrange + when(versionMock.getDefaultVersionDifference()).thenReturn(null); + when(versionMock.isDeaccessioned()).thenReturn(false); + when(versionMock.isReleased()).thenReturn(false); + when(versionMock.isDraft()).thenReturn(false); + // Assuming VersionState is ARCHIVED, isArchived() would be true but the logic doesn't check for it. + + // Act + Optional summaryOpt = DatasetVersionSummary.from(versionMock); + + // Assert + assertThat(summaryOpt).isPresent(); + assertThat(summaryOpt.get().content()).isNull(); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommandTest.java new file mode 100644 index 00000000000..a02ffa4b50f --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionCountCommandTest.java @@ -0,0 +1,118 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersionServiceBean; +import edu.harvard.iq.dataverse.PermissionServiceBean; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.EnumSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GetDatasetVersionCountCommandTest { + + @Mock + private CommandContext commandContextMock; + @Mock + private DataverseRequest dataverseRequestMock; + @Mock + private DatasetVersionServiceBean datasetVersionServiceMock; + @Mock + private PermissionServiceBean permissionServiceMock; + @Mock + private Dataset datasetMock; + @Mock + private AuthenticatedUser userMock; + + private static final Long TEST_DATASET_ID = 42L; + + @BeforeEach + void setUp() { + when(commandContextMock.datasetVersion()).thenReturn(datasetVersionServiceMock); + when(commandContextMock.permissions()).thenReturn(permissionServiceMock); + when(datasetMock.getId()).thenReturn(TEST_DATASET_ID); + when(dataverseRequestMock.getUser()).thenReturn(userMock); + } + + @Test + @DisplayName("Should count ALL versions when user has ViewUnpublishedDataset permission") + void execute_whenUserCanViewUnpublished_countsAllVersions() throws CommandException { + // Arrange + // Simulate that the user has the required permission + when(permissionServiceMock.hasPermissionsFor( + eq(userMock), + eq(datasetMock), + eq(EnumSet.of(Permission.ViewUnpublishedDataset)) + )).thenReturn(true); + + // Define the expected count when all versions are included + Long expectedTotalCount = 10L; + when(datasetVersionServiceMock.getDatasetVersionCount(TEST_DATASET_ID, true)).thenReturn(expectedTotalCount); + + GetDatasetVersionCountCommand command = new GetDatasetVersionCountCommand(dataverseRequestMock, datasetMock); + + // Act + Long actualCount = command.execute(commandContextMock); + + // Assert + assertEquals(expectedTotalCount, actualCount, "The count should include all versions (published and unpublished)."); + + // Verify that the permission check was performed + verify(permissionServiceMock).hasPermissionsFor( + eq(userMock), + eq(datasetMock), + eq(EnumSet.of(Permission.ViewUnpublishedDataset)) + ); + + // Verify the service was called with 'true' to indicate unpublished versions should be counted + verify(datasetVersionServiceMock).getDatasetVersionCount(TEST_DATASET_ID, true); + } + + @Test + @DisplayName("Should count ONLY published versions when user lacks ViewUnpublishedDataset permission") + void execute_whenUserCannotViewUnpublished_countsOnlyPublishedVersions() throws CommandException { + // Arrange + // Simulate that the user does NOT have the required permission + when(permissionServiceMock.hasPermissionsFor( + eq(userMock), + eq(datasetMock), + eq(EnumSet.of(Permission.ViewUnpublishedDataset)) + )).thenReturn(false); + + // Define the expected count when only published versions are included + Long expectedPublishedCount = 5L; + when(datasetVersionServiceMock.getDatasetVersionCount(TEST_DATASET_ID, false)).thenReturn(expectedPublishedCount); + + GetDatasetVersionCountCommand command = new GetDatasetVersionCountCommand(dataverseRequestMock, datasetMock); + + // Act + Long actualCount = command.execute(commandContextMock); + + // Assert + assertEquals(expectedPublishedCount, actualCount, "The count should only include published versions."); + + // Verify that the permission check was performed + verify(permissionServiceMock).hasPermissionsFor( + eq(userMock), + eq(datasetMock), + eq(EnumSet.of(Permission.ViewUnpublishedDataset)) + ); + + // Verify the service was called with 'false' to indicate only published versions should be counted + verify(datasetVersionServiceMock).getDatasetVersionCount(TEST_DATASET_ID, false); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommandTest.java new file mode 100644 index 00000000000..b86188f440e --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetVersionSummariesCommandTest.java @@ -0,0 +1,202 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.DatasetVersionServiceBean; +import edu.harvard.iq.dataverse.PermissionServiceBean; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.datasetversionsummaries.DatasetVersionSummary; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidCommandArgumentsException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GetDatasetVersionSummariesCommandTest { + + @Mock + private CommandContext contextMock; + @Mock + private DataverseRequest requestMock; + @Mock + private Dataset datasetMock; + @Mock + private PermissionServiceBean permissionsMock; + @Mock + private DatasetVersionServiceBean versionServiceMock; + @Mock + private AuthenticatedUser userMock; + @Mock + private DatasetVersion versionMock; + + private static final Long DATASET_ID = 42L; + + /** + * Helper method to set up mocks required for tests that execute the full command logic. + */ + private void setupMocksForSuccessfulExecution() { + when(contextMock.permissions()).thenReturn(permissionsMock); + when(contextMock.datasetVersion()).thenReturn(versionServiceMock); + when(requestMock.getUser()).thenReturn(userMock); + when(datasetMock.getId()).thenReturn(DATASET_ID); + } + + @Test + @DisplayName("execute should call findVersions with 'canViewUnpublished' as TRUE when user has permission") + void execute_should_call_findVersions_with_true_when_user_has_permission() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + when(permissionsMock.hasPermissionsFor(requestMock.getUser(), datasetMock, EnumSet.of(Permission.ViewUnpublishedDataset))) + .thenReturn(true); + when(versionServiceMock.findVersions(anyLong(), any(), any(), anyBoolean(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, null, null); + + // Act + command.execute(contextMock); + + // Assert + ArgumentCaptor canViewUnpublishedCaptor = ArgumentCaptor.forClass(Boolean.class); + verify(versionServiceMock).findVersions( + eq(DATASET_ID), + eq(null), + eq(null), + canViewUnpublishedCaptor.capture(), + eq(true) + ); + assertThat(canViewUnpublishedCaptor.getValue()).isTrue(); + } + + @Test + @DisplayName("execute should call findVersions with 'canViewUnpublished' as FALSE when user lacks permission") + void execute_should_call_findVersions_with_false_when_user_lacks_permission() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + when(permissionsMock.hasPermissionsFor(requestMock.getUser(), datasetMock, EnumSet.of(Permission.ViewUnpublishedDataset))) + .thenReturn(false); + when(versionServiceMock.findVersions(anyLong(), any(), any(), anyBoolean(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, null, null); + + // Act + command.execute(contextMock); + + // Assert + ArgumentCaptor canViewUnpublishedCaptor = ArgumentCaptor.forClass(Boolean.class); + verify(versionServiceMock).findVersions( + eq(DATASET_ID), + eq(null), + eq(null), + canViewUnpublishedCaptor.capture(), + eq(true) + ); + assertThat(canViewUnpublishedCaptor.getValue()).isFalse(); + } + + @Test + @DisplayName("execute should pass pagination parameters correctly to findVersions") + void execute_should_pass_pagination_parameters_correctly() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + Integer expectedLimit = 10; + Integer expectedOffset = 20; + when(versionServiceMock.findVersions(anyLong(), any(), any(), anyBoolean(), anyBoolean())) + .thenReturn(Collections.emptyList()); + + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, expectedLimit, expectedOffset); + + // Act + command.execute(contextMock); + + // Assert + verify(versionServiceMock).findVersions( + eq(DATASET_ID), + eq(expectedOffset), + eq(expectedLimit), + anyBoolean(), + eq(true) + ); + } + + @Test + @DisplayName("execute should enrich contributors and convert versions to summaries") + void execute_should_enrich_contributors_and_convert_to_summaries() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + when(versionServiceMock.findVersions(anyLong(), any(), any(), anyBoolean(), anyBoolean())) + .thenReturn(List.of(versionMock)); + + // Arrange: Mock contributor names retrieval + String expectedContributors = "Contributor"; + when(versionServiceMock.getContributorsNames(versionMock)).thenReturn(expectedContributors); + + // Arrange: Prepare a dummy summary object + DatasetVersionSummary dummySummary = new DatasetVersionSummary(1L, "V1", null, null, null, null); + + // Arrange: Mock the static 'from' method to return our dummy summary + try (MockedStatic mockedStatic = Mockito.mockStatic(DatasetVersionSummary.class)) { + mockedStatic.when(() -> DatasetVersionSummary.from(versionMock)) + .thenReturn(Optional.of(dummySummary)); + + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, null, null); + + // Act + List result = command.execute(contextMock); + + // Assert + verify(versionServiceMock).getContributorsNames(versionMock); + verify(versionMock).setContributorNames(expectedContributors); + + // Verify final conversion + assertThat(result) + .isNotNull() + .hasSize(1) + .containsExactly(dummySummary); + } + } + + @Test + @DisplayName("execute should throw InvalidCommandArgumentsException for negative limit") + void execute_should_throw_exception_for_negative_limit() { + // Arrange + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, -1, 0); + + // Act & Assert + assertThrows(InvalidCommandArgumentsException.class, () -> command.execute(contextMock)); + } + + @Test + @DisplayName("execute should throw InvalidCommandArgumentsException for negative offset") + void execute_should_throw_exception_for_negative_offset() { + // Arrange + GetDatasetVersionSummariesCommand command = new GetDatasetVersionSummariesCommand(requestMock, datasetMock, 10, -1); + + // Act & Assert + assertThrows(InvalidCommandArgumentsException.class, () -> command.execute(contextMock)); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommandTest.java new file mode 100644 index 00000000000..f131f81cdff --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/GetFileVersionDifferencesCommandTest.java @@ -0,0 +1,238 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.*; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidCommandArgumentsException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GetFileVersionDifferencesCommandTest { + + @Mock + private CommandContext contextMock; + @Mock + private DataverseRequest requestMock; + @Mock + private FileMetadata fileMetadataMock; + @Mock + private PermissionServiceBean permissionsMock; + @Mock + private DataFileServiceBean fileServiceMock; + @Mock + private DatasetVersionServiceBean datasetVersionServiceMock; + @Mock + private Dataset datasetMock; + @Mock + private DatasetVersion datasetVersionMock; + @Mock + private DataFile dataFileMock; + + private static final Long DATASET_ID = 1L; + + @BeforeEach + void setUp() { + when(fileMetadataMock.getDataFile()).thenReturn(dataFileMock); + } + + /** + * Helper method to set up mocks required for tests that execute the full command logic. + */ + private void setupMocksForSuccessfulExecution() { + when(contextMock.permissions()).thenReturn(permissionsMock); + when(contextMock.files()).thenReturn(fileServiceMock); + when(fileMetadataMock.getDatasetVersion()).thenReturn(datasetVersionMock); + when(datasetVersionMock.getDataset()).thenReturn(datasetMock); + when(datasetMock.getId()).thenReturn(DATASET_ID); + } + + @Test + @DisplayName("execute should call findFileMetadataHistory with 'canViewUnpublished' as TRUE when user has permission") + void execute_should_call_history_with_true_when_user_has_permission() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + when(permissionsMock.hasPermissionsFor(requestMock.getUser(), datasetMock, EnumSet.of(Permission.ViewUnpublishedDataset))) + .thenReturn(true); + when(fileServiceMock.findFileMetadataHistory(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Collections.emptyList()); + + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, null, null); + + // Act + command.execute(contextMock); + + // Assert + ArgumentCaptor canViewUnpublishedCaptor = ArgumentCaptor.forClass(Boolean.class); + verify(fileServiceMock).findFileMetadataHistory( + eq(DATASET_ID), + eq(dataFileMock), + canViewUnpublishedCaptor.capture(), + eq(null), + eq(null) + ); + assertThat(canViewUnpublishedCaptor.getValue()).isTrue(); + } + + @Test + @DisplayName("execute should call findFileMetadataHistory with 'canViewUnpublished' as FALSE when user lacks permission") + void execute_should_call_history_with_false_when_user_lacks_permission() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + when(permissionsMock.hasPermissionsFor(requestMock.getUser(), datasetMock, EnumSet.of(Permission.ViewUnpublishedDataset))) + .thenReturn(false); + when(fileServiceMock.findFileMetadataHistory(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Collections.emptyList()); + + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, null, null); + + // Act + command.execute(contextMock); + + // Assert + ArgumentCaptor canViewUnpublishedCaptor = ArgumentCaptor.forClass(Boolean.class); + verify(fileServiceMock).findFileMetadataHistory( + eq(DATASET_ID), + eq(dataFileMock), + canViewUnpublishedCaptor.capture(), + eq(null), + eq(null) + ); + assertThat(canViewUnpublishedCaptor.getValue()).isFalse(); + } + + @Test + @DisplayName("execute should pass pagination parameters correctly to findFileMetadataHistory") + void execute_should_pass_pagination_parameters_correctly() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + Integer expectedLimit = 10; + Integer expectedOffset = 20; + when(fileServiceMock.findFileMetadataHistory(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Collections.emptyList()); + + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, expectedLimit, expectedOffset); + + // Act + command.execute(contextMock); + + // Assert + verify(fileServiceMock).findFileMetadataHistory( + eq(DATASET_ID), + eq(dataFileMock), + anyBoolean(), + eq(expectedLimit), + eq(expectedOffset) + ); + } + + @Test + @DisplayName("execute should correctly convert file history to FileVersionDifference list") + void execute_should_convert_history_to_differences() throws CommandException { + // Arrange + setupMocksForSuccessfulExecution(); + // This test needs an extra mock because it processes items from the history list + when(contextMock.datasetVersion()).thenReturn(datasetVersionServiceMock); + + // Create mock history: 3 versions of a file's metadata + FileMetadata fm3 = new FileMetadata(); // Newest + fm3.setId(3L); + fm3.setDatasetVersion(datasetVersionMock); + FileMetadata fm2 = new FileMetadata(); // Middle + fm2.setId(2L); + fm2.setDatasetVersion(datasetVersionMock); + FileMetadata fm1 = new FileMetadata(); // Oldest + fm1.setId(1L); + fm1.setDatasetVersion(datasetVersionMock); + + VersionedFileMetadata vfm3 = mock(VersionedFileMetadata.class); + VersionedFileMetadata vfm2 = mock(VersionedFileMetadata.class); + VersionedFileMetadata vfm1 = mock(VersionedFileMetadata.class); + when(vfm3.getFileMetadata()).thenReturn(fm3); + when(vfm2.getFileMetadata()).thenReturn(fm2); + when(vfm1.getFileMetadata()).thenReturn(fm1); + + List history = List.of(vfm3, vfm2, vfm1); + when(fileServiceMock.findFileMetadataHistory(any(), any(), anyBoolean(), any(), any())) + .thenReturn(history); + + when(fileServiceMock.getPreviousFileMetadata(any(FileMetadata.class))).thenAnswer( + invocation -> { + FileMetadata current = invocation.getArgument(0); + if (current != null && current.getId().equals(3L)) { + return fm2; + } + if (current != null && current.getId().equals(2L)) { + return fm1; + } + return null; + } + ); + + // Mock the logic to retrieve contributor names + when(datasetVersionServiceMock.getContributorsNames(any(DatasetVersion.class))).thenReturn(Collections.emptyList().toString()); + + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, null, null); + + // Act + List differences = command.execute(contextMock); + + // Assert + assertThat(differences).hasSize(3); + + // Check the difference for the newest version (v3 vs v2) + assertThat(differences.get(0).getNewFileMetadata()).isEqualTo(fm3); + assertThat(differences.get(0).getOriginalFileMetadata()).isEqualTo(fm2); + + // Check the difference for the middle version (v2 vs v1) + assertThat(differences.get(1).getNewFileMetadata()).isEqualTo(fm2); + assertThat(differences.get(1).getOriginalFileMetadata()).isEqualTo(fm1); + + // Check the difference for the oldest version (v1 vs null) + assertThat(differences.get(2).getNewFileMetadata()).isEqualTo(fm1); + assertThat(differences.get(2).getOriginalFileMetadata()).isNull(); + + // Verify setting contributor for each file metadata + verify(datasetVersionServiceMock, times(3)).getContributorsNames(datasetVersionMock); + } + + @Test + @DisplayName("execute should throw InvalidCommandArgumentsException for negative limit") + void execute_should_throw_exception_for_negative_limit() { + // Arrange + Integer invalidLimit = -1; + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, invalidLimit, 0); + + // Act & Assert + assertThrows(InvalidCommandArgumentsException.class, () -> command.execute(contextMock)); + } + + @Test + @DisplayName("execute should throw InvalidCommandArgumentsException for negative offset") + void execute_should_throw_exception_for_negative_offset() { + // Arrange + Integer invalidOffset = -1; + GetFileVersionDifferencesCommand command = new GetFileVersionDifferencesCommand(requestMock, fileMetadataMock, 10, invalidOffset); + + // Act & Assert + assertThrows(InvalidCommandArgumentsException.class, () -> command.execute(contextMock)); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinterTest.java b/src/test/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinterTest.java new file mode 100644 index 00000000000..fff0cebcd3e --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/json/FileVersionDifferenceJsonPrinterTest.java @@ -0,0 +1,231 @@ +package edu.harvard.iq.dataverse.util.json; + +import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.FileVersionDifference; +import jakarta.json.JsonObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FileVersionDifferenceJsonPrinterTest { + @Mock + private FileVersionDifference fileVersionDifference; + @Mock + private FileMetadata newFileMetadata; + @Mock + private FileMetadata originalFileMetadata; + @Mock + private DatasetVersion datasetVersion; + @Mock + private DataFile dataFile; + + @BeforeEach + void setUp() { + when(fileVersionDifference.getNewFileMetadata()).thenReturn(newFileMetadata); + when(newFileMetadata.getDatasetVersion()).thenReturn(datasetVersion); + when(datasetVersion.getVersionNote()).thenReturn("Test Version Note"); + } + + @Test + @DisplayName("should correctly serialize basic dataset and file details") + void jsonFileVersionDifference_with_basic_dataset_and_file_details() { + // Arrange + when(newFileMetadata.getDataFile()).thenReturn(dataFile); + when(dataFile.getId()).thenReturn(42L); + when(datasetVersion.getFriendlyVersionNumber()).thenReturn("V1"); + when(datasetVersion.getVersionState()).thenReturn(DatasetVersion.VersionState.RELEASED); + when(datasetVersion.getPublicationDateAsString()).thenReturn("2025-10-16"); + when(fileVersionDifference.getDifferenceSummaryGroups()).thenReturn(Collections.emptyList()); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + assertEquals("V1", result.getString("datasetVersion")); + assertEquals("RELEASED", result.getString("versionState")); + assertEquals("2025-10-16", result.getString("publishedDate")); + assertEquals(42, result.getInt("datafileId")); + assertNotNull(result.getJsonObject("fileDifferenceSummary")); + assertEquals("Test Version Note", result.getJsonObject("fileDifferenceSummary").getString("versionNote")); + } + + @Test + @DisplayName("should report file action as 'Added' for a new file") + void jsonFileVersionDifference_file_added() { + // Arrange + when(fileVersionDifference.getOriginalFileMetadata()).thenReturn(null); + when(newFileMetadata.getDataFile()).thenReturn(dataFile); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + assertEquals("Added", result.getJsonObject("fileDifferenceSummary").getString("file")); + } + + @Test + @DisplayName("should report file action as 'Deleted' for a removed file") + void jsonFileVersionDifference_file_deleted() { + // Arrange + when(fileVersionDifference.getOriginalFileMetadata()).thenReturn(originalFileMetadata); + when(newFileMetadata.getDataFile()).thenReturn(null); // Key condition for deleted + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + assertEquals("Deleted", result.getJsonObject("fileDifferenceSummary").getString("file")); + } + + @Test + @DisplayName("should report file action as 'Replaced' for a replaced file") + void jsonFileVersionDifference_file_replaced() { + // Arrange + DataFile originalDataFile = new DataFile(); + originalDataFile.setId(100L); + DataFile newDataFile = new DataFile(); + newDataFile.setId(101L); // Different file object + + when(fileVersionDifference.getOriginalFileMetadata()).thenReturn(originalFileMetadata); + when(originalFileMetadata.getDataFile()).thenReturn(originalDataFile); + when(newFileMetadata.getDataFile()).thenReturn(newDataFile); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + assertEquals("Replaced", result.getJsonObject("fileDifferenceSummary").getString("file")); + } + + @Test + @DisplayName("should correctly serialize count-based summary groups") + void jsonFileVersionDifference_as_count_based_summary() { + // Arrange + FileVersionDifference fvd = new FileVersionDifference(newFileMetadata, originalFileMetadata, true); + FileVersionDifference.FileDifferenceSummaryItem item = fvd.new FileDifferenceSummaryItem("", 2, 1, 0, 0, true); + FileVersionDifference.FileDifferenceSummaryGroup group = fvd.new FileDifferenceSummaryGroup("Tags"); + group.getFileDifferenceSummaryItems().add(item); + + when(fileVersionDifference.getDifferenceSummaryGroups()).thenReturn(Collections.singletonList(group)); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + JsonObject summary = result.getJsonObject("fileDifferenceSummary"); + assertNotNull(summary); + JsonObject tags = summary.getJsonObject("Tags"); + assertNotNull(tags); + assertEquals(2, tags.getInt("Added")); + assertEquals(1, tags.getInt("Changed")); + assertFalse(tags.containsKey("Deleted"), "Zero-count fields should not be present"); + } + + @Test + @DisplayName("should correctly serialize the 'File Access' group as a name-value pair") + void jsonFileVersionDifference_as_name_value_for_file_access() { + // Arrange + FileVersionDifference fvd = new FileVersionDifference(newFileMetadata, originalFileMetadata, true); + FileVersionDifference.FileDifferenceSummaryItem item = fvd.new FileDifferenceSummaryItem("Public", 0, 1, 0, 0, false); + FileVersionDifference.FileDifferenceSummaryGroup group = fvd.new FileDifferenceSummaryGroup("File Access"); + group.getFileDifferenceSummaryItems().add(item); + when(fileVersionDifference.getDifferenceSummaryGroups()).thenReturn(Collections.singletonList(group)); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + JsonObject summary = result.getJsonObject("fileDifferenceSummary"); + assertEquals("Public", summary.getString("FileAccess")); + } + + + @Test + @DisplayName("should correctly serialize list-item-based summary groups") + void jsonFileVersionDifference_as_list_item_based_summary() { + // Arrange + FileVersionDifference fvd = new FileVersionDifference(newFileMetadata, originalFileMetadata, true); + + FileVersionDifference.FileDifferenceSummaryItem itemA = fvd.new FileDifferenceSummaryItem("Category A", 1, 0, 0, 0, false); + FileVersionDifference.FileDifferenceSummaryItem itemB = fvd.new FileDifferenceSummaryItem("Category B", 0, 0, 1, 0, false); + + FileVersionDifference.FileDifferenceSummaryGroup group = fvd.new FileDifferenceSummaryGroup("Categories"); + group.getFileDifferenceSummaryItems().addAll(Arrays.asList(itemA, itemB)); + + when(fileVersionDifference.getDifferenceSummaryGroups()).thenReturn(Collections.singletonList(group)); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + JsonObject summary = result.getJsonObject("fileDifferenceSummary"); + assertNotNull(summary); + jakarta.json.JsonArray categories = summary.getJsonArray("Categories"); + assertNotNull(categories); + assertEquals(2, categories.size()); + assertEquals("Category A", categories.getJsonObject(0).getString("name")); + assertEquals("Added", categories.getJsonObject(0).getString("action")); + assertEquals("Category B", categories.getJsonObject(1).getString("name")); + assertEquals("Deleted", categories.getJsonObject(1).getString("action")); + } + + @Test + @DisplayName("should correctly serialize a mix of all summary group types") + void jsonFileVersionDifference_with_multiple_and_mixed_types() { + // Arrange + FileVersionDifference fvd = new FileVersionDifference(newFileMetadata, originalFileMetadata, true); + + List groups = new ArrayList<>(); + + // Group 1: Tags (counts) + FileVersionDifference.FileDifferenceSummaryItem tagsItem = fvd.new FileDifferenceSummaryItem("", 2, 0, 1, 0, true); + FileVersionDifference.FileDifferenceSummaryGroup tagsGroup = fvd.new FileDifferenceSummaryGroup("Tags"); + tagsGroup.getFileDifferenceSummaryItems().add(tagsItem); + groups.add(tagsGroup); + + // Group 2: Categories (list items) + FileVersionDifference.FileDifferenceSummaryItem categoriesItem = fvd.new FileDifferenceSummaryItem("Science", 1, 0, 0, 0, false); + FileVersionDifference.FileDifferenceSummaryGroup categoriesGroup = fvd.new FileDifferenceSummaryGroup("Categories"); + categoriesGroup.getFileDifferenceSummaryItems().add(categoriesItem); + groups.add(categoriesGroup); + + // Group 3: File Access (name/value) + FileVersionDifference.FileDifferenceSummaryItem accessItem = fvd.new FileDifferenceSummaryItem("Restricted", 0, 1, 0, 0, false); + FileVersionDifference.FileDifferenceSummaryGroup accessGroup = fvd.new FileDifferenceSummaryGroup("File Access"); + accessGroup.getFileDifferenceSummaryItems().add(accessItem); + groups.add(accessGroup); + + when(fileVersionDifference.getDifferenceSummaryGroups()).thenReturn(groups); + + // Act + JsonObject result = FileVersionDifferenceJsonPrinter.jsonFileVersionDifference(fileVersionDifference).build(); + + // Assert + JsonObject summary = result.getJsonObject("fileDifferenceSummary"); + + // Check Categories (should be first alphabetically) + assertEquals(1, summary.getJsonArray("Categories").size()); + assertEquals("Science", summary.getJsonArray("Categories").getJsonObject(0).getString("name")); + + // Check File Access + assertEquals("Restricted", summary.getString("FileAccess")); + + // Check Tags + assertEquals(2, summary.getJsonObject("Tags").getInt("Added")); + assertEquals(1, summary.getJsonObject("Tags").getInt("Deleted")); + } +}