From 63c1c73ef85445f94f0728ee924cd7a919942c37 Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Mon, 23 Feb 2026 17:12:10 +0100 Subject: [PATCH 01/11] fix(health-report): fix CLI args, multi-check support, and report-diff comparison logic --- .../dspace/app/healthreport/HealthReport.java | 85 +++-- .../HealthReportScriptConfiguration.java | 14 +- .../org/dspace/app/reportdiff/ReportDiff.java | 303 ++++++++++++++++-- .../ReportDiffScriptConfiguration.java | 7 +- .../main/resources/report-diff-fields.json | 96 +++--- .../java/org/dspace/scripts/ReportDiffIT.java | 72 ++++- dspace/config/launcher.xml | 8 +- 7 files changed, 456 insertions(+), 129 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java index 36bb15c1056f..d93a88b0b976 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java @@ -13,8 +13,10 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import javax.mail.MessagingException; @@ -59,9 +61,9 @@ public class HealthReport extends DSpaceRunnable checks = Report.checks(); /** - * `-i`: Info, show help information. + * `-h`: Help, show help information. */ - private boolean info = false; + private boolean help = false; /** * `-e`: Email, send report to specified email address. @@ -69,9 +71,10 @@ public class HealthReport extends DSpaceRunnable specificChecks = new ArrayList<>(); /** * `-f`: For, specify the last N days to consider. @@ -80,9 +83,9 @@ public class HealthReport extends DSpaceRunnable= getNumberOfChecks()) { - specificCheck = -1; + String[] checkOptions = commandLine.getOptionValues('c'); + for (String checkOption : checkOptions) { + try { + int checkIndex = Integer.parseInt(checkOption); + if (checkIndex < 0 || checkIndex >= getNumberOfChecks()) { + handler.logError("Invalid value for check: " + checkOption + + ". Must be an integer from 0 to " + (getNumberOfChecks() - 1) + "."); + throw new ParseException("Invalid check index: " + checkOption); + } + specificChecks.add(checkIndex); + } catch (NumberFormatException e) { + handler.logError("Invalid value for check: '" + checkOption + + "'. It has to be an integer number from 0 to " + (getNumberOfChecks() - 1) + "."); + throw new ParseException("Invalid check value: " + checkOption); } - } catch (NumberFormatException e) { - log.info("Invalid value for check. It has to be a number from the displayed range."); - return; } } - // `-f`: For, specify the last N days to consider. + // `-f`: For, specify the last N days to consider. Must be a positive integer. if (commandLine.hasOption('f')) { String daysOption = commandLine.getOptionValue('f'); try { forLastNDays = Integer.parseInt(daysOption); + if (forLastNDays <= 0) { + handler.logError("Invalid value for -f: " + daysOption + + ". Must be a positive integer (greater than 0)."); + throw new ParseException("Invalid -f value: " + daysOption); + } } catch (NumberFormatException e) { - log.info("Invalid value for last N days. Argument f has to be a number."); - return; + handler.logError("Invalid value for -f: '" + daysOption + + "'. Must be a positive integer."); + throw new ParseException("Invalid -f value: " + daysOption); } } - // `-o`: Output, specify a file to save the report. - if (commandLine.hasOption('o')) { - fileName = commandLine.getOptionValue('o'); + // `-r`: Report, specify a file to save the report. + if (commandLine.hasOption('r')) { + reportFile = commandLine.getOptionValue('r'); } } @Override public void internalRun() throws Exception { - if (info) { + if (help) { printHelp(); return; } @@ -157,7 +173,7 @@ public void internalRun() throws Exception { JSONArray checksArray = new JSONArray(); for (Map.Entry check_entry : Report.checks().entrySet()) { ++position; - if (specificCheck != -1 && specificCheck != position) { + if (!specificChecks.isEmpty() && !specificChecks.contains(position)) { continue; } @@ -198,9 +214,9 @@ public void internalRun() throws Exception { context.commit(); // save output to file - if (fileName != null) { + if (reportFile != null) { InputStream inputStream = toInputStream(sbReport.toString(), StandardCharsets.UTF_8); - handler.writeFilestream(context, fileName, inputStream, "export"); + handler.writeFilestream(context, reportFile, inputStream, "export"); context.restoreAuthSystemState(); @@ -226,14 +242,15 @@ public void internalRun() throws Exception { @Override public void printHelp() { - handler.logInfo("\n\nINFORMATION\nThis process creates a health report of your DSpace.\n" + + handler.logInfo("\n\nHELP\nThis process creates a health report of your DSpace.\n" + "You can choose from these available options:\n" + - " -i, --info Show help information\n" + + " -h, --help Show help information\n" + " -e, --email Send report to specified email address\n" + - " -c, --check Perform only specific check by index (0-" + (getNumberOfChecks() - 1) + ")\n" + - " -f, --for Specify the last N days to consider\n" + - " -o, --output Specify a file to save the report\n\n" + - "If you want to execute only one check using -c, use check index:\n" + checksNamesToString() + "\n" + " -c, --check Perform specific check(s) by index (0-" + (getNumberOfChecks() - 1) + + "). Can be used multiple times, e.g. -c 0 -c 3 -c 4\n" + + " -f, --for Specify the last N days to consider (positive integer)\n" + + " -r, --report Specify a file to save the report\n\n" + + "Available checks:\n" + checksNamesToString() + "\n" ); } diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java index 771cc70aadb9..bd8245f34363 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java @@ -32,20 +32,22 @@ public void setDspaceRunnableClass(Class dspaceRunnableClass) { public Options getOptions() { if (options == null) { Options options = new Options(); - options.addOption("i", "info", false, + options.addOption("h", "help", false, "Show help information."); options.addOption("e", "email", true, "Send report to this email address."); options.getOption("e").setType(String.class); options.addOption("c", "check", true, - String.format("Perform only specific check (use index from 0 to %d, " + - "otherwise perform default checks).", HealthReport.getNumberOfChecks() - 1)); + String.format("Perform specific check(s) by index (0 to %d). " + + "Can be used multiple times, e.g. -c 0 -c 3 -c 4.", + HealthReport.getNumberOfChecks() - 1)); options.getOption("c").setType(String.class); + options.getOption("c").setArgs(Integer.MAX_VALUE); options.addOption("f", "for", true, - "Report for last N days. Used only in general information for now."); + "Report for last N days (positive integer). Used only in general information for now."); options.getOption("f").setType(String.class); - options.addOption("o", "output", true, - "Save report to the file."); + options.addOption("r", "report", true, + "Specify the report file to store the output."); super.options = options; } diff --git a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java index eaaf7bee6035..215fed799f80 100644 --- a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java +++ b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java @@ -61,9 +61,9 @@ public class ReportDiff extends DSpaceRunnable { private EPersonService ePersonService; /** - * `-i`: Info, show help information. + * `-h`: Help, show help information. */ - private boolean info = false; + private boolean help = false; /** * `-d`: Dates, show all dates that the report was generated for a specific check type. @@ -158,9 +158,9 @@ public ReportDiffScriptConfiguration getScriptConfiguration() { public void setup() throws ParseException { ePersonService = EPersonServiceFactory.getInstance().getEPersonService(); reportResultService = ContentServiceFactory.getInstance().getReportResultService(); - // `-i`: Info, show help information. - if (commandLine.hasOption('i')) { - info = true; + // `-h`: Help, show help information. + if (commandLine.hasOption('h')) { + help = true; return; } @@ -201,7 +201,7 @@ public void setup() throws ParseException { @Override public void internalRun() throws Exception { // If the user requested help information, we will display it. - if (info) { + if (help) { printHelp(); return; } @@ -364,6 +364,7 @@ private void displayReportDates() { * Compare two reports based on the specified `from` and `to` dates. * If the reports are not found, log an appropriate message. * If the reports are found, generate a comparison report showing the differences. + * The comparison is based on the intersection of check names present in both reports. * * @param context the application context */ @@ -371,13 +372,8 @@ private void compareReports(Context context) { try { context.setCurrentUser(ePersonService.find(context, getEpersonIdentifier())); - ReportResult fromReport = specificCheck != -1 - ? reportResultService.findByLastModifiedAndCheckType(context, from, specificCheck) - : reportResultService.findByLastModified(context, from); - - ReportResult toReport = specificCheck != -1 - ? reportResultService.findByLastModifiedAndCheckType(context, to, specificCheck) - : reportResultService.findByLastModified(context, to); + ReportResult fromReport = reportResultService.findByLastModified(context, from); + ReportResult toReport = reportResultService.findByLastModified(context, to); if (fromReport == null || toReport == null) { handler.logInfo("No reports found for specified dates."); @@ -406,9 +402,121 @@ private void compareReports(Context context) { } } + /** + * Holds the result of normalizing two reports to their intersection, + * including information about checks that were skipped (present in one report only). + */ + private static class NormalizationResult { + final String normalizedFromJson; + final String normalizedToJson; + /** Check names that exist only in the "from" report. */ + final List onlyInFrom; + /** Check names that exist only in the "to" report. */ + final List onlyInTo; + + NormalizationResult(String normalizedFromJson, String normalizedToJson, + List onlyInFrom, List onlyInTo) { + this.normalizedFromJson = normalizedFromJson; + this.normalizedToJson = normalizedToJson; + this.onlyInFrom = onlyInFrom; + this.onlyInTo = onlyInTo; + } + } + + /** + * Normalize two report JSON strings so that they only contain checks + * that are present (by name) in both reports. This allows correct comparison + * when reports were created with different check selections. + * + * If the `-c` option was specified, additionally filters to only include + * checks matching the specified check index (by name from the configured check list). + * + * @param fromJson the JSON string of the "from" report + * @param toJson the JSON string of the "to" report + * @return a {@link NormalizationResult} containing normalized JSON and skipped check info + * @throws IOException if JSON parsing fails + */ + private NormalizationResult normalizeReportsToIntersection(String fromJson, String toJson) throws IOException { + JsonNode fromRoot = mapper.readTree(fromJson); + JsonNode toRoot = mapper.readTree(toJson); + + JsonNode fromChecks = fromRoot.get("checks"); + JsonNode toChecks = toRoot.get("checks"); + + if (fromChecks == null || toChecks == null || !fromChecks.isArray() || !toChecks.isArray()) { + return new NormalizationResult(fromJson, toJson, + new ArrayList<>(), new ArrayList<>()); + } + + // Build maps of check name -> check node for both reports + Map fromCheckMap = new LinkedHashMap<>(); + for (JsonNode check : fromChecks) { + JsonNode nameNode = check.get("name"); + if (nameNode != null) { + fromCheckMap.put(nameNode.asText(), check); + } + } + + Map toCheckMap = new LinkedHashMap<>(); + for (JsonNode check : toChecks) { + JsonNode nameNode = check.get("name"); + if (nameNode != null) { + toCheckMap.put(nameNode.asText(), check); + } + } + + // Compute intersection of check names + List commonNames = new ArrayList<>(fromCheckMap.keySet()); + commonNames.retainAll(toCheckMap.keySet()); + + // If specificCheck is set, further filter to only that check name + if (specificCheck != -1) { + String targetCheckName = HealthReport.getCheckName(specificCheck); + if (targetCheckName != null) { + commonNames.retainAll(java.util.Collections.singletonList(targetCheckName)); + } + } + + if (commonNames.isEmpty()) { + handler.logInfo("No common checks found between the two reports for comparison."); + } + + // Determine checks that are only in one report + List onlyInFrom = new ArrayList<>(fromCheckMap.keySet()); + onlyInFrom.removeAll(toCheckMap.keySet()); + List onlyInTo = new ArrayList<>(toCheckMap.keySet()); + onlyInTo.removeAll(fromCheckMap.keySet()); + + // Build normalized JSON with only the common checks (in the same order) + com.fasterxml.jackson.databind.node.ObjectNode normalizedFrom = + mapper.createObjectNode(); + com.fasterxml.jackson.databind.node.ArrayNode normalizedFromChecks = + mapper.createArrayNode(); + for (String name : commonNames) { + normalizedFromChecks.add(fromCheckMap.get(name)); + } + normalizedFrom.set("checks", normalizedFromChecks); + + com.fasterxml.jackson.databind.node.ObjectNode normalizedTo = + mapper.createObjectNode(); + com.fasterxml.jackson.databind.node.ArrayNode normalizedToChecks = + mapper.createArrayNode(); + for (String name : commonNames) { + normalizedToChecks.add(toCheckMap.get(name)); + } + normalizedTo.set("checks", normalizedToChecks); + + return new NormalizationResult( + mapper.writeValueAsString(normalizedFrom), + mapper.writeValueAsString(normalizedTo), + onlyInFrom, onlyInTo); + } + /** * Generate a comparison report between two ReportResult objects. * The report includes the type, last modified dates, and the differences in JSON format. + * When comparing reports with different check selections, only the intersection + * of common check names is compared. * * @param fromReport the "from" report * @param toReport the "to" report @@ -423,6 +531,11 @@ private String generateReportComparison(ReportResult fromReport, ReportResult to return "One of the reports has no value. Cannot compare."; } + // Normalize both reports to contain only intersection of check names + NormalizationResult normalized = normalizeReportsToIntersection(fromJson, toJson); + String normalizedFromJson = normalized.normalizedFromJson; + String normalizedToJson = normalized.normalizedToJson; + StringBuilder sb = new StringBuilder(); // Header @@ -444,17 +557,37 @@ private String generateReportComparison(ReportResult fromReport, ReportResult to sb.append("Report Period: ").append(timePeriod).append("\n\n"); // Enhanced Key Changes Table - String keyChangesTable = generateEnhancedKeyChangesTable(fromJson, toJson, + String keyChangesTable = generateEnhancedKeyChangesTable(normalizedFromJson, normalizedToJson, fromReport.getLastModified(), toReport.getLastModified()); sb.append(keyChangesTable); // Detailed Change Log sb.append("Section 2: Detailed Change Log\n\n"); sb.append("Changes Summary\n"); - String detailedSummary = generateDetailedSummary(fromJson, toJson); + String detailedSummary = generateDetailedSummary(normalizedFromJson, normalizedToJson); sb.append(detailedSummary).append("\n"); - sb.append(generateDiff(fromJson, toJson)); + sb.append(generateDiff(normalizedFromJson, normalizedToJson)); + + // Section 3: Skipped Checks (not present in both reports) + if (!normalized.onlyInFrom.isEmpty() || !normalized.onlyInTo.isEmpty()) { + sb.append("\nSection 3: Skipped Checks\n\n"); + sb.append("The following checks could not be compared because they were not present in both reports.\n\n"); + if (!normalized.onlyInFrom.isEmpty()) { + sb.append("Only in 'From' report (").append(fromReport.getLastModified()).append("):\n"); + for (String name : normalized.onlyInFrom) { + sb.append(" - ").append(name).append("\n"); + } + sb.append("\n"); + } + if (!normalized.onlyInTo.isEmpty()) { + sb.append("Only in 'To' report (").append(toReport.getLastModified()).append("):\n"); + for (String name : normalized.onlyInTo) { + sb.append(" - ").append(name).append("\n"); + } + sb.append("\n"); + } + } return sb.toString(); } @@ -589,8 +722,135 @@ private String formatBytes(long bytes) { return (bytes / (1024 * 1024 * 1024)) + " GB"; } + /** + * Resolve a field path with attribute selectors to a JSON value. + *

+ * Supports XPath-like selector syntax for matching array elements by a named field: + *

+     *   /checks/[name=General Information]/report/publishedItems
+     * 
+ * The segment {@code [name=General Information]} means: find the element in the {@code checks} + * array whose {@code "name"} field equals {@code "General Information"}. + *

+ * Regular path segments (e.g. {@code /report/collectionsSizesInfo/totalSize}) are resolved + * as standard JSON object field traversal. Numeric segments (e.g. {@code /0}) are resolved + * as array indices. + * + * @param rootNode the root JSON node to resolve against + * @param fieldPath the selector path, e.g. + * {@code /checks/[name=Item summary]/report/communitiesCount} + * @return the resolved {@link JsonNode}, or {@code null} if not found + */ + private JsonNode resolveFieldPath(JsonNode rootNode, String fieldPath) { + if (fieldPath == null || rootNode == null) { + return null; + } + + // Remove leading slash and split into segments + String path = fieldPath.startsWith("/") ? fieldPath.substring(1) : fieldPath; + // Split carefully: we need to handle segments like [name=General Information] + // which contain spaces but no slashes + List segments = splitPathSegments(path); + + JsonNode current = rootNode; + for (String segment : segments) { + if (current == null) { + return null; + } + + if (segment.startsWith("[") && segment.endsWith("]")) { + // Attribute selector, e.g. [name=General Information] + // The previous segment should have navigated us to an array node + if (!current.isArray()) { + return null; + } + String selectorContent = segment.substring(1, segment.length() - 1); + int eqIndex = selectorContent.indexOf('='); + if (eqIndex < 0) { + return null; + } + String attrName = selectorContent.substring(0, eqIndex).trim(); + String attrValue = selectorContent.substring(eqIndex + 1).trim(); + + // Find matching element in the array + JsonNode matched = null; + for (JsonNode element : current) { + JsonNode attrNode = element.get(attrName); + if (attrNode != null && attrValue.equals(attrNode.asText())) { + matched = element; + break; + } + } + current = matched; + } else if (current.isArray() && segment.matches("\\d+")) { + // Numeric index into array + int index = Integer.parseInt(segment); + current = (index >= 0 && index < current.size()) ? current.get(index) : null; + } else { + // Regular object field + current = current.get(segment); + } + } + + return current; + } + + /** + * Split a path string into segments, keeping bracket selectors as single segments. + * For example, {@code "checks/[name=General Information]/report/directoryStats/0/size_bytes"} + * becomes: {@code ["checks", "[name=General Information]", "report", "directoryStats", "0", "size_bytes"]}. + * + * @param path the path to split (without leading slash) + * @return list of path segments + */ + private List splitPathSegments(String path) { + List segments = new ArrayList<>(); + int i = 0; + while (i < path.length()) { + if (path.charAt(i) == '[') { + // Find matching closing bracket + int closeBracket = path.indexOf(']', i); + if (closeBracket < 0) { + closeBracket = path.length() - 1; + } + segments.add(path.substring(i, closeBracket + 1)); + i = closeBracket + 1; + // Skip following slash if present + if (i < path.length() && path.charAt(i) == '/') { + i++; + } + } else { + // Regular segment - find next slash or bracket + int nextSlash = path.indexOf('/', i); + int nextBracket = path.indexOf('[', i); + int end; + if (nextSlash < 0 && nextBracket < 0) { + end = path.length(); + } else if (nextSlash < 0) { + end = nextBracket; + } else if (nextBracket < 0) { + end = nextSlash; + } else { + end = Math.min(nextSlash, nextBracket); + } + String segment = path.substring(i, end); + if (!segment.isEmpty()) { + segments.add(segment); + } + i = end; + // Skip slash separator + if (i < path.length() && path.charAt(i) == '/') { + i++; + } + } + } + return segments; + } + /** * Generate enhanced key changes table with dynamic sizing and configurable field names. + * Uses selector-based field resolution that works regardless of check ordering or selection. + * Field paths use XPath-like syntax, e.g. {@code /checks/[name=Item summary]/report/publishedItems}. * * @param oldJson the old JSON report * @param newJson the new JSON report @@ -610,8 +870,15 @@ private String generateEnhancedKeyChangesTable(String oldJson, String newJson, List changes = new ArrayList<>(); for (String fieldPath : fieldOrder) { - JsonNode oldValue = getValueFromPath(oldNode, fieldPath); - JsonNode newValue = getValueFromPath(newNode, fieldPath); + JsonNode oldValue = resolveFieldPath(oldNode, fieldPath); + JsonNode newValue = resolveFieldPath(newNode, fieldPath); + + // Skip fields that don't exist in either report (check not present in both) + boolean oldMissing = oldValue == null || oldValue.isMissingNode(); + boolean newMissing = newValue == null || newValue.isMissingNode(); + if (oldMissing && newMissing) { + continue; + } if (!Objects.equals(getDisplayValue(oldValue), getDisplayValue(newValue))) { String displayName = fieldMappings.getOrDefault(fieldPath, fieldPath); diff --git a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiffScriptConfiguration.java b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiffScriptConfiguration.java index da1933590bb2..4c89469d7b16 100644 --- a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiffScriptConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiffScriptConfiguration.java @@ -33,14 +33,15 @@ public void setDspaceRunnableClass(Class dspaceRunnableClass) { public Options getOptions() { if (options == null) { Options options = new Options(); - options.addOption("i", "info", false, + options.addOption("h", "help", false, "Show help information."); options.addOption("e", "email", true, "Send report to this email address."); options.getOption("e").setType(String.class); options.addOption("c", "check", true, - String.format("Perform only specific check (use index from 0 to %d, " + - "otherwise perform default checks).", HealthReport.getNumberOfChecks() - 1)); + String.format("Filter comparison to a specific check by index (0 to %d). " + + "Only the specified check will be compared from both reports.", + HealthReport.getNumberOfChecks() - 1)); options.getOption("c").setType(String.class); options.addOption("d", "dates", false, "Show all report dates"); diff --git a/dspace-api/src/main/resources/report-diff-fields.json b/dspace-api/src/main/resources/report-diff-fields.json index a1e1f687aefc..51f668f79d96 100644 --- a/dspace-api/src/main/resources/report-diff-fields.json +++ b/dspace-api/src/main/resources/report-diff-fields.json @@ -1,54 +1,54 @@ { "fieldMappings": { - "/checks/0/report/directoryStats/0/size_bytes": "Assetstore Size (bytes)", - "/checks/0/report/directoryStats/1/size_bytes": "Log Directory Size (bytes)", - "/checks/1/report/communitiesCount": "Communities", - "/checks/1/report/collectionsCount": "Collections", - "/checks/1/report/collectionsSizesInfo/totalSize": "Total Content Size", - "/checks/1/report/itemsCount": "Items", - "/checks/1/report/publishedItems": "Published Items", - "/checks/1/report/notPublishedItems": "Unpublished Items", - "/checks/1/report/withdrawnItems": "Withdrawn Items", - "/checks/1/report/workspaceItemsCount": "Workspace Items", - "/checks/1/report/waitingForApproval": "Workflow Items", - "/checks/1/report/bitstreamsCount": "Bitstreams", - "/checks/1/report/bundlesCount": "Bundles", - "/checks/1/report/collectionsSizesInfo/orphanBitstreamsCount": "Orphaned Bitstreams", - "/checks/1/report/collectionsSizesInfo/deletedBitstreams": "Deleted Bitstreams", - "/checks/1/report/metadataValuesCount": "Metadata Values", - "/checks/1/report/handlesCount": "Handles", - "/checks/1/report/ePersonsCount": "Users", - "/checks/1/report/groupsCount": "Groups", - "/checks/2/report/selfRegistered": "Self Registered Users", - "/checks/2/report/subscribers": "Subscribers", - "/checks/2/report/subscribedCollections": "Subscribed Collections", - "/checks/2/report/emptyGroups": "Empty Groups", - "/checks/3/report/licenses": "Licenses" + "/checks/[name=General Information]/report/directoryStats/0/size_bytes": "Assetstore Size (bytes)", + "/checks/[name=General Information]/report/directoryStats/1/size_bytes": "Log Directory Size (bytes)", + "/checks/[name=Item summary]/report/communitiesCount": "Communities", + "/checks/[name=Item summary]/report/collectionsCount": "Collections", + "/checks/[name=Item summary]/report/collectionsSizesInfo/totalSize": "Total Content Size", + "/checks/[name=Item summary]/report/itemsCount": "Items", + "/checks/[name=Item summary]/report/publishedItems": "Published Items", + "/checks/[name=Item summary]/report/notPublishedItems": "Unpublished Items", + "/checks/[name=Item summary]/report/withdrawnItems": "Withdrawn Items", + "/checks/[name=Item summary]/report/workspaceItemsCount": "Workspace Items", + "/checks/[name=Item summary]/report/waitingForApproval": "Workflow Items", + "/checks/[name=Item summary]/report/bitstreamsCount": "Bitstreams", + "/checks/[name=Item summary]/report/bundlesCount": "Bundles", + "/checks/[name=Item summary]/report/collectionsSizesInfo/orphanBitstreamsCount": "Orphaned Bitstreams", + "/checks/[name=Item summary]/report/collectionsSizesInfo/deletedBitstreams": "Deleted Bitstreams", + "/checks/[name=Item summary]/report/metadataValuesCount": "Metadata Values", + "/checks/[name=Item summary]/report/handlesCount": "Handles", + "/checks/[name=Item summary]/report/ePersonsCount": "Users", + "/checks/[name=Item summary]/report/groupsCount": "Groups", + "/checks/[name=User summary]/report/selfRegistered": "Self Registered Users", + "/checks/[name=User summary]/report/subscribers": "Subscribers", + "/checks/[name=User summary]/report/subscribedCollections": "Subscribed Collections", + "/checks/[name=User summary]/report/emptyGroups": "Empty Groups", + "/checks/[name=License summary]/report/licenses": "Licenses" }, "fieldOrder": [ - "/checks/0/report/directoryStats/0/size_bytes", - "/checks/0/report/directoryStats/1/size_bytes", - "/checks/1/report/communitiesCount", - "/checks/1/report/collectionsCount", - "/checks/1/report/collectionsSizesInfo/totalSize", - "/checks/1/report/itemsCount", - "/checks/1/report/publishedItems", - "/checks/1/report/notPublishedItems", - "/checks/1/report/withdrawnItems", - "/checks/1/report/workspaceItemsCount", - "/checks/1/report/waitingForApproval", - "/checks/1/report/bitstreamsCount", - "/checks/1/report/bundlesCount", - "/checks/1/report/collectionsSizesInfo/orphanBitstreamsCount", - "/checks/1/report/collectionsSizesInfo/deletedBitstreams", - "/checks/1/report/metadataValuesCount", - "/checks/1/report/handlesCount", - "/checks/1/report/ePersonsCount", - "/checks/1/report/groupsCount", - "/checks/2/report/selfRegistered", - "/checks/2/report/subscribers", - "/checks/2/report/subscribedCollections", - "/checks/2/report/emptyGroups", - "/checks/3/report/licenses" + "/checks/[name=General Information]/report/directoryStats/0/size_bytes", + "/checks/[name=General Information]/report/directoryStats/1/size_bytes", + "/checks/[name=Item summary]/report/communitiesCount", + "/checks/[name=Item summary]/report/collectionsCount", + "/checks/[name=Item summary]/report/collectionsSizesInfo/totalSize", + "/checks/[name=Item summary]/report/itemsCount", + "/checks/[name=Item summary]/report/publishedItems", + "/checks/[name=Item summary]/report/notPublishedItems", + "/checks/[name=Item summary]/report/withdrawnItems", + "/checks/[name=Item summary]/report/workspaceItemsCount", + "/checks/[name=Item summary]/report/waitingForApproval", + "/checks/[name=Item summary]/report/bitstreamsCount", + "/checks/[name=Item summary]/report/bundlesCount", + "/checks/[name=Item summary]/report/collectionsSizesInfo/orphanBitstreamsCount", + "/checks/[name=Item summary]/report/collectionsSizesInfo/deletedBitstreams", + "/checks/[name=Item summary]/report/metadataValuesCount", + "/checks/[name=Item summary]/report/handlesCount", + "/checks/[name=Item summary]/report/ePersonsCount", + "/checks/[name=Item summary]/report/groupsCount", + "/checks/[name=User summary]/report/selfRegistered", + "/checks/[name=User summary]/report/subscribers", + "/checks/[name=User summary]/report/subscribedCollections", + "/checks/[name=User summary]/report/emptyGroups", + "/checks/[name=License summary]/report/licenses" ] } \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java index ce3e8b168be8..77a2293ba7d9 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java @@ -111,7 +111,7 @@ private String formatDate(Date date) { @Test public void testHelpInformation() throws Exception { TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); - String[] args = new String[] { "report-diff", "-i" }; + String[] args = new String[] { "report-diff", "-h" }; ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); List infoMessages = handler.getInfoMessages(); @@ -193,9 +193,8 @@ public void testCompareSpecificCheck() throws Exception { ReportResult report1 = reportResultService.create(context); report1.setType("healthcheck"); - report1.setValue("{\"checks\":[{\"name\":\"Check1\",\"report\":{\"key\":\"value1\"}},{\"name\":\"Check2\"" + + report1.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value1\"}},{\"name\":\"Item summary\"" + ",\"report\":{\"key\":\"other\"}}]}"); - report1.setArgs("-c: 0"); reportResultService.update(context, report1); // Force commit and flush to ensure timestamp is set context.commit(); @@ -205,9 +204,8 @@ public void testCompareSpecificCheck() throws Exception { ReportResult report2 = reportResultService.create(context); report2.setType("healthcheck"); - report2.setValue("{\"checks\":[{\"name\":\"Check1\",\"report\":{\"key\":\"value2\"}},{\"name\":\"Check2\"" + + report2.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value2\"}},{\"name\":\"Item summary\"" + ",\"report\":{\"key\":\"other\"}}]}"); - report2.setArgs("-c: 0"); reportResultService.update(context, report2); context.commit(); context.restoreAuthSystemState(); @@ -215,6 +213,7 @@ public void testCompareSpecificCheck() throws Exception { report1 = reportResultService.find(context, report1.getID()); report2 = reportResultService.find(context, report2.getID()); TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + // -c 0 filters comparison to only General Information check String[] args = new String[] { "report-diff", "-f", formatDate(report1.getLastModified()), "-t", formatDate(report2.getLastModified()), "-c", "0" }; ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); @@ -370,7 +369,6 @@ public void testReportDiff() throws Exception { report1.setType("healthcheck"); report1.setValue("{\"checks\":[{\"name\":\"Check1\",\"report\":{\"key\":\"value1\"}},{\"name\":\"Check2\"" + ",\"report\":{\"key\":\"other\"}}]}"); - report1.setArgs("-c: 0"); reportResultService.update(context, report1); // Force commit and flush to ensure timestamp is set @@ -383,7 +381,6 @@ public void testReportDiff() throws Exception { report2.setType("healthcheck"); report2.setValue("{\"checks\":[{\"name\":\"Check1\",\"report\":{\"key\":\"value2\"}},{\"name\":\"Check2\"" + ",\"report\":{\"key\":\"other\"}}]}"); - report2.setArgs("-c: 0"); reportResultService.update(context, report2); context.commit(); context.restoreAuthSystemState(); @@ -436,10 +433,10 @@ public void testShowDatesLimit() throws Exception { public void testProfessionalReportFormat() throws Exception { context.turnOffAuthorisationSystem(); - // Create first report with sample health data + // Create first report with sample health data using real check name ReportResult report1 = reportResultService.create(context); report1.setType("healthcheck"); - report1.setValue("{\"checks\":[{\"name\":\"HealthCheck\",\"report\":{" + + report1.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{" + "\"publishedItems\":0," + "\"ePersonsCount\":1," + "\"communitiesCount\":0," + @@ -456,7 +453,7 @@ public void testProfessionalReportFormat() throws Exception { // Create second report with changes ReportResult report2 = reportResultService.create(context); report2.setType("healthcheck"); - report2.setValue("{\"checks\":[{\"name\":\"HealthCheck\",\"report\":{" + + report2.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{" + "\"publishedItems\":2," + "\"ePersonsCount\":1721," + "\"communitiesCount\":9," + @@ -584,10 +581,10 @@ public void testCalculateTimePeriod() throws Exception { public void testEnhancedKeyChangesTable() throws Exception { context.turnOffAuthorisationSystem(); - // Create first report with sample health data + // Create first report with sample health data using real check names ReportResult report1 = reportResultService.create(context); report1.setType("healthcheck"); - report1.setValue("{\"checks\":[{\"name\":\"Info summary\",\"report\":{}}," + + report1.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{}}," + "{\"name\":\"Item summary\",\"report\":{" + "\"publishedItems\":10," + "\"ePersonsCount\":5," + @@ -604,7 +601,7 @@ public void testEnhancedKeyChangesTable() throws Exception { // Create second report with changes ReportResult report2 = reportResultService.create(context); report2.setType("healthcheck"); - report2.setValue("{\"checks\":[{\"name\":\"Info summary\",\"report\":{}}," + + report2.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{}}," + "{\"name\":\"Item summary\",\"report\":{" + "\"publishedItems\":25," + "\"ePersonsCount\":8," + @@ -709,4 +706,53 @@ public void testSizeDifferenceFormatting() throws Exception { assertThat("Size differences should show actual size change (9 KB).'", hasSizeDifference, org.hamcrest.Matchers.is(true)); } + + @Test + public void testSkippedChecksSection() throws Exception { + context.turnOffAuthorisationSystem(); + + // Create report1 with checks A and B + ReportResult report1 = reportResultService.create(context); + report1.setType("healthcheck"); + report1.setValue("{\"checks\":[" + + "{\"name\":\"General Information\",\"report\":{\"key\":\"val1\"}}," + + "{\"name\":\"Only In From\",\"report\":{\"key\":\"fromOnly\"}}" + + "]}"); + reportResultService.update(context, report1); + context.commit(); + + Thread.sleep(1000); + + // Create report2 with checks A and C (B missing, C new) + ReportResult report2 = reportResultService.create(context); + report2.setType("healthcheck"); + report2.setValue("{\"checks\":[" + + "{\"name\":\"General Information\",\"report\":{\"key\":\"val2\"}}," + + "{\"name\":\"Only In To\",\"report\":{\"key\":\"toOnly\"}}" + + "]}"); + reportResultService.update(context, report2); + context.commit(); + context.restoreAuthSystemState(); + + report1 = reportResultService.find(context, report1.getID()); + report2 = reportResultService.find(context, report2.getID()); + + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "report-diff", "-f", formatDate(report1.getLastModified()), + "-t", formatDate(report2.getLastModified()) }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + List infoMessages = handler.getInfoMessages(); + + // Should show the skipped checks section + assertThat(infoMessages, hasItem(containsString("Skipped Checks"))); + assertThat(infoMessages, hasItem(containsString("not present in both reports"))); + assertThat(infoMessages, hasItem(containsString("Only In From"))); + assertThat(infoMessages, hasItem(containsString("Only In To"))); + + // The common check "General Information" should be compared normally + assertThat("Should contain diff for common check", + hasDiffOperation(infoMessages, "REPLACE", CHECK_KEY_PATH), + org.hamcrest.Matchers.is(true)); + } } diff --git a/dspace/config/launcher.xml b/dspace/config/launcher.xml index 3853b4e2fabd..dba2f2f73e1b 100644 --- a/dspace/config/launcher.xml +++ b/dspace/config/launcher.xml @@ -7,13 +7,7 @@ org.dspace.storage.bitstore.BitStoreMigrate - - healthcheck - Create health check report - - org.dspace.health.Report - - + checker Run the checksum checker From 1d714c16f0229bd8a158e8339e51d69657c7c870 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 08:02:38 +0100 Subject: [PATCH 02/11] Fix ReportDiff setup and date validation --- .../dspace/app/healthreport/HealthReport.java | 7 +-- .../org/dspace/app/reportdiff/ReportDiff.java | 61 ++++++------------- 2 files changed, 21 insertions(+), 47 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java index d93a88b0b976..4eba9f6b2d97 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java @@ -259,13 +259,12 @@ public void printHelp() { * This method is used to print the options used for the report. */ private String printCommandlineOptions() { - // Return key-value pairs of options StringBuilder options = new StringBuilder(); for (Option option : commandLine.getOptions()) { String key = option.getOpt(); - String value = commandLine.getOptionValue(key); - if (value != null) { - options.append(String.format(" -%s: %s\n", key, value)); + String[] values = commandLine.getOptionValues(key); + if (values != null) { + options.append(String.format(" -%s: %s\n", key, String.join(", ", values))); } else { options.append(String.format(" -%s\n", key)); } diff --git a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java index 215fed799f80..72d232040dd5 100644 --- a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java +++ b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java @@ -135,6 +135,11 @@ private void loadFieldConfiguration() { fieldOrder.add(fieldNode.asText()); } } + } else { + log.warn("Report diff fields configuration '{}' not found on the classpath. " + + "Field mappings will be empty.", REPORT_DIFF_FIELDS); + fieldMappings = new LinkedHashMap<>(); + fieldOrder = new ArrayList<>(); } } catch (IOException e) { log.error("Error loading report diff fields configuration '{}': {}. Using empty configuration.", @@ -269,18 +274,25 @@ private Date parseDateOption(String optionValue) { } /** - * Validate the date range specified by `from` and `to`. - * If the dates are invalid, log an error and return false. - * If both dates are set, ensure that `to` is not before `from`. + * Validate the date range specified by {@code from} and {@code to}. + * Logs a specific error if either date is missing (not provided and not resolvable from the DB) + * or if {@code to} precedes {@code from}. * - * @return true if the date range is valid, false otherwise + * @return true if both dates are set and {@code to} is not before {@code from}, false otherwise */ private boolean validateDateRange() { if (to != null && from != null && to.before(from)) { handler.logError("The 'to' date cannot be before the 'from' date."); return false; - } else if (Objects.isNull(from) || Objects.isNull(to)) { - handler.logError("Both 'from' and 'to' dates must be specified when using a specific check."); + } + if (Objects.isNull(from)) { + handler.logError("The 'from' date could not be determined. " + + "Specify it with -f or ensure at least 2 reports exist in the database."); + return false; + } + if (Objects.isNull(to)) { + handler.logError("The 'to' date could not be determined. " + + "Specify it with -t or ensure at least 1 report exists in the database."); return false; } return true; @@ -939,43 +951,6 @@ private String generateEnhancedKeyChangesTable(String oldJson, String newJson, return table.toString(); } - /** - * Get value from JSON node using path notation (JSON Pointer style). - */ - private JsonNode getValueFromPath(JsonNode node, String path) { - try { - // Use Jackson's JSON Pointer functionality for paths like /checks/0/report/publishedItems - if (path.startsWith("/")) { - return node.at(path); - } - - // Fallback for simple dot notation paths - return getValueFromSimplePath(node, path); - } catch (Exception e) { - return null; - } - } - - /** - * Get value from simple dot-notation path. - */ - private JsonNode getValueFromSimplePath(JsonNode node, String path) { - if (path.isEmpty()) { - return node; - } - String[] parts = path.split("\\."); - JsonNode current = node; - - for (String part : parts) { - if (current == null || !current.has(part)) { - return null; - } - current = current.get(part); - } - - return current; - } - /** * Simple data class for table rows. */ From 2d36bbfae822a97a2979169c7f3260a9eb2b7a07 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 08:06:33 +0100 Subject: [PATCH 03/11] fix failed integration test --- .../src/test/java/org/dspace/scripts/ReportDiffIT.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java index 77a2293ba7d9..3b3699d5c1a1 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java @@ -659,12 +659,14 @@ public void testSizeDifferenceFormatting() throws Exception { // Create reports with size differences from "0 bytes" to "9 KB" String fromReportJson = "{ \"checks\": [" + - " { \"report\": { \"totalSize\": \"0 bytes\" } }" + + " { \"name\": \"Item summary\", \"report\": {" + + " \"collectionsSizesInfo\": { \"totalSize\": \"0 bytes\" } } }" + "]}"; String toReportJson = "{ \"checks\": [" + - " { \"report\": { \"totalSize\": \"9 KB\" } }" + + " { \"name\": \"Item summary\", \"report\": {" + + " \"collectionsSizesInfo\": { \"totalSize\": \"9 KB\" } } }" + "]}"; ReportResult fromReport = reportResultService.create(context); From aece190f5e3cfb55e98e9d0a29bdb77bc7be0719 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 08:25:45 +0100 Subject: [PATCH 04/11] checkstyle --- .../src/test/java/org/dspace/scripts/ReportDiffIT.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java index 3b3699d5c1a1..1fbe025a6ae2 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java @@ -193,8 +193,8 @@ public void testCompareSpecificCheck() throws Exception { ReportResult report1 = reportResultService.create(context); report1.setType("healthcheck"); - report1.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value1\"}},{\"name\":\"Item summary\"" + - ",\"report\":{\"key\":\"other\"}}]}"); + report1.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value1\"}}," + + "{\"name\":\"Item summary\",\"report\":{\"key\":\"other\"}}]}"); reportResultService.update(context, report1); // Force commit and flush to ensure timestamp is set context.commit(); @@ -204,8 +204,8 @@ public void testCompareSpecificCheck() throws Exception { ReportResult report2 = reportResultService.create(context); report2.setType("healthcheck"); - report2.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value2\"}},{\"name\":\"Item summary\"" + - ",\"report\":{\"key\":\"other\"}}]}"); + report2.setValue("{\"checks\":[{\"name\":\"General Information\",\"report\":{\"key\":\"value2\"}}," + + "{\"name\":\"Item summary\",\"report\":{\"key\":\"other\"}}]}"); reportResultService.update(context, report2); context.commit(); context.restoreAuthSystemState(); From f7de663bbc3aa8f7d2d9d86bbc2b4a6280f8696f Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:00:49 +0100 Subject: [PATCH 05/11] added tests, used -c 1 2 instead of -c 1 -c 2 --- .../dspace/app/healthreport/HealthReport.java | 2 +- .../HealthReportScriptConfiguration.java | 2 +- .../org/dspace/scripts/HealthReportIT.java | 116 ++++++++++++++++++ .../java/org/dspace/scripts/ReportDiffIT.java | 71 +++++++++++ 4 files changed, 189 insertions(+), 2 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java index 4eba9f6b2d97..52d346b5d0ee 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java @@ -247,7 +247,7 @@ public void printHelp() { " -h, --help Show help information\n" + " -e, --email Send report to specified email address\n" + " -c, --check Perform specific check(s) by index (0-" + (getNumberOfChecks() - 1) + - "). Can be used multiple times, e.g. -c 0 -c 3 -c 4\n" + + "). Accepts multiple space-separated values, e.g. -c 0 3 4\n" + " -f, --for Specify the last N days to consider (positive integer)\n" + " -r, --report Specify a file to save the report\n\n" + "Available checks:\n" + checksNamesToString() + "\n" diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java index bd8245f34363..9f184a2e2b72 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java @@ -39,7 +39,7 @@ public Options getOptions() { options.getOption("e").setType(String.class); options.addOption("c", "check", true, String.format("Perform specific check(s) by index (0 to %d). " + - "Can be used multiple times, e.g. -c 0 -c 3 -c 4.", + "Accepts multiple space-separated values, e.g. -c 0 3 4.", HealthReport.getNumberOfChecks() - 1)); options.getOption("c").setType(String.class); options.getOption("c").setArgs(Integer.MAX_VALUE); diff --git a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java index e77a907ef731..7605c3d17856 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java @@ -14,13 +14,16 @@ import static org.hamcrest.Matchers.hasSize; import java.io.ByteArrayInputStream; +import java.io.File; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.HashSet; import java.util.List; import java.util.Set; import org.dspace.AbstractIntegrationTestWithDatabase; +import org.dspace.app.healthreport.HealthReport; import org.dspace.app.launcher.ScriptLauncher; import org.dspace.app.scripts.handler.impl.TestDSpaceRunnableHandler; import org.dspace.builder.CollectionBuilder; @@ -139,4 +142,117 @@ public void testLicenseCheck() throws Exception { assertThat(messages, hasItem(containsString("UUIDs of items without license bundle:"))); assertThat(messages, hasItem(containsString("PUB"))); } + + /** + * Verifies that -h/--help prints help text and does not run any checks. + * use -h instead of -i. + */ + @Test + public void testHelpOption() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "health-report", "-h" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), empty()); + List messages = handler.getInfoMessages(); + assertThat(messages, hasItem(containsString("HELP"))); + assertThat(messages, hasItem(containsString("Available checks:"))); + } + + /** + * Verifies that multiple -c values run only the specified checks. + * Covers Issue #1328 item 2: support multiple check selection (e.g. -c 0 -c 3). + */ + @Test + public void testMultipleChecks() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + // Run only check 0 (General Information) and check 3 (License summary): space-separated + String[] args = new String[] { "health-report", "-c", "0", "3" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), empty()); + List messages = handler.getInfoMessages(); + assertThat(messages, hasItem(containsString("HEALTH REPORT:"))); + assertThat(messages, hasItem(containsString("General Information"))); + assertThat(messages, hasItem(containsString("License summary"))); + // Item summary (check index 1) should NOT be present + boolean hasItemSummary = messages.stream().anyMatch(m -> m.contains("Item summary:")); + assertThat("Only selected checks should run", hasItemSummary, org.hamcrest.Matchers.is(false)); + } + + /** + * Verifies that an out-of-range -c value causes a script error. + * Covers Issue #1328 item 3: validate -c values. + */ + @Test + public void testInvalidCheckOutOfRange() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + int maxCheck = HealthReport.getNumberOfChecks() - 1; + String[] args = new String[] { "health-report", "-c", String.valueOf(maxCheck + 1) }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), + hasItem(containsString("Must be an integer from 0 to " + maxCheck))); + } + + /** + * Verifies that a non-integer -c value causes a script error. + * Covers Issue #1328 item 3: validate -c values. + */ + @Test + public void testInvalidCheckNonInteger() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "health-report", "-c", "abc" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), + hasItem(containsString("It has to be an integer number from 0 to"))); + } + + /** + * Verifies that a non-positive -f value (zero) causes a script error. + * Covers Issue #1328 item 3: validate -f must be positive integer (greater than 0). + */ + @Test + public void testInvalidForDaysZero() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "health-report", "-f", "0" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), + hasItem(containsString("Must be a positive integer (greater than 0)"))); + } + + /** + * Verifies that a non-integer -f value causes a script error. + * Covers Issue #1328 item 3: validate -f must be integer. + */ + @Test + public void testInvalidForDaysNonInteger() throws Exception { + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "health-report", "-f", "notanumber" }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), + hasItem(containsString("Must be a positive integer"))); + } + + /** + * Verifies that -r/--report saves report output to the specified file. + * -o/--output renamed to -r/--report. + */ + @Test + public void testReportFileSaved() throws Exception { + File tempFile = File.createTempFile("health-report-test-", ".txt"); + tempFile.deleteOnExit(); + + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "health-report", "-r", tempFile.getAbsolutePath() }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + assertThat(handler.getErrorMessages(), empty()); + assertThat("Report file must exist after -r option", tempFile.exists(), org.hamcrest.Matchers.is(true)); + String content = Files.readString(tempFile.toPath()); + assertThat("Report file must contain health report header", content, containsString("HEALTH REPORT:")); + } } \ No newline at end of file diff --git a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java index 1fbe025a6ae2..38f63edfde64 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java @@ -757,4 +757,75 @@ public void testSkippedChecksSection() throws Exception { hasDiffOperation(infoMessages, "REPLACE", CHECK_KEY_PATH), org.hamcrest.Matchers.is(true)); } + + /** + * Verifies Issue #1334: when one report has more checks than the other, the diff + * compares only the intersection and does NOT show null values for fields in + * the common check. + */ + @Test + public void testIntersectionComparisonNoNullsForCommonChecks() throws Exception { + context.turnOffAuthorisationSystem(); + + // report1 has 5 checks (all) + String allChecksJson = "{\"checks\":[" + + "{\"name\":\"General Information\",\"report\":{\"directoryStats\":[" + + " {\"size_bytes\":1000}," + + " {\"size_bytes\":2000}" + + "]}}," + + "{\"name\":\"Item summary\",\"report\":{\"itemsCount\":100,\"publishedItems\":80}}," + + "{\"name\":\"User summary\",\"report\":{\"selfRegistered\":5}}," + + "{\"name\":\"License summary\",\"report\":{\"licenses\":3}}," + + "{\"name\":\"Embargo check\",\"report\":{}}" + + "]}"; + + // report2 has only 1 check (Item summary) + String singleCheckJson = "{\"checks\":[" + + "{\"name\":\"Item summary\",\"report\":{\"itemsCount\":120,\"publishedItems\":100}}" + + "]}"; + + ReportResult report1 = reportResultService.create(context); + report1.setType("healthcheck"); + report1.setValue(allChecksJson); + reportResultService.update(context, report1); + context.commit(); + + Thread.sleep(1000); + + ReportResult report2 = reportResultService.create(context); + report2.setType("healthcheck"); + report2.setValue(singleCheckJson); + reportResultService.update(context, report2); + context.commit(); + context.restoreAuthSystemState(); + + report1 = reportResultService.find(context, report1.getID()); + report2 = reportResultService.find(context, report2.getID()); + + TestDSpaceRunnableHandler handler = new TestDSpaceRunnableHandler(); + String[] args = new String[] { "report-diff", "-f", formatDate(report1.getLastModified()), + "-t", formatDate(report2.getLastModified()) }; + ScriptLauncher.handleScript(args, ScriptLauncher.getConfig(kernelImpl), handler, kernelImpl); + + List infoMessages = handler.getInfoMessages(); + + // The diff output must not contain null values for the common "Item summary" check + boolean hasNullInKeyChanges = infoMessages.stream() + .anyMatch(msg -> msg.contains("| null") || msg.contains("null |")); + assertThat("Key Changes table must not show null for fields present in both reports", + hasNullInKeyChanges, org.hamcrest.Matchers.is(false)); + + // The checks only present in report1 (General Information, User summary, License summary, Embargo check) + // should appear in the Skipped Checks section + assertThat(infoMessages, hasItem(containsString("Skipped Checks"))); + assertThat(infoMessages, hasItem(containsString("General Information"))); + assertThat(infoMessages, hasItem(containsString("User summary"))); + assertThat(infoMessages, hasItem(containsString("License summary"))); + assertThat(infoMessages, hasItem(containsString("Embargo check"))); + + // Item summary - the common check - must show actual diff (itemsCount 100 -> 120) + assertThat("Should contain diff for common Item summary check", + hasDiffOperation(infoMessages, "REPLACE", "/checks/0/report/itemsCount"), + org.hamcrest.Matchers.is(true)); + } } From 1b891a2cb3c18bea25fa3826823ecc6e50cad317 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:08:18 +0100 Subject: [PATCH 06/11] used UNLIMITED_VALUES unstead of MAX_VALUE --- .../app/healthreport/HealthReportScriptConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java index 9f184a2e2b72..257c7e06f72f 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReportScriptConfiguration.java @@ -42,7 +42,7 @@ public Options getOptions() { "Accepts multiple space-separated values, e.g. -c 0 3 4.", HealthReport.getNumberOfChecks() - 1)); options.getOption("c").setType(String.class); - options.getOption("c").setArgs(Integer.MAX_VALUE); + options.getOption("c").setArgs(org.apache.commons.cli.Option.UNLIMITED_VALUES); options.addOption("f", "for", true, "Report for last N days (positive integer). Used only in general information for now."); options.getOption("f").setType(String.class); From 32457afe4d31adb5133ac81e3bcade10ceddc18f Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:09:22 +0100 Subject: [PATCH 07/11] fix doc --- .../src/main/java/org/dspace/app/healthreport/HealthReport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java index 52d346b5d0ee..5a86c46d470a 100644 --- a/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java +++ b/dspace-api/src/main/java/org/dspace/app/healthreport/HealthReport.java @@ -109,7 +109,7 @@ public void setup() throws ParseException { } // `-c`: Check, perform only specific checks by index (0-`getNumberOfChecks()`). - // Supports multiple values e.g. -c 0 -c 3 -c 4 + // Supports multiple values e.g. -c 0 3 4 if (commandLine.hasOption('c')) { String[] checkOptions = commandLine.getOptionValues('c'); for (String checkOption : checkOptions) { From ae824e8db71715dcc85f314270d54a5945c9c617 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:18:42 +0100 Subject: [PATCH 08/11] fix doc --- .../src/test/java/org/dspace/scripts/HealthReportIT.java | 8 +++----- .../src/test/java/org/dspace/scripts/ReportDiffIT.java | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java index 7605c3d17856..d99f443fddc0 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java @@ -161,7 +161,7 @@ public void testHelpOption() throws Exception { /** * Verifies that multiple -c values run only the specified checks. - * Covers Issue #1328 item 2: support multiple check selection (e.g. -c 0 -c 3). + * Support multiple check selection (e.g. -c 0 -c 3). */ @Test public void testMultipleChecks() throws Exception { @@ -182,7 +182,6 @@ public void testMultipleChecks() throws Exception { /** * Verifies that an out-of-range -c value causes a script error. - * Covers Issue #1328 item 3: validate -c values. */ @Test public void testInvalidCheckOutOfRange() throws Exception { @@ -197,7 +196,6 @@ public void testInvalidCheckOutOfRange() throws Exception { /** * Verifies that a non-integer -c value causes a script error. - * Covers Issue #1328 item 3: validate -c values. */ @Test public void testInvalidCheckNonInteger() throws Exception { @@ -211,7 +209,7 @@ public void testInvalidCheckNonInteger() throws Exception { /** * Verifies that a non-positive -f value (zero) causes a script error. - * Covers Issue #1328 item 3: validate -f must be positive integer (greater than 0). + * Validate -f must be positive integer (greater than 0). */ @Test public void testInvalidForDaysZero() throws Exception { @@ -225,7 +223,7 @@ public void testInvalidForDaysZero() throws Exception { /** * Verifies that a non-integer -f value causes a script error. - * Covers Issue #1328 item 3: validate -f must be integer. + * Validate -f must be integer. */ @Test public void testInvalidForDaysNonInteger() throws Exception { diff --git a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java index 38f63edfde64..764866bb0fd7 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/ReportDiffIT.java @@ -759,7 +759,7 @@ public void testSkippedChecksSection() throws Exception { } /** - * Verifies Issue #1334: when one report has more checks than the other, the diff + * When one report has more checks than the other, the diff * compares only the intersection and does NOT show null values for fields in * the common check. */ From f7972098d25f1f3dc5e42def1e3503703746ad69 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 10:58:36 +0100 Subject: [PATCH 09/11] improved doc --- .../src/main/java/org/dspace/app/reportdiff/ReportDiff.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java index 72d232040dd5..ff4e24be4148 100644 --- a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java +++ b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java @@ -812,6 +812,10 @@ private JsonNode resolveFieldPath(JsonNode rootNode, String fieldPath) { * For example, {@code "checks/[name=General Information]/report/directoryStats/0/size_bytes"} * becomes: {@code ["checks", "[name=General Information]", "report", "directoryStats", "0", "size_bytes"]}. * + *

Limitation: The parser finds the first {@code ]} after an opening {@code [}, so check + * names that themselves contain bracket characters (e.g., {@code [name=Check [beta]]}) are not + * supported and will produce incorrect segments. Check names must not contain {@code [} or {@code ]}. + * * @param path the path to split (without leading slash) * @return list of path segments */ From a5385b42e1806b06cfc2d585103233f16519f3e1 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 12:02:23 +0100 Subject: [PATCH 10/11] used multilist for -c , removed unused method --- .../org/dspace/app/reportdiff/ReportDiff.java | 40 +++++++++++++------ .../content/ReportResultServiceImpl.java | 6 --- .../dspace/content/dao/ReportResultDAO.java | 11 ----- .../content/dao/impl/ReportResultDAOImpl.java | 14 ------- .../content/service/ReportResultService.java | 10 ----- 5 files changed, 28 insertions(+), 53 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java index ff4e24be4148..66b63258cfb3 100644 --- a/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java +++ b/dspace-api/src/main/java/org/dspace/app/reportdiff/ReportDiff.java @@ -77,9 +77,10 @@ public class ReportDiff extends DSpaceRunnable { private long limit = -1; /** - * `-c`: Check, perform only specific check by index (0-`getNumberOfChecks()`). + * `-c`: Check, perform only specific checks by index (0-`getNumberOfChecks()`). + * Supports multiple values. */ - private int specificCheck = -1; + private List specificChecks = new ArrayList<>(); /** * `-f`: From, specify the start date for the report. @@ -169,12 +170,17 @@ public void setup() throws ParseException { return; } - // `-c`: Check, perform only specific check by index (0-`getNumberOfChecks()`). + // `-c`: Check, perform only specific checks by index (0-`getNumberOfChecks()`). + // Supports multiple values e.g. -c 0 3 4 if (commandLine.hasOption('c')) { - specificCheck = parseCheckOption(commandLine.getOptionValue('c')); - if (specificCheck == -1) { - // Error already logged in parseCheckOption - return; + String[] checkOptions = commandLine.getOptionValues('c'); + for (String checkOption : checkOptions) { + int parsedCheck = parseCheckOption(checkOption); + if (parsedCheck == -1) { + // Error already logged in parseCheckOption + return; + } + specificChecks.add(parsedCheck); } } @@ -481,12 +487,16 @@ private NormalizationResult normalizeReportsToIntersection(String fromJson, Stri List commonNames = new ArrayList<>(fromCheckMap.keySet()); commonNames.retainAll(toCheckMap.keySet()); - // If specificCheck is set, further filter to only that check name - if (specificCheck != -1) { - String targetCheckName = HealthReport.getCheckName(specificCheck); - if (targetCheckName != null) { - commonNames.retainAll(java.util.Collections.singletonList(targetCheckName)); + // If specificChecks are set, further filter to only those check names + if (!specificChecks.isEmpty()) { + List targetCheckNames = new ArrayList<>(); + for (int checkIndex : specificChecks) { + String targetCheckName = HealthReport.getCheckName(checkIndex); + if (targetCheckName != null) { + targetCheckNames.add(targetCheckName); + } } + commonNames.retainAll(targetCheckNames); } if (commonNames.isEmpty()) { @@ -499,6 +509,12 @@ private NormalizationResult normalizeReportsToIntersection(String fromJson, Stri List onlyInTo = new ArrayList<>(toCheckMap.keySet()); onlyInTo.removeAll(fromCheckMap.keySet()); + // When specific checks are requested, do not report other checks as skipped + if (!specificChecks.isEmpty()) { + onlyInFrom.clear(); + onlyInTo.clear(); + } + // Build normalized JSON with only the common checks (in the same order) com.fasterxml.jackson.databind.node.ObjectNode normalizedFrom = mapper.createObjectNode(); diff --git a/dspace-api/src/main/java/org/dspace/content/ReportResultServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/ReportResultServiceImpl.java index 4bfa18b668ba..15722dfee6c1 100644 --- a/dspace-api/src/main/java/org/dspace/content/ReportResultServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/ReportResultServiceImpl.java @@ -52,12 +52,6 @@ public ReportResult findByLastModified(Context context, Date lastModified) throw return reportResultDAO.findByLastModified(context, lastModified); } - @Override - public ReportResult findByLastModifiedAndCheckType(Context context, Date lastModified, int checkType) - throws SQLException { - return reportResultDAO.findByLastModifiedAndCheckType(context, lastModified, checkType); - } - @Override public void delete(Context context, ReportResult reportResult) throws SQLException { reportResultDAO.delete(context, reportResult); diff --git a/dspace-api/src/main/java/org/dspace/content/dao/ReportResultDAO.java b/dspace-api/src/main/java/org/dspace/content/dao/ReportResultDAO.java index 8f2aeabb2e7c..6cf00a632728 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/ReportResultDAO.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/ReportResultDAO.java @@ -30,16 +30,5 @@ public interface ReportResultDAO extends GenericDAO { * @throws SQLException if a database error occurs */ ReportResult findByLastModified(Context context, Date lastModified) throws SQLException; - - /** - * Find a ReportResult by its last modified date and check type. - * - * @param context the DSpace context - * @param lastModified the exact last modified date to search for - * @param checkType the check type index to filter by (searches within args field) - * @return the ReportResult matching both criteria, or null if not found - * @throws SQLException if a database error occurs - */ - ReportResult findByLastModifiedAndCheckType(Context context, Date lastModified, int checkType) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/ReportResultDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/ReportResultDAOImpl.java index a85d53f5f4be..f8706565da76 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/ReportResultDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/ReportResultDAOImpl.java @@ -34,18 +34,4 @@ public ReportResult findByLastModified(Context context, Date lastModified) throw return singleResult(query); } - - @Override - public ReportResult findByLastModifiedAndCheckType(Context context, Date lastModified, int checkType) - throws SQLException { - // Use string matching for checkType in args (args contains command line options like "-c: 0") - Query query = createQuery(context, "SELECT r FROM ReportResult r WHERE r.lastModified = :lastModified " + - "AND r.args LIKE :argsPattern"); - - query.setParameter("lastModified", lastModified); - query.setParameter("argsPattern", "%-c: " + checkType + "%"); - query.setHint("org.hibernate.cacheable", Boolean.TRUE); - - return singleResult(query); - } } diff --git a/dspace-api/src/main/java/org/dspace/content/service/ReportResultService.java b/dspace-api/src/main/java/org/dspace/content/service/ReportResultService.java index aa52ec31de99..fabfaeb997a7 100644 --- a/dspace-api/src/main/java/org/dspace/content/service/ReportResultService.java +++ b/dspace-api/src/main/java/org/dspace/content/service/ReportResultService.java @@ -70,16 +70,6 @@ public interface ReportResultService { */ ReportResult findByLastModified(Context context, Date lastModified) throws SQLException; - /** - * Find a ReportResult by last modified date and check type. - * - * @param context the DSpace context - * @param lastModified the exact last modified date to search for - * @param checkType the check type index to filter by - * @return the matching ReportResult, or null if not found - * @throws SQLException if a database error occurs - */ - ReportResult findByLastModifiedAndCheckType(Context context, Date lastModified, int checkType) throws SQLException; /** * Deletes the specified ReportResult instance in the given context. From c47bef0fdd4e762bfff73dcb3e7e4388d9cbb2d0 Mon Sep 17 00:00:00 2001 From: Paurikova2 Date: Wed, 25 Feb 2026 12:21:56 +0100 Subject: [PATCH 11/11] improved doc --- .../src/test/java/org/dspace/scripts/HealthReportIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java index d99f443fddc0..939ad6b41442 100644 --- a/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java +++ b/dspace-api/src/test/java/org/dspace/scripts/HealthReportIT.java @@ -160,8 +160,8 @@ public void testHelpOption() throws Exception { } /** - * Verifies that multiple -c values run only the specified checks. - * Support multiple check selection (e.g. -c 0 -c 3). + * Verifies that multiple values for a single -c option run only the specified checks. + * Supports multiple check selection (e.g. -c 0 3). */ @Test public void testMultipleChecks() throws Exception {