diff --git a/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java b/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java
new file mode 100644
index 00000000..9680c0ce
--- /dev/null
+++ b/src/main/java/org/phoebus/channelfinder/common/SearchParameterMergerUtil.java
@@ -0,0 +1,89 @@
+package org.phoebus.channelfinder.common;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * Utility class for merging search parameters from URL query string and JSON request body.
+ *
+ *
Merging strategy:
+ *
+ *
+ * - For regular search parameters (e.g., ~name, property names, ~tag): values from both URL
+ * and body are added as separate values under the same key in the MultiValueMap.
+ *
- For control parameters (~size, ~from, ~search_after, ~track_total_hits): URL values take
+ * precedence over body values (body values ignored if URL value exists).
+ *
+ */
+public final class SearchParameterMergerUtil {
+
+ private static final String CONTROL_SIZE = "~size";
+ private static final String CONTROL_FROM = "~from";
+ private static final String CONTROL_SEARCH_AFTER = "~search_after";
+ private static final String CONTROL_TRACK_TOTAL_HITS = "~track_total_hits";
+
+ private SearchParameterMergerUtil() {
+ // Utility class - private constructor
+ }
+
+ private static boolean isControlParameter(String key) {
+ return CONTROL_SIZE.equals(key)
+ || CONTROL_FROM.equals(key)
+ || CONTROL_SEARCH_AFTER.equals(key)
+ || CONTROL_TRACK_TOTAL_HITS.equals(key);
+ }
+
+ /**
+ * Merges URL request parameters with body parameters.
+ *
+ * URL parameters take precedence for control keys. For regular search parameters, body values
+ * are added as additional values for the same key in the MultiValueMap.
+ *
+ * @param urlParams URL query parameters (may be null or empty)
+ * @param bodyParams JSON request body as a map (may be null or empty)
+ * @return merged parameters as a MultiValueMap
+ */
+ public static MultiValueMap mergeParameters(
+ MultiValueMap urlParams, Map bodyParams) {
+ MultiValueMap merged = new LinkedMultiValueMap<>();
+
+ // Add URL parameters first
+ if (urlParams != null && !urlParams.isEmpty()) {
+ for (Map.Entry> entry : urlParams.entrySet()) {
+ merged.put(entry.getKey(), new LinkedList<>(entry.getValue()));
+ }
+ }
+
+ // Merge body parameters if present
+ if (bodyParams != null && !bodyParams.isEmpty()) {
+ mergeBodyParams(merged, bodyParams);
+ }
+
+ return merged;
+ }
+
+ private static void mergeBodyParams(
+ MultiValueMap merged, Map bodyParams) {
+ for (Map.Entry entry : bodyParams.entrySet()) {
+ String key = entry.getKey();
+ String bodyValue = entry.getValue();
+
+ if (bodyValue == null || bodyValue.trim().isEmpty()) {
+ continue; // Skip empty body values
+ }
+
+ if (isControlParameter(key)) {
+ // For control parameters, URL takes precedence
+ if (!merged.containsKey(key)) {
+ merged.set(key, bodyValue);
+ }
+ } else {
+ // For regular search parameters, add the body value to the same key.
+ merged.add(key, bodyValue);
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java b/src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java
index 29689d2a..23a54a39 100644
--- a/src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java
+++ b/src/main/java/org/phoebus/channelfinder/repository/ChannelRepository.java
@@ -293,7 +293,7 @@ public Iterable saveAll(Iterable channels) {
}
BulkResponse result;
try {
- result = client.bulk(br.refresh(Refresh.True).build());
+ result = client.bulk(br.refresh(Refresh.WaitFor).build());
// Log errors, if any
if (result.errors()) {
logger.log(Level.SEVERE, TextUtil.BULK_HAD_ERRORS);
@@ -806,8 +806,39 @@ public Scroll scroll(String scrollId, MultiValueMap searchParame
@Override
public void deleteAllById(Iterable extends String> ids) {
- // TODO Auto-generated method stub
+ List idList =
+ StreamSupport.stream(ids.spliterator(), false)
+ .filter(id -> id != null && !id.isBlank())
+ .map(String::valueOf)
+ .distinct()
+ .toList();
+
+ for (int i = 0; i < idList.size(); i += chunkSize) {
+ List chunk = idList.stream().skip(i).limit(chunkSize).toList();
+ BulkRequest.Builder br = new BulkRequest.Builder();
+ for (String id : chunk) {
+ br.operations(op -> op.delete(del -> del.index(esService.getES_CHANNEL_INDEX()).id(id)));
+ }
+ br.refresh(Refresh.True);
+ try {
+ BulkResponse result = client.bulk(br.build());
+ if (result.errors()) {
+ String message = MessageFormat.format(TextUtil.FAILED_TO_DELETE_CHANNEL, chunk);
+ logger.log(Level.SEVERE, TextUtil.BULK_HAD_ERRORS);
+ for (BulkResponseItem item : result.items()) {
+ if (item.error() != null) {
+ logger.log(Level.SEVERE, () -> item.error().reason());
+ }
+ }
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, null);
+ }
+ } catch (IOException e) {
+ String message = MessageFormat.format(TextUtil.FAILED_TO_DELETE_CHANNEL, chunk);
+ logger.log(Level.SEVERE, message, e);
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, e);
+ }
+ }
}
@PreDestroy
diff --git a/src/main/java/org/phoebus/channelfinder/service/ChannelService.java b/src/main/java/org/phoebus/channelfinder/service/ChannelService.java
index 039cca32..9005a12c 100644
--- a/src/main/java/org/phoebus/channelfinder/service/ChannelService.java
+++ b/src/main/java/org/phoebus/channelfinder/service/ChannelService.java
@@ -2,6 +2,7 @@
import com.google.common.collect.Lists;
import java.text.MessageFormat;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -94,14 +95,10 @@ public Channel create(String channelName, Channel channel) {
public Iterable create(Iterable channels) {
requireRole(ROLES.CF_CHANNEL, "channels batch");
- Map existing =
- channelRepository
- .findAllById(
- StreamSupport.stream(channels.spliterator(), true).map(Channel::getName).toList())
- .stream()
- .collect(Collectors.toMap(Channel::getName, c -> c));
+ List channelList = Lists.newArrayList(channels);
+ Map existing = findExistingChannels(channelList);
- for (Channel channel : channels) {
+ for (Channel channel : channelList) {
if (existing.containsKey(channel.getName())) {
requireOwner(existing.get(channel.getName()));
channel.setOwner(existing.get(channel.getName()).getOwner());
@@ -110,11 +107,11 @@ public Iterable create(Iterable channels) {
}
}
- validateChannels(channels);
- channelRepository.deleteAll(channels);
- resetOwnersToExisting(channels);
+ validateChannels(channelList);
+ channelRepository.deleteAll(channelList);
+ resetOwnersToExisting(channelList);
- List created = channelRepository.indexAll(Lists.newArrayList(channels));
+ List created = channelRepository.indexAll(channelList);
channelProcessorService.sendToProcessors(created);
return created;
}
@@ -151,20 +148,22 @@ public Channel update(String channelName, Channel channel) {
public Iterable update(Iterable channels) {
requireRole(ROLES.CF_CHANNEL, "channels batch");
- for (Channel channel : channels) {
- Optional existing = channelRepository.findById(channel.getName());
- if (existing.isPresent()) {
- requireOwner(existing.get());
- channel.setOwner(existing.get().getOwner());
+ List channelList = Lists.newArrayList(channels);
+ Map existing = findExistingChannels(channelList);
+
+ for (Channel channel : channelList) {
+ if (existing.containsKey(channel.getName())) {
+ requireOwner(existing.get(channel.getName()));
+ channel.setOwner(existing.get(channel.getName()).getOwner());
} else {
requireOwner(channel);
}
}
- validateChannels(channels);
- resetOwnersToExisting(channels);
+ validateChannels(channelList);
+ resetOwnersToExisting(channelList);
- List updated = Lists.newArrayList(channelRepository.saveAll(channels));
+ List updated = Lists.newArrayList(channelRepository.saveAll(channelList));
channelProcessorService.sendToProcessors(updated);
return updated;
}
@@ -181,6 +180,42 @@ public void remove(String channelName) {
channelRepository.deleteById(channelName);
}
+ public long remove(Iterable channelNames) {
+ requireRole(ROLES.CF_CHANNEL, "channels batch");
+
+ List distinctChannelNames =
+ StreamSupport.stream(channelNames.spliterator(), false)
+ .filter(name -> name != null && !name.isBlank())
+ .collect(Collectors.toCollection(LinkedHashSet::new))
+ .stream()
+ .toList();
+
+ if (distinctChannelNames.isEmpty()) {
+ return 0;
+ }
+
+ Map existingChannels =
+ channelRepository.findAllById(distinctChannelNames).stream()
+ .collect(Collectors.toMap(Channel::getName, c -> c));
+
+ for (String channelName : distinctChannelNames) {
+ Channel existing = existingChannels.get(channelName);
+ if (existing == null) {
+ throw new ChannelNotFoundException(channelName);
+ }
+ requireOwner(existing);
+ audit.log(Level.INFO, () -> MessageFormat.format(TextUtil.DELETE_CHANNEL, channelName));
+ }
+
+ channelRepository.deleteAllById(distinctChannelNames);
+ return distinctChannelNames.size();
+ }
+
+ private Map findExistingChannels(List channels) {
+ return channelRepository.findAllById(channels.stream().map(Channel::getName).toList()).stream()
+ .collect(Collectors.toMap(Channel::getName, c -> c));
+ }
+
private void validateChannel(Channel channel) {
if (channel.getName() == null || channel.getName().isEmpty()) {
throw new ChannelValidationException(
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
index 4295d710..54901260 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannel.java
@@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import java.util.List;
+import java.util.Map;
import org.phoebus.channelfinder.entity.Channel;
import org.phoebus.channelfinder.entity.SearchResult;
import org.springframework.util.MultiValueMap;
@@ -27,7 +28,10 @@ public interface IChannel {
@Operation(
summary = "Query channels",
description =
- "Query a collection of Channel instances based on tags, property values, and channel names.",
+ "Query a collection of Channel instances based on tags, property values, and channel names. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "queryChannels",
tags = {"Channel"})
@ApiResponses(
@@ -49,12 +53,25 @@ public interface IChannel {
@GetMapping
List query(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default List query(MultiValueMap allRequestParams) {
+ return query(allRequestParams, null);
+ }
@Operation(
summary = "Combined query for channels",
description =
- "Query for a collection of Channel instances and get a count and the first 10k hits.",
+ "Query for a collection of Channel instances and get a count and the first 10k hits. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "combinedQueryChannels",
tags = {"Channel"})
@ApiResponses(
@@ -77,11 +94,25 @@ List query(
@GetMapping("/combined")
SearchResult combinedQuery(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default SearchResult combinedQuery(MultiValueMap allRequestParams) {
+ return combinedQuery(allRequestParams, null);
+ }
@Operation(
summary = "Count channels matching query",
- description = "Get the number of channels matching the given query parameters.",
+ description =
+ "Get the number of channels matching the given query parameters. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "countChannels",
tags = {"Channel"})
@ApiResponses(
@@ -98,7 +129,17 @@ SearchResult combinedQuery(
@GetMapping("/count")
long queryCount(
@Parameter(description = SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default long queryCount(MultiValueMap allRequestParams) {
+ return queryCount(allRequestParams, null);
+ }
@Operation(
summary = "Get channel by name",
@@ -269,4 +310,28 @@ long queryCount(
})
@DeleteMapping("/{channelName}")
void remove(@PathVariable("channelName") String channelName);
+
+ @Operation(
+ summary = "Delete multiple channels",
+ description = "Delete multiple channel instances identified by a request-body list of names.",
+ operationId = "deleteChannels",
+ tags = {"Channel"})
+ @ApiResponses(
+ value = {
+ @ApiResponse(responseCode = "200", description = "Number of channels deleted"),
+ @ApiResponse(
+ responseCode = "401",
+ description = "Unauthorized",
+ content = @Content(schema = @Schema(implementation = ResponseStatusException.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "Channel not found",
+ content = @Content(schema = @Schema(implementation = ResponseStatusException.class))),
+ @ApiResponse(
+ responseCode = "500",
+ description = "Error while trying to delete channels",
+ content = @Content(schema = @Schema(implementation = ResponseStatusException.class)))
+ })
+ @DeleteMapping
+ long remove(@RequestBody List channelNames);
}
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
index f0a77e4e..63f3e779 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/api/IChannelScroll.java
@@ -6,11 +6,13 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import java.util.Map;
import org.phoebus.channelfinder.common.CFResourceDescriptors;
import org.phoebus.channelfinder.entity.Scroll;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@@ -18,7 +20,11 @@ public interface IChannelScroll {
@Operation(
summary = "Scroll query for channels",
- description = "Retrieve a collection of Channel instances based on multi-parameter search.",
+ description =
+ "Retrieve a collection of Channel instances based on multi-parameter search. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "scrollQueryChannels",
tags = {"ChannelScroll"})
@ApiResponses(
@@ -35,12 +41,25 @@ public interface IChannelScroll {
@GetMapping
Scroll query(
@Parameter(description = CFResourceDescriptors.SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap allRequestParams);
+ MultiValueMap allRequestParams,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default Scroll query(MultiValueMap allRequestParams) {
+ return query(allRequestParams, null);
+ }
@Operation(
summary = "Scroll query by scrollId",
description =
- "Retrieve a collection of Channel instances using a scrollId and search parameters.",
+ "Retrieve a collection of Channel instances using a scrollId and search parameters. "
+ + "Search parameters can be provided via URL query string or JSON request body, or both. "
+ + "URL parameters take precedence for control parameters (~size, ~from, ~search_after, ~track_total_hits). "
+ + "Regular search parameters from URL and body are combined as separate values in the query.",
operationId = "scrollQueryById",
tags = {"ChannelScroll"})
@ApiResponses(
@@ -59,5 +78,15 @@ Scroll query(
@Parameter(description = "Scroll ID from previous query") @PathVariable("scrollId")
String scrollId,
@Parameter(description = CFResourceDescriptors.SEARCH_PARAM_DESCRIPTION) @RequestParam
- MultiValueMap searchParameters);
+ MultiValueMap searchParameters,
+ @Parameter(
+ description =
+ "Optional JSON request body containing search parameters. Used to bypass URL length limitations.")
+ @RequestBody(required = false)
+ Map searchParamsBody);
+
+ // Backward-compatible overload when no request body is provided.
+ default Scroll query(String scrollId, MultiValueMap searchParameters) {
+ return query(scrollId, searchParameters, null);
+ }
}
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
index ab094ad1..485c39ce 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelController.java
@@ -1,6 +1,8 @@
package org.phoebus.channelfinder.web.v0.controller;
import java.util.List;
+import java.util.Map;
+import org.phoebus.channelfinder.common.SearchParameterMergerUtil;
import org.phoebus.channelfinder.entity.Channel;
import org.phoebus.channelfinder.entity.SearchResult;
import org.phoebus.channelfinder.service.ChannelService;
@@ -22,18 +24,27 @@ public ChannelController(ChannelService channelService) {
}
@Override
- public List query(MultiValueMap allRequestParams) {
- return channelService.query(allRequestParams);
+ public List query(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.query(mergedParams);
}
@Override
- public SearchResult combinedQuery(MultiValueMap allRequestParams) {
- return channelService.combinedQuery(allRequestParams);
+ public SearchResult combinedQuery(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.combinedQuery(mergedParams);
}
@Override
- public long queryCount(MultiValueMap allRequestParams) {
- return channelService.queryCount(allRequestParams);
+ public long queryCount(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelService.queryCount(mergedParams);
}
@Override
@@ -65,4 +76,9 @@ public Iterable update(Iterable channels) {
public void remove(String channelName) {
channelService.remove(channelName);
}
+
+ @Override
+ public long remove(List channelNames) {
+ return channelService.remove(channelNames);
+ }
}
diff --git a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
index fe8c6b9d..7451b863 100644
--- a/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
+++ b/src/main/java/org/phoebus/channelfinder/web/v0/controller/ChannelScrollController.java
@@ -1,5 +1,7 @@
package org.phoebus.channelfinder.web.v0.controller;
+import java.util.Map;
+import org.phoebus.channelfinder.common.SearchParameterMergerUtil;
import org.phoebus.channelfinder.entity.Scroll;
import org.phoebus.channelfinder.service.ChannelScrollService;
import org.phoebus.channelfinder.web.v0.api.IChannelScroll;
@@ -20,12 +22,20 @@ public ChannelScrollController(ChannelScrollService channelScrollService) {
}
@Override
- public Scroll query(MultiValueMap allRequestParams) {
- return channelScrollService.search(null, allRequestParams);
+ public Scroll query(
+ MultiValueMap allRequestParams, Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(allRequestParams, searchParamsBody);
+ return channelScrollService.search(null, mergedParams);
}
@Override
- public Scroll query(String scrollId, MultiValueMap searchParameters) {
- return channelScrollService.search(scrollId, searchParameters);
+ public Scroll query(
+ String scrollId,
+ MultiValueMap searchParameters,
+ Map searchParamsBody) {
+ MultiValueMap mergedParams =
+ SearchParameterMergerUtil.mergeParameters(searchParameters, searchParamsBody);
+ return channelScrollService.search(scrollId, mergedParams);
}
}
diff --git a/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java b/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java
new file mode 100644
index 00000000..5bc721e3
--- /dev/null
+++ b/src/test/java/org/phoebus/channelfinder/common/SearchParameterMergerUtilTest.java
@@ -0,0 +1,185 @@
+package org.phoebus.channelfinder.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/** Unit tests for SearchParameterMergerUtil. */
+class SearchParameterMergerUtilTest {
+
+ @Test
+ void testMergeParameters_urlOnly() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+ urlParams.add("prop", "value1");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, null);
+
+ assertEquals(2, merged.size());
+ assertEquals("name1", merged.getFirst("~name"));
+ assertEquals("value1", merged.getFirst("prop"));
+ }
+
+ @Test
+ void testMergeParameters_bodyOnly() {
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "value2");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(new LinkedMultiValueMap<>(), bodyParams);
+
+ assertEquals(2, merged.size());
+ assertEquals("name2", merged.getFirst("~name"));
+ assertEquals("value2", merged.getFirst("prop"));
+ }
+
+ @Test
+ void testMergeParameters_bothUrlAndBody_mergeRegularParams() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+ urlParams.add("prop", "value1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "value2");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Regular params should be added as separate values in MultiValueMap
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("name1"));
+ assertTrue(merged.get("~name").contains("name2"));
+ assertEquals(2, merged.get("prop").size());
+ assertTrue(merged.get("prop").contains("value1"));
+ assertTrue(merged.get("prop").contains("value2"));
+ }
+
+ @Test
+ void testMergeParameters_controlParams_urlTakesPrecedence() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~size", "100");
+ urlParams.add("~from", "0");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~size", "50");
+ bodyParams.put("~from", "10");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Control params: URL should take precedence
+ assertEquals("100", merged.getFirst("~size"));
+ assertEquals("0", merged.getFirst("~from"));
+ }
+
+ @Test
+ void testMergeParameters_controlParams_bodyUsedWhenUrlMissing() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~size", "50");
+ bodyParams.put("~from", "10");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Control params from body should be used if not in URL
+ assertEquals("50", merged.getFirst("~size"));
+ assertEquals("10", merged.getFirst("~from"));
+ }
+
+ @Test
+ void testMergeParameters_searchAfterControl() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~search_after", "abc123");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~search_after", "def456");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // URL should take precedence
+ assertEquals("abc123", merged.getFirst("~search_after"));
+ }
+
+ @Test
+ void testMergeParameters_trackTotalHitsControl() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~track_total_hits", "true");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~track_total_hits", "false");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // URL should take precedence
+ assertEquals("true", merged.getFirst("~track_total_hits"));
+ }
+
+ @Test
+ void testMergeParameters_emptyBodyValues() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "name1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "name2");
+ bodyParams.put("prop", "");
+ bodyParams.put("tag", " ");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Empty/whitespace values should be skipped
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("name1"));
+ assertTrue(merged.get("~name").contains("name2"));
+ assertFalse(merged.containsKey("prop"));
+ assertFalse(merged.containsKey("tag"));
+ }
+
+ @Test
+ void testMergeParameters_nullInputs() {
+ MultiValueMap merged = SearchParameterMergerUtil.mergeParameters(null, null);
+
+ assertTrue(merged.isEmpty());
+ }
+
+ @Test
+ void testMergeParameters_complexMix() {
+ MultiValueMap urlParams = new LinkedMultiValueMap<>();
+ urlParams.add("~name", "ch1");
+ urlParams.add("~size", "100");
+ urlParams.add("prop1", "val1");
+
+ Map bodyParams = new HashMap<>();
+ bodyParams.put("~name", "ch2");
+ bodyParams.put("~size", "50");
+ bodyParams.put("prop1", "val2");
+ bodyParams.put("prop2", "val3");
+
+ MultiValueMap merged =
+ SearchParameterMergerUtil.mergeParameters(urlParams, bodyParams);
+
+ // Regular params merged as multiple values, control params URL takes precedence
+ assertEquals(2, merged.get("~name").size());
+ assertTrue(merged.get("~name").contains("ch1"));
+ assertTrue(merged.get("~name").contains("ch2"));
+ assertEquals("100", merged.getFirst("~size"));
+ assertEquals(2, merged.get("prop1").size());
+ assertTrue(merged.get("prop1").contains("val1"));
+ assertTrue(merged.get("prop1").contains("val2"));
+ assertEquals("val3", merged.getFirst("prop2"));
+ }
+}
diff --git a/src/test/java/org/phoebus/channelfinder/service/ChannelServiceTest.java b/src/test/java/org/phoebus/channelfinder/service/ChannelServiceTest.java
index 5d49034d..55aa53da 100644
--- a/src/test/java/org/phoebus/channelfinder/service/ChannelServiceTest.java
+++ b/src/test/java/org/phoebus/channelfinder/service/ChannelServiceTest.java
@@ -1,9 +1,14 @@
package org.phoebus.channelfinder.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
@@ -16,6 +21,7 @@
import org.phoebus.channelfinder.entity.Channel;
import org.phoebus.channelfinder.entity.Property;
import org.phoebus.channelfinder.entity.Tag;
+import org.phoebus.channelfinder.exceptions.ChannelNotFoundException;
import org.phoebus.channelfinder.exceptions.ChannelValidationException;
import org.phoebus.channelfinder.exceptions.PropertyNotFoundException;
import org.phoebus.channelfinder.exceptions.TagNotFoundException;
@@ -138,4 +144,34 @@ void createChannel_validChannelWithTagAndProperty_noException() {
assertDoesNotThrow(() -> channelService.create("ch", channel));
}
+
+ @Test
+ void removeMultipleChannels_validChannels_returnsDeletedCount() {
+ when(authorizationService.isAuthorizedOwner(any(), any(Channel.class))).thenReturn(true);
+ when(channelRepository.findAllById(any()))
+ .thenReturn(
+ List.of(
+ new Channel("ch1", "owner"),
+ new Channel("ch2", "owner"),
+ new Channel("ch3", "owner")));
+
+ long deleted = channelService.remove(List.of("ch1", "ch2", "ch3"));
+
+ assertEquals(3L, deleted);
+ verify(channelRepository, times(1)).deleteAllById(any());
+ verify(channelRepository, never()).deleteById(anyString());
+ }
+
+ @Test
+ void removeMultipleChannels_whenOneMissing_throwsAndStopsFurtherDeletes() {
+ when(authorizationService.isAuthorizedOwner(any(), any(Channel.class))).thenReturn(true);
+ when(channelRepository.findAllById(any())).thenReturn(List.of(new Channel("ch1", "owner")));
+
+ assertThrows(
+ ChannelNotFoundException.class,
+ () -> channelService.remove(List.of("ch1", "missing", "ch3")));
+
+ verify(channelRepository, never()).deleteById(anyString());
+ verify(channelRepository, never()).deleteAllById(any());
+ }
}