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: + * + *

+ */ +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 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()); + } }