diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java index f09c35e98..14a6e972a 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java @@ -23,7 +23,10 @@ import org.slf4j.LoggerFactory; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -66,6 +69,20 @@ public static Date getDate(Object value) { } if (value instanceof Date) { return (Date) value; + } + if (value instanceof Instant) { + return Date.from((Instant) value); + } + if (value instanceof OffsetDateTime) { + return Date.from(((OffsetDateTime) value).toInstant()); + } + if (value instanceof ZonedDateTime) { + return Date.from(((ZonedDateTime) value).toInstant()); + } + // LocalDateTime carries no timezone; treat as UTC per Unomi's server-side convention. + // Callers that need a specific zone should use ZonedDateTime or OffsetDateTime instead. + if (value instanceof LocalDateTime) { + return Date.from(((LocalDateTime) value).atZone(ZoneOffset.UTC).toInstant()); } else { JavaDateFormatter formatter = new JavaDateFormatter("strict_date_optional_time||epoch_millis"); DateMathParser dateMathParser = new DateMathParser(formatter, DateTimeFormatter.ISO_DATE_TIME); diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/ConditionEvaluatorDispatcher.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/ConditionEvaluatorDispatcher.java index 29b9286c7..950d766bd 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/ConditionEvaluatorDispatcher.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/ConditionEvaluatorDispatcher.java @@ -46,6 +46,21 @@ */ public interface ConditionEvaluatorDispatcher { + /** + * Adds a new evaluator to the dispatcher. + * + * @param name the name of the evaluator + * @param evaluator the evaluator to add + */ + void addEvaluator(String name, ConditionEvaluator evaluator); + + /** + * Removes an evaluator from the dispatcher. + * + * @param name the name of the evaluator to remove + */ + void removeEvaluator(String name); + /** * Evaluates the provided {@link Condition} on the given {@link Item} using an empty context. * This is a convenience overload equivalent to calling diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java index 2ad7e23ea..3785147a5 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java @@ -18,6 +18,7 @@ package org.apache.unomi.persistence.spi.conditions.evaluator.impl; import org.apache.unomi.api.Item; +import org.apache.unomi.api.services.DefinitionsService; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.metrics.MetricAdapter; import org.apache.unomi.metrics.MetricsService; @@ -50,6 +51,7 @@ public class ConditionEvaluatorDispatcherImpl private MetricsService metricsService; private ScriptExecutor scriptExecutor; + private DefinitionsService definitionsService; public ConditionEvaluatorDispatcherImpl() { } @@ -73,6 +75,24 @@ public void unbindEvaluator(ConditionEvaluator evaluator, Map pr evaluators.remove((String) props.get("conditionEvaluatorId")); } + public void setDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + + public void unsetDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = null; + } + + @Override + public void addEvaluator(String name, ConditionEvaluator evaluator) { + evaluators.put(name, evaluator); + } + + @Override + public void removeEvaluator(String name) { + evaluators.remove(name); + } + @Override public boolean eval(Condition condition, Item item) { return eval(condition, item, new HashMap<>()); diff --git a/services/pom.xml b/services/pom.xml index f0a61413d..fb9eaa15b 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -145,8 +145,14 @@ - org.slf4j - slf4j-simple + commons-io + commons-io + test + + + ch.qos.logback + logback-classic + 1.2.11 test diff --git a/services/src/test/java/org/apache/unomi/services/TestHelper.java b/services/src/test/java/org/apache/unomi/services/TestHelper.java new file mode 100644 index 000000000..a23cbce55 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/TestHelper.java @@ -0,0 +1,838 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services; + +import org.apache.commons.io.FileUtils; +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.PartialList; +import org.apache.unomi.api.actions.ActionType; +import org.apache.unomi.api.security.SecurityServiceConfiguration; +import org.apache.unomi.api.services.*; +import org.apache.unomi.api.services.cache.MultiTypeCacheService; +import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tasks.TaskExecutor; +import org.apache.unomi.api.tenants.AuditService; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.persistence.spi.conditions.evaluator.impl.ConditionEvaluatorDispatcherImpl; +import org.apache.unomi.services.common.security.AuditServiceImpl; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.*; +import org.apache.unomi.services.impl.cluster.ClusterServiceImpl; +import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; +import org.apache.unomi.services.impl.scheduler.*; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.event.EventAdmin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestHelper { + + private static final Logger LOGGER = LoggerFactory.getLogger(TestHelper.class); + private static final int MAX_RETRIES = 20; + private static final long RETRY_DELAY_MS = 100; + + /** + * Creates a security service instance for testing purposes. + * Initializes a new KarafSecurityService with audit service and default configuration. + * + * @return A configured KarafSecurityService instance + */ + public static KarafSecurityService createSecurityService() { + KarafSecurityService securityService = new KarafSecurityService(); + AuditService auditService = new AuditServiceImpl(); + securityService.setTenantAuditService(auditService); + securityService.setConfiguration(new SecurityServiceConfiguration()); + securityService.init(); + return securityService; + } + + /** + * Creates an execution context manager for testing purposes. + * Sets up an ExecutionContextManagerImpl with the provided security service. + * + * @param securityService The security service to use in the context manager + * @return A configured ExecutionContextManagerImpl instance + */ + public static ExecutionContextManagerImpl createExecutionContextManager(KarafSecurityService securityService) { + ExecutionContextManagerImpl executionContextManager = new ExecutionContextManagerImpl(); + executionContextManager.setSecurityService(securityService); + return executionContextManager; + } + + public static DefinitionsServiceImpl createDefinitionService( + PersistenceService persistenceService, + BundleContext bundleContext, + SchedulerService schedulerService, + MultiTypeCacheService multiTypeCacheService, + ExecutionContextManager executionContextManager, + TenantService tenantService + ) { + TestEventAdmin eventAdmin = new TestEventAdmin(); + return createDefinitionService(persistenceService, bundleContext, schedulerService, + multiTypeCacheService, executionContextManager, tenantService, eventAdmin); + } + + public static DefinitionsServiceImpl createDefinitionService( + PersistenceService persistenceService, + BundleContext bundleContext, + SchedulerService schedulerService, + MultiTypeCacheService multiTypeCacheService, + ExecutionContextManager executionContextManager, + TenantService tenantService, + EventAdmin eventAdmin + ) { + DefinitionsServiceImpl definitionsService = new DefinitionsServiceImpl(); + definitionsService.setPersistenceService(persistenceService); + definitionsService.setBundleContext(bundleContext); + definitionsService.setSchedulerService(schedulerService); + definitionsService.setCacheService(multiTypeCacheService); + definitionsService.setContextManager(executionContextManager); + definitionsService.setTenantService(tenantService); + definitionsService.setEventAdmin(eventAdmin); + + definitionsService.postConstruct(); + return definitionsService; + } + + /** + * Creates a DefinitionsServiceImpl with a TestEventAdmin and returns both. + * This allows tests to register EventHandlers with the TestEventAdmin. + * + * @param persistenceService the persistence service + * @param bundleContext the bundle context + * @param schedulerService the scheduler service + * @param multiTypeCacheService the cache service + * @param executionContextManager the execution context manager + * @param tenantService the tenant service + * @return a pair containing the DefinitionsServiceImpl and the TestEventAdmin + */ + public static java.util.Map.Entry createDefinitionServiceWithEventAdmin( + PersistenceService persistenceService, + BundleContext bundleContext, + SchedulerService schedulerService, + MultiTypeCacheService multiTypeCacheService, + ExecutionContextManager executionContextManager, + TenantService tenantService + ) { + TestEventAdmin eventAdmin = new TestEventAdmin(); + DefinitionsServiceImpl definitionsService = createDefinitionService( + persistenceService, bundleContext, schedulerService, + multiTypeCacheService, executionContextManager, tenantService, + eventAdmin + ); + return new AbstractMap.SimpleEntry<>(definitionsService, eventAdmin); + } + + + /** + * Creates a scheduler service instance for testing purposes with ClusterService support. + * + * @param nodeId The unique identifier for this node + * @param persistenceService The persistence service to use + * @param executionContextManager The execution context manager to use + * @param bundleContext The bundle context to use + * @param clusterService The cluster service to use (can be null) + * @param lockTimeout The lock timeout to use (in milliseconds) + * @param executorNode Whether this node is an executor node + * @param construct Whether to call postConstruct on the service + * @param completedTaskTtlDays The TTL for completed tasks (in days) + * @return A configured SchedulerServiceImpl instance + */ + public static SchedulerServiceImpl createSchedulerService( + String nodeId, + PersistenceService persistenceService, + ExecutionContextManager executionContextManager, + BundleContext bundleContext, + ClusterService clusterService, + long lockTimeout, + boolean executorNode, + boolean construct, + long completedTaskTtlDays) { + + // Instantiate and wire task manager beans as in blueprint.xml + + // Task Metrics Manager + TaskMetricsManager taskMetricsManager = new TaskMetricsManager(); + + // Task State Manager + TaskStateManager taskStateManager = new TaskStateManager(); + + // Task Executor Registry + TaskExecutorRegistry taskExecutorRegistry = new TaskExecutorRegistry(); + + // Task History Manager + TaskHistoryManager taskHistoryManager = new TaskHistoryManager(); + taskHistoryManager.setNodeId(nodeId); + taskHistoryManager.setMetricsManager(taskMetricsManager); + + // Task Lock Manager + TaskLockManager taskLockManager = new TaskLockManager(); + taskLockManager.setNodeId(nodeId); + taskLockManager.setLockTimeout(lockTimeout > 0 ? lockTimeout : 10000L); + taskLockManager.setMetricsManager(taskMetricsManager); + + // Task Execution Manager + TaskExecutionManager taskExecutionManager = new TaskExecutionManager(); + taskExecutionManager.setNodeId(nodeId); + taskExecutionManager.setThreadPoolSize(4); + taskExecutionManager.setStateManager(taskStateManager); + taskExecutionManager.setLockManager(taskLockManager); + taskExecutionManager.setMetricsManager(taskMetricsManager); + taskExecutionManager.setHistoryManager(taskHistoryManager); + taskExecutionManager.setExecutorRegistry(taskExecutorRegistry); + taskExecutionManager.initialize(); // Initialize after all dependencies are set + + // Task Recovery Manager + TaskRecoveryManager taskRecoveryManager = new TaskRecoveryManager(); + taskRecoveryManager.setNodeId(nodeId); + taskRecoveryManager.setStateManager(taskStateManager); + taskRecoveryManager.setLockManager(taskLockManager); + taskRecoveryManager.setMetricsManager(taskMetricsManager); + taskRecoveryManager.setExecutionManager(taskExecutionManager); + taskRecoveryManager.setExecutorRegistry(taskExecutorRegistry); + + // Task Validation Manager + TaskValidationManager taskValidationManager = new TaskValidationManager(); + + PersistenceSchedulerProvider persistenceSchedulerProvider = new PersistenceSchedulerProvider(); + persistenceSchedulerProvider.setPersistenceService(persistenceService); + persistenceSchedulerProvider.setExecutorNode(executorNode); + persistenceSchedulerProvider.setNodeId(nodeId); + persistenceSchedulerProvider.setLockManager(taskLockManager); + persistenceSchedulerProvider.setClusterService(clusterService); + persistenceSchedulerProvider.setCompletedTaskTtlDays(completedTaskTtlDays); + + SchedulerServiceImpl schedulerService = new SchedulerServiceImpl(); + schedulerService.setMetricsManager(taskMetricsManager); + schedulerService.setStateManager(taskStateManager); + schedulerService.setExecutorRegistry(taskExecutorRegistry); + schedulerService.setHistoryManager(taskHistoryManager); + schedulerService.setLockManager(taskLockManager); + schedulerService.setExecutionManager(taskExecutionManager); + schedulerService.setRecoveryManager(taskRecoveryManager); + schedulerService.setValidationManager(taskValidationManager); + schedulerService.setBundleContext(bundleContext); + schedulerService.setThreadPoolSize(4); // Ensure enough threads for parallel execution + schedulerService.setExecutorNode(executorNode); + schedulerService.setNodeId(nodeId); + schedulerService.setPurgeTaskEnabled(false); + if (lockTimeout > 0) { + schedulerService.setLockTimeout(lockTimeout); // Set a default lock timeout for tests + } + + // Set the persistence provider on the scheduler service (optional dependency) + if (persistenceSchedulerProvider != null) { + schedulerService.setPersistenceProvider(persistenceSchedulerProvider); + } + + // Set the schedulerService on all managers that need it + taskLockManager.setSchedulerService(schedulerService); + taskExecutionManager.setSchedulerService(schedulerService); + taskRecoveryManager.setSchedulerService(schedulerService); + + if (construct) { + schedulerService.postConstruct(); + } + return schedulerService; + } + + /** + * Creates a scheduler service instance for testing purposes with ClusterService support. + * Uses default TTL of 30 days for completed tasks. + * + * @param nodeId The unique identifier for this node + * @param persistenceService The persistence service to use + * @param executionContextManager The execution context manager to use + * @param bundleContext The bundle context to use + * @param clusterService The cluster service to use (can be null) + * @param lockTimeout The lock timeout to use (in milliseconds) + * @param executorNode Whether this node is an executor node + * @param construct Whether to call postConstruct on the service + * @return A configured SchedulerServiceImpl instance + */ + public static SchedulerServiceImpl createSchedulerService( + String nodeId, + PersistenceService persistenceService, + ExecutionContextManager executionContextManager, + BundleContext bundleContext, + ClusterService clusterService, + long lockTimeout, + boolean executorNode, + boolean construct) { + return createSchedulerService(nodeId, persistenceService, executionContextManager, bundleContext, clusterService, lockTimeout, executorNode, construct, 30); + } + + /** + * Creates a scheduler service instance without a PersistenceSchedulerProvider for testing optional dependency scenarios. + * + * @param nodeId The unique identifier for this node + * @param persistenceService The persistence service to use + * @param executionContextManager The execution context manager to use + * @param bundleContext The bundle context to use + * @param clusterService The cluster service to use (can be null) + * @param lockTimeout The lock timeout to use (in milliseconds) + * @param executorNode Whether this node is an executor node + * @param construct Whether to call postConstruct on the service + * @return A configured SchedulerServiceImpl instance without persistence provider + */ + public static SchedulerServiceImpl createSchedulerServiceWithoutPersistenceProvider( + String nodeId, + PersistenceService persistenceService, + ExecutionContextManager executionContextManager, + BundleContext bundleContext, + ClusterService clusterService, + long lockTimeout, + boolean executorNode, + boolean construct) { + + // Create all required managers + TaskStateManager taskStateManager = new TaskStateManager(); + TaskLockManager taskLockManager = new TaskLockManager(); + TaskExecutionManager taskExecutionManager = new TaskExecutionManager(); + TaskRecoveryManager taskRecoveryManager = new TaskRecoveryManager(); + TaskMetricsManager taskMetricsManager = new TaskMetricsManager(); + TaskHistoryManager taskHistoryManager = new TaskHistoryManager(); + TaskValidationManager taskValidationManager = new TaskValidationManager(); + TaskExecutorRegistry taskExecutorRegistry = new TaskExecutorRegistry(); + + // Configure managers + taskLockManager.setNodeId(nodeId); + taskLockManager.setLockTimeout(lockTimeout > 0 ? lockTimeout : 10000); + taskLockManager.setMetricsManager(taskMetricsManager); + + taskHistoryManager.setNodeId(nodeId); + taskHistoryManager.setMetricsManager(taskMetricsManager); + + taskExecutionManager.setNodeId(nodeId); + taskExecutionManager.setThreadPoolSize(4); + taskExecutionManager.setStateManager(taskStateManager); + taskExecutionManager.setLockManager(taskLockManager); + taskExecutionManager.setMetricsManager(taskMetricsManager); + taskExecutionManager.setHistoryManager(taskHistoryManager); + taskExecutionManager.setExecutorRegistry(taskExecutorRegistry); + taskExecutionManager.initialize(); + + taskRecoveryManager.setNodeId(nodeId); + taskRecoveryManager.setStateManager(taskStateManager); + taskRecoveryManager.setLockManager(taskLockManager); + taskRecoveryManager.setMetricsManager(taskMetricsManager); + taskRecoveryManager.setExecutionManager(taskExecutionManager); + taskRecoveryManager.setExecutorRegistry(taskExecutorRegistry); + + // Create scheduler service + SchedulerServiceImpl schedulerService = new SchedulerServiceImpl(); + schedulerService.setBundleContext(bundleContext); + schedulerService.setThreadPoolSize(4); + schedulerService.setExecutorNode(executorNode); + schedulerService.setNodeId(nodeId); + schedulerService.setPurgeTaskEnabled(false); + if (lockTimeout > 0) { + schedulerService.setLockTimeout(lockTimeout); + } + + // Set all required managers + schedulerService.setStateManager(taskStateManager); + schedulerService.setLockManager(taskLockManager); + schedulerService.setExecutionManager(taskExecutionManager); + schedulerService.setRecoveryManager(taskRecoveryManager); + schedulerService.setMetricsManager(taskMetricsManager); + schedulerService.setHistoryManager(taskHistoryManager); + schedulerService.setValidationManager(taskValidationManager); + schedulerService.setExecutorRegistry(taskExecutorRegistry); + + // Note: persistenceProvider is intentionally not set to test optional dependency + + // Set the schedulerService on all managers that need it + taskLockManager.setSchedulerService(schedulerService); + taskExecutionManager.setSchedulerService(schedulerService); + taskRecoveryManager.setSchedulerService(schedulerService); + + if (construct) { + schedulerService.postConstruct(); + } + return schedulerService; + } + + + + /** + * Creates and wires a new ClusterServiceImpl with the specified persistence service and node ID. + * Callers must invoke postConstruct() themselves if initialization behaviour is needed. + * + * NOTE: Due to circular dependency between ClusterService and SchedulerService, + * when using both services together: + * 1. Create the ClusterService first using this method + * 2. Create the SchedulerService using createSchedulerService() and pass the ClusterService + * 3. If tasks were not initialized during startup, call clusterService.initializeScheduledTasks() + * + * @param persistenceService The persistence service to use + * @param nodeId The unique identifier for this node + * @param bundleContext The bundle context to use for service trackers (can be null) + * @return A configured ClusterServiceImpl instance + */ + public static ClusterServiceImpl createClusterService(PersistenceService persistenceService, String nodeId, BundleContext bundleContext) { + return createClusterService(persistenceService, nodeId, "127.0.0.1", "127.0.0.1", bundleContext); + } + + + /** + * Creates and wires a new ClusterServiceImpl with custom addresses and bundle context. + * Callers must invoke postConstruct() themselves if initialization behaviour is needed. + * + * NOTE: Due to circular dependency between ClusterService and SchedulerService, + * when using both services together: + * 1. Create the ClusterService first using this method + * 2. Create the SchedulerService using createSchedulerService() and pass the ClusterService + * 3. If tasks were not initialized during startup, call clusterService.initializeScheduledTasks() + * + * @param persistenceService The persistence service to use + * @param nodeId The unique identifier for this node + * @param publicAddress The public address for the node + * @param internalAddress The internal address for the node + * @param bundleContext The bundle context to use for service trackers (can be null) + * @return A configured ClusterServiceImpl instance + */ + public static ClusterServiceImpl createClusterService( + PersistenceService persistenceService, + String nodeId, + String publicAddress, + String internalAddress, + BundleContext bundleContext) { + ClusterServiceImpl clusterService = new ClusterServiceImpl(); + clusterService.setPersistenceService(persistenceService); + clusterService.setPublicAddress(publicAddress); + clusterService.setInternalAddress(internalAddress); + clusterService.setNodeStatisticsUpdateFrequency(60000); + clusterService.setNodeId(nodeId); + + return clusterService; + } + + + /** + * Creates a test action type with specified configuration. + * Initializes an ActionType with the provided ID and action executor. + * + * @param id The unique identifier for the action type + * @param actionExecutor The name of the action executor to use + * @return A configured ActionType instance + */ + public static ActionType createActionType(String id, String actionExecutor) { + ActionType actionType = new ActionType() { + private Metadata metadata = new Metadata(); + @Override + public String getItemId() { + return id; + } + @Override + public String getItemType() { + return "actionType"; + } + @Override + public Metadata getMetadata() { + return metadata; + } + @Override + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } + @Override + public Long getVersion() { + return 1L; + } + }; + Metadata actionMetadata = new Metadata(); + actionMetadata.setId(id); + actionMetadata.setEnabled(true); + actionType.setMetadata(actionMetadata); + if (actionExecutor != null) { + actionType.setActionExecutor(actionExecutor); + } + return actionType; + } + + /** + * Sets up common test data in the tenant service. + * Creates standard test tenants with basic configuration. + * + * @param tenantService The tenant service to populate with test data + */ + public static void setupCommonTestData(TenantService tenantService) { + // Create standard test tenants + tenantService.createTenant("system", Collections.singletonMap("description", "System tenant")); + tenantService.createTenant("tenant1", Collections.singletonMap("description", "Tenant 1")); + tenantService.createTenant("tenant2", Collections.singletonMap("description", "Tenant 2")); + } + + /** + * Creates a mock bundle context for testing purposes. + * Sets up a mock BundleContext with basic behavior for bundle operations. + * + * @return A configured mock BundleContext instance + */ + public static BundleContext createMockBundleContext() { + BundleContext bundleContext = mock(BundleContext.class); + Bundle bundle = mock(Bundle.class); + when(bundleContext.getBundle()).thenReturn(bundle); + when(bundle.getBundleContext()).thenReturn(bundleContext); + // Default to no predefined entries for any path to avoid strict stubbing issues in tests + when(bundle.findEntries( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyBoolean() + )).thenReturn(null); + when(bundleContext.getBundles()).thenReturn(new Bundle[0]); + return bundleContext; + } + + /** + * Creates an event service instance for testing purposes. + * Initializes an EventServiceImpl with all required dependencies. + * + * @param persistenceService The persistence service to use + * @param bundleContext The bundle context to use + * @param definitionsService The definitions service to use + * @param tenantService The tenant service to use + * @param tracerService The tracer service to use + * @return A configured EventServiceImpl instance + */ + + /** + * Creates a TypeResolutionService instance for testing purposes. + * + * @param definitionsService The definitions service to use + * @return A configured TypeResolutionServiceImpl instance + */ + + public static void setupSegmentActionTypes(DefinitionsServiceImpl definitionsService) { + // Register the evaluateProfileSegmentsAction type + ActionType actionType = new ActionType(); + actionType.setItemId("evaluateProfileSegmentsAction"); + actionType.setActionExecutor("evaluateProfileSegments"); + + Metadata metadata = new Metadata(); + metadata.setId("evaluateProfileSegmentsAction"); + metadata.setName("Evaluate Profile Segments"); + metadata.setDescription("Evaluates the segments for a profile and updates the profile with the matching segments"); + metadata.setSystemTags(Collections.singleton("profileTags")); + metadata.setEnabled(true); + metadata.setHidden(false); + actionType.setMetadata(metadata); + + definitionsService.setActionType(actionType); + } + + /** + * Creates a test task executor with specified behavior. + * The executor will run the provided execution and handle success/failure callbacks. + * + * @param taskType The type identifier for the task executor + * @param execution The runnable containing the execution logic + * @return A configured TaskExecutor instance + */ + public static TaskExecutor createTestExecutor(String taskType, Runnable execution) { + return new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + try { + execution.run(); + callback.complete(); + } catch (Exception e) { + callback.fail(e.getMessage()); + } + } + }; + } + + /** + * Creates a test task with specified configuration. + * Initializes a new ScheduledTask with the provided parameters and default settings. + * + * @param taskId The unique identifier for the task + * @param taskType The type of task to create + * @param persistent Whether the task should be persistent + * @return A configured ScheduledTask instance + */ + public static ScheduledTask createTestTask(String taskId, String taskType, boolean persistent) { + ScheduledTask task = new ScheduledTask(); + task.setItemId(taskId); + task.setTaskType(taskType); + task.setEnabled(true); + task.setPersistent(persistent); + task.setStatus(ScheduledTask.TaskStatus.SCHEDULED); + return task; + } + + /** + * Cleans up the default storage directory used in tests. + * Attempts to delete the directory with retries in case of failures. + * Optimized for speed with shorter retry intervals. + * + * @param maxRetries The maximum number of deletion attempts + * @throws RuntimeException if the directory cannot be deleted after all retries + */ + public static void cleanDefaultStorageDirectory(int maxRetries) { + Path defaultStorageDir = Paths.get(InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR).toAbsolutePath().normalize(); + if (!Files.exists(defaultStorageDir)) { + return; // Already clean, skip expensive operations + } + int count = 0; + while (Files.exists(defaultStorageDir) && count < maxRetries) { + try { + FileUtils.deleteDirectory(defaultStorageDir.toFile()); + // If deletion succeeded, break early + if (!Files.exists(defaultStorageDir)) { + return; + } + } catch (IOException e) { + LOGGER.warn("Error deleting default storage directory, will retry", e); + } + // Use shorter sleep time (100ms instead of 1000ms) for faster retries + // This significantly speeds up test execution when cleanup is needed + if (count < maxRetries - 1) { // Don't sleep after last attempt + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + count++; + } + if (Files.exists(defaultStorageDir)) { + throw new RuntimeException("Failed to delete default storage directory after " + maxRetries + " retries"); + } + } + + /** + * Generic retry method that will retry an operation until it succeeds or reaches max retries. + * @param operation The operation to retry that returns a result + * @param successCondition The predicate to test if the operation was successful + * @param The type of result returned by the operation + * @return The result of the successful operation + * @throws RuntimeException if max retries are reached without success + */ + public static T retryUntil(Supplier operation, Predicate successCondition) { + int attempts = 0; + T result = null; + boolean success = false; + + while (!success && attempts < MAX_RETRIES) { + result = operation.get(); + success = successCondition.test(result); + + if (!success) { + attempts++; + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Retry interrupted", e); + } + } + } + + if (!success) { + throw new RuntimeException("Operation failed after " + MAX_RETRIES + " attempts"); + } + + return result; + } + + /** + * Retries a query operation until items are available or timeout is reached. + * This is useful for tests that need to wait for refresh delay in in-memory persistence service. + * The method will retry the query until the expected number of items are found, or until + * any items are found if expectedCount is null. + * + * @param querySupplier Supplier that returns the query result (List or PartialList) + * @param expectedCount Expected number of items (null to wait for any items > 0) + * @param maxWaitMs Maximum time to wait in milliseconds (defaults to 2000ms if <= 0) + * @param The type of query result (List or PartialList) + * @return The query result when condition is met + * @throws RuntimeException if timeout is reached without meeting the condition + */ + @SuppressWarnings("unchecked") + public static T retryQueryUntilAvailable(Supplier querySupplier, Integer expectedCount, long maxWaitMs) { + long maxWait = maxWaitMs > 0 ? maxWaitMs : 2000L; + long startTime = System.currentTimeMillis(); + long retryDelay = Math.min(50L, maxWait / 20); // Adaptive retry delay + + T result = querySupplier.get(); + + while (System.currentTimeMillis() - startTime < maxWait) { + int currentCount = 0; + + if (result instanceof List) { + currentCount = ((List) result).size(); + } else if (result instanceof PartialList) { + currentCount = ((PartialList) result).getList().size(); + } + + // Check if condition is met + boolean conditionMet = expectedCount != null + ? currentCount == expectedCount + : currentCount > 0; + + if (conditionMet) { + return result; + } + + // Wait before retrying + try { + Thread.sleep(retryDelay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Retry interrupted", e); + } + + result = querySupplier.get(); + } + + // If we get here, timeout was reached + int finalCount = 0; + if (result instanceof List) { + finalCount = ((List) result).size(); + } else if (result instanceof PartialList) { + finalCount = ((PartialList) result).getList().size(); + } + + String message = expectedCount != null + ? String.format("Query did not return expected count %d after %d ms (got %d)", expectedCount, maxWait, finalCount) + : String.format("Query did not return any items after %d ms", maxWait); + throw new RuntimeException(message); + } + + /** + * Retries a query operation until items are available. + * Uses default timeout of 2000ms. + * + * @param querySupplier Supplier that returns the query result + * @param expectedCount Expected number of items (null to wait for any items > 0) + * @param The type of query result + * @return The query result when condition is met + */ + public static T retryQueryUntilAvailable(Supplier querySupplier, Integer expectedCount) { + return retryQueryUntilAvailable(querySupplier, expectedCount, 2000L); + } + + /** + * Retries a query operation until any items are available. + * Uses default timeout of 2000ms. + * + * @param querySupplier Supplier that returns the query result + * @param The type of query result + * @return The query result when items are found + */ + public static T retryQueryUntilAvailable(Supplier querySupplier) { + return retryQueryUntilAvailable(querySupplier, null, 2000L); + } + + /** + * Common tearDown method to be used by test classes to clean up resources. + * This centralizes the common teardown logic to reduce duplication. + * + * @param schedulerService The scheduler service instance to stop + * @param multiTypeCacheService The cache service to clear + * @param persistenceService The persistence service to purge + * @param tenantService The tenant service to reset + * @param tenantIds Array of tenant IDs to clear from the cache + * @throws Exception If an error occurs during teardown + */ + public static void tearDown( + SchedulerService schedulerService, + MultiTypeCacheService multiTypeCacheService, + PersistenceService persistenceService, + TenantService tenantService, + String... tenantIds) throws Exception { + + // Stop scheduler service + if (schedulerService instanceof SchedulerServiceImpl) { + // instanceof already handles null; preDestroy shuts down threads cleanly + ((SchedulerServiceImpl) schedulerService).preDestroy(); + } + + // Clear cache by clearing each tenant + if (multiTypeCacheService != null && tenantIds != null) { + for (String tenantId : tenantIds) { + if (tenantId != null) { + multiTypeCacheService.clear(tenantId); + } + } + } + + // Clear persistence service data if possible + if (persistenceService instanceof InMemoryPersistenceServiceImpl) { + // purge(null) purges all items — used to reset test state between test methods + ((InMemoryPersistenceServiceImpl) persistenceService).purge((Date) null); + } + + // Reset tenant context + if (tenantService instanceof TestTenantService) { + // clearCurrentTenantId() removes the ThreadLocal entry rather than setting it to null + ((TestTenantService) tenantService).clearCurrentTenantId(); + } + } + + /** + * This method is a no-op. It exists as a documentation reminder to null out references + * in the calling code after the call. Java is pass-by-value, so passing references here + * cannot null out the caller's variables. + * + * @param objects The objects whose references the caller should null out after this call + */ + public static void cleanupReferences(Object... objects) { + // No-op. The caller is responsible for setting its own instance variables to null. + // This method exists solely as a visible reminder to do so. + } + + /** + * Injects the definitions service into the condition evaluator dispatcher. + * This is required for proper condition type resolution, especially for conditions + * with parent conditions that need to be resolved through the definitions service. + * + * @param conditionEvaluatorDispatcher The condition evaluator dispatcher to inject into + * @param definitionsService The definitions service to inject + */ + public static void injectDefinitionsServiceIntoDispatcher( + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, + DefinitionsService definitionsService) { + if (conditionEvaluatorDispatcher instanceof ConditionEvaluatorDispatcherImpl) { + ((ConditionEvaluatorDispatcherImpl) conditionEvaluatorDispatcher).setDefinitionsService(definitionsService); + } + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/ExecutionContextManagerImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/ExecutionContextManagerImplTest.java new file mode 100644 index 000000000..4a732c83d --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/ExecutionContextManagerImplTest.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.ExecutionContext; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.security.SecurityServiceConfiguration; +import org.apache.unomi.api.security.UnomiRoles; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.security.auth.Subject; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class ExecutionContextManagerImplTest { + + @Mock + private SecurityService securityService; + + private ExecutionContextManagerImpl contextManager; + + @BeforeEach + public void setUp() { + contextManager = new ExecutionContextManagerImpl(); + contextManager.setSecurityService(securityService); + } + + @Test + public void testCreateContextWithPermissions() { + // Set up test data + Subject subject = new Subject(); + Set roles = new HashSet<>(Arrays.asList(UnomiRoles.ADMINISTRATOR, UnomiRoles.USER)); + + // Mock security service behavior + when(securityService.getCurrentSubject()).thenReturn(subject); + when(securityService.extractRolesFromSubject(subject)).thenReturn(roles); + when(securityService.getPermissionsForRole(UnomiRoles.ADMINISTRATOR)) + .thenReturn(new HashSet<>(Arrays.asList("READ", "WRITE", SecurityServiceConfiguration.PERMISSION_DELETE))); + when(securityService.getPermissionsForRole(UnomiRoles.USER)) + .thenReturn(new HashSet<>(Arrays.asList("READ"))); + + // Create context + ExecutionContext context = contextManager.createContext("testTenant"); + + // Verify roles and permissions + assertEquals(roles, context.getRoles(), "Roles should match"); + Set expectedPermissions = new HashSet<>(Arrays.asList("READ", "WRITE", SecurityServiceConfiguration.PERMISSION_DELETE)); + assertEquals(expectedPermissions, context.getPermissions(), "Permissions should be aggregated"); + + // Verify security service interactions + verify(securityService).getCurrentSubject(); + verify(securityService).extractRolesFromSubject(subject); + verify(securityService).getPermissionsForRole(UnomiRoles.ADMINISTRATOR); + verify(securityService).getPermissionsForRole(UnomiRoles.USER); + } + + @Test + public void testExecuteAsSystem() { + // Set up test data + Subject systemSubject = new Subject(); + Set systemRoles = new HashSet<>(Arrays.asList(UnomiRoles.ADMINISTRATOR)); + Set systemPermissions = new HashSet<>(Arrays.asList("READ", "WRITE", SecurityServiceConfiguration.PERMISSION_DELETE, "ADMIN")); + + // Mock security service behavior + when(securityService.getSystemSubject()).thenReturn(systemSubject); + when(securityService.extractRolesFromSubject(systemSubject)).thenReturn(systemRoles); + when(securityService.getPermissionsForRole(UnomiRoles.ADMINISTRATOR)).thenReturn(systemPermissions); + + // Execute system operation + String result = contextManager.executeAsSystem(() -> { + ExecutionContext context = contextManager.getCurrentContext(); + assertTrue(context.hasRole(UnomiRoles.ADMINISTRATOR), "Should have admin role"); + assertTrue(context.hasPermission("ADMIN"), "Should have admin permission"); + return "success"; + }); + + assertEquals("success", result, "Operation should execute successfully"); + + // Verify security service interactions + verify(securityService).getSystemSubject(); + verify(securityService).extractRolesFromSubject(systemSubject); + verify(securityService).getPermissionsForRole(UnomiRoles.ADMINISTRATOR); + } + + @Test + public void testExecuteAsTenant() { + // Set up test data + Subject subject = new Subject(); + Set roles = new HashSet<>(Arrays.asList(UnomiRoles.USER)); + Set permissions = new HashSet<>(Arrays.asList("READ")); + + // Mock security service behavior + when(securityService.getCurrentSubject()).thenReturn(subject); + when(securityService.extractRolesFromSubject(subject)).thenReturn(roles); + when(securityService.getPermissionsForRole(UnomiRoles.USER)).thenReturn(permissions); + + // Execute tenant operation + String result = contextManager.executeAsTenant("testTenant", () -> { + ExecutionContext context = contextManager.getCurrentContext(); + assertEquals("testTenant", context.getTenantId(), "Should have correct tenant"); + assertTrue(context.hasRole(UnomiRoles.USER), "Should have user role"); + assertTrue(context.hasPermission("READ"), "Should have read permission"); + return "success"; + }); + + assertEquals("success", result, "Operation should execute successfully"); + + // Verify security service interactions + verify(securityService).getCurrentSubject(); + verify(securityService).extractRolesFromSubject(subject); + verify(securityService).getPermissionsForRole(UnomiRoles.USER); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java new file mode 100644 index 000000000..8e63ca86f --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java @@ -0,0 +1,2867 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import com.fasterxml.jackson.databind.SerializationFeature; +import org.apache.commons.beanutils.PropertyUtils; +import org.apache.unomi.api.*; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.query.DateRange; +import org.apache.unomi.api.query.IpRange; +import org.apache.unomi.api.query.NumericRange; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.TenantTransformationListener; +import org.apache.unomi.persistence.spi.CustomObjectMapper; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.aggregate.*; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.text.Normalizer; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * An in-memory implementation of PersistenceService for testing purposes. + */ +public class InMemoryPersistenceServiceImpl implements PersistenceService { + + private static final Logger LOGGER = LoggerFactory.getLogger(InMemoryPersistenceServiceImpl.class); + private static final Pattern SAFE_FILENAME_PATTERN = Pattern.compile("[^a-zA-Z0-9-_.]"); + public static final String DEFAULT_STORAGE_DIR = "data/persistence"; + + // System items list - matches Elasticsearch/OpenSearch persistence services + // System items have their itemType appended to the document ID: tenantId_itemId_itemType + private static final Collection systemItems = Arrays.asList("actionType", "campaign", "campaignevent", "goal", "userList", + "propertyType", "scope", "conditionType", "rule", "scoring", "segment", "groovyAction", "topic", "patch", "jsonSchema", + "importConfig", "exportConfig", "rulestats"); + + private final Map itemsById = new ConcurrentHashMap<>(); + private final Map>> propertyMappings = new ConcurrentHashMap<>(); + private final Map scrollStates = new ConcurrentHashMap<>(); + private final Map sequenceNumbersByIndex = new ConcurrentHashMap<>(); + private final Map primaryTermsByIndex = new ConcurrentHashMap<>(); + private final Map fileLocks = new ConcurrentHashMap<>(); + private final ConditionEvaluatorDispatcher conditionEvaluatorDispatcher; + private final ExecutionContextManager executionContextManager; + private final CustomObjectMapper objectMapper; + private final Path storageRootPath; + private final boolean fileStorageEnabled; + private final boolean clearStorageOnInit; + private final boolean prettyPrintJson; + + // Refresh delay simulation (simulates Elasticsearch/OpenSearch behavior) + private final boolean simulateRefreshDelay; + private final long refreshIntervalMs; + private final Map pendingRefreshItems = new ConcurrentHashMap<>(); // itemKey -> timestamp when it becomes available + private final Set refreshedIndexes = ConcurrentHashMap.newKeySet(); // indexes that have been refreshed + private Thread refreshThread; + private volatile boolean shutdownRefreshThread = false; + + // Refresh policy per item type (simulates Elasticsearch/OpenSearch refresh policies) + // Valid values: False/NONE (default - wait for automatic refresh), True/IMMEDIATE (immediate refresh), WaitFor/WAIT_UNTIL (wait for refresh) + private final Map itemTypeToRefreshPolicy = new ConcurrentHashMap<>(); + + // Default query limit (simulates Elasticsearch/OpenSearch default query limit) + private Integer defaultQueryLimit = 10; + + // Tenant transformation listeners (simulates Elasticsearch/OpenSearch tenant transformations) + private final List transformationListeners = new ArrayList<>(); + + /** + * Refresh policy enum that simulates Elasticsearch/OpenSearch refresh behavior. + * + * - FALSE/NONE: Don't refresh immediately, wait for automatic refresh (default behavior) + * - TRUE/IMMEDIATE: Force an immediate refresh after indexing + * - WAIT_FOR/WAIT_UNTIL: Wait for refresh to complete before returning (similar to True but with different semantics) + */ + public enum RefreshPolicy { + /** + * FALSE/NONE - Don't refresh immediately. Changes become visible after the next automatic refresh. + * This is the default and most efficient option. + */ + FALSE, + + /** + * TRUE/IMMEDIATE - Force an immediate refresh after indexing. + * Changes are immediately visible but more resource-intensive. + */ + TRUE, + + /** + * WAIT_FOR/WAIT_UNTIL - Wait for refresh to complete before returning. + * Similar to TRUE but ensures the refresh operation completes before the request returns. + */ + WAIT_FOR + } + + private static class ScrollState { + private final List items; + private final int currentPosition; + private final long expiryTime; + private final int pageSize; + + public ScrollState(List items, int currentPosition, long expiryTime, int pageSize) { + this.items = items; + this.currentPosition = currentPosition; + this.expiryTime = expiryTime; + this.pageSize = pageSize; + } + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher) { + this(executionContextManager, conditionEvaluatorDispatcher, DEFAULT_STORAGE_DIR, true, true, true, true, 1000L); + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, String storageDir) { + this(executionContextManager, conditionEvaluatorDispatcher, storageDir, true, true, true, true, 1000L); + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, String storageDir, boolean enableFileStorage) { + this(executionContextManager, conditionEvaluatorDispatcher, storageDir, enableFileStorage, true, true, true, 1000L); + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, + String storageDir, boolean enableFileStorage, boolean clearStorageOnInit, boolean prettyPrintJson) { + this(executionContextManager, conditionEvaluatorDispatcher, storageDir, enableFileStorage, clearStorageOnInit, prettyPrintJson, true, 1000L); + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, + String storageDir, boolean enableFileStorage, boolean clearStorageOnInit, boolean prettyPrintJson, boolean simulateRefreshDelay, long refreshIntervalMs) { + this(executionContextManager, conditionEvaluatorDispatcher, storageDir, enableFileStorage, clearStorageOnInit, prettyPrintJson, simulateRefreshDelay, refreshIntervalMs, 10); + } + + public InMemoryPersistenceServiceImpl(ExecutionContextManager executionContextManager, ConditionEvaluatorDispatcher conditionEvaluatorDispatcher, + String storageDir, boolean enableFileStorage, boolean clearStorageOnInit, boolean prettyPrintJson, boolean simulateRefreshDelay, long refreshIntervalMs, int defaultQueryLimit) { + this.executionContextManager = executionContextManager; + this.conditionEvaluatorDispatcher = conditionEvaluatorDispatcher; + this.fileStorageEnabled = enableFileStorage; + this.clearStorageOnInit = clearStorageOnInit; + this.prettyPrintJson = prettyPrintJson; + this.simulateRefreshDelay = simulateRefreshDelay; + this.refreshIntervalMs = refreshIntervalMs > 0 ? refreshIntervalMs : 1000L; + this.defaultQueryLimit = defaultQueryLimit > 0 ? defaultQueryLimit : 10; + + // Initialize objectMapper even when file storage is disabled - it's needed for property mapping + this.objectMapper = new CustomObjectMapper(); + if (prettyPrintJson) { + this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + } + + if (fileStorageEnabled) { + this.storageRootPath = Paths.get(storageDir).toAbsolutePath().normalize(); + LOGGER.debug("Using storage root path: {}", storageRootPath); + + // Create storage directory if it doesn't exist + try { + if (clearStorageOnInit && Files.exists(storageRootPath)) { + // Delete all contents of the storage directory + Files.walk(storageRootPath) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + LOGGER.error("Failed to delete path: {}", path, e); + } + }); + } + Files.createDirectories(storageRootPath); + loadPersistedItems(); + } catch (IOException e) { + // For AccessDeniedException (common in tests), log without stack trace to reduce noise + if (e instanceof AccessDeniedException) { + LOGGER.error("Failed to create or access storage directory: {} - {}", storageRootPath, e.getMessage()); + } else { + LOGGER.error("Failed to create or access storage directory: {}", storageRootPath, e); + } + throw new RuntimeException("Failed to initialize storage", e); + } + } else { + this.storageRootPath = null; + } + + // Start background refresh thread if refresh delay simulation is enabled + if (simulateRefreshDelay) { + startRefreshThread(); + } + } + + /** + * Starts the background thread that periodically refreshes indexes, simulating Elasticsearch/OpenSearch behavior. + * By default, Elasticsearch refreshes indexes every 1 second, making newly indexed documents searchable. + */ + private void startRefreshThread() { + refreshThread = new Thread(() -> { + while (!shutdownRefreshThread) { + try { + Thread.sleep(refreshIntervalMs); + performPeriodicRefresh(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + LOGGER.error("Error in refresh thread", e); + } + } + }, "InMemoryPersistenceService-RefreshThread"); + refreshThread.setDaemon(true); + refreshThread.start(); + } + + /** + * Performs periodic refresh of pending items, making them available for querying. + * This simulates Elasticsearch's automatic refresh behavior. + */ + private void performPeriodicRefresh() { + long currentTime = System.currentTimeMillis(); + Set itemsToRefresh = new HashSet<>(); + + for (Map.Entry entry : pendingRefreshItems.entrySet()) { + if (entry.getValue() <= currentTime) { + itemsToRefresh.add(entry.getKey()); + } + } + + if (!itemsToRefresh.isEmpty()) { + for (String itemKey : itemsToRefresh) { + pendingRefreshItems.remove(itemKey); + // Extract index name from itemKey (format: "index:itemId:tenantId") + String[] parts = itemKey.split(":", 3); + if (parts.length >= 1) { + refreshedIndexes.add(parts[0]); + } + } + LOGGER.debug("Periodically refreshed {} items", itemsToRefresh.size()); + } + } + + /** + * Shuts down the refresh thread. Should be called when the service is being destroyed. + */ + public void shutdown() { + shutdownRefreshThread = true; + if (refreshThread != null) { + refreshThread.interrupt(); + try { + refreshThread.join(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private void loadPersistedItems() { + if (!fileStorageEnabled) { + return; + } + + try { + if (Files.exists(storageRootPath)) { + Files.walk(storageRootPath) + .filter(path -> path.toString().endsWith(".json")) + .forEach(this::loadItemFromFile); + } + } catch (IOException e) { + throw new RuntimeException("Failed to load persisted items from " + storageRootPath, e); + } + } + + private void loadItemFromFile(Path filePath) { + try { + String json = Files.readString(filePath, StandardCharsets.UTF_8); + Item item = objectMapper.readValue(json, Item.class); + String key = getKey(item.getItemId(), getIndex(item.getClass())); + itemsById.put(key, item); + // Items loaded from file are considered immediately available (already persisted) + // They don't need to wait for refresh + if (simulateRefreshDelay) { + String indexName = getIndexName(item); + refreshedIndexes.add(indexName); + } + } catch (IOException e) { + throw new RuntimeException("Failed to load item from file: " + filePath, e); + } + } + + private String sanitizePathComponent(String input) { + if (input == null || input.isEmpty()) { + return "_"; + } + + // Normalize to ASCII and replace dots with underscores + String normalized = Normalizer.normalize(input, Normalizer.Form.NFD) + .replaceAll("[^\\x00-\\x7F]", "") + .replace('.', '_'); + + // Replace unsafe characters with underscore + String safe = SAFE_FILENAME_PATTERN.matcher(normalized).replaceAll("_"); + + // Ensure the string is not empty and doesn't start with a dot + if (safe.isEmpty() || safe.startsWith("_")) { + safe = "x" + safe; + } + + return safe; + } + + private Path getItemPath(Item item) { + String indexName = getIndexName(item); + indexName = sanitizePathComponent(indexName); + String tenantId = sanitizePathComponent(item.getTenantId()); + String itemId = sanitizePathComponent(item.getItemId()); + + return storageRootPath.resolve(indexName) + .resolve(tenantId) + .resolve(itemId + ".json"); + } + + private void persistItem(Item item) { + if (!fileStorageEnabled) { + return; + } + + Path itemPath = getItemPath(item); + String pathKey = itemPath.toString(); + Object lock = fileLocks.computeIfAbsent(pathKey, k -> new Object()); + + synchronized (lock) { + // Retry logic for handling transient file system issues in concurrent scenarios + int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + // Create parent directories, handling race conditions where directory might already exist + // or be created/deleted by another thread + Path parent = itemPath.getParent(); + if (parent != null) { + try { + Files.createDirectories(parent); + } catch (IOException e) { + // Files.createDirectories should not throw if directory exists, so if it throws, + // check if directory was created by another thread (race condition) + if (Files.exists(parent) && Files.isDirectory(parent)) { + // Directory exists (created by another thread), continue + } else { + // Directory doesn't exist - this might be a transient issue, retry + retryCount++; + if (retryCount < maxRetries) { + try { + Thread.sleep(10); // Small delay before retry + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry", ie); + } + continue; + } + LOGGER.error("Failed to create parent directory after {} retries: {}", retryCount, parent, e); + throw new RuntimeException("Failed to create parent directory", e); + } + } + } + + String json = objectMapper.writeValueAsString(item); + Files.writeString(itemPath, json, StandardCharsets.UTF_8); + // Success - break out of retry loop + return; + } catch (IOException e) { + retryCount++; + if (retryCount < maxRetries) { + // Check if file exists (might have been created by another thread or previous attempt) + if (Files.exists(itemPath)) { + // File exists, operation might have succeeded - verify by trying to read it + try { + Files.readString(itemPath); + // File exists and is readable, consider operation successful + return; + } catch (IOException readException) { + // File exists but can't be read, might be in use - retry + } + } + try { + Thread.sleep(10); // Small delay before retry + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry", ie); + } + } else { + LOGGER.error("Failed to persist item to file after {} retries: {}", retryCount, itemPath, e); + throw new RuntimeException("Failed to persist item", e); + } + } + } + } + } + + private void deleteItemFile(Item item) { + if (!fileStorageEnabled) { + return; + } + + Path itemPath = getItemPath(item); + String pathKey = itemPath.toString(); + Object lock = fileLocks.computeIfAbsent(pathKey, k -> new Object()); + + synchronized (lock) { + try { + Files.deleteIfExists(itemPath); + + // Try to clean up empty directories + Path parent = itemPath.getParent(); + while (parent != null && !parent.equals(storageRootPath)) { + try { + if (Files.exists(parent)) { + try (Stream stream = Files.list(parent)) { + // Check if directory is truly empty + if (stream.findFirst().isEmpty()) { + // Directory is empty, try to delete it + try { + Files.delete(parent); + parent = parent.getParent(); + } catch (DirectoryNotEmptyException e) { + // Directory became non-empty between check and delete (race condition) + // This is expected in concurrent scenarios - just stop cleanup + break; + } catch (IOException e) { + // Directory may have been deleted by another thread + // This is expected in concurrent scenarios - just stop cleanup + break; + } + } else { + // Directory is not empty, stop cleanup + break; + } + } + } else { + // Directory doesn't exist, stop cleanup + break; + } + } catch (IOException e) { + // Directory may have been deleted by another thread, or may not be empty + // This is expected in concurrent scenarios - just stop cleanup + break; + } + } + } catch (IOException e) { + LOGGER.error("Failed to delete item file: {}", itemPath, e); + } + } + } + + @Override + public boolean isValidCondition(Condition condition, Item item) { + if (condition == null) { + return false; + } + if (conditionEvaluatorDispatcher == null) { + throw new IllegalStateException("ConditionEvaluatorDispatcher is not set"); + } + try { + return conditionEvaluatorDispatcher.eval(condition, item); + } catch (Exception e) { + return false; + } + } + + private String getKey(String itemId, String index) { + return index + ":" + itemId + ":" + executionContextManager.getCurrentContext().getTenantId(); + } + + /** + * Get the document ID for an item type, matching Elasticsearch/OpenSearch format. + * For system items, the format is: tenantId_itemId_itemType + * For non-system items, the format is: tenantId_itemId + * + * @param itemId the item ID + * @param itemType the item type + * @return the document ID + */ + private String getDocumentIDForItemType(String itemId, String itemType) { + String tenantId = executionContextManager.getCurrentContext().getTenantId(); + String baseId = systemItems.contains(itemType) ? (itemId + "_" + itemType.toLowerCase()) : itemId; + return tenantId + "_" + baseId; + } + + /** + * Strip tenant prefix from document ID, matching Elasticsearch/OpenSearch format. + * + * @param documentId the document ID + * @return the document ID without tenant prefix + */ + private String stripTenantFromDocumentId(String documentId) { + if (documentId == null) { + return null; + } + String tenantId = executionContextManager.getCurrentContext().getTenantId(); + if (documentId.startsWith(tenantId + "_")) { + return documentId.substring(tenantId.length() + 1); + } + // Also check for system tenant + String systemTenant = "system"; + if (documentId.startsWith(systemTenant + "_")) { + return documentId.substring(systemTenant.length() + 1); + } + return documentId; + } + + /** + * Extract itemId from document ID for system items, matching Elasticsearch/OpenSearch behavior. + * For system items, document ID format is: tenantId_itemId_itemType + * This method removes the tenant prefix and itemType suffix to get the actual itemId. + * + * @param documentId the document ID (may include tenant prefix) + * @param itemType the item type + * @return the extracted itemId + */ + private String extractItemIdFromDocumentId(String documentId, String itemType) { + if (documentId == null) { + return null; + } + String strippedId = stripTenantFromDocumentId(documentId); + if (!systemItems.contains(itemType)) { + // For non-system items, the stripped ID is the itemId + return strippedId; + } else { + // For system items, check if there's an itemType suffix and remove it + // This handles the case where document ID is: tenantId_itemId_itemType + String itemTypeSuffix = "_" + itemType.toLowerCase(); + if (strippedId != null && strippedId.endsWith(itemTypeSuffix)) { + return strippedId.substring(0, strippedId.length() - itemTypeSuffix.length()); + } + // If no suffix, return as-is (might be from old format or migration) + return strippedId; + } + } + + /** + * Get the sequence number for an item, incrementing it if necessary + * @param indexName the index name + * @param increment whether to increment the sequence number + * @return the sequence number + */ + private Long getSequenceNumber(String indexName, boolean increment) { + if (increment) { + return sequenceNumbersByIndex.compute(indexName, (k, v) -> v != null ? v + 1 : 1L); + } else { + return sequenceNumbersByIndex.computeIfAbsent(indexName, k -> 0L); + } + } + + /** + * Get the primary term for an index + * @param indexName the index name + * @return the primary term + */ + private Long getPrimaryTerm(String indexName) { + return primaryTermsByIndex.computeIfAbsent(indexName, k -> 1L); + } + + @Override + public String getName() { + return "InMemoryPersistenceServiceImpl"; + } + + @Override + public boolean save(Item item) { + if (item.getItemId() == null) { + item.setItemId(UUID.randomUUID().toString()); + } + return save(item, false, true); + } + + @Override + public boolean save(Item item, boolean useBatching) { + return save(item, useBatching, true); + } + + @Override + public boolean save(Item item, Boolean useBatching, Boolean alwaysOverwrite) { + if (item == null) { + return false; + } + + item.setTenantId(executionContextManager.getCurrentContext().getTenantId()); + + // Apply tenant transformations before save (simulates Elasticsearch/OpenSearch behavior) + item = handleItemTransformation(item); + + String indexName = getIndexName(item); + String key = getKey(item.getItemId(), indexName); + Item existingItem = itemsById.get(key); + + // Handle _seq_no and _primary_term fields using system metadata + // Check for optimistic concurrency control + if (existingItem != null) { + Object existingSeqNo = existingItem.getSystemMetadata("_seq_no"); + Object existingPrimaryTerm = existingItem.getSystemMetadata("_primary_term"); + + // If the item has _seq_no and _primary_term specified, check them against the existing item + Object requestedSeqNo = item.getSystemMetadata("_seq_no"); + Object requestedPrimaryTerm = item.getSystemMetadata("_primary_term"); + + if (requestedSeqNo != null && requestedPrimaryTerm != null) { + // If sequence numbers don't match the existing ones, it's a conflict + if (existingSeqNo != null && + ((Number) requestedSeqNo).longValue() != ((Number) existingSeqNo).longValue()) { + LOGGER.warn("Sequence number conflict detected for item {}: requested={}, current={}", + item.getItemId(), requestedSeqNo, existingSeqNo); + return false; + } + + // If primary terms don't match, it's a conflict + if (existingPrimaryTerm != null && + ((Number) requestedPrimaryTerm).longValue() != ((Number) existingPrimaryTerm).longValue()) { + LOGGER.warn("Primary term conflict detected for item {}: requested={}, current={}", + item.getItemId(), requestedPrimaryTerm, existingPrimaryTerm); + return false; + } + } + } + + // Get sequence number for this item + Long currentSeqNo = getSequenceNumber(indexName, true); + + // Get primary term for this index + Long currentPrimaryTerm = getPrimaryTerm(indexName); + + // Set the new sequence number and primary term on the item + item.setSystemMetadata("_seq_no", currentSeqNo); + item.setSystemMetadata("_primary_term", currentPrimaryTerm); + + // Handle item versioning (the existing version system) + if ((existingItem == null || existingItem.getVersion() == null) && (item.getVersion() == null)) { + // New item or item without version, set initial version + item.setVersion(1L); + } else { + // Existing item being updated, increment version + if (existingItem != null && existingItem.getVersion() != null) { + item.setVersion(existingItem.getVersion() + 1); + } else { + item.setVersion(item.getVersion() + 1); + } + } + + itemsById.put(key, item); + + if (fileStorageEnabled) { + persistItem(item); + } + + // Handle refresh policy per item type (simulates Elasticsearch/OpenSearch refresh policies) + // Request-based override (via system metadata) takes precedence over per-item-type policy + if (simulateRefreshDelay) { + String itemType = item.getItemType(); + if (item instanceof CustomItem) { + String customItemType = ((CustomItem) item).getCustomItemType(); + if (customItemType != null) { + itemType = customItemType; + } + } + RefreshPolicy refreshPolicy = getRefreshPolicy(itemType, item); + + switch (refreshPolicy) { + case TRUE: + case WAIT_FOR: + // Immediate refresh: make item available immediately + // For WAIT_FOR, we also ensure refresh completes (same behavior as TRUE in in-memory) + pendingRefreshItems.remove(key); + refreshedIndexes.add(indexName); + LOGGER.debug("Immediately refreshed item {} of type {} due to refresh policy {}", + item.getItemId(), itemType, refreshPolicy); + break; + case FALSE: + default: + // Default behavior: wait for automatic refresh + long refreshTime = System.currentTimeMillis() + refreshIntervalMs; + pendingRefreshItems.put(key, refreshTime); + break; + } + } + + return true; + } + + /** + * Gets the refresh policy for a given item type. + * Checks for request-based override in item's system metadata first, + * then falls back to per-item-type policy, and finally defaults to FALSE. + * + * @param itemType the item type + * @param item the item (may be null) - used to check for request-based override + * @return the refresh policy for this item type + */ + private RefreshPolicy getRefreshPolicy(String itemType, Item item) { + // Check for request-based refresh policy override in system metadata + // This simulates Elasticsearch/OpenSearch's refresh parameter in API requests + if (item != null) { + Object refreshOverride = item.getSystemMetadata("refresh"); + if (refreshOverride != null) { + if (refreshOverride instanceof RefreshPolicy) { + return (RefreshPolicy) refreshOverride; + } else if (refreshOverride instanceof String) { + RefreshPolicy parsed = parseRefreshPolicy((String) refreshOverride); + LOGGER.debug("Using request-based refresh policy override: {} for item type {}", parsed, itemType); + return parsed; + } else if (refreshOverride instanceof Boolean) { + // Support boolean values: true -> TRUE, false -> FALSE + return ((Boolean) refreshOverride) ? RefreshPolicy.TRUE : RefreshPolicy.FALSE; + } + } + } + + // Fall back to per-item-type policy + return itemTypeToRefreshPolicy.getOrDefault(itemType, RefreshPolicy.FALSE); + } + + /** + * Gets the refresh policy for a given item type (without item context). + * Used when item is not available. + * + * @param itemType the item type + * @return the refresh policy for this item type + */ + private RefreshPolicy getRefreshPolicy(String itemType) { + return itemTypeToRefreshPolicy.getOrDefault(itemType, RefreshPolicy.FALSE); + } + + /** + * Sets the refresh policy for a specific item type. + * This simulates Elasticsearch/OpenSearch's itemTypeToRefreshPolicy configuration. + * + * @param itemType the item type + * @param refreshPolicy the refresh policy (FALSE, TRUE, or WAIT_FOR) + */ + public void setRefreshPolicy(String itemType, RefreshPolicy refreshPolicy) { + if (itemType != null && refreshPolicy != null) { + itemTypeToRefreshPolicy.put(itemType, refreshPolicy); + LOGGER.debug("Set refresh policy for item type {} to {}", itemType, refreshPolicy); + } + } + + /** + * Sets refresh policies from a JSON string, matching Elasticsearch/OpenSearch configuration format. + * Example: {"event":"WAIT_FOR","rule":"FALSE","scheduledTask":"TRUE"} + * + * @param refreshPolicyJson JSON string mapping item types to refresh policies + */ + public void setItemTypeToRefreshPolicy(String refreshPolicyJson) { + if (refreshPolicyJson == null || refreshPolicyJson.trim().isEmpty()) { + return; + } + + try { + if (objectMapper != null) { + @SuppressWarnings("unchecked") + Map policies = objectMapper.readValue(refreshPolicyJson, Map.class); + for (Map.Entry entry : policies.entrySet()) { + String itemType = entry.getKey(); + String policyStr = entry.getValue(); + try { + RefreshPolicy policy = RefreshPolicy.valueOf(policyStr.toUpperCase()); + setRefreshPolicy(itemType, policy); + } catch (IllegalArgumentException e) { + // Try to map Elasticsearch/OpenSearch string values + RefreshPolicy policy = parseRefreshPolicy(policyStr); + setRefreshPolicy(itemType, policy); + } + } + } + } catch (Exception e) { + LOGGER.error("Failed to parse refresh policy JSON: {}", refreshPolicyJson, e); + } + } + + /** + * Parses refresh policy string values from Elasticsearch/OpenSearch configuration. + * Supports: False/NONE, True/IMMEDIATE, WaitFor/WAIT_UNTIL + * + * @param policyStr the policy string + * @return the corresponding RefreshPolicy enum value + */ + private RefreshPolicy parseRefreshPolicy(String policyStr) { + if (policyStr == null) { + return RefreshPolicy.FALSE; + } + + String upper = policyStr.toUpperCase(); + if ("FALSE".equals(upper) || "NONE".equals(upper)) { + return RefreshPolicy.FALSE; + } else if ("TRUE".equals(upper) || "IMMEDIATE".equals(upper)) { + return RefreshPolicy.TRUE; + } else if ("WAITFOR".equals(upper) || "WAIT_FOR".equals(upper) || "WAIT_UNTIL".equals(upper)) { + return RefreshPolicy.WAIT_FOR; + } + + LOGGER.warn("Unknown refresh policy value: {}, defaulting to FALSE", policyStr); + return RefreshPolicy.FALSE; + } + + /** + * Checks if an item is available for querying (i.e., has been refreshed). + * In Elasticsearch/OpenSearch, items are not immediately available after indexing. + * + * @param itemKey the item key (format: "index:itemId:tenantId") + * @param indexName the index name + * @return true if the item is available for querying, false otherwise + */ + private boolean isItemAvailableForQuery(String itemKey, String indexName) { + if (!simulateRefreshDelay) { + return true; // If refresh delay is disabled, all items are immediately available + } + + // Per-item pending state takes priority: a FALSE-policy save after a TRUE save on the + // same index must still defer visibility for that specific item. + Long refreshTime = pendingRefreshItems.get(itemKey); + if (refreshTime != null) { + return System.currentTimeMillis() >= refreshTime; + } + + // Item not explicitly deferred: available (index-refreshed, loaded from file, etc.) + return true; + } + + private String getIndexName(Item item) { + String indexName = getIndex(item.getClass()); + if (item instanceof CustomItem) { + String customItemType = ((CustomItem) item).getCustomItemType(); + if (customItemType != null) { + indexName = customItemType; + } else { + indexName = item.getItemType(); + } + } + return indexName; + } + + @Override + public T load(String itemId, Class clazz) { + Item item = itemsById.get(getKey(itemId, getIndex(clazz))); + if (item != null && clazz.isAssignableFrom(item.getClass()) && executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) { + // Apply reverse tenant transformations after load (simulates Elasticsearch/OpenSearch behavior) + return (T) handleItemReverseTransformation(item); + } + return null; + } + + @Override + public T load(String itemId, Date dateHint, Class clazz) { + return load(itemId, clazz); + } + + @Override + public boolean remove(String itemId, Class clazz) { + String key = getKey(itemId, getIndex(clazz)); + Item item = itemsById.get(key); + if (item != null && clazz.isAssignableFrom(item.getClass()) && + executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + deleteItemFile(item); + return true; + } + return false; + } + + @Override + public boolean removeByQuery(Condition condition, Class clazz) { + // In Elasticsearch, deleteByQuery works on all matching documents regardless of refresh status + // So we need to query all items, not just refreshed ones + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + List itemsToRemove = new ArrayList<>(); + + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (clazz.isAssignableFrom(item.getClass()) && + currentTenantId.equals(item.getTenantId())) { + // Check condition if provided (but don't filter by refresh status for delete operations) + if (condition == null || testMatch(condition, item)) { + itemsToRemove.add((T) item); + } + } + } + + for (T item : itemsToRemove) { + remove(item.getItemId(), clazz); + } + return true; + } + + @Override + public List getAllItems(Class clazz) { + return filterItemsByClass(clazz); + } + + @Override + public PartialList getAllItems(Class clazz, int offset, int size, String sortBy) { + List items = filterItemsByClass(clazz); + items = sortItems(items, sortBy); + return createPartialList(items, offset, size); + } + + @Override + public List query(Condition condition, String sortBy, Class clazz) { + List items = filterItemsByClass(clazz); + items = filterItemsByCondition(items, condition); + return sortItems(items, sortBy); + } + + @Override + public PartialList query(Condition condition, String sortBy, Class clazz, int offset, int size) { + List items = query(condition, sortBy, clazz); + return createPartialList(items, offset, size); + } + + @Override + public List query(String fieldName, String fieldValue, String sortBy, Class clazz) { + List items = filterItemsByClass(clazz); + items = items.stream() + .filter(item -> matchesField(item, fieldName, fieldValue)) + .collect(Collectors.toList()); + return sortItems(items, sortBy); + } + + @Override + public List query(String fieldName, String[] fieldValues, String sortBy, Class clazz) { + if (fieldValues == null || fieldValues.length == 0) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(); + for (String fieldValue : fieldValues) { + results.addAll(query(fieldName, fieldValue, sortBy, clazz)); + } + // deduplicate while preserving order + return new ArrayList<>(new LinkedHashSet<>(results)); + } + + @Override + public PartialList query(String fieldName, String fieldValue, String sortBy, Class clazz, int offset, int size) { + List items = query(fieldName, fieldValue, sortBy, clazz); + return createPartialList(items, offset, size); + } + + /** + * Test-harness helper for range queries. Not on PersistenceService on master yet. + */ + public PartialList rangeQuery(String fieldName, String from, String to, String sortBy, Class clazz, int offset, int size) { + List items = filterItemsByClass(clazz); + items = items.stream() + .filter(item -> isInRange(item, fieldName, from, to)) + .collect(Collectors.toList()); + items = sortItems(items, sortBy); + return createPartialList(items, offset, size); + } + + private boolean isInRange(Item item, String fieldName, String from, String to) { + try { + Object value = PropertyUtils.getProperty(item, fieldName); + if (value instanceof Comparable) { + Comparable comparableValue = (Comparable) value; + Comparable fromValue = from != null ? (Comparable) convertValue(from, value.getClass()) : null; + Comparable toValue = to != null ? (Comparable) convertValue(to, value.getClass()) : null; + + boolean matches = true; + if (fromValue != null) { + matches = matches && comparableValue.compareTo(fromValue) >= 0; + } + if (toValue != null) { + matches = matches && comparableValue.compareTo(toValue) <= 0; + } + return matches; + } + } catch (Exception e) { + LOGGER.debug("Error checking range for field: " + fieldName, e); + } + return false; + } + + private Object convertValue(String value, Class targetType) { + if (targetType == Integer.class) { + return Integer.parseInt(value); + } else if (targetType == Long.class) { + return Long.parseLong(value); + } else if (targetType == Double.class) { + return Double.parseDouble(value); + } else if (targetType == Float.class) { + return Float.parseFloat(value); + } else if (targetType == String.class) { + return value; + } + return value; + } + + @Override + public PartialList query(Condition condition, String sortBy, Class clazz, int offset, int size, String scrollTimeValidity) { + List matchingItems = query(condition, sortBy, clazz); + int totalSize = matchingItems.size(); + + // Apply default query limit when size < 0 (simulates Elasticsearch/OpenSearch behavior) + int effectiveSize = size < 0 ? defaultQueryLimit : size; + + // If size is negative and no scroll, use default limit + if (size < 0 && scrollTimeValidity == null) { + int fromIndex = Math.min(offset, totalSize); + int toIndex = Math.min(offset + effectiveSize, totalSize); + List pageItems = matchingItems.subList(fromIndex, toIndex); + // Preserve original size parameter in pageSize to indicate what was requested + return new PartialList<>(pageItems, offset, size, totalSize, PartialList.Relation.EQUAL); + } + + if (scrollTimeValidity != null) { + // Don't create scroll state for empty results or when all items fit in one page + if (totalSize == 0 || totalSize <= effectiveSize) { + List pageItems = matchingItems.subList(0, totalSize); + // Preserve original size parameter in pageSize + return new PartialList<>(pageItems, 0, size, totalSize, PartialList.Relation.EQUAL); + } + + // Generate a unique scroll ID + String scrollId = UUID.randomUUID().toString(); + // Parse scroll time validity (assuming it's in milliseconds) + long validityTime = getScrollTimeValidityMs(scrollTimeValidity); + // Store scroll state with filtered items + scrollStates.put(scrollId, new ScrollState(new ArrayList<>(matchingItems), effectiveSize, System.currentTimeMillis() + validityTime, effectiveSize)); + // Return first page with scroll ID + List pageItems = matchingItems.subList(0, effectiveSize); + // Preserve original size parameter in pageSize + PartialList partialList = new PartialList<>(pageItems, 0, size, totalSize, PartialList.Relation.EQUAL); + partialList.setScrollIdentifier(scrollId); + partialList.setScrollTimeValidity(scrollTimeValidity); + return partialList; + } + + int fromIndex = Math.min(offset, totalSize); + int toIndex = Math.min(offset + effectiveSize, totalSize); + List pageItems = matchingItems.subList(fromIndex, toIndex); + // Preserve original size parameter in pageSize + return new PartialList<>(pageItems, offset, size, totalSize, PartialList.Relation.EQUAL); + } + + @Override + public void setPropertyMapping(PropertyType property, String itemType) { + Map> mappings = propertyMappings.computeIfAbsent(itemType, k -> new HashMap<>()); + Map properties = mappings.computeIfAbsent("properties", k -> new HashMap<>()); + Map fieldProperties = (Map) properties.computeIfAbsent("properties", k -> new HashMap<>()); + + // Convert PropertyType to Map using CustomObjectMapper + try { + // First convert to JSON string then back to Map to ensure proper conversion + String jsonString = objectMapper.writeValueAsString(property); + @SuppressWarnings("unchecked") + Map propertyMap = objectMapper.readValue(jsonString, Map.class); + fieldProperties.put(property.getItemId(), propertyMap); + } catch (IOException e) { + throw new RuntimeException("Failed to convert PropertyType " + property.getItemId() + " to mapping", e); + } + } + + @Override + public Map> getPropertiesMapping(String itemType) { + return propertyMappings.get(itemType); + } + + @Override + public Map getPropertyMapping(String property, String itemType) { + Map> mappings = propertyMappings.get(itemType); + if (mappings == null || !mappings.containsKey("properties")) { + return null; + } + Map properties = (Map) mappings.get("properties"); + Map fieldProperties = (Map) properties.computeIfAbsent("properties", k -> new HashMap<>()); + return (Map) fieldProperties.get(property); + } + + @Override + public void refresh() { + if (simulateRefreshDelay) { + // Immediately refresh all pending items, making them available for querying + // This simulates Elasticsearch's refresh() API which forces an immediate refresh + int itemsRefreshed = pendingRefreshItems.size(); + Set indexesToRefresh = new HashSet<>(); + + for (Map.Entry entry : pendingRefreshItems.entrySet()) { + String itemKey = entry.getKey(); + // Extract index name from itemKey (format: "index:itemId:tenantId") + String[] parts = itemKey.split(":", 3); + if (parts.length >= 1) { + indexesToRefresh.add(parts[0]); + } + } + + // Clear all pending items + pendingRefreshItems.clear(); + + // Mark all indexes as refreshed + refreshedIndexes.addAll(indexesToRefresh); + LOGGER.debug("Manually refreshed all indexes, made {} items immediately available", itemsRefreshed); + } else { + LOGGER.debug("Refresh called on in-memory persistence service (refresh delay simulation disabled)"); + } + } + + @Override + public void purge(Date date) { + if (date == null) { + LOGGER.debug("Purging all items from all tenants (full reset)"); + itemsById.clear(); + pendingRefreshItems.clear(); + refreshedIndexes.clear(); + return; + } + + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + List keysToRemove = new ArrayList<>(); + + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + // Only purge items for the current tenant + if (currentTenantId.equals(item.getTenantId()) && + item.getCreationDate() != null && + item.getCreationDate().before(date)) { + keysToRemove.add(entry.getKey()); + if (fileStorageEnabled) { + deleteItemFile(item); + } + } + } + + for (String key : keysToRemove) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + } + + LOGGER.info("Purged {} items older than {} for tenant {}", keysToRemove.size(), date, currentTenantId); + } + + @Override + public void purge(String scope) { + if (scope == null) { + return; + } + + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + List keysToRemove = new ArrayList<>(); + + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + // Only purge items for the current tenant + if (currentTenantId.equals(item.getTenantId()) && + scope.equals(item.getScope())) { + keysToRemove.add(entry.getKey()); + if (fileStorageEnabled) { + deleteItemFile(item); + } + } + } + + for (String key : keysToRemove) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + } + + LOGGER.info("Purged {} items with scope {} for tenant {}", keysToRemove.size(), scope, currentTenantId); + } + + @Override + public void refreshIndex(Class clazz, Date dateHint) { + if (simulateRefreshDelay && clazz != null) { + // Immediately refresh the specified index, making all pending items in it available for querying + // This simulates Elasticsearch's refreshIndex() API which forces an immediate refresh of a specific index + String indexName = getIndex(clazz); + int itemsRefreshed = 0; + + // Remove all pending items for this index from the pending list + Set keysToRemove = new HashSet<>(); + for (Map.Entry entry : pendingRefreshItems.entrySet()) { + String itemKey = entry.getKey(); + // Extract index name from itemKey (format: "index:itemId:tenantId") + String[] parts = itemKey.split(":", 3); + if (parts.length >= 1 && indexName.equals(parts[0])) { + keysToRemove.add(itemKey); + itemsRefreshed++; + } + } + + for (String key : keysToRemove) { + pendingRefreshItems.remove(key); + } + + // Mark this index as refreshed + refreshedIndexes.add(indexName); + LOGGER.debug("Manually refreshed index {} for class {} with date hint {}, made {} items immediately available", + indexName, clazz.getName(), dateHint, itemsRefreshed); + } else { + if (clazz != null) { + LOGGER.debug("RefreshIndex called for class {} with date hint {} (refresh delay simulation disabled)", + clazz.getName(), dateHint); + } + } + } + + @Override + public void createMapping(String itemType, String mappingConfig) { + if (itemType == null || mappingConfig == null) { + throw new IllegalArgumentException("Item type and mapping configuration cannot be null"); + } + + try { + // Parse the mapping configuration using the object mapper + if (objectMapper != null) { + @SuppressWarnings("unchecked") + Map> mappingMap = objectMapper.readValue(mappingConfig, Map.class); + // Store the mapping configuration + propertyMappings.put(itemType, mappingMap); + LOGGER.info("Created mapping for item type: {}", itemType); + } else { + // If object mapper is null (file storage disabled), use a simple HashMap + Map> mappingMap = new HashMap<>(); + Map properties = new HashMap<>(); + properties.put("mappingConfig", mappingConfig); + mappingMap.put("properties", properties); + propertyMappings.put(itemType, mappingMap); + LOGGER.info("Created simple mapping for item type: {}", itemType); + } + } catch (IOException e) { + throw new RuntimeException("Failed to parse mapping configuration: " + e.getMessage(), e); + } + } + + @Override + public boolean removeIndex(String itemType) { + if (itemType == null) { + return false; + } + + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + + // We don't remove mappings as they are shared across tenants + // But we remove items of the specified type for the current tenant only + List keysToRemove = new ArrayList<>(); + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (itemType.equals(item.getItemType()) && + currentTenantId.equals(item.getTenantId())) { + keysToRemove.add(entry.getKey()); + if (fileStorageEnabled) { + deleteItemFile(item); + } + } + } + + for (String key : keysToRemove) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + } + + LOGGER.info("Removed index for item type {}, deleted {} items for tenant {}", + itemType, keysToRemove.size(), currentTenantId); + return true; + } + + @Override + public boolean createIndex(String itemType) { + if (itemType == null) { + return false; + } + + // For in-memory implementation, creating an index just means ensuring we have a mapping + if (!propertyMappings.containsKey(itemType)) { + propertyMappings.put(itemType, new HashMap<>()); + LOGGER.info("Created index for item type: {}", itemType); + } else { + LOGGER.debug("Index for item type {} already exists", itemType); + } + + // If file storage is enabled, ensure the directory exists + if (fileStorageEnabled) { + try { + Path indexPath = storageRootPath.resolve(sanitizePathComponent(itemType)); + Files.createDirectories(indexPath); + } catch (IOException e) { + LOGGER.error("Failed to create directory for item type: {}", itemType, e); + return false; + } + } + + return true; + } + + @Override + public boolean testMatch(Condition condition, Item item) { + if (condition == null) { + return true; + } + if (item == null) { + return false; + } + if (conditionEvaluatorDispatcher == null) { + throw new IllegalStateException("ConditionEvaluatorDispatcher is not set"); + } + return conditionEvaluatorDispatcher.eval(condition, item); + } + + @Override + public long getAllItemsCount(String itemType) { + if (itemType == null) { + return 0; + } + + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + LOGGER.debug("Counting all items of type {} for tenant {}", itemType, currentTenantId); + + // Filter by refresh status to simulate Elasticsearch/OpenSearch behavior + Map filteredItems = new HashMap<>(); + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (item.getItemType().equals(itemType) && currentTenantId.equals(item.getTenantId())) { + String itemKey = entry.getKey(); + if (isItemAvailableForQuery(itemKey, itemType)) { + filteredItems.put(itemKey, item); + } + } + } + return filteredItems.size(); + } + + @Override + public Map getSingleValuesMetrics(Condition condition, String[] metrics, String field, String itemType) { + if (metrics == null || metrics.length == 0 || field == null) { + return Collections.emptyMap(); + } + + String finalField; + if (field.endsWith(".keyword")) { + finalField = field.substring(0, field.length() - ".keyword".length()); + } else { + finalField = field; + } + + // Get all items of the specified type that match the condition and are available for querying + List matchingItems = new ArrayList<>(); + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (item.getItemType().equals(itemType)) { + String itemKey = entry.getKey(); + if (isItemAvailableForQuery(itemKey, itemType) && (condition == null || testMatch(condition, item))) { + matchingItems.add(item); + } + } + } + + Map results = new HashMap<>(); + + // Extract field values from matching items + List numericValues = matchingItems.stream() + .map(item -> { + Object value = getPropertyValue(item, finalField); + return value instanceof Number ? (Number) value : null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // Calculate requested metrics + for (String metric : metrics) { + switch (metric.toLowerCase()) { + case "card": + results.put("_card", (double) matchingItems.stream() + .map(item -> getPropertyValue(item, finalField)) + .filter(Objects::nonNull) + .distinct() + .count()); + break; + case "sum": + if (!numericValues.isEmpty()) { + results.put("_sum", numericValues.stream() + .mapToDouble(Number::doubleValue) + .sum()); + } + break; + case "min": + if (!numericValues.isEmpty()) { + results.put("_min", numericValues.stream() + .mapToDouble(Number::doubleValue) + .min() + .orElse(0.0)); + } + break; + case "max": + if (!numericValues.isEmpty()) { + results.put("_max", numericValues.stream() + .mapToDouble(Number::doubleValue) + .max() + .orElse(0.0)); + } + break; + case "avg": + if (!numericValues.isEmpty()) { + results.put("_avg", numericValues.stream() + .mapToDouble(Number::doubleValue) + .average() + .orElse(0.0)); + } + break; + } + } + + return results; + } + + @Override + public boolean update(Item item, Date dateHint, Class clazz, Map sourceMap) { + if (item == null || sourceMap == null || clazz == null) { + return false; + } + + @SuppressWarnings("unchecked") + String key = getKey(item.getItemId(), getIndex((Class) clazz)); + Item existingItem = itemsById.get(key); + if (existingItem == null || !clazz.isAssignableFrom(existingItem.getClass()) || + !executionContextManager.getCurrentContext().getTenantId().equals(existingItem.getTenantId())) { + return false; + } + + try { + // Update properties using PropertyUtils + for (Map.Entry entry : sourceMap.entrySet()) { + String propertyName = entry.getKey().toString(); + Object propertyValue = entry.getValue(); + try { + PropertyUtils.setProperty(existingItem, propertyName, propertyValue); + } catch (Exception e) { + LOGGER.debug("Failed to set property: " + propertyName, e); + return false; + } + } + + // Increment version + if (existingItem.getVersion() != null) { + existingItem.setVersion(existingItem.getVersion() + 1); + } else { + existingItem.setVersion(1L); + } + + // Apply tenant transformations before save (simulates Elasticsearch/OpenSearch behavior) + existingItem = handleItemTransformation(existingItem); + + // Save updated item + itemsById.put(key, existingItem); + if (fileStorageEnabled) { + persistItem(existingItem); + } + + // Handle refresh policy per item type for updates (same as save) + // Request-based override (via system metadata) takes precedence over per-item-type policy + if (simulateRefreshDelay) { + String itemType = existingItem.getItemType(); + if (existingItem instanceof CustomItem) { + String customItemType = ((CustomItem) existingItem).getCustomItemType(); + if (customItemType != null) { + itemType = customItemType; + } + } + String indexName = getIndexName(existingItem); + RefreshPolicy refreshPolicy = getRefreshPolicy(itemType, existingItem); + + switch (refreshPolicy) { + case TRUE: + case WAIT_FOR: + // Immediate refresh: make item available immediately + pendingRefreshItems.remove(key); + refreshedIndexes.add(indexName); + LOGGER.debug("Immediately refreshed updated item {} of type {} due to refresh policy {}", + existingItem.getItemId(), itemType, refreshPolicy); + break; + case FALSE: + default: + // Default behavior: wait for automatic refresh + long refreshTime = System.currentTimeMillis() + refreshIntervalMs; + pendingRefreshItems.put(key, refreshTime); + break; + } + } + + return true; + } catch (Exception e) { + LOGGER.error("Error updating item", e); + return false; + } + } + + @Override + public boolean update(Item item, Date dateHint, Class clazz, String propertyName, Object propertyValue) { + if (item == null || propertyName == null || clazz == null) { + return false; + } + + return update(item, dateHint, clazz, Collections.singletonMap(propertyName, propertyValue)); + } + + @Override + public boolean update(Item item, Date dateHint, Class clazz, Map sourceMap, boolean noScriptCall) { + // In this implementation, noScriptCall doesn't affect behavior since we don't execute scripts + return update(item, dateHint, clazz, sourceMap); + } + + @Override + public List update(Map items, Date dateHint, Class clazz) { + if (items == null || clazz == null) { + return null; + } + + List failedUpdates = new ArrayList<>(); + + for (Map.Entry entry : items.entrySet()) { + Item item = entry.getKey(); + Map sourceMap = entry.getValue(); + + if (!update(item, dateHint, clazz, sourceMap)) { + failedUpdates.add(item.getItemId()); + } + } + + return failedUpdates; + } + + @Override + public boolean updateWithScript(Item item, Date dateHint, Class clazz, String script, Map scriptParams) { + if (item == null || script == null) { + return false; + } + + // Execute the script based on its name/content + boolean success = false; + if (script.contains("updatePastEventOccurences")) { + success = executeUpdatePastEventOccurrencesScript(item, scriptParams); + } else if (script.contains("updateProfileId")) { + success = executeUpdateProfileIdScript(item, scriptParams); + } else if (script.contains("resetScoringPlan")) { + success = executeResetScoringPlanScript(item, scriptParams); + } else if (script.contains("evaluateScoringPlanElement")) { + success = executeEvaluateScoringPlanElementScript(item, scriptParams); + } + + // Throw for unrecognized scripts so callers notice missing handlers early + if (!success) { + String sanitizedScript = script.replaceAll("[\\r\\n]", "").substring(0, Math.min(script.length(), 100)); + throw new UnsupportedOperationException( + "No in-memory script handler for script: " + sanitizedScript + + ". Add a handler in updateWithScript() or use a supported script name."); + } + + return true; + } + + private boolean executeResetScoringPlanScript(Item item, Map params) { + try { + @SuppressWarnings("unchecked") + Map systemProperties = (Map) PropertyUtils.getProperty(item, "systemProperties"); + if (systemProperties == null) { + systemProperties = new HashMap<>(); + PropertyUtils.setProperty(item, "systemProperties", systemProperties); + } + + systemProperties.remove("scores"); + systemProperties.remove("scoringPlan"); + + save(item); + return true; + } catch (Exception e) { + LOGGER.error("Error executing resetScoringPlan script", e); + return false; + } + } + + private boolean executeEvaluateScoringPlanElementScript(Item item, Map params) { + try { + String scoringPlanId = (String) params.get("scoringPlanId"); + String elementId = (String) params.get("elementId"); + Integer score = (Integer) params.get("score"); + + @SuppressWarnings("unchecked") + Map systemProperties = (Map) PropertyUtils.getProperty(item, "systemProperties"); + if (systemProperties == null) { + systemProperties = new HashMap<>(); + PropertyUtils.setProperty(item, "systemProperties", systemProperties); + } + + @SuppressWarnings("unchecked") + Map scores = (Map) systemProperties.computeIfAbsent("scores", k -> new HashMap<>()); + scores.put(scoringPlanId + "--" + elementId, score); + + save(item); + return true; + } catch (Exception e) { + LOGGER.error("Error executing evaluateScoringPlanElement script", e); + return false; + } + } + + @Override + public boolean updateWithQueryAndScript(Class clazz, String[] scripts, Map[] scriptParams, Condition[] conditions) { + return updateWithQueryAndScript(null, clazz, scripts, scriptParams, conditions); + } + + @Override + public boolean updateWithQueryAndScript(Date dateHint, Class clazz, String[] scripts, Map[] scriptParams, Condition[] conditions) { + if (scripts == null || scriptParams == null || conditions == null || + scripts.length != scriptParams.length || scripts.length != conditions.length) { + return false; + } + + // In Elasticsearch, updateByQuery works on all matching documents regardless of refresh status + // So we need to query all items, not just refreshed ones (similar to removeByQuery) + boolean success = true; + for (int i = 0; i < scripts.length; i++) { + @SuppressWarnings({"unchecked", "rawtypes"}) + List items = new ArrayList<>(); + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + + // Query all items directly from itemsById, not filtering by refresh status + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (clazz.isAssignableFrom(item.getClass()) && + currentTenantId.equals(item.getTenantId())) { + // Check condition if provided (but don't filter by refresh status) + if (conditions[i] == null || testMatch(conditions[i], item)) { + items.add(item); + } + } + } + + for (Item item : items) { + success &= updateWithScript(item, dateHint, clazz, scripts[i], scriptParams[i]); + } + } + return success; + } + + @Override + public boolean updateWithQueryAndStoredScript(Class clazz, String[] scripts, Map[] scriptParams, Condition[] conditions) { + return updateWithQueryAndStoredScript(null, clazz, scripts, scriptParams, conditions); + } + + @Override + public boolean updateWithQueryAndStoredScript(Date dateHint, Class clazz, String[] scripts, Map[] scriptParams, Condition[] conditions) { + return updateWithQueryAndScript(dateHint, clazz, scripts, scriptParams, conditions); + } + + @Override + public boolean updateWithQueryAndStoredScript(Class[] classes, String[] scripts, Map[] scriptParams, Condition[] conditions, boolean waitForComplete) { + if (classes == null || classes.length != scripts.length) { + return false; + } + + boolean success = true; + for (int i = 0; i < scripts.length; i++) { + success &= updateWithQueryAndScript(null, classes[i], new String[]{scripts[i]}, new Map[]{scriptParams[i]}, new Condition[]{conditions[i]}); + } + return success; + } + + @Override + public boolean storeScripts(Map scripts) { + // In-memory implementation doesn't need to store scripts + return true; + } + + @Override + public PartialList queryCustomItem(Condition condition, String sortBy, String customItemType, int offset, int size, String scrollTimeValidity) { + // Get all items that match the item type and are available for querying + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + List customItems = new ArrayList<>(); + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (item instanceof CustomItem && customItemType.equals(item.getItemType()) + && currentTenantId.equals(item.getTenantId())) { + String itemKey = entry.getKey(); + if (isItemAvailableForQuery(itemKey, customItemType) && (condition == null || testMatch(condition, item))) { + customItems.add((CustomItem) item); + } + } + } + + // Sort items if needed + if (sortBy != null) { + customItems = sortItems(customItems, sortBy); + } + + // Apply offset and size + int totalSize = customItems.size(); + int fromIndex = Math.min(offset, totalSize); + int toIndex = size < 0 ? totalSize : Math.min(offset + size, totalSize); + List pagedItems = customItems.subList(fromIndex, toIndex); + + PartialList result = new PartialList<>(pagedItems, offset, size, totalSize, PartialList.Relation.EQUAL); + + // Setup scroll state if scrollTimeValidity is provided + if (scrollTimeValidity != null && !scrollTimeValidity.isEmpty()) { + // Generate a unique scroll ID + String scrollId = UUID.randomUUID().toString(); + + // Parse the scroll time validity (in milliseconds) + long scrollTimeValidityMs = getScrollTimeValidityMs(scrollTimeValidity); + + // Calculate expiry time + long expiryTime = System.currentTimeMillis() + scrollTimeValidityMs; + + // Store the scroll state + scrollStates.put(scrollId, new ScrollState((List)(List)customItems, toIndex, expiryTime, size)); + + // Add scroll information to the result + result.setScrollIdentifier(scrollId); + result.setScrollTimeValidity(scrollTimeValidity); + } + + return result; + } + + private static long getScrollTimeValidityMs(String scrollTimeValidity) { + long scrollTimeValidityMs = 60000; // Default to 1 minute if parsing fails + try { + if (scrollTimeValidity.endsWith("ms")) { + scrollTimeValidityMs = Long.parseLong(scrollTimeValidity.substring(0, scrollTimeValidity.length() - 2)); + } else if (scrollTimeValidity.endsWith("m")) { + scrollTimeValidityMs = Long.parseLong(scrollTimeValidity.substring(0, scrollTimeValidity.length() - 1)) * 60 * 1000L; + } else if (scrollTimeValidity.endsWith("s")) { + scrollTimeValidityMs = Long.parseLong(scrollTimeValidity.substring(0, scrollTimeValidity.length() - 1)) * 1000L; + } else if (scrollTimeValidity.endsWith("h")) { + scrollTimeValidityMs = Long.parseLong(scrollTimeValidity.substring(0, scrollTimeValidity.length() - 1)) * 60 * 60 * 1000L; + } else { + scrollTimeValidityMs = Long.parseLong(scrollTimeValidity); + } + } catch (NumberFormatException e) { + LOGGER.error("Invalid scroll time validity string: {}", scrollTimeValidity, e); + } + return scrollTimeValidityMs; + } + + @Override + public PartialList continueCustomItemScrollQuery(String customItemType, String scrollIdentifier, String scrollTimeValidity) { + if (scrollIdentifier == null) { + return new PartialList<>(Collections.emptyList(), 0, 0, 0, PartialList.Relation.EQUAL); + } + + // Clean up expired scroll states + scrollStates.entrySet().removeIf(entry -> entry.getValue().expiryTime < System.currentTimeMillis()); + + ScrollState state = scrollStates.get(scrollIdentifier); + if (state == null) { + return new PartialList<>(Collections.emptyList(), 0, 0, 0, PartialList.Relation.EQUAL); + } + + // Get next page of items, filtering by type and converting to CustomItem + int fromIndex = state.currentPosition; + int remainingItems = state.items.size() - fromIndex; + int pageSize = state.pageSize < 0 ? remainingItems : Math.min(state.pageSize, remainingItems); + int toIndex = fromIndex + pageSize; + + List pageItems = state.items.subList(fromIndex, toIndex).stream() + .filter(item -> item instanceof CustomItem) + .filter(item -> customItemType.equals(item.getItemType())) + .filter(item -> executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) + .map(item -> (CustomItem) item) + .collect(Collectors.toList()); + + // Update scroll state with new position + if (toIndex >= state.items.size()) { + // End of scroll, remove the state + scrollStates.remove(scrollIdentifier); + } else { + // Parse the new scroll time validity if provided + long scrollTimeValidityMs = getScrollTimeValidityMs(scrollTimeValidity); + + // Extend the scroll timeout + scrollStates.put(scrollIdentifier, new ScrollState((List)(List)state.items, toIndex, + System.currentTimeMillis() + scrollTimeValidityMs, state.pageSize)); + } + + PartialList partialList = new PartialList<>(pageItems, fromIndex, pageSize, state.items.size(), PartialList.Relation.EQUAL); + if (toIndex < state.items.size()) { + partialList.setScrollIdentifier(scrollIdentifier); + partialList.setScrollTimeValidity(scrollTimeValidity); + } + + return partialList; + } + + @Override + public CustomItem loadCustomItem(String itemId, Date dateHint, String itemType) { + return loadCustomItem(itemId, itemType); + } + + @Override + public CustomItem loadCustomItem(String itemId, String itemType) { + if (itemId == null || itemType == null) { + return null; + } + + // Create a key that includes the item type and tenant ID + String key = getKey(itemId, itemType); + Item item = itemsById.get(key); + + if (item instanceof CustomItem && + itemType.equals(item.getItemType()) && + executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) { + // Apply reverse tenant transformations after load (simulates Elasticsearch/OpenSearch behavior) + return (CustomItem) handleItemReverseTransformation(item); + } + + return null; + } + + @Override + public boolean removeCustomItem(String itemId, String itemType) { + if (itemId == null || itemType == null) { + return false; + } + + String key = getKey(itemId, itemType); + Item item = itemsById.get(key); + + if (item instanceof CustomItem && + itemType.equals(item.getItemType()) && + executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + if (fileStorageEnabled) { + deleteItemFile(item); + } + return true; + } + + return false; + } + + @Override + @SuppressWarnings("unchecked") + public PartialList continueScrollQuery(Class clazz, String scrollTimeValidity, String scrollIdentifier) { + if (scrollIdentifier == null) { + return new PartialList<>(Collections.emptyList(), 0, 0, 0, PartialList.Relation.EQUAL); + } + + // Clean up expired scroll states + scrollStates.entrySet().removeIf(entry -> entry.getValue().expiryTime < System.currentTimeMillis()); + + ScrollState state = scrollStates.get(scrollIdentifier); + if (state == null) { + return new PartialList<>(Collections.emptyList(), 0, 0, 0, PartialList.Relation.EQUAL); + } + + // Parse scroll time validity + long validityTime = getScrollTimeValidityMs(scrollTimeValidity); + + // Get next page of items + int fromIndex = state.currentPosition; + int remainingItems = state.items.size() - fromIndex; + int pageSize = state.pageSize < 0 ? remainingItems : Math.min(state.pageSize, remainingItems); + int toIndex = fromIndex + pageSize; + + @SuppressWarnings("unchecked") + List pageItems = state.items.subList(fromIndex, toIndex).stream() + .map(item -> (T) item) + .collect(Collectors.toList()); + + // Update scroll state with new position + if (toIndex >= state.items.size()) { + // End of scroll, remove the state + scrollStates.remove(scrollIdentifier); + } else { + scrollStates.put(scrollIdentifier, new ScrollState(state.items, toIndex, System.currentTimeMillis() + validityTime, state.pageSize)); + } + + PartialList partialList = new PartialList<>(pageItems, fromIndex, pageSize, state.items.size(), PartialList.Relation.EQUAL); + if (toIndex < state.items.size()) { + partialList.setScrollIdentifier(scrollIdentifier); + partialList.setScrollTimeValidity(scrollTimeValidity); + } + return partialList; + } + + @Override + public Map aggregateQuery(Condition condition, BaseAggregate aggregate, String itemType) { + // This is the deprecated version, delegate to the optimized version + return aggregateWithOptimizedQuery(condition, aggregate, itemType); + } + + @Override + public Map aggregateWithOptimizedQuery(Condition condition, BaseAggregate aggregate, String itemType) { + return aggregateWithOptimizedQuery(condition, aggregate, itemType, -1); + } + + @Override + public Map aggregateWithOptimizedQuery(Condition condition, BaseAggregate aggregate, String itemType, int size) { + Map results = new HashMap<>(); + + // Get all items of the specified type that are available for querying + List items = new ArrayList<>(); + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (item.getItemType().equals(itemType)) { + String itemKey = entry.getKey(); + if (isItemAvailableForQuery(itemKey, itemType) && (condition == null || testMatch(condition, item))) { + items.add(item); + } + } + } + + if (aggregate instanceof TermsAggregate) { + handleTermsAggregate(items, (TermsAggregate) aggregate, results, size); + } else if (aggregate instanceof DateAggregate) { + handleDateAggregate(items, (DateAggregate) aggregate, results); + } else if (aggregate instanceof NumericRangeAggregate) { + handleNumericRangeAggregate(items, (NumericRangeAggregate) aggregate, results); + } else if (aggregate instanceof DateRangeAggregate) { + handleDateRangeAggregate(items, (DateRangeAggregate) aggregate, results); + } else if (aggregate instanceof IpRangeAggregate) { + handleIpRangeAggregate(items, (IpRangeAggregate) aggregate, results); + } + + return results; + } + + private void handleTermsAggregate(List items, TermsAggregate aggregate, Map results, int size) { + Map fieldValueCounts = items.stream() + .map(item -> { + try { + return PropertyUtils.getProperty(item, aggregate.getField()); + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + value -> value, + Collectors.counting() + )); + + fieldValueCounts.forEach((value, count) -> + results.put(String.valueOf(value), count)); + + if (size > 0 && results.size() > size) { + Map limitedResults = results.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(size) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (e1, e2) -> e1, + LinkedHashMap::new + )); + results.clear(); + results.putAll(limitedResults); + } + } + + private void handleDateAggregate(List items, DateAggregate aggregate, Map results) { + Map dateValueCounts = items.stream() + .map(item -> { + try { + Object value = PropertyUtils.getProperty(item, aggregate.getField()); + return value instanceof Date ? (Date) value : null; + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + date -> date, + Collectors.counting() + )); + + dateValueCounts.forEach((date, count) -> + results.put(String.valueOf(date.getTime()), count)); + } + + private void handleNumericRangeAggregate(List items, NumericRangeAggregate aggregate, Map results) { + Map rangeValueCounts = items.stream() + .map(item -> { + try { + Object value = PropertyUtils.getProperty(item, aggregate.getField()); + if (value instanceof Number) { + double numericValue = ((Number) value).doubleValue(); + return getRangeKey(numericValue, aggregate.getRanges()); + } + return null; + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + range -> range, + Collectors.counting() + )); + + results.putAll(rangeValueCounts); + } + + private String getRangeKey(double value, List ranges) { + for (NumericRange range : ranges) { + if ((range.getFrom() == null || value >= range.getFrom()) && + (range.getTo() == null || value < range.getTo())) { + return range.getKey(); + } + } + return null; + } + + private void handleDateRangeAggregate(List items, DateRangeAggregate aggregate, Map results) { + Map rangeValueCounts = items.stream() + .map(item -> { + try { + Object value = PropertyUtils.getProperty(item, aggregate.getField()); + if (value instanceof Date) { + return getDateRangeKey((Date) value, aggregate.getDateRanges()); + } + return null; + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + range -> range, + Collectors.counting() + )); + + results.putAll(rangeValueCounts); + } + + private String getDateRangeKey(Date value, List ranges) { + for (DateRange range : ranges) { + Date from = (Date) range.getFrom(); + Date to = (Date) range.getTo(); + if ((from == null || value.compareTo(from) >= 0) && + (to == null || value.compareTo(to) < 0)) { + return range.getKey(); + } + } + return null; + } + + private void handleIpRangeAggregate(List items, IpRangeAggregate aggregate, Map results) { + Map rangeValueCounts = items.stream() + .map(item -> { + try { + Object value = PropertyUtils.getProperty(item, aggregate.getField()); + if (value instanceof String) { + return getIpRangeKey((String) value, aggregate.getRanges()); + } + return null; + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + range -> range, + Collectors.counting() + )); + + results.putAll(rangeValueCounts); + } + + private String getIpRangeKey(String ip, List ranges) { + long ipLong = ipToLong(ip); + for (IpRange range : ranges) { + long fromIp = ipToLong(range.getFrom()); + long toIp = ipToLong(range.getTo()); + if (ipLong >= fromIp && ipLong <= toIp) { + return range.getKey(); + } + } + return null; + } + + @Override + public PartialList queryFullText(String fieldName, String fieldValue, String fulltext, String sortBy, Class clazz, int offset, int size) { + // First get items matching the field criteria + List fieldMatches = query(fieldName, fieldValue, sortBy, clazz); + + // Then filter by fulltext search + List fulltextMatches = fieldMatches.stream() + .filter(item -> matchesFullText(item, fulltext)) + .collect(Collectors.toList()); + + return createPartialList(fulltextMatches, offset, size); + } + + @Override + public PartialList queryFullText(String fulltext, String sortBy, Class clazz, int offset, int size) { + // Get all items of the specified class + List allItems = getAllItems(clazz); + + // Filter by fulltext search + List matches = allItems.stream() + .filter(item -> matchesFullText(item, fulltext)) + .collect(Collectors.toList()); + + return createPartialList(matches, offset, size); + } + + @Override + public PartialList queryFullText(String fulltext, Condition query, String sortBy, Class clazz, int offset, int size) { + // First get items matching the condition + List conditionMatches = query(query, sortBy, clazz); + + // Then filter by fulltext search + List matches = conditionMatches.stream() + .filter(item -> matchesFullText(item, fulltext)) + .collect(Collectors.toList()); + + return createPartialList(matches, offset, size); + } + + private PartialList createPartialList(List items, int offset, int size) { + int totalSize = items.size(); + // Apply default query limit when size < 0 (simulates Elasticsearch/OpenSearch behavior) + int effectiveSize = size < 0 ? defaultQueryLimit : size; + int fromIndex = Math.min(offset, totalSize); + int toIndex = Math.min(offset + effectiveSize, totalSize); + List pageItems = items.subList(fromIndex, toIndex); + // Preserve original size parameter in pageSize to indicate what was requested + // even though we apply a default limit when size < 0 + return new PartialList<>(pageItems, offset, size, totalSize, PartialList.Relation.EQUAL); + } + + private boolean matchesFullText(Item item, String fulltext) { + if (fulltext == null || fulltext.trim().isEmpty()) { + return true; + } + + String lowercaseFulltext = fulltext.toLowerCase(); + + // Check standard Item fields + if (item.getItemId() != null && item.getItemId().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + if (item.getScope() != null && item.getScope().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + + // Get all properties using PropertyUtils + try { + Map describe = PropertyUtils.describe(item); + for (Map.Entry entry : describe.entrySet()) { + if (entry.getValue() == null) { + continue; + } + + // Skip class property from PropertyUtils.describe() + if ("class".equals(entry.getKey())) { + continue; + } + + // Handle different value types + Object value = entry.getValue(); + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map mapValue = (Map) value; + if (containsFullText(mapValue, lowercaseFulltext)) { + return true; + } + } else if (value instanceof Collection) { + Collection collection = (Collection) value; + for (Object element : collection) { + if (element != null) { + if (element instanceof Map) { + @SuppressWarnings("unchecked") + Map mapElement = (Map) element; + if (containsFullText(mapElement, lowercaseFulltext)) { + return true; + } + } else { + if (element.toString().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + } + } + } + } else { + if (value.toString().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + } + } + } catch (Exception e) { + LOGGER.debug("Error accessing properties via PropertyUtils", e); + } + + return false; + } + + private boolean containsFullText(Map properties, String lowercaseFulltext) { + if (properties == null) { + return false; + } + + for (Map.Entry entry : properties.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + + // Check the property name itself + if (entry.getKey().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + + // Handle different value types + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map mapValue = (Map) value; + if (containsFullText(mapValue, lowercaseFulltext)) { + return true; + } + } else if (value instanceof Collection) { + Collection collection = (Collection) value; + for (Object element : collection) { + if (element != null) { + if (element instanceof Map) { + @SuppressWarnings("unchecked") + Map mapElement = (Map) element; + if (containsFullText(mapElement, lowercaseFulltext)) { + return true; + } + } else { + if (element.toString().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + } + } + } + } else { + if (value.toString().toLowerCase().contains(lowercaseFulltext)) { + return true; + } + } + } + return false; + } + + private long ipToLong(String ipAddress) { + if (ipAddress == null) return 0; + String[] octets = ipAddress.split("\\."); + if (octets.length != 4) return 0; + long result = 0; + for (String octet : octets) { + try { + result = result * 256 + Integer.parseInt(octet); + } catch (NumberFormatException e) { + return 0; + } + } + return result; + } + + @Override + public long queryCount(Condition condition, String itemType) { + // Filter by refresh status to simulate Elasticsearch/OpenSearch behavior + long count = 0; + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (item.getItemType().equals(itemType)) { + String itemKey = entry.getKey(); + if (isItemAvailableForQuery(itemKey, itemType) && (condition == null || testMatch(condition, item))) { + count++; + } + } + } + return count; + } + + @Override + public boolean isConsistent(Item item) { + // In Elasticsearch, isConsistent returns true if refresh policy is not FALSE + // This indicates whether changes are immediately visible (consistent) + // Request-based override takes precedence over per-item-type policy + if (simulateRefreshDelay && item != null) { + String itemType = item.getItemType(); + if (item instanceof CustomItem) { + String customItemType = ((CustomItem) item).getCustomItemType(); + if (customItemType != null) { + itemType = customItemType; + } + } + RefreshPolicy policy = getRefreshPolicy(itemType, item); + return policy != RefreshPolicy.FALSE; + } + return true; // as we work in memory this is always true + } + + @Override + public void purgeTimeBasedItems(int olderThanInDays, Class clazz) { + if (olderThanInDays <= 0 || clazz == null) { + return; + } + + // Calculate the date olderThanInDays days ago + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DAY_OF_YEAR, -olderThanInDays); + Date cutoffDate = calendar.getTime(); + + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); + String itemType = getIndex(clazz); + List keysToRemove = new ArrayList<>(); + + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + // Use creation date instead of timestamp, and check tenant + if (currentTenantId.equals(item.getTenantId()) && + item.getItemType().equals(itemType) && + item.getCreationDate() != null && + item.getCreationDate().before(cutoffDate)) { + keysToRemove.add(entry.getKey()); + if (fileStorageEnabled) { + deleteItemFile(item); + } + } + } + + for (String key : keysToRemove) { + itemsById.remove(key); + // Remove from pending refresh list if present + if (simulateRefreshDelay) { + pendingRefreshItems.remove(key); + } + } + + LOGGER.info("Purged {} items of type {} older than {} days for tenant {}", + keysToRemove.size(), itemType, olderThanInDays, currentTenantId); + } + + @Override + public boolean migrateTenantData(String fromTenantId, String toTenantId, List itemTypes) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public long getApiCallCount(String apiName) { + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public long calculateStorageSize(String itemType) { + throw new UnsupportedOperationException("Not implemented"); + } + + private Object getPropertyValue(Item item, String field) { + try { + return PropertyUtils.getProperty(item, field); + } catch (Exception e) { + LOGGER.debug("Error getting property value for field: " + field, e); + return null; + } + } + + private boolean executeUpdatePastEventOccurrencesScript(Item item, Map params) { + if (!params.containsKey(item.getItemId())) { + return true; + } + + try { + @SuppressWarnings("unchecked") + Map pastEventKeyValue = (Map) params.get(item.getItemId()); + String pastEventKey = (String) pastEventKeyValue.get("pastEventKey"); + Long valueToAdd = (Long) pastEventKeyValue.get("valueToAdd"); + + // Get or create systemProperties map + @SuppressWarnings("unchecked") + Map systemProperties = (Map) PropertyUtils.getProperty(item, "systemProperties"); + if (systemProperties == null) { + systemProperties = new HashMap<>(); + PropertyUtils.setProperty(item, "systemProperties", systemProperties); + } + + // Initialize pastEvents list if needed + @SuppressWarnings("unchecked") + List> pastEvents = (List>) systemProperties.computeIfAbsent("pastEvents", k -> new ArrayList<>()); + + // Update or add past event + boolean exists = false; + for (Map pastEvent : pastEvents) { + if (pastEventKey.equals(pastEvent.get("key"))) { + pastEvent.put("count", valueToAdd); + exists = true; + break; + } + } + + if (!exists) { + Map newPastEvent = new HashMap<>(); + newPastEvent.put("key", pastEventKey); + newPastEvent.put("count", valueToAdd); + pastEvents.add(newPastEvent); + } + + // Update lastUpdated timestamp + systemProperties.put("lastUpdated", new Date()); + + // Save the updated item + save(item); + return true; + + } catch (Exception e) { + LOGGER.error("Error executing updatePastEventOccurrences script", e); + return false; + } + } + + private boolean executeUpdateProfileIdScript(Item item, Map params) { + if (!params.containsKey("profileId")) { + return false; + } + + try { + String newProfileId = (String) params.get("profileId"); + + // Update profileId if it exists + try { + String currentProfileId = (String) PropertyUtils.getProperty(item, "profileId"); + if (currentProfileId != null && !newProfileId.equals(currentProfileId)) { + try { + PropertyUtils.setProperty(item, "profileId", newProfileId); + } catch (NoSuchMethodException e) { + // No setter method, try to set field directly + Field profileIdField = item.getClass().getDeclaredField("profileId"); + profileIdField.setAccessible(true); + profileIdField.set(item, newProfileId); + } + } + } catch (NoSuchMethodException e) { + // No getter method, try to get field directly + try { + Field profileIdField = item.getClass().getDeclaredField("profileId"); + profileIdField.setAccessible(true); + String currentProfileId = (String) profileIdField.get(item); + if (currentProfileId != null && !newProfileId.equals(currentProfileId)) { + profileIdField.set(item, newProfileId); + } + } catch (NoSuchFieldException ex) { + // Item class doesn't have profileId field, skip update + } + } + + // Update inner profile.itemId if it exists + Profile profile = (Profile) PropertyUtils.getProperty(item, "profile"); + if (profile != null && profile.getItemId() != null && !newProfileId.equals(profile.getItemId())) { + profile.setItemId(newProfileId); + } + + // Save the updated item + save(item); + return true; + + } catch (Exception e) { + LOGGER.error("Error executing updateProfileId script", e); + return false; + } + } + + // Add new utility methods for common operations + private List sortItems(List items, String sortBy) { + if (sortBy == null) { + return items; + } + + List sortedItems = new ArrayList<>(items); + Collections.sort(sortedItems, (o1, o2) -> { + try { + String[] sortByArray = sortBy.split(":"); + String propertyName = sortByArray[0]; + String sortOrder = sortByArray.length > 1 ? sortByArray[1] : "asc"; + Object propertyValue1 = PropertyUtils.getProperty(o1, propertyName); + Object propertyValue2 = PropertyUtils.getProperty(o2, propertyName); + if (propertyValue1 == null && propertyValue2 == null) { + return 0; + } else if (propertyValue1 == null) { + return "desc".equals(sortOrder) ? 1 : -1; + } else if (propertyValue2 == null) { + return "desc".equals(sortOrder) ? -1 : 1; + } + if (!(propertyValue1 instanceof Comparable)) { + return 0; + } + int comparisonResult = ((Comparable) propertyValue1).compareTo(propertyValue2); + return "desc".equals(sortOrder) ? -comparisonResult : comparisonResult; + } catch (Exception e) { + LOGGER.debug("Error comparing properties for sorting", e); + return 0; + } + }); + return sortedItems; + } + + private List filterItemsByClass(Class clazz) { + String indexName = getIndex(clazz); + return itemsById.entrySet().stream() + .filter(entry -> { + Item item = entry.getValue(); + String itemKey = entry.getKey(); + // Filter by class and tenant + if (!clazz.isAssignableFrom(item.getClass()) || + !executionContextManager.getCurrentContext().getTenantId().equals(item.getTenantId())) { + return false; + } + // Filter out items that are not yet available for querying (refresh delay simulation) + return isItemAvailableForQuery(itemKey, indexName); + }) + .map(entry -> (T) entry.getValue()) + .collect(Collectors.toList()); + } + + private List filterItemsByCondition(List items, Condition condition) { + if (condition == null) { + return items; + } + return items.stream() + .filter(item -> testMatch(condition, item)) + .collect(Collectors.toList()); + } + + @Override + public PartialList getAllItems(Class clazz, int offset, int size, String sortBy, String scrollTimeValidity) { + return getAllItems(clazz, offset, size, sortBy); + } + + private boolean matchesField(Item item, String fieldName, String fieldValue) { + if (item == null || fieldName == null || fieldValue == null) { + return false; + } + + try { + Object value = getValueFromPath(item, fieldName); + if (value == null) { + return false; + } + + if (value instanceof Collection) { + return ((Collection) value).contains(fieldValue); + } + + return value.toString().equals(fieldValue); + } catch (Exception e) { + LOGGER.debug("Error matching field: " + fieldName, e); + return false; + } + } + + private Object getValueFromPath(Object obj, String path) { + if (obj == null || path == null) { + return null; + } + + try { + Object current = obj; + StringBuilder currentPart = new StringBuilder(); + boolean inQuotes = false; + boolean escaped = false; + char quoteChar = 0; + + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + + if (escaped) { + if (c == '.' || c == '[' || c == ']' || c == '\'' || c == '"' || c == '\\') { + currentPart.append(c); + } else { + currentPart.append('\\').append(c); + } + escaped = false; + continue; + } + + switch (c) { + case '\\': + if (!inQuotes) { + escaped = true; + } else { + currentPart.append(c); + } + break; + case '\'': + case '"': + if (!inQuotes) { + inQuotes = true; + quoteChar = c; + } else if (c == quoteChar) { + inQuotes = false; + quoteChar = 0; + } else { + currentPart.append(c); + } + break; + case '[': + if (!inQuotes) { + if (currentPart.length() > 0) { + current = resolveValue(current, currentPart.toString()); + currentPart.setLength(0); + } + } else { + currentPart.append(c); + } + break; + case ']': + if (!inQuotes) { + if (currentPart.length() > 0) { + String arrayIndex = currentPart.toString().trim(); + current = resolveArrayValue(current, arrayIndex); + currentPart.setLength(0); + } + } else { + currentPart.append(c); + } + break; + case '.': + if (!inQuotes && !escaped) { + if (currentPart.length() > 0) { + current = resolveValue(current, currentPart.toString()); + currentPart.setLength(0); + } + } else { + currentPart.append(c); + } + break; + default: + currentPart.append(c); + } + } + + // Handle any remaining part + if (currentPart.length() > 0) { + current = resolveValue(current, currentPart.toString()); + } + + return current; + } catch (Exception e) { + LOGGER.debug("Error accessing path: " + path, e); + return null; + } + } + + private Object resolveArrayValue(Object obj, String index) { + if (obj == null) { + return null; + } + + if (obj instanceof List) { + try { + List list = (List) obj; + int idx = Integer.parseInt(index); + if (idx >= 0 && idx < list.size()) { + return list.get(idx); + } + } catch (NumberFormatException e) { + // Fall through to try Map access + } + } + + if (obj instanceof Map) { + return ((Map) obj).get(index); + } + + return null; + } + + private Object resolveValue(Object obj, String key) { + if (obj == null) { + return null; + } + + if (obj instanceof Map) { + return ((Map) obj).get(key); + } + + // Try getter method first + try { + String getterName = "get" + key.substring(0, 1).toUpperCase() + key.substring(1); + Method getter = obj.getClass().getMethod(getterName); + try { + return getter.invoke(obj); + } catch (IllegalAccessException | InvocationTargetException e) { + LOGGER.debug("Error invoking getter method: " + getterName, e); + return null; + } + } catch (NoSuchMethodException e) { + // Try boolean getter + try { + String isName = "is" + key.substring(0, 1).toUpperCase() + key.substring(1); + Method isGetter = obj.getClass().getMethod(isName); + try { + return isGetter.invoke(obj); + } catch (IllegalAccessException | InvocationTargetException e2) { + LOGGER.debug("Error invoking boolean getter method: " + isName, e2); + return null; + } + } catch (NoSuchMethodException e2) { + // Try field access + try { + Field field = obj.getClass().getDeclaredField(key); + field.setAccessible(true); + try { + return field.get(obj); + } catch (IllegalAccessException e3) { + LOGGER.debug("Error accessing field: " + key, e3); + return null; + } + } catch (NoSuchFieldException e3) { + return null; + } + } + } + } + + private String getIndex(Class clazz) { + return Item.getItemType(clazz); + } + + /** + * Applies tenant transformations to an item before save/update. + * This simulates Elasticsearch/OpenSearch tenant transformation behavior. + * + * @param item the item to transform + * @param the item type + * @return the transformed item (or original if no transformations applied) + */ + private T handleItemTransformation(T item) { + return applyTransformations(item, + (listener, i) -> listener.transformItem(i, i.getTenantId()), + "item transformation"); + } + + /** + * Applies reverse tenant transformations to an item after load. + * This simulates Elasticsearch/OpenSearch tenant reverse transformation behavior. + * + * @param item the item to reverse transform + * @param the item type + * @return the reverse transformed item (or original if no transformations applied) + */ + private T handleItemReverseTransformation(T item) { + return applyTransformations(item, + (listener, i) -> listener.reverseTransformItem(i, i.getTenantId()), + "item reverse transformation"); + } + + /** + * Shared helper that walks the sorted listener list and applies a transformation function. + * + * @param item the item to transform + * @param transform BiFunction receiving (listener, currentItem) and returning the (possibly new) item + * @param logLabel short label used in the warning log when a listener throws + * @param the item type + * @return the (possibly transformed) item + */ + @SuppressWarnings("unchecked") + private T applyTransformations(T item, + java.util.function.BiFunction transform, + String logLabel) { + if (item != null) { + String tenantId = item.getTenantId(); + if (tenantId != null && !transformationListeners.isEmpty()) { + List sortedListeners = new ArrayList<>(transformationListeners); + sortedListeners.sort((a, b) -> Integer.compare(b.getPriority(), a.getPriority())); + + for (TenantTransformationListener listener : sortedListeners) { + if (listener.isTransformationEnabled()) { + try { + Item transformedItem = transform.apply(listener, item); + if (transformedItem != null) { + item = (T) transformedItem; + } + } catch (Exception e) { + // Log error but continue with other listeners since transformation is optional + LOGGER.warn("Error during {} for tenant {} with listener {}", + logLabel, tenantId, listener.getTransformationType(), e); + } + } + } + } + } + return item; + } + + /** + * Adds a tenant transformation listener (for testing purposes). + * This simulates OSGi service registration in Elasticsearch/OpenSearch implementations. + * + * @param listener the transformation listener to add + */ + public void addTransformationListener(TenantTransformationListener listener) { + if (listener != null) { + transformationListeners.add(listener); + LOGGER.debug("Added tenant transformation listener: {}", listener.getTransformationType()); + } + } + + /** + * Removes a tenant transformation listener (for testing purposes). + * + * @param listener the transformation listener to remove + */ + public void removeTransformationListener(TenantTransformationListener listener) { + if (listener != null) { + transformationListeners.remove(listener); + LOGGER.debug("Removed tenant transformation listener: {}", listener.getTransformationType()); + } + } + + /** + * Gets the default query limit. + * + * @return the default query limit + */ + public Integer getDefaultQueryLimit() { + return defaultQueryLimit; + } + + /** + * Sets the default query limit. + * + * @param defaultQueryLimit the default query limit to set + */ + public void setDefaultQueryLimit(Integer defaultQueryLimit) { + this.defaultQueryLimit = defaultQueryLimit != null && defaultQueryLimit > 0 ? defaultQueryLimit : 10; + } +} + diff --git a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java new file mode 100644 index 000000000..c9e04fbed --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java @@ -0,0 +1,6659 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.*; +import org.apache.unomi.api.actions.ActionType; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.query.DateRange; +import org.apache.unomi.api.query.IpRange; +import org.apache.unomi.api.query.NumericRange; +import org.apache.unomi.api.services.SchedulerService; +import org.apache.unomi.api.tenants.TenantTransformationListener; +import org.apache.unomi.persistence.spi.CustomObjectMapper; +import org.apache.unomi.persistence.spi.aggregate.DateRangeAggregate; +import org.apache.unomi.persistence.spi.aggregate.IpRangeAggregate; +import org.apache.unomi.persistence.spi.aggregate.NumericRangeAggregate; +import org.apache.unomi.persistence.spi.aggregate.TermsAggregate; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.AuditServiceImpl; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.cache.MultiTypeCacheServiceImpl; +import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.osgi.framework.BundleContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.*; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.apache.unomi.api.tenants.TenantService.SYSTEM_TENANT; +import static org.junit.jupiter.api.Assertions.*; + +public class InMemoryPersistenceServiceImplTest { + private static final Logger LOGGER = LoggerFactory.getLogger(InMemoryPersistenceServiceImplTest.class); + // Fast refresh interval for tests (50ms instead of 1000ms) - significantly speeds up test execution + // while still testing refresh delay behavior + private static final long FAST_REFRESH_INTERVAL_MS = 50L; + private InMemoryPersistenceServiceImpl persistenceService; + private ConditionEvaluatorDispatcher conditionEvaluatorDispatcher; + private ExecutionContextManagerImpl executionContextManager; + private KarafSecurityService securityService; + private AuditServiceImpl auditService; + private DefinitionsServiceImpl definitionsService; + + // Test helper class + public static class TestMetadataItem extends MetadataItem { + public static final String ITEM_TYPE = "testMetadataItem"; + private Metadata metadata; + private Map properties = new HashMap<>(); + private String name; + private Set tags; + private Double numericValue; + + public TestMetadataItem() { + setItemType(ITEM_TYPE); + } + + @Override + public String getItemType() { + // If itemType was explicitly set (different from default), use that + // Otherwise return the default ITEM_TYPE constant + if (itemType != null && !itemType.equals(ITEM_TYPE)) { + return itemType; + } + return ITEM_TYPE; + } + + @Override + public Metadata getMetadata() { + return metadata; + } + + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } + + public void setProperty(String name, Object value) { + properties.put(name, value); + } + + public Object getProperty(String name) { + return properties.get(name); + } + + public Map getProperties() { + return properties; + } + + public void setProperties(Map properties) { + this.properties = properties; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Set getTags() { + return tags; + } + + public void setTags(Set tags) { + this.tags = tags; + } + + public Double getNumericValue() { + return numericValue; + } + + public void setNumericValue(Double numericValue) { + this.numericValue = numericValue; + } + } + + @BeforeEach + void setUp() throws IOException { + CustomObjectMapper.getCustomInstance().registerBuiltInItemTypeClass(TestMetadataItem.ITEM_TYPE, TestMetadataItem.class); + CustomObjectMapper.getCustomInstance().registerBuiltInItemTypeClass(SimpleItem.ITEM_TYPE, SimpleItem.class); + CustomObjectMapper.getCustomInstance().registerBuiltInItemTypeClass(NestedItem.ITEM_TYPE, NestedItem.class); + // Clean up any existing persistence service before deleting directory + if (persistenceService != null) { + try { + persistenceService.purge((Date)null); + if (persistenceService instanceof InMemoryPersistenceServiceImpl) { + ((InMemoryPersistenceServiceImpl) persistenceService).shutdown(); + } + } catch (Exception e) { + // Ignore cleanup errors + } + persistenceService = null; + } + // Skip directory cleanup when file storage is disabled - saves significant time + // File storage is disabled by default for most tests (only FileStorageOperations needs it) + // Only clean directory if it exists and we might be using file storage + Path defaultStorageDir = Paths.get(InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR).toAbsolutePath().normalize(); + if (Files.exists(defaultStorageDir)) { + TestHelper.cleanDefaultStorageDirectory(3); + } + conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + // Create service with file storage DISABLED by default for fast test execution + // File storage is primarily a debugging tool and adds significant I/O overhead + // (directory cleanup, file operations, loadPersistedItems on startup) + // Only FileStorageOperations nested class needs it - it creates its own instances with file storage enabled + // Also disable refresh delay by default - only RefreshDelaySimulationTests needs it + persistenceService = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, false, 1000L); + + // Set up minimal definitions service and register dummy action types to prevent warnings + // when rules with unresolved action types are loaded from persisted files + setupDummyActionTypes(); + } + + /** + * Sets up a minimal definitions service and registers dummy action types to prevent warnings + * when rules with unresolved action types are loaded from persisted files. + * This ensures that any rules loaded from previous test runs have valid action types. + * + * Note: These action types use a no-op executor name. Since this test doesn't set up + * an ActionExecutorDispatcher, these action types are only used for rule resolution, + * not execution. If they are ever executed in other contexts, the missing executor + * will be handled gracefully (returning NO_CHANGE). + */ + private void setupDummyActionTypes() { + // Create minimal definitions service using TestHelper (automatically creates and wires EventAdmin) + BundleContext bundleContext = TestHelper.createMockBundleContext(); + SchedulerService schedulerService = TestHelper.createSchedulerService( + "test-definitions-node", persistenceService, executionContextManager, bundleContext, null, -1, false, false); + MultiTypeCacheServiceImpl multiTypeCacheService = + new MultiTypeCacheServiceImpl(); + TestTenantService tenantService = new TestTenantService(); + + definitionsService = TestHelper.createDefinitionService( + persistenceService, bundleContext, schedulerService, multiTypeCacheService, + executionContextManager, tenantService); + + // Inject definitionsService into the dispatcher + TestHelper.injectDefinitionsServiceIntoDispatcher(conditionEvaluatorDispatcher, definitionsService); + + // Register common dummy action types that might be referenced in persisted rules + // These action types are only needed for rule resolution (ParserHelper.resolveActionTypes), + // not for actual execution. Since this test doesn't set up an ActionExecutorDispatcher, + // these action types won't be executed. If they are ever executed in other contexts, + // the ActionExecutorDispatcher will handle missing executors gracefully by returning NO_CHANGE. + ActionType unknownActionType = TestHelper.createActionType("unknown", "noop"); + definitionsService.setActionType(unknownActionType); + + ActionType testActionType = TestHelper.createActionType("test", "noop"); + definitionsService.setActionType(testActionType); + } + + @AfterEach + void tearDown() throws IOException { + // Properly shutdown persistence service to release file handles + if (persistenceService != null) { + try { + persistenceService.purge((Date)null); + if (persistenceService instanceof InMemoryPersistenceServiceImpl) { + ((InMemoryPersistenceServiceImpl) persistenceService).shutdown(); + } + } catch (Exception e) { + // Ignore cleanup errors + } + persistenceService = null; + } + // Only clean up directory if file storage was enabled (most tests don't use it) + // This saves significant time by skipping expensive directory operations + Path defaultStorageDir = Paths.get(InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR).toAbsolutePath().normalize(); + if (Files.exists(defaultStorageDir)) { + try { + // Use fewer retries and shorter sleep times for faster cleanup + TestHelper.cleanDefaultStorageDirectory(3); + } catch (Exception e) { + // Log but don't fail test if cleanup fails + LOGGER.warn("Failed to clean up storage directory in tearDown: {}", e.getMessage()); + } + } + } + + @Nested + class BasicOperations { + @Test + void shouldSaveAndLoadItem() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("firstName", "John"); + + // when + boolean saved = persistenceService.save(profile); + Profile loaded = persistenceService.load("test-profile", Profile.class); + + // then + assertTrue(saved); + assertNotNull(loaded); + assertEquals("John", loaded.getProperty("firstName")); + } + + @Test + void shouldRemoveItem() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + persistenceService.save(profile); + + // when + boolean removed = persistenceService.remove("test-profile", Profile.class); + Profile loaded = persistenceService.load("test-profile", Profile.class); + + // then + assertTrue(removed); + assertNull(loaded); + } + + @Test + void shouldHandleVersioning() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-version"); + item.setName("Test Version"); + + // Initial save should set version to 1 + persistenceService.save(item); + assertEquals(1L, item.getVersion()); + + // Subsequent saves should increment version + persistenceService.save(item); + assertEquals(2L, item.getVersion()); + + // Load and verify version persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(2L, loaded.getVersion()); + } + + @Test + void shouldHandleVersioningWithExplicitVersion() { + // Create a test item with explicit version + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-explicit-version"); + item.setName("Test Explicit Version"); + item.setVersion(5L); + + // Save should increment existing version + persistenceService.save(item); + assertEquals(6L, item.getVersion()); + + // Load and verify version persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(6L, loaded.getVersion()); + } + } + + @Nested + class ConditionEvaluation { + @Test + void shouldEvaluatePropertyCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("age", 25); + + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("profilePropertyCondition")); + condition.setParameter("propertyName", "properties.age"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", 25); + + // when + boolean result = persistenceService.isValidCondition(condition, profile); + + // then + assertTrue(result); + } + + @Test + void shouldEvaluateMetadataCondition() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "metadata.tags"); + condition.setParameter("comparisonOperator", "contains"); + condition.setParameter("propertyValue", "tag1"); + + // when + boolean result = persistenceService.isValidCondition(condition, item); + + // then + assertTrue(result); + } + + @Test + void shouldEvaluateBooleanCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("age", 25); + profile.setProperty("active", true); + + Condition ageCondition = new Condition(); + ageCondition.setConditionType(TestConditionEvaluators.getConditionType("profilePropertyCondition")); + ageCondition.setParameter("propertyName", "properties.age"); + ageCondition.setParameter("comparisonOperator", "equals"); + ageCondition.setParameter("propertyValue", 25); + + Condition activeCondition = new Condition(); + activeCondition.setConditionType(TestConditionEvaluators.getConditionType("profilePropertyCondition")); + activeCondition.setParameter("propertyName", "properties.active"); + activeCondition.setParameter("comparisonOperator", "equals"); + activeCondition.setParameter("propertyValue", true); + + Condition booleanCondition = new Condition(); + booleanCondition.setConditionType(TestConditionEvaluators.getConditionType("booleanCondition")); + booleanCondition.setParameter("operator", "and"); + booleanCondition.setParameter("subConditions", Arrays.asList(ageCondition, activeCondition)); + + // when + boolean result = persistenceService.isValidCondition(booleanCondition, profile); + + // then + assertTrue(result); + } + + @Test + void shouldEvaluateMapPropertyCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + Map preferences = new HashMap<>(); + preferences.put("color", "blue"); + profile.setProperty("preferences", preferences); + + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.preferences.color"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", "blue"); + + // when + boolean result = persistenceService.isValidCondition(condition, profile); + + // then + assertTrue(result); + } + } + + @Nested + class FieldMatchingOperations { + @Test + void shouldMatchSimpleField() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setName("test-name"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until item is available (handles refresh delay) + List results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("name", "test-name", null, TestMetadataItem.class), + 1 + ); + + // then + assertEquals(1, results.size()); + assertEquals("test-item", results.get(0).getItemId()); + } + + @Test + void shouldMatchNestedProperty() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setProperty("nested.field", "test-value"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until item is available (handles refresh delay) + List results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.nested\\.field", "test-value", null, TestMetadataItem.class), + 1 + ); + + // then + assertEquals(1, results.size()); + assertEquals("test-item", results.get(0).getItemId()); + } + + @Test + void shouldMatchMetadataField() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Metadata metadata = new Metadata(); + metadata.setId("test-metadata"); + metadata.setName("test-metadata-name"); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until item is available (handles refresh delay) + List results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("metadata.name", "test-metadata-name", null, TestMetadataItem.class), + 1 + ); + + // then + assertEquals(1, results.size()); + assertEquals("test-item", results.get(0).getItemId()); + } + + @Test + void shouldMatchCollectionField() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until item is available (handles refresh delay) + List results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("tags", "tag1", null, TestMetadataItem.class), + 1 + ); + + // then + assertEquals(1, results.size()); + assertEquals("test-item", results.get(0).getItemId()); + } + + @Test + void shouldHandleNonExistentAndNullFields() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Map map = new HashMap<>(); + map.put("nested", null); + item.setProperty("map", map); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry queries until item is available (handles refresh delay) + // Even though we expect 0 results, we should wait for the item to be available + List results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("nonexistent", "any-value", null, TestMetadataItem.class), + 0 + ); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.map.nested.field", "any-value", null, TestMetadataItem.class), + 0 + ); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("nonexistent.nested.field", "any-value", null, TestMetadataItem.class), + 0 + ); + + // then + assertEquals(0, results1.size()); + assertEquals(0, results2.size()); + assertEquals(0, results3.size()); + } + + @Test + void shouldHandleComplexDotNotation() { + // Test both escaped dots and bracket notation in a single comprehensive test + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + + // Setup nested structure + Map level1 = new HashMap<>(); + Map level2 = new HashMap<>(); + level2.put("key.with.dots", "test-value"); + level2.put("regular.key", "another-value"); + level1.put("nested.field", level2); + item.setProperty("map", level1); + + // Add direct properties with dots + item.setProperty("direct.key.with.dots", "direct-value"); + + persistenceService.save(item); + + // Test different notation styles - retry queries until items are available + List results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.direct\\.key\\.with\\.dots", "direct-value", null, TestMetadataItem.class), + 1 + ); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.map['nested.field']['key.with.dots']", "test-value", null, TestMetadataItem.class), + 1 + ); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.map.nested\\.field.regular\\.key", "another-value", null, TestMetadataItem.class), + 1 + ); + + // Verify all notation styles work + assertEquals(1, results1.size()); + assertEquals(1, results2.size()); + assertEquals(1, results3.size()); + assertEquals("test-item", results1.get(0).getItemId()); + assertEquals("test-item", results2.get(0).getItemId()); + assertEquals("test-item", results3.get(0).getItemId()); + } + + @Test + void shouldHandleSpecialFieldTypes() { + // Test boolean fields and direct field access in one test + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + + // Test boolean property + item.setProperty("active", true); + + // Test direct field access + item.tags = new HashSet<>(Arrays.asList("tag1", "tag2")); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + + // Test collection in map + Map map = new HashMap<>(); + map.put("list.of.items", Arrays.asList("item1", "item2")); + item.setProperty("nested", map); + + persistenceService.save(item); + + // Verify all special field types - retry queries until items are available + List results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.active", "true", null, TestMetadataItem.class), + 1 + ); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("tags", "tag1", null, TestMetadataItem.class), + 1 + ); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.nested.list\\.of\\.items", "item1", null, TestMetadataItem.class), + 1 + ); + + assertEquals(1, results1.size()); + assertEquals(1, results2.size()); + assertEquals(1, results3.size()); + assertEquals("test-item", results1.get(0).getItemId()); + assertEquals("test-item", results2.get(0).getItemId()); + assertEquals("test-item", results3.get(0).getItemId()); + } + } + + @Nested + class TestMatchOperations { + @Test + void shouldReturnTrueForNullCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + + // when + boolean result = persistenceService.testMatch(null, profile); + + // then + assertTrue(result); + } + + @Test + void shouldReturnFalseForNullItem() { + // given + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + + // when + boolean result = persistenceService.testMatch(condition, null); + + // then + assertFalse(result); + } + + @Test + void shouldMatchPropertyCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("age", 25); + + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.age"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", 25); + + // when + boolean result = persistenceService.testMatch(condition, profile); + + // then + assertTrue(result); + } + + @Test + void shouldNotMatchPropertyCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("age", 25); + + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.age"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", 30); + + // when + boolean result = persistenceService.testMatch(condition, profile); + + // then + assertFalse(result); + } + + @Test + void shouldMatchComplexCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("age", 25); + profile.setProperty("active", true); + + Condition ageCondition = new Condition(); + ageCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + ageCondition.setParameter("propertyName", "properties.age"); + ageCondition.setParameter("comparisonOperator", "equals"); + ageCondition.setParameter("propertyValue", 25); + + Condition activeCondition = new Condition(); + activeCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + activeCondition.setParameter("propertyName", "properties.active"); + activeCondition.setParameter("comparisonOperator", "equals"); + activeCondition.setParameter("propertyValue", true); + + Condition booleanCondition = new Condition(); + booleanCondition.setConditionType(TestConditionEvaluators.getConditionType("booleanCondition")); + booleanCondition.setParameter("operator", "and"); + booleanCondition.setParameter("subConditions", Arrays.asList(ageCondition, activeCondition)); + + // when + boolean result = persistenceService.testMatch(booleanCondition, profile); + + // then + assertTrue(result); + } + } + + @Nested + class ScrollOperations { + @Test + void shouldSupportScrollQueries() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 25; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("index", i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - initial query with scroll (retry until items are available) + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, 10, "1000"), + 10 + ); + + // then - first page + assertNotNull(firstPage); + assertEquals(10, firstPage.getList().size()); + assertEquals(25, firstPage.getTotalSize()); + assertNotNull(firstPage.getScrollIdentifier()); + String scrollId = firstPage.getScrollIdentifier(); + + // when - continue scroll + PartialList secondPage = persistenceService.continueScrollQuery(Profile.class, "2000", scrollId); + + // then - second page + assertNotNull(secondPage); + assertEquals(10, secondPage.getList().size()); + assertEquals(25, secondPage.getTotalSize()); + assertNotNull(secondPage.getScrollIdentifier()); + + // when - continue scroll for last page + PartialList lastPage = persistenceService.continueScrollQuery(Profile.class, "2000", secondPage.getScrollIdentifier()); + + // then - last page + assertNotNull(lastPage); + assertEquals(5, lastPage.getList().size()); + assertEquals(25, lastPage.getTotalSize()); + assertEquals(PartialList.Relation.EQUAL, lastPage.getTotalSizeRelation()); + } + + @Test + void shouldHandleExpiredScrolls() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + persistenceService.save(profile); + + // when - initial query with very short scroll validity (retry until item is available) + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, 1, "1"), + 1 + ); + + // Wait for scroll to expire (retry until scroll is expired) + // Scroll validity is 1ms, so we check if it has expired by calling continueScrollQuery + String scrollId = firstPage.getScrollIdentifier(); + TestHelper.retryUntil( + () -> persistenceService.continueScrollQuery(Profile.class, "1000", scrollId), + result -> result.getList().isEmpty() && result.getScrollIdentifier() == null + ); + + // when - try to continue expired scroll + PartialList secondPage = persistenceService.continueScrollQuery(Profile.class, "1000", scrollId); + + // then + assertNotNull(secondPage); + assertTrue(secondPage.getList().isEmpty()); + assertEquals(0, secondPage.getTotalSize()); + } + + @Test + void shouldHandleInvalidScrollId() { + // when + PartialList result = persistenceService.continueScrollQuery(Profile.class, "1000", "invalid-scroll-id"); + + // then + assertNotNull(result); + assertTrue(result.getList().isEmpty()); + assertEquals(0, result.getTotalSize()); + } + + @Test + void shouldHandleEmptyResultSet() { + // when - query with condition that matches no items + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "nonexistent"); + condition.setParameter("propertyValue", "value"); + + PartialList result = persistenceService.query(condition, null, Profile.class, 0, 10, "1000"); + + // then + assertNotNull(result); + assertTrue(result.getList().isEmpty()); + assertEquals(0, result.getTotalSize()); + assertNull(result.getScrollIdentifier()); + } + + @Test + void shouldSupportScrollQueriesWithCondition() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("type", i % 2 == 0 ? "even" : "odd"); + profiles.add(profile); + persistenceService.save(profile); + } + + // Create condition to match only even profiles + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("profilePropertyCondition")); + condition.setParameter("propertyName", "properties.type"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", "even"); + + // when - initial query with scroll (retry until items are available) + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(condition, null, Profile.class, 0, 3, "1000"), + 3 + ); + + // then - first page + assertNotNull(firstPage); + assertEquals(3, firstPage.getList().size()); + assertEquals(8, firstPage.getTotalSize()); // 8 even numbers in 0-14 + assertNotNull(firstPage.getScrollIdentifier()); + + // when - continue scroll + PartialList secondPage = persistenceService.continueScrollQuery(Profile.class, "1000", firstPage.getScrollIdentifier()); + + // then - second page + assertNotNull(secondPage); + assertEquals(3, secondPage.getList().size()); + assertEquals(8, secondPage.getTotalSize()); + + // when - get last page + PartialList lastPage = persistenceService.continueScrollQuery(Profile.class, "1000", secondPage.getScrollIdentifier()); + + // then - last page + assertNotNull(lastPage); + assertEquals(2, lastPage.getList().size()); + assertEquals(8, lastPage.getTotalSize()); + assertNull(lastPage.getScrollIdentifier()); + } + + @Test + void shouldHandleExactPageSize() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - initial query with scroll and page size equal to total items (retry until items are available) + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, 10, "1000"), + 10 + ); + + // then + assertNotNull(firstPage); + assertEquals(10, firstPage.getList().size()); + assertEquals(10, firstPage.getTotalSize()); + assertNull(firstPage.getScrollIdentifier()); // No scroll ID needed as all items are returned + } + + @Test + void shouldSupportMultipleConcurrentScrolls() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - start two scroll queries (retry until items are available) + PartialList firstScroll = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, 4, "1000"), + 4 + ); + PartialList secondScroll = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, 3, "1000"), + 3 + ); + + // then - both scrolls should work independently + assertNotNull(firstScroll.getScrollIdentifier()); + assertNotNull(secondScroll.getScrollIdentifier()); + assertNotEquals(firstScroll.getScrollIdentifier(), secondScroll.getScrollIdentifier()); + + // when - continue both scrolls + PartialList firstScrollContinue = persistenceService.continueScrollQuery(Profile.class, "1000", firstScroll.getScrollIdentifier()); + PartialList secondScrollContinue = persistenceService.continueScrollQuery(Profile.class, "1000", secondScroll.getScrollIdentifier()); + + // then - both scrolls should maintain their own state + assertEquals(4, firstScrollContinue.getList().size()); + assertEquals(3, secondScrollContinue.getList().size()); + } + } + + @Nested + class AggregationOperations { + @Test + void shouldAggregateByTerms() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("category", i % 2 == 0 ? "A" : "B"); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - retry aggregation until items are available (handles refresh delay) + TermsAggregate termsAggregate = new TermsAggregate("properties.category"); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(null, termsAggregate, Profile.ITEM_TYPE), + r -> r != null && r.size() == 2 && r.get("A") != null && r.get("A") == 5L + ); + + // then + assertNotNull(results); + assertEquals(2, results.size()); + assertEquals(5L, results.get("A")); + assertEquals(5L, results.get("B")); + } + + @Test + void shouldAggregateByDateRange() { + // given + List profiles = new ArrayList<>(); + Calendar cal = Calendar.getInstance(); + cal.set(2024, Calendar.JANUARY, 1); + Date startDate = cal.getTime(); + + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + cal.setTime(startDate); + cal.add(Calendar.DAY_OF_MONTH, i); + profile.setProperty("lastVisit", cal.getTime()); + profiles.add(profile); + persistenceService.save(profile); + } + + // Create date ranges + List ranges = new ArrayList<>(); + cal.setTime(startDate); + + DateRange firstWeek = new DateRange(); + firstWeek.setKey("week1"); + firstWeek.setFrom(startDate); + cal.add(Calendar.DAY_OF_MONTH, 7); + firstWeek.setTo(cal.getTime()); + + DateRange secondWeek = new DateRange(); + secondWeek.setKey("week2"); + secondWeek.setFrom(cal.getTime()); + cal.add(Calendar.DAY_OF_MONTH, 7); + secondWeek.setTo(cal.getTime()); + + ranges.add(firstWeek); + ranges.add(secondWeek); + + // when - retry aggregation until items are available (handles refresh delay) + DateRangeAggregate dateRangeAggregate = new DateRangeAggregate("properties.lastVisit", "yyyy-MM-dd", ranges); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(null, dateRangeAggregate, Profile.ITEM_TYPE), + r -> r != null && r.size() == 2 && r.get("week1") != null && r.get("week1") == 7L + ); + + // then + assertNotNull(results); + assertEquals(2, results.size()); + assertEquals(7L, results.get("week1")); // Days 0-6 + assertEquals(3L, results.get("week2")); // Days 7-9 + } + + @Test + void shouldAggregateByNumericRange() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("age", 20 + i * 5); // Ages: 20, 25, 30, 35, 40, 45, 50, 55, 60, 65 + profiles.add(profile); + persistenceService.save(profile); + } + + // Create numeric ranges + List ranges = new ArrayList<>(); + + NumericRange young = new NumericRange(); + young.setKey("young"); + young.setFrom(20.0); + young.setTo(35.0); + + NumericRange middleAged = new NumericRange(); + middleAged.setKey("middleAged"); + middleAged.setFrom(35.0); + middleAged.setTo(50.0); + + NumericRange senior = new NumericRange(); + senior.setKey("senior"); + senior.setFrom(50.0); + senior.setTo(70.0); + + ranges.add(young); + ranges.add(middleAged); + ranges.add(senior); + + // when - retry aggregation until items are available (handles refresh delay) + NumericRangeAggregate numericRangeAggregate = new NumericRangeAggregate("properties.age", ranges); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(null, numericRangeAggregate, Profile.ITEM_TYPE), + r -> r != null && r.size() == 3 && r.get("young") != null && r.get("young") == 3L + ); + + // then + assertNotNull(results); + assertEquals(3, results.size()); + assertEquals(3L, results.get("young")); // 20, 25, 30 + assertEquals(3L, results.get("middleAged")); // 35, 40, 45 + assertEquals(4L, results.get("senior")); // 50, 55, 60, 65 + } + + @Test + void shouldAggregateByIpRange() { + // given + List profiles = new ArrayList<>(); + String[] ips = { + "192.168.1.1", + "192.168.1.100", + "192.168.2.1", + "192.168.2.100", + "10.0.0.1" + }; + + for (int i = 0; i < ips.length; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("ipAddress", ips[i]); + profiles.add(profile); + persistenceService.save(profile); + } + + // Create IP ranges + List ranges = new ArrayList<>(); + + IpRange subnet1 = new IpRange(); + subnet1.setKey("subnet1"); + subnet1.setFrom("192.168.1.0"); + subnet1.setTo("192.168.1.255"); + + IpRange subnet2 = new IpRange(); + subnet2.setKey("subnet2"); + subnet2.setFrom("192.168.2.0"); + subnet2.setTo("192.168.2.255"); + + IpRange otherRange = new IpRange(); + otherRange.setKey("other"); + otherRange.setFrom("10.0.0.0"); + otherRange.setTo("10.255.255.255"); + + ranges.add(subnet1); + ranges.add(subnet2); + ranges.add(otherRange); + + // when - retry aggregation until items are available (handles refresh delay) + IpRangeAggregate ipRangeAggregate = new IpRangeAggregate("properties.ipAddress", ranges); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(null, ipRangeAggregate, Profile.ITEM_TYPE), + r -> r != null && r.size() == 3 && r.get("subnet1") != null && r.get("subnet1") == 2L + ); + + // then + assertNotNull(results); + assertEquals(3, results.size()); + assertEquals(2L, results.get("subnet1")); // 192.168.1.1, 192.168.1.100 + assertEquals(2L, results.get("subnet2")); // 192.168.2.1, 192.168.2.100 + assertEquals(1L, results.get("other")); // 10.0.0.1 + } + + @Test + void shouldAggregateWithSizeLimit() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("category", String.valueOf((char)('A' + (i % 3)))); // Creates categories A, B, C + profiles.add(profile); + persistenceService.save(profile); + } + + // when - retry aggregation until items are available (handles refresh delay) + TermsAggregate termsAggregate = new TermsAggregate("properties.category"); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(null, termsAggregate, Profile.ITEM_TYPE, 2), + r -> r != null && r.size() == 2 + ); + + // then + assertNotNull(results); + assertEquals(2, results.size()); + // Should only return the top 2 categories by count + assertTrue(results.values().stream().allMatch(count -> count >= 3)); + } + + @Test + void shouldAggregateWithCondition() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("category", i % 2 == 0 ? "A" : "B"); + profile.setProperty("active", i < 5); // First 5 profiles are active + profiles.add(profile); + persistenceService.save(profile); + } + + // Create condition to match only active profiles + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.active"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", true); + + // when - retry aggregation until items are available (handles refresh delay) + TermsAggregate termsAggregate = new TermsAggregate("properties.category"); + Map results = TestHelper.retryUntil( + () -> persistenceService.aggregateWithOptimizedQuery(condition, termsAggregate, Profile.ITEM_TYPE), + r -> r != null && r.size() == 2 && r.values().stream().mapToLong(Long::longValue).sum() == 5L + ); + + // then + assertNotNull(results); + assertEquals(2, results.size()); + // Should only count active profiles + assertEquals(5L, results.values().stream().mapToLong(Long::longValue).sum()); + } + } + + @Nested + class CountOperations { + @Test + void shouldCountAllItemsOfType() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - retry query until all items are available (handles refresh delay) + long count = TestHelper.retryUntil( + () -> persistenceService.queryCount(null, Profile.ITEM_TYPE), + c -> c == 10L + ); + + // then + assertEquals(10, count); + } + + @Test + void shouldCountItemsMatchingCondition() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profile.setProperty("active", i % 2 == 0); // Even numbered profiles are active + profiles.add(profile); + persistenceService.save(profile); + } + + // Create condition to match only active profiles + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.active"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", true); + + // when - retry query until items are available (handles refresh delay) + long count = TestHelper.retryUntil( + () -> persistenceService.queryCount(condition, Profile.ITEM_TYPE), + c -> c == 5L + ); + + // then + assertEquals(5, count); // Should count only active profiles + } + + @Test + void shouldReturnZeroForNonExistentType() { + // when + long count = persistenceService.queryCount(null, "nonexistent-type"); + + // then + assertEquals(0, count); + } + + @Test + void shouldReturnZeroForNonMatchingCondition() { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("active", true); + persistenceService.save(profile); + + // Create condition that won't match any profiles + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.active"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", false); + + // when + long count = persistenceService.queryCount(condition, Profile.ITEM_TYPE); + + // then + assertEquals(0, count); + } + + @Test + void shouldRespectTenantIsolationInGetAllItemsCount() { + // Given - items of different types for different tenants + String itemType1 = "tenant-test-type1"; + String itemType2 = "tenant-test-type2"; + + // Create items in tenant1 + executionContextManager.executeAsTenant("tenant1", () -> { + for (int i = 0; i < 5; i++) { + CustomItem item = new CustomItem(); + item.setItemId("tenant1-" + itemType1 + "-" + i); + item.setItemType(itemType1); + persistenceService.save(item); + } + + for (int i = 0; i < 3; i++) { + CustomItem item = new CustomItem(); + item.setItemId("tenant1-" + itemType2 + "-" + i); + item.setItemType(itemType2); + persistenceService.save(item); + } + return null; + }); + + // Create items in tenant2 + executionContextManager.executeAsTenant("tenant2", () -> { + for (int i = 0; i < 7; i++) { + CustomItem item = new CustomItem(); + item.setItemId("tenant2-" + itemType1 + "-" + i); + item.setItemType(itemType1); + persistenceService.save(item); + } + + for (int i = 0; i < 4; i++) { + CustomItem item = new CustomItem(); + item.setItemId("tenant2-" + itemType2 + "-" + i); + item.setItemType(itemType2); + persistenceService.save(item); + } + return null; + }); + + // When - count items from different tenant contexts (retry until items are available) + long tenant1Type1Count = executionContextManager.executeAsTenant("tenant1", () -> + TestHelper.retryUntil( + () -> persistenceService.getAllItemsCount(itemType1), + c -> c == 5L + )); + + long tenant1Type2Count = executionContextManager.executeAsTenant("tenant1", () -> + TestHelper.retryUntil( + () -> persistenceService.getAllItemsCount(itemType2), + c -> c == 3L + )); + + long tenant2Type1Count = executionContextManager.executeAsTenant("tenant2", () -> + TestHelper.retryUntil( + () -> persistenceService.getAllItemsCount(itemType1), + c -> c == 7L + )); + + long tenant2Type2Count = executionContextManager.executeAsTenant("tenant2", () -> + TestHelper.retryUntil( + () -> persistenceService.getAllItemsCount(itemType2), + c -> c == 4L + )); + + // Then - counts should reflect tenant isolation + assertEquals(5, tenant1Type1Count, "Tenant1 should see 5 items of type1"); + assertEquals(3, tenant1Type2Count, "Tenant1 should see 3 items of type2"); + assertEquals(7, tenant2Type1Count, "Tenant2 should see 7 items of type1"); + assertEquals(4, tenant2Type2Count, "Tenant2 should see 4 items of type2"); + + // And - tenant1 should not see tenant2's items and vice versa + executionContextManager.executeAsTenant("tenant1", () -> { + assertEquals(0, persistenceService.getAllItemsCount("non-existent-type"), + "Should return 0 for non-existent item type"); + return null; + }); + + // Test null item type + executionContextManager.executeAsTenant("tenant1", () -> { + assertEquals(0, persistenceService.getAllItemsCount(null), + "Should handle null item type gracefully"); + return null; + }); + } + } + + @Nested + class FullTextSearchOperations { + @Test + void shouldSearchByFullText() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setProperty("description", "This is description that should match"); + item1.setProperty("tags", Arrays.asList("tag1", "tag2")); + Metadata metadata1 = new Metadata(); + metadata1.setId("item1"); + metadata1.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item1.setMetadata(metadata1); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setProperty("description", "Another description"); + item2.setProperty("tags", Arrays.asList("tag3", "tag4")); + Metadata metadata2 = new Metadata(); + metadata2.setId("item2"); + metadata2.setTags(new HashSet<>(Arrays.asList("tag3", "tag4"))); + item2.setMetadata(metadata2); + persistenceService.save(item2); + + // when - retry query until items are available (handles refresh delay) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText("match", null, TestMetadataItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + assertEquals("item1", results.getList().get(0).getItemId()); + } + + @Test + void shouldSearchByFullTextWithFieldCriteria() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setProperty("category", "electronics"); + item1.setProperty("description", "A matching product"); + Metadata metadata1 = new Metadata(); + metadata1.setId("item1"); + metadata1.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item1.setMetadata(metadata1); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setProperty("category", "electronics"); + item2.setProperty("description", "Another product"); + Metadata metadata2 = new Metadata(); + metadata2.setId("item2"); + metadata2.setTags(new HashSet<>(Arrays.asList("tag3", "tag4"))); + item2.setMetadata(metadata2); + persistenceService.save(item2); + + // when - retry query until items are available (handles refresh delay) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "properties.category", "electronics", "matching", null, TestMetadataItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + assertEquals("item1", results.getList().get(0).getItemId()); + } + + @Test + void shouldSearchByFullTextWithCondition() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setProperty("active", true); + item1.setProperty("description", "A test product"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item1.setMetadata(metadata); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setProperty("active", false); + item2.setProperty("description", "Another test product"); + Metadata metadata2 = new Metadata(); + metadata2.setId("test-item"); + metadata2.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item2.setMetadata(metadata); + persistenceService.save(item2); + + // Create condition for active items + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.active"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", true); + + // when - retry query until items are available (handles refresh delay) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "test", condition, null, TestMetadataItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + assertEquals("item1", results.getList().get(0).getItemId()); + } + + @Test + void shouldHandleNestedProperties() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + Map nested = new HashMap<>(); + nested.put("deepValue", "test nested value"); + item.setProperty("nested", nested); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until items are available (handles refresh delay) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "nested value", null, TestMetadataItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + assertEquals("item1", results.getList().get(0).getItemId()); + } + + @Test + void shouldHandlePagination() { + // given + for (int i = 0; i < 5; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setProperty("description", "test item " + i); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + } + + // when - retry queries until items are available AND totalSize is correct (handles refresh delay) + // Use retryUntil to check both list size and totalSize to avoid flakiness + PartialList page1 = TestHelper.retryUntil( + () -> persistenceService.queryFullText("test", null, TestMetadataItem.class, 0, 2), + result -> result != null && result.getList().size() == 2 && result.getTotalSize() == 5 + ); + PartialList page2 = TestHelper.retryUntil( + () -> persistenceService.queryFullText("test", null, TestMetadataItem.class, 2, 2), + result -> result != null && result.getList().size() == 2 && result.getTotalSize() == 5 + ); + PartialList page3 = TestHelper.retryUntil( + () -> persistenceService.queryFullText("test", null, TestMetadataItem.class, 4, 2), + result -> result != null && result.getList().size() == 1 && result.getTotalSize() == 5 + ); + + // then + assertEquals(2, page1.getList().size(), "Page 1 should have 2 items"); + assertEquals(2, page2.getList().size(), "Page 2 should have 2 items"); + assertEquals(1, page3.getList().size(), "Page 3 should have 1 item"); + assertEquals(5, page1.getTotalSize(), "Page 1 totalSize should be 5"); + assertEquals(5, page2.getTotalSize(), "Page 2 totalSize should be 5"); + assertEquals(5, page3.getTotalSize(), "Page 3 totalSize should be 5"); + } + + @Test + void shouldHandleEmptyResults() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setProperty("description", "A sample item"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry query until item is available (handles refresh delay) + // Even though we expect 0 results, we should wait for the item to be available + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "nonexistent", null, TestMetadataItem.class, 0, 10), + 0 + ); + + // then + assertTrue(results.getList().isEmpty()); + assertEquals(0, results.getTotalSize()); + } + + @Test + void shouldBeCaseInsensitive() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setProperty("description", "TEST Description"); + Metadata metadata = new Metadata(); + metadata.setId("test-item"); + metadata.setTags(new HashSet<>(Arrays.asList("tag1", "tag2"))); + item.setMetadata(metadata); + persistenceService.save(item); + + // when - retry queries until item is available (handles refresh delay) + PartialList results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "test", null, TestMetadataItem.class, 0, 10), + 1 + ); + PartialList results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "TEST", null, TestMetadataItem.class, 0, 10), + 1 + ); + PartialList results3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "tEsT", null, TestMetadataItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results1.getList().size()); + assertEquals(1, results2.getList().size()); + assertEquals(1, results3.getList().size()); + } + + @Nested + class GenericItemTests { + @Test + void shouldSearchSimpleProperties() { + // given + SimpleItem item = new SimpleItem(); + item.setItemId("simple1"); + item.setSimpleProperty("searchable text"); + item.setNumericProperty(42); + item.setBooleanProperty(true); + persistenceService.save(item); + + // when - search in string property (retry until item is available) + PartialList results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable", null, SimpleItem.class, 0, 10), + 1 + ); + + // when - search in numeric property (retry until item is available) + PartialList results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "42", null, SimpleItem.class, 0, 10), + 1 + ); + + // when - search in boolean property (retry until item is available) + PartialList results3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "true", null, SimpleItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results1.getList().size()); + assertEquals(1, results2.getList().size()); + assertEquals(1, results3.getList().size()); + } + + @Test + void shouldSearchNestedMapProperties() { + // given + NestedItem item = new NestedItem(); + item.setItemId("nested1"); + Map nestedMap = new HashMap<>(); + nestedMap.put("level1", "searchable"); + Map level2 = new HashMap<>(); + level2.put("level2", "nested searchable"); + nestedMap.put("deeper", level2); + item.setNestedMap(nestedMap); + persistenceService.save(item); + + // when - search in first level (retry until item is available) + PartialList results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable", null, NestedItem.class, 0, 10), + 1 + ); + + // when - search in nested level (retry until item is available) + PartialList results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "nested searchable", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results1.getList().size()); + assertEquals(1, results2.getList().size()); + } + + @Test + void shouldSearchInCollections() { + // given + NestedItem item = new NestedItem(); + item.setItemId("collection1"); + item.setStringList(Arrays.asList("first", "second", "third")); + Set> complexSet = new HashSet<>(); + Map setElement = new HashMap<>(); + setElement.put("key", "searchable value"); + complexSet.add(setElement); + item.setComplexSet(complexSet); + persistenceService.save(item); + + // when - search in string list (retry until item is available) + PartialList results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "second", null, NestedItem.class, 0, 10), + 1 + ); + + // when - search in complex set (retry until item is available) + PartialList results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable value", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results1.getList().size()); + assertEquals(1, results2.getList().size()); + } + + @Test + void shouldSearchPropertyNames() { + // given + NestedItem item = new NestedItem(); + item.setItemId("propnames1"); + Map nestedMap = new HashMap<>(); + nestedMap.put("searchable_key", "some value"); + item.setNestedMap(nestedMap); + persistenceService.save(item); + + // when - search in property names (retry until item is available) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable_key", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + } + + @Test + void shouldHandleNullValues() { + // given + NestedItem item = new NestedItem(); + item.setItemId("nulls1"); + Map nestedMap = new HashMap<>(); + nestedMap.put("nullValue", null); + nestedMap.put("validValue", "searchable"); + item.setNestedMap(nestedMap); + item.setStringList(null); + persistenceService.save(item); + + // when - retry until item is available + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + } + + @Test + void shouldHandleSpecialCharacters() { + // given + SimpleItem item = new SimpleItem(); + item.setItemId("special1"); + item.setSimpleProperty("Text with special chars: !@#$%^&*()"); + persistenceService.save(item); + + // when - retry until item is available + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "!@#$%", null, SimpleItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + } + + @Test + void shouldHandleEmptyCollections() { + // given + NestedItem item = new NestedItem(); + item.setItemId("empty1"); + item.setStringList(new ArrayList<>()); + item.setComplexSet(new HashSet<>()); + item.setNestedMap(new HashMap<>()); + persistenceService.save(item); + + // when - retry query (expects 0 results, but should still wait for refresh) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "nonexistent", null, NestedItem.class, 0, 10), + 0 + ); + + // then + assertEquals(0, results.getList().size()); + } + + @Test + void shouldSearchAcrossMultipleItems() { + // given + SimpleItem item1 = new SimpleItem(); + item1.setItemId("multi1"); + item1.setSimpleProperty("common text"); + persistenceService.save(item1); + + NestedItem item2 = new NestedItem(); + item2.setItemId("multi2"); + Map nestedMap = new HashMap<>(); + nestedMap.put("key", "common text"); + item2.setNestedMap(nestedMap); + persistenceService.save(item2); + + // when - search across different item types (retry until items are available) + PartialList results1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "common", null, SimpleItem.class, 0, 10), + 1 + ); + PartialList results2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "common", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results1.getList().size()); + assertEquals(1, results2.getList().size()); + } + + @Test + void shouldHandleRecursiveStructures() { + // given + NestedItem item = new NestedItem(); + item.setItemId("recursive1"); + Map level1 = new HashMap<>(); + Map level2 = new HashMap<>(); + Map level3 = new HashMap<>(); + level3.put("deepest", "searchable"); + level2.put("level3", level3); + level1.put("level2", level2); + item.setNestedMap(level1); + persistenceService.save(item); + + // when - retry until item is available + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryFullText( + "searchable", null, NestedItem.class, 0, 10), + 1 + ); + + // then + assertEquals(1, results.getList().size()); + } + } + } + + @Nested + class SingleValuesMetricsOperations { + @Test + void shouldHandleNullOrEmptyParameters() { + // when + Map result1 = persistenceService.getSingleValuesMetrics(null, null, null, "testType"); + Map result2 = persistenceService.getSingleValuesMetrics(null, new String[0], "field", "testType"); + Map result3 = persistenceService.getSingleValuesMetrics(null, new String[]{"card"}, null, "testType"); + + // then + assertTrue(result1.isEmpty()); + assertTrue(result2.isEmpty()); + assertTrue(result3.isEmpty()); + } + + @Test + void shouldCalculateAllMetricsForNumericValues() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setNumericValue(10.0); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setNumericValue(20.0); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setNumericValue(30.0); + item3.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + persistenceService.save(item3); + + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "numericValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.get("_card") != null && r.get("_card") == 3.0 + ); + + // then + assertEquals(3.0, results.get("_card"), 0.001); + assertEquals(60.0, results.get("_sum"), 0.001); + assertEquals(10.0, results.get("_min"), 0.001); + assertEquals(30.0, results.get("_max"), 0.001); + assertEquals(20.0, results.get("_avg"), 0.001); + } + + @Test + void shouldHandleNullValues() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setNumericValue(10.0); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setNumericValue(null); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setNumericValue(30.0); + item3.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + persistenceService.save(item3); + + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "numericValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.get("_card") != null && r.get("_card") == 2.0 + ); + + // then + assertEquals(2.0, results.get("_card"), 0.001); // Only counts non-null values + assertEquals(40.0, results.get("_sum"), 0.001); + assertEquals(10.0, results.get("_min"), 0.001); + assertEquals(30.0, results.get("_max"), 0.001); + assertEquals(20.0, results.get("_avg"), 0.001); + } + + @Test + void shouldHandleEmptyResultSet() { + // given + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when + Map results = persistenceService.getSingleValuesMetrics( + null, metrics, "numericValue", TestMetadataItem.ITEM_TYPE); + + // then + assertEquals(0.0, results.get("_card"), 0.001); + // ES/OS implementations don't return these metrics for empty sets + assertFalse(results.containsKey("_sum")); + assertFalse(results.containsKey("_min")); + assertFalse(results.containsKey("_max")); + assertFalse(results.containsKey("_avg")); + } + + @Test + void shouldFilterByCondition() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setNumericValue(10.0); + item1.setProperty("category", "A"); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setNumericValue(20.0); + item2.setProperty("category", "B"); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setNumericValue(30.0); + item3.setProperty("category", "A"); + item3.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + persistenceService.save(item3); + + // Create condition for category A + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.category"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", "A"); + + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + condition, metrics, "numericValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.get("_card") != null && r.get("_card") == 2.0 + ); + + // then + assertEquals(2.0, results.get("_card"), 0.001); + assertEquals(40.0, results.get("_sum"), 0.001); + assertEquals(10.0, results.get("_min"), 0.001); + assertEquals(30.0, results.get("_max"), 0.001); + assertEquals(20.0, results.get("_avg"), 0.001); + } + + @Test + void shouldHandleNonNumericValues() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setProperty("stringValue", "value1"); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setProperty("stringValue", "value2"); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "properties.stringValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.get("_card") != null && r.get("_card") == 2.0 + ); + + // then + assertEquals(2.0, results.get("_card"), 0.001); + // ES/OS implementations don't return numeric metrics for non-numeric fields + assertFalse(results.containsKey("_sum")); + assertFalse(results.containsKey("_min")); + assertFalse(results.containsKey("_max")); + assertFalse(results.containsKey("_avg")); + } + + @Test + void shouldHandleSpecificMetricsRequest() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setNumericValue(10.0); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setNumericValue(20.0); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + + String[] metrics = {"min", "max"}; // Only request min and max + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "numericValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.size() == 2 && r.get("_min") != null + ); + + // then + assertEquals(2, results.size()); + assertEquals(10.0, results.get("_min"), 0.001); + assertEquals(20.0, results.get("_max"), 0.001); + assertFalse(results.containsKey("_card")); + assertFalse(results.containsKey("_sum")); + assertFalse(results.containsKey("_avg")); + } + + @Test + void shouldHandleInvalidMetricNames() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setNumericValue(10.0); + item.setItemType(TestMetadataItem.ITEM_TYPE); + persistenceService.save(item); + + String[] metrics = {"invalid_metric", "card", "another_invalid"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "numericValue", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.size() == 1 && r.get("_card") != null && r.get("_card") == 1.0 + ); + + // then + assertEquals(1, results.size()); + assertEquals(1.0, results.get("_card"), 0.001); + } + + @Test + void shouldHandleNestedFieldPath() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + Map nestedMap = new HashMap<>(); + nestedMap.put("value", 10.0); + item1.setProperty("nested", nestedMap); + item1.setItemType(TestMetadataItem.ITEM_TYPE); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + nestedMap = new HashMap<>(); + nestedMap.put("value", 20.0); + item2.setProperty("nested", nestedMap); + item2.setItemType(TestMetadataItem.ITEM_TYPE); + + persistenceService.save(item1); + persistenceService.save(item2); + + String[] metrics = {"card", "sum", "min", "max", "avg"}; + + // when - retry metrics calculation until items are available (handles refresh delay) + Map results = TestHelper.retryUntil( + () -> persistenceService.getSingleValuesMetrics( + null, metrics, "properties.nested.value", TestMetadataItem.ITEM_TYPE), + r -> r != null && r.get("_card") != null && r.get("_card") == 2.0 + ); + + // then + assertEquals(2.0, results.get("_card"), 0.001); + assertEquals(30.0, results.get("_sum"), 0.001); + assertEquals(10.0, results.get("_min"), 0.001); + assertEquals(20.0, results.get("_max"), 0.001); + assertEquals(15.0, results.get("_avg"), 0.001); + } + } + + @Nested + class PaginationTests { + @Test + void shouldHandleNegativeSizeInQuery() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - query with size = -1 (retry until items are available) + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, -1), + 10 + ); + + // then + assertEquals(10, result.getList().size()); + assertEquals(10, result.getTotalSize()); + assertEquals(-1, result.getPageSize()); + assertEquals(0, result.getOffset()); + } + + @Test + void shouldHandleNegativeSizeWithOffset() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - query with size = -1 and offset = 5 (retry until items are available) + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 5, -1), + 5 + ); + + // then + assertEquals(5, result.getList().size()); + assertEquals(10, result.getTotalSize()); + assertEquals(-1, result.getPageSize()); + assertEquals(5, result.getOffset()); + } + + @Test + void shouldHandleNegativeSizeInScrollQuery() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - initial query with scroll and size = -1 (retry until items are available) + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(null, null, Profile.class, 0, -1, "1000"), + 10 + ); + + // then + assertEquals(10, result.getList().size()); + assertEquals(10, result.getTotalSize()); + assertEquals(-1, result.getPageSize()); + assertNull(result.getScrollIdentifier()); // No scroll needed when getting all items + } + + @Test + void shouldHandleNegativeSizeInGetAllItems() { + // given + List profiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Profile profile = new Profile(); + profile.setItemId("profile-" + i); + profiles.add(profile); + persistenceService.save(profile); + } + + // when - getAllItems with size = -1 (retry until items are available) + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.getAllItems(Profile.class, 0, -1, null), + 10 + ); + + // then + assertEquals(10, result.getList().size()); + assertEquals(10, result.getTotalSize()); + assertEquals(-1, result.getPageSize()); + assertEquals(0, result.getOffset()); + } + } + + @Nested + class FileStorageOperations { + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + tempDir = Files.createTempDirectory("unomi-test"); + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true); + } + + @AfterEach + void tearDown() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // Ignore + } + }); + } + } + + @Test + void shouldPersistItemToFile() throws IOException { + // given + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("firstName", "John"); + + // when + persistenceService.save(profile); + + // then + Path expectedPath = tempDir.resolve(Profile.ITEM_TYPE) + .resolve(executionContextManager.getCurrentContext().getTenantId()) + .resolve("test-profile.json"); + assertTrue(Files.exists(expectedPath)); + String content = Files.readString(expectedPath); + assertTrue(content.contains("John")); + } + + @Test + void shouldLoadPersistedItemsOnStartup() throws IOException { + // given + // First persistence service instance to save items + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true, true, true); + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + profile1.setProperty("name", "John"); + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + profile2.setProperty("name", "Jane"); + persistenceService.save(profile1); + persistenceService.save(profile2); + + // when + // Create new persistence service instance that should load persisted items + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true, false, true); + + // then + Profile loaded1 = persistenceService.load("profile1", Profile.class); + Profile loaded2 = persistenceService.load("profile2", Profile.class); + assertNotNull(loaded1); + assertNotNull(loaded2); + assertEquals("John", loaded1.getProperty("name")); + assertEquals("Jane", loaded2.getProperty("name")); + } + + @Test + void shouldHandleFileStorageDisabled() { + // given + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), false); + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("firstName", "John"); + + // when + persistenceService.save(profile); + + // then + Path expectedPath = tempDir.resolve(Profile.ITEM_TYPE) + .resolve(executionContextManager.getCurrentContext().getTenantId()) + .resolve("test_profile.json"); + assertFalse(Files.exists(expectedPath)); + + // Verify in-memory operation still works + Profile loaded = persistenceService.load("test-profile", Profile.class); + assertNotNull(loaded); + assertEquals("John", loaded.getProperty("firstName")); + } + + @Test + void shouldHandleSpecialCharactersInPaths() { + // given + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true); + Profile profile = new Profile(); + profile.setItemId("test/profile:with?special*chars"); + profile.setProperty("data", "test"); + + // when + persistenceService.save(profile); + + // then + Path expectedPath = tempDir.resolve(Profile.ITEM_TYPE) + .resolve(executionContextManager.getCurrentContext().getTenantId()) + .resolve("test_profile_with_special_chars.json"); + assertTrue(Files.exists(expectedPath)); + + // Verify item can be loaded + Profile loaded = persistenceService.load("test/profile:with?special*chars", Profile.class); + assertNotNull(loaded); + assertEquals("test", loaded.getProperty("data")); + } + + @Test + void shouldDeleteFileWhenItemRemoved() { + // given + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true); + Profile profile = new Profile(); + profile.setItemId("test-profile"); + persistenceService.save(profile); + + Path expectedPath = tempDir.resolve(Profile.ITEM_TYPE) + .resolve(executionContextManager.getCurrentContext().getTenantId()) + .resolve("test-profile.json"); + assertTrue(Files.exists(expectedPath)); + + // when + persistenceService.remove("test-profile", Profile.class); + + // then + assertFalse(Files.exists(expectedPath)); + assertNull(persistenceService.load("test-profile", Profile.class)); + } + + @Test + void shouldCleanupEmptyDirectories() { + // given + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true); + Profile profile = new Profile(); + profile.setItemId("test-profile"); + persistenceService.save(profile); + + Path tenantDir = tempDir.resolve(Profile.ITEM_TYPE) + .resolve(executionContextManager.getCurrentContext().getTenantId()); + assertTrue(Files.exists(tenantDir)); + + // when + persistenceService.remove("test-profile", Profile.class); + + // then + assertFalse(Files.exists(tenantDir)); + } + + @Test + void shouldHandleMultipleTenantsAndTypes() throws IOException { + // given + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, tempDir.toString(), true); + + // Create items for default tenant + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + persistenceService.save(profile1); + + // Create items for another tenant + executionContextManager.executeAsTenant("tenant2", () -> { + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + persistenceService.save(profile2); + return null; + }); + + // Create different type for default tenant + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + persistenceService.save(item); + + // then + assertTrue(Files.exists(tempDir.resolve(Profile.ITEM_TYPE) + .resolve(SYSTEM_TENANT) + .resolve("profile1.json"))); + assertTrue(Files.exists(tempDir.resolve(Profile.ITEM_TYPE) + .resolve("tenant2") + .resolve("profile2.json"))); + assertTrue(Files.exists(tempDir.resolve(TestMetadataItem.ITEM_TYPE) + .resolve(SYSTEM_TENANT) + .resolve("item1.json"))); + } + + @Test + void shouldHandleFileSystemErrors() throws IOException { + // given + Path readOnlyDir = tempDir.resolve("readonly"); + try { + Files.createDirectory(readOnlyDir); + readOnlyDir.toFile().setReadOnly(); + + // Try to create persistence service with read-only directory + assertThrows(RuntimeException.class, () -> { + new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher, + readOnlyDir.resolve("data").toString(), true); + }); + + } finally { + readOnlyDir.toFile().setWritable(true); + } + } + + @Test + void shouldHandleDotsInFileNames() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test.item.with.dots"); + item.setProperty("test", "value"); + persistenceService.save(item); + + // when + TestMetadataItem loaded = persistenceService.load("test.item.with.dots", TestMetadataItem.class); + + // then + assertNotNull(loaded); + assertEquals("value", loaded.getProperty("test")); + + // Verify file was created with underscores + Path expectedPath = tempDir + .resolve(TestMetadataItem.ITEM_TYPE) + .resolve(SYSTEM_TENANT) + .resolve("test_item_with_dots.json"); + assertTrue(Files.exists(expectedPath), "File should exist at: " + expectedPath); + } + } + + @Nested + class PropertyConditionEvaluatorTests { + @Test + void shouldHandleNumericComparisons() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setNumericValue(42.5); + persistenceService.save(item); + + // Test integer comparison + Condition intCondition = new Condition(); + intCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + intCondition.setParameter("propertyName", "numericValue"); + intCondition.setParameter("comparisonOperator", "greaterThan"); + intCondition.setParameter("propertyValueInteger", 40); + + // Test double comparison + Condition doubleCondition = new Condition(); + doubleCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + doubleCondition.setParameter("propertyName", "numericValue"); + doubleCondition.setParameter("comparisonOperator", "lessThan"); + doubleCondition.setParameter("propertyValueDouble", 43.0); + + // when + boolean intResult = persistenceService.testMatch(intCondition, item); + boolean doubleResult = persistenceService.testMatch(doubleCondition, item); + + // then + assertTrue(intResult); + assertTrue(doubleResult); + } + + @Test + void shouldNotThrowWhenActualValueIsNotNumericForNumericComparison() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item-non-numeric"); + item.setProperty("notANumber", "abc"); + persistenceService.save(item); + + Condition intCondition = new Condition(); + intCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + intCondition.setParameter("propertyName", "properties.notANumber"); + intCondition.setParameter("comparisonOperator", "greaterThan"); + intCondition.setParameter("propertyValueInteger", 10); + + // when + boolean result = persistenceService.testMatch(intCondition, item); + + // then + assertFalse(result, "Non-numeric actual value should not match numeric comparison"); + } + + @Test + void shouldHandleDateComparisons() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Calendar cal = Calendar.getInstance(); + cal.set(2024, Calendar.JANUARY, 15); + item.setProperty("date", cal.getTime()); + persistenceService.save(item); + + // Test exact date comparison + Condition dateCondition = new Condition(); + dateCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + dateCondition.setParameter("propertyName", "properties.date"); + dateCondition.setParameter("comparisonOperator", "equals"); + dateCondition.setParameter("propertyValueDate", cal.getTime()); + + // Test date expression + Calendar futureDate = Calendar.getInstance(); + futureDate.set(2024, Calendar.DECEMBER, 31); + Condition dateExprCondition = new Condition(); + dateExprCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + dateExprCondition.setParameter("propertyName", "properties.date"); + dateExprCondition.setParameter("comparisonOperator", "lessThan"); + dateExprCondition.setParameter("propertyValueDateExpr", futureDate.getTime()); + + // when + boolean dateResult = persistenceService.testMatch(dateCondition, item); + boolean dateExprResult = persistenceService.testMatch(dateExprCondition, item); + + // then + assertTrue(dateResult); + assertTrue(dateExprResult); + } + + @Test + void shouldHandleModernDateTypes() { + // given - test that modern Java date/time types are properly converted + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item-modern-dates"); + // Use UTC consistently to avoid timezone mismatches + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cal.set(2024, Calendar.JANUARY, 15, 10, 30, 0); + cal.set(Calendar.MILLISECOND, 0); + Date testDate = cal.getTime(); + item.setProperty("date", testDate); + persistenceService.save(item); + + // Test with OffsetDateTime + OffsetDateTime offsetDateTime = OffsetDateTime.of(2024, 1, 15, 10, 30, 0, 0, ZoneOffset.UTC); + Condition offsetDateTimeCondition = new Condition(); + offsetDateTimeCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + offsetDateTimeCondition.setParameter("propertyName", "properties.date"); + offsetDateTimeCondition.setParameter("comparisonOperator", "equals"); + offsetDateTimeCondition.setParameter("propertyValueDate", offsetDateTime); + + // Test with ZonedDateTime + ZonedDateTime zonedDateTime = ZonedDateTime.of(2024, 1, 15, 10, 30, 0, 0, ZoneId.of("UTC")); + Condition zonedDateTimeCondition = new Condition(); + zonedDateTimeCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + zonedDateTimeCondition.setParameter("propertyName", "properties.date"); + zonedDateTimeCondition.setParameter("comparisonOperator", "equals"); + zonedDateTimeCondition.setParameter("propertyValueDate", zonedDateTime); + + // Test with Instant + Instant instant = testDate.toInstant(); + Condition instantCondition = new Condition(); + instantCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + instantCondition.setParameter("propertyName", "properties.date"); + instantCondition.setParameter("comparisonOperator", "equals"); + instantCondition.setParameter("propertyValueDate", instant); + + // when + boolean offsetDateTimeResult = persistenceService.testMatch(offsetDateTimeCondition, item); + boolean zonedDateTimeResult = persistenceService.testMatch(zonedDateTimeCondition, item); + boolean instantResult = persistenceService.testMatch(instantCondition, item); + + // then - all modern date types should work correctly + assertTrue(offsetDateTimeResult, "OffsetDateTime should be properly converted and matched"); + assertTrue(zonedDateTimeResult, "ZonedDateTime should be properly converted and matched"); + assertTrue(instantResult, "Instant should be properly converted and matched"); + } + + @Test + void shouldHandleLegacyDateFormats() { + // given - test backward compatibility with migrated datasets from older Unomi/Elasticsearch versions + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item-legacy-dates"); + // Use UTC consistently to avoid timezone mismatches + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cal.set(2024, Calendar.JANUARY, 15, 10, 30, 0); + cal.set(Calendar.MILLISECOND, 0); + Date testDate = cal.getTime(); + item.setProperty("date", testDate); + persistenceService.save(item); + + // Test with epoch milliseconds (common in older Elasticsearch versions) + long epochMillis = testDate.getTime(); + Condition epochMillisCondition = new Condition(); + epochMillisCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + epochMillisCondition.setParameter("propertyName", "properties.date"); + epochMillisCondition.setParameter("comparisonOperator", "equals"); + epochMillisCondition.setParameter("propertyValueDate", String.valueOf(epochMillis)); + + // Test with ISO-8601 string format (case-insensitive - legacy systems might use lowercase) + String isoDateLowercase = "2024-01-15t10:30:00z"; + Condition isoLowercaseCondition = new Condition(); + isoLowercaseCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + isoLowercaseCondition.setParameter("propertyName", "properties.date"); + isoLowercaseCondition.setParameter("comparisonOperator", "equals"); + isoLowercaseCondition.setParameter("propertyValueDate", isoDateLowercase); + + // when + boolean epochMillisResult = persistenceService.testMatch(epochMillisCondition, item); + boolean isoLowercaseResult = persistenceService.testMatch(isoLowercaseCondition, item); + + // then - all legacy formats should work correctly + assertTrue(epochMillisResult, "Epoch milliseconds string should be properly parsed and matched"); + assertTrue(isoLowercaseResult, "Case-insensitive ISO date format should be properly parsed and matched"); + } + + @Test + void shouldHandleCollectionOperations() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setTags(new HashSet<>(Arrays.asList("tag1", "tag2", "tag3"))); + persistenceService.save(item); + + // Test hasSomeOf + Condition hasSomeOfCondition = new Condition(); + hasSomeOfCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + hasSomeOfCondition.setParameter("propertyName", "tags"); + hasSomeOfCondition.setParameter("comparisonOperator", "hasSomeOf"); + hasSomeOfCondition.setParameter("propertyValues", Arrays.asList("tag1", "tag4")); + + // Test hasNoneOf + Condition hasNoneOfCondition = new Condition(); + hasNoneOfCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + hasNoneOfCondition.setParameter("propertyName", "tags"); + hasNoneOfCondition.setParameter("comparisonOperator", "hasNoneOf"); + hasNoneOfCondition.setParameter("propertyValues", Arrays.asList("tag4", "tag5")); + + // Test all + Condition allCondition = new Condition(); + allCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + allCondition.setParameter("propertyName", "tags"); + allCondition.setParameter("comparisonOperator", "all"); + allCondition.setParameter("propertyValues", Arrays.asList("tag1", "tag2")); + + // when + boolean hasSomeOfResult = persistenceService.testMatch(hasSomeOfCondition, item); + boolean hasNoneOfResult = persistenceService.testMatch(hasNoneOfCondition, item); + boolean allResult = persistenceService.testMatch(allCondition, item); + + // then + assertTrue(hasSomeOfResult); + assertTrue(hasNoneOfResult); + assertTrue(allResult); + } + + @Test + void shouldHandleGeoDistanceCalculations() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Map location = new HashMap<>(); + location.put("lat", 40.7128); + location.put("lon", -74.0060); + item.setProperty("location", location); + persistenceService.save(item); + + // Test distance condition + Condition distanceCondition = new Condition(); + distanceCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + distanceCondition.setParameter("propertyName", "properties.location"); + distanceCondition.setParameter("comparisonOperator", "distance"); + distanceCondition.setParameter("unit", "km"); + distanceCondition.setParameter("distance", 10.0); + distanceCondition.setParameter("center", "40.7128,-74.0060"); + + // when + boolean result = persistenceService.testMatch(distanceCondition, item); + + // then + assertTrue(result); + } + + @Test + void shouldHandleDayComparisons() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + Calendar cal = Calendar.getInstance(); + cal.set(2024, Calendar.JANUARY, 15, 14, 30, 0); // 2:30 PM + item.setProperty("timestamp", cal.getTime()); + persistenceService.save(item); + + // Test isDay condition + Condition isDayCondition = new Condition(); + isDayCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + isDayCondition.setParameter("propertyName", "properties.timestamp"); + isDayCondition.setParameter("comparisonOperator", "isDay"); + isDayCondition.setParameter("propertyValueDate", cal.getTime()); + + // Test isNotDay condition with different day + cal.add(Calendar.DAY_OF_MONTH, 1); + Condition isNotDayCondition = new Condition(); + isNotDayCondition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + isNotDayCondition.setParameter("propertyName", "properties.timestamp"); + isNotDayCondition.setParameter("comparisonOperator", "isNotDay"); + isNotDayCondition.setParameter("propertyValueDate", cal.getTime()); + + // when + boolean isDayResult = persistenceService.testMatch(isDayCondition, item); + boolean isNotDayResult = persistenceService.testMatch(isNotDayCondition, item); + + // then + assertTrue(isDayResult); + assertTrue(isNotDayResult); + } + } + + @Nested + class ScriptExecutionTests { + + @Test + public void testUpdatePastEventOccurrencesScript() { + // Create a test profile + Profile profile = new Profile(); + profile.setItemId("test-profile-id"); + persistenceService.save(profile); + + // Create script parameters + Map pastEventKeyValue = new HashMap<>(); + pastEventKeyValue.put("pastEventKey", "test-event"); + pastEventKeyValue.put("valueToAdd", 5L); + + Map scriptParams = new HashMap<>(); + scriptParams.put(profile.getItemId(), pastEventKeyValue); + + // Execute script + boolean result = persistenceService.updateWithScript(profile, Profile.class, + "updatePastEventOccurences", scriptParams); + assertTrue(result); + + // Verify the update + Profile updatedProfile = persistenceService.load(profile.getItemId(), Profile.class); + assertNotNull(updatedProfile); + + @SuppressWarnings("unchecked") + Map systemProperties = updatedProfile.getSystemProperties(); + assertNotNull(systemProperties); + + @SuppressWarnings("unchecked") + List> pastEvents = (List>) systemProperties.get("pastEvents"); + assertNotNull(pastEvents); + assertEquals(1, pastEvents.size()); + + Map pastEvent = pastEvents.get(0); + assertEquals("test-event", pastEvent.get("key")); + assertEquals(5L, pastEvent.get("count")); + } + + @Test + public void testUpdateProfileIdScript() { + // Create a test session + Session session = new Session(); + session.setItemId("test-session-id"); + + Profile profile = new Profile(); + profile.setItemId("old-profile-id"); + session.setProfile(profile); + + persistenceService.save(session); + + // Create script parameters + Map scriptParams = new HashMap<>(); + scriptParams.put("profileId", "new-profile-id"); + + // Execute script + boolean result = persistenceService.updateWithScript(session, Session.class, + "updateProfileId", scriptParams); + assertTrue(result); + + // Verify the update + Session updatedSession = persistenceService.load(session.getItemId(), Session.class); + assertNotNull(updatedSession); + assertEquals("new-profile-id", updatedSession.getProfileId()); + assertEquals("new-profile-id", updatedSession.getProfile().getItemId()); + } + + @Test + public void testUpdateWithQueryAndScript() { + // Create test profiles + Profile profile1 = new Profile(); + profile1.setItemId("test-profile-1"); + persistenceService.save(profile1); + + Profile profile2 = new Profile(); + profile2.setItemId("test-profile-2"); + persistenceService.save(profile2); + + // Create script parameters for both profiles + Map pastEventKeyValue1 = new HashMap<>(); + pastEventKeyValue1.put("pastEventKey", "test-event"); + pastEventKeyValue1.put("valueToAdd", 5L); + + Map pastEventKeyValue2 = new HashMap<>(); + pastEventKeyValue2.put("pastEventKey", "test-event"); + pastEventKeyValue2.put("valueToAdd", 3L); + + Map scriptParams1 = new HashMap<>(); + scriptParams1.put(profile1.getItemId(), pastEventKeyValue1); + + Map scriptParams2 = new HashMap<>(); + scriptParams2.put(profile2.getItemId(), pastEventKeyValue2); + + // Create conditions that match each profile + Condition condition1 = new Condition(); + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("matchAllCondition"); + conditionType.setQueryBuilder("matchAllConditionQueryBuilder"); + conditionType.setConditionEvaluator("matchAllConditionEvaluator"); + condition1.setConditionType(conditionType); + + // Execute scripts + String[] scripts = new String[]{"updatePastEventOccurences"}; + Map[] scriptParamsArray = new Map[]{scriptParams1}; + Condition[] conditions = new Condition[]{condition1}; + + boolean result = persistenceService.updateWithQueryAndScript(Profile.class, scripts, scriptParamsArray, conditions); + assertTrue(result); + + // Verify updates + Profile updatedProfile1 = persistenceService.load(profile1.getItemId(), Profile.class); + assertNotNull(updatedProfile1); + + @SuppressWarnings("unchecked") + Map systemProperties1 = updatedProfile1.getSystemProperties(); + assertNotNull(systemProperties1); + + @SuppressWarnings("unchecked") + List> pastEvents1 = (List>) systemProperties1.get("pastEvents"); + assertNotNull(pastEvents1); + assertEquals(1, pastEvents1.size()); + assertEquals(5L, pastEvents1.get(0).get("count")); + } + + @Test + public void testStoreScripts() { + Map scripts = new HashMap<>(); + scripts.put("test-script", "ctx._source.test = params.value"); + + // Store scripts should always return true for in-memory implementation + assertTrue(persistenceService.storeScripts(scripts)); + } + + @Test + void shouldHandleVersioningInScriptUpdates() { + // Create a test profile + Profile profile = new Profile(); + profile.setItemId("test-profile-version"); + persistenceService.save(profile); + assertEquals(1L, profile.getVersion()); + + // Create script parameters + Map pastEventKeyValue = new HashMap<>(); + pastEventKeyValue.put("pastEventKey", "test-event"); + pastEventKeyValue.put("valueToAdd", 5L); + + Map scriptParams = new HashMap<>(); + scriptParams.put(profile.getItemId(), pastEventKeyValue); + + // Execute script update + boolean result = persistenceService.updateWithScript(profile, Profile.class, + "updatePastEventOccurences", scriptParams); + assertTrue(result); + + // Verify version was incremented + Profile updatedProfile = persistenceService.load(profile.getItemId(), Profile.class); + assertNotNull(updatedProfile); + assertEquals(2L, updatedProfile.getVersion()); + } + + @Test + void shouldHandleVersioningInQueryAndScriptUpdates() { + // Create test profiles + Profile profile1 = new Profile(); + profile1.setItemId("test-profile-version-1"); + persistenceService.save(profile1); + assertEquals(1L, profile1.getVersion()); + + Profile profile2 = new Profile(); + profile2.setItemId("test-profile-version-2"); + persistenceService.save(profile2); + assertEquals(1L, profile2.getVersion()); + + // Create script parameters + Map pastEventKeyValue = new HashMap<>(); + pastEventKeyValue.put("pastEventKey", "test-event"); + pastEventKeyValue.put("valueToAdd", 5L); + + Map scriptParams = new HashMap<>(); + scriptParams.put(profile1.getItemId(), pastEventKeyValue); + scriptParams.put(profile2.getItemId(), pastEventKeyValue); + + // Create condition that matches both profiles + Condition condition = new Condition(); + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("matchAllCondition"); + conditionType.setQueryBuilder("matchAllConditionQueryBuilder"); + conditionType.setConditionEvaluator("matchAllConditionEvaluator"); + condition.setConditionType(conditionType); + + // Execute script update + String[] scripts = new String[]{"updatePastEventOccurences"}; + Map[] scriptParamsArray = new Map[]{scriptParams}; + Condition[] conditions = new Condition[]{condition}; + + boolean result = persistenceService.updateWithQueryAndScript(Profile.class, scripts, scriptParamsArray, conditions); + assertTrue(result); + + // Verify versions were incremented + Profile updatedProfile1 = persistenceService.load(profile1.getItemId(), Profile.class); + Profile updatedProfile2 = persistenceService.load(profile2.getItemId(), Profile.class); + assertNotNull(updatedProfile1); + assertNotNull(updatedProfile2); + assertEquals(2L, updatedProfile1.getVersion()); + assertEquals(2L, updatedProfile2.getVersion()); + } + } + + @Nested + class PropertyMappingTests { + @Test + void shouldSetAndGetPropertyMapping() { + // Create a property type with some test data + PropertyType propertyType = new PropertyType(); + propertyType.setMetadata(new Metadata()); + propertyType.getMetadata().setId("test-metadata-id"); + propertyType.getMetadata().setName("Test Property"); + propertyType.getMetadata().setDescription("A test property"); + propertyType.setItemId("testProperty"); + propertyType.setValueTypeId("string"); + propertyType.setTarget("profiles"); + + persistenceService.setPropertyMapping(propertyType, "testItemType"); + + // Verify the mapping structure + Map> mapping = persistenceService.getPropertiesMapping("testItemType"); + assertNotNull(mapping); + assertTrue(mapping.containsKey("properties")); + + // Get and verify the property mapping + Map propertyMapping = persistenceService.getPropertyMapping("testProperty", "testItemType"); + assertNotNull(propertyMapping); + + // Verify the converted map contains all the expected fields + assertEquals("testProperty", propertyMapping.get("itemId")); + assertEquals("string", propertyMapping.get("type")); // valueTypeId is mapped to type at JSON serialization + assertEquals("profiles", propertyMapping.get("target")); + + // Verify metadata was properly converted + @SuppressWarnings("unchecked") + Map metadata = (Map) propertyMapping.get("metadata"); + assertNotNull(metadata); + assertEquals("test-metadata-id", metadata.get("id")); + assertEquals("Test Property", metadata.get("name")); + assertEquals("A test property", metadata.get("description")); + } + + @Test + void shouldHandleNonExistentPropertyMapping() { + assertNull(persistenceService.getPropertyMapping("nonexistent", "testItemType")); + assertNull(persistenceService.getPropertiesMapping("nonexistentType")); + } + } + + @Nested + class RangeQueryTests { + @Test + void shouldHandleNumericRangeQueries() { + // given + List items = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setNumericValue((double) i); + items.add(item); + persistenceService.save(item); + } + + // when - query with both bounds (retry until items are available) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", "2", "4", "numericValue:asc", TestMetadataItem.class, 0, -1), + 3 + ); + + // then + assertEquals(3, results.getList().size()); + assertEquals(2.0, results.getList().get(0).getNumericValue()); + assertEquals(3.0, results.getList().get(1).getNumericValue()); + assertEquals(4.0, results.getList().get(2).getNumericValue()); + + // when - query with lower bound only (retry until items are available) + results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", "4", null, "numericValue:asc", TestMetadataItem.class, 0, -1), + 2 + ); + + // then + assertEquals(2, results.getList().size()); + assertEquals(4.0, results.getList().get(0).getNumericValue()); + assertEquals(5.0, results.getList().get(1).getNumericValue()); + + // when - query with upper bound only (retry until items are available) + results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", null, "2", "numericValue:asc", TestMetadataItem.class, 0, -1), + 2 + ); + + // then + assertEquals(2, results.getList().size()); + assertEquals(1.0, results.getList().get(0).getNumericValue()); + assertEquals(2.0, results.getList().get(1).getNumericValue()); + } + + @Test + void shouldHandleStringRangeQueries() { + // given + List items = new ArrayList<>(); + for (char c = 'A'; c <= 'E'; c++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + c); + item.setName(String.valueOf(c)); + items.add(item); + persistenceService.save(item); + } + + // when - query with both bounds (retry until items are available) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "name", "B", "D", "name:asc", TestMetadataItem.class, 0, -1), + 3 + ); + + // then + assertEquals(3, results.getList().size()); + assertEquals("B", results.getList().get(0).getName()); + assertEquals("C", results.getList().get(1).getName()); + assertEquals("D", results.getList().get(2).getName()); + + // when - query with lower bound only (retry until items are available) + results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "name", "D", null, "name:asc", TestMetadataItem.class, 0, -1), + 2 + ); + + // then + assertEquals(2, results.getList().size()); + assertEquals("D", results.getList().get(0).getName()); + assertEquals("E", results.getList().get(1).getName()); + + // when - query with upper bound only (retry until items are available) + results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "name", null, "B", "name:asc", TestMetadataItem.class, 0, -1), + 2 + ); + + // then + assertEquals(2, results.getList().size()); + assertEquals("A", results.getList().get(0).getName()); + assertEquals("B", results.getList().get(1).getName()); + } + + @Test + void shouldHandlePaginationInRangeQueries() { + // given + List items = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setNumericValue((double) i); + items.add(item); + persistenceService.save(item); + } + + // when - first page (retry until items are available) + PartialList page1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", "1", "5", "numericValue:asc", TestMetadataItem.class, 0, 2), + 2 + ); + + // then + assertEquals(2, page1.getList().size()); + assertEquals(1.0, page1.getList().get(0).getNumericValue()); + assertEquals(2.0, page1.getList().get(1).getNumericValue()); + + // when - second page (retry until items are available) + PartialList page2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", "1", "5", "numericValue:asc", TestMetadataItem.class, 2, 2), + 2 + ); + + // then + assertEquals(2, page2.getList().size()); + assertEquals(3.0, page2.getList().get(0).getNumericValue()); + assertEquals(4.0, page2.getList().get(1).getNumericValue()); + + // when - last page (retry until items are available) + PartialList page3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.rangeQuery( + "numericValue", "1", "5", "numericValue:asc", TestMetadataItem.class, 4, 2), + 1 + ); + + // then + assertEquals(1, page3.getList().size()); + assertEquals(5.0, page3.getList().get(0).getNumericValue()); + } + + @Test + void shouldHandleNonExistentFieldInRangeQueries() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setNumericValue(1.0); + persistenceService.save(item); + + // when + PartialList results = persistenceService.rangeQuery( + "nonexistentField", "1", "5", "numericValue:asc", TestMetadataItem.class, 0, -1); + + // then + assertEquals(0, results.getList().size()); + } + + @Test + void shouldHandleInvalidRangeValues() { + // given + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setNumericValue(1.0); + persistenceService.save(item); + + // when - invalid numeric range + PartialList results = persistenceService.rangeQuery( + "numericValue", "invalid", "5", "numericValue:asc", TestMetadataItem.class, 0, -1); + + // then + assertEquals(0, results.getList().size()); + } + } + + @Nested + class UpdateOperationTests { + @Test + void shouldUpdateItemWithSourceMap() { + // Create and save initial item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setName("Initial Name"); + item.setNumericValue(1.0); + persistenceService.save(item); + + // Create update map + Map updates = new HashMap<>(); + updates.put("name", "Updated Name"); + updates.put("numericValue", 2.0); + + // Perform update + boolean result = persistenceService.update(item, null, TestMetadataItem.class, updates); + assertTrue(result); + + // Verify updates + TestMetadataItem updated = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals("Updated Name", updated.getName()); + assertEquals(2.0, updated.getNumericValue()); + assertEquals(2, updated.getVersion()); + } + + @Test + void shouldUpdateItemWithSingleProperty() { + // Create and save initial item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setName("Initial Name"); + persistenceService.save(item); + + // Perform update + boolean result = persistenceService.update(item, null, TestMetadataItem.class, "name", "Updated Name"); + assertTrue(result); + + // Verify update + TestMetadataItem updated = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals("Updated Name", updated.getName()); + assertEquals(2, updated.getVersion()); + } + + @Test + void shouldUpdateItemWithSourceMapAndNoScriptCall() { + // Create and save initial item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setName("Initial Name"); + persistenceService.save(item); + + // Create update map + Map updates = new HashMap<>(); + updates.put("name", "Updated Name"); + + // Perform update + boolean result = persistenceService.update(item, null, TestMetadataItem.class, updates, true); + assertTrue(result); + + // Verify update + TestMetadataItem updated = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals("Updated Name", updated.getName()); + assertEquals(2, updated.getVersion()); + } + + @Test + void shouldUpdateMultipleItems() { + // Create and save initial items + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("test-item-1"); + item1.setName("Item 1"); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("test-item-2"); + item2.setName("Item 2"); + persistenceService.save(item2); + + // Create updates map + Map updates = new HashMap<>(); + updates.put(item1, Collections.singletonMap("name", "Updated Item 1")); + updates.put(item2, Collections.singletonMap("name", "Updated Item 2")); + + // Perform updates + List failedUpdates = persistenceService.update(updates, null, TestMetadataItem.class); + assertTrue(failedUpdates.isEmpty()); + + // Verify updates + TestMetadataItem updated1 = persistenceService.load(item1.getItemId(), TestMetadataItem.class); + TestMetadataItem updated2 = persistenceService.load(item2.getItemId(), TestMetadataItem.class); + assertEquals("Updated Item 1", updated1.getName()); + assertEquals("Updated Item 2", updated2.getName()); + assertEquals(2, updated1.getVersion()); + assertEquals(2, updated2.getVersion()); + } + + @Test + void shouldHandleNonExistentItem() { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("non-existent"); + + Map updates = new HashMap<>(); + updates.put("name", "Updated Name"); + + boolean result = persistenceService.update(item, null, TestMetadataItem.class, updates); + assertFalse(result); + } + + @Test + void shouldHandleInvalidPropertyName() { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setName("Initial Name"); + persistenceService.save(item); + + boolean result = persistenceService.update(item, null, TestMetadataItem.class, "nonExistentProperty", "value"); + assertFalse(result); + + TestMetadataItem unchanged = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals("Initial Name", unchanged.getName()); + assertEquals(item.getVersion(), unchanged.getVersion()); + } + } + + @Nested + class FileStorageConcurrencyTests { + @Test + void shouldHandleConcurrentFileOperations() throws InterruptedException { + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completionLatch = new CountDownLatch(threadCount); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + + // Start all threads but wait for them to be ready + for (int i = 0; i < threadCount; i++) { + final int index = i; + new Thread(() -> { + try { + // Wait for all threads to be ready before starting operations + startLatch.await(); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("concurrent-item-" + index); + persistenceService.save(item); + persistenceService.load(item.getItemId(), TestMetadataItem.class); + persistenceService.remove(item.getItemId(), TestMetadataItem.class); + } catch (Exception e) { + exceptions.add(e); + } finally { + completionLatch.countDown(); + } + }).start(); + } + + // Release all threads to start operations concurrently + // The startLatch.await() in each thread ensures all threads are ready before proceeding + startLatch.countDown(); + + // Wait for all threads to complete with a longer timeout + boolean completed = completionLatch.await(10, TimeUnit.SECONDS); + assertTrue(completed, "Test timed out - not all threads completed within 10 seconds"); + assertTrue(exceptions.isEmpty(), "Concurrent operations should not throw exceptions. Found: " + exceptions); + } + + } + + @Nested + class FieldAccessPatternTests { + private TestMetadataItem createTestItem(String id, Map properties) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId(id); + item.setProperties(properties); + return item; + } + + @Test + void shouldHandleDotNotationAccess() { + // Setup test data + Map nestedMap = new HashMap<>(); + nestedMap.put("simple", "value"); + nestedMap.put("nested.key", "nested-value"); + + Map properties = new HashMap<>(); + properties.put("map", nestedMap); + + TestMetadataItem item = createTestItem("test1", properties); + persistenceService.save(item); + + // Test simple dot notation + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + Map loadedProps = loaded.getProperties(); + Map loadedMap = (Map) loadedProps.get("map"); + + assertEquals("value", loadedMap.get("simple")); + assertEquals("nested-value", loadedMap.get("nested.key")); + } + + @Test + void shouldHandleBackslashEscapedDots() { + // Setup test data + Map properties = new HashMap<>(); + properties.put("user.name", "test-user"); + properties.put("complex.key.value", "complex-value"); + + TestMetadataItem item = createTestItem("test2", properties); + persistenceService.save(item); + + // Test accessing properties with dots + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + Map loadedProps = loaded.getProperties(); + + assertEquals("test-user", loadedProps.get("user.name")); + assertEquals("complex-value", loadedProps.get("complex.key.value")); + } + + @Test + void shouldHandleMixedNotationAccess() { + // Setup test data + Map nestedMap = new HashMap<>(); + nestedMap.put("key.with.dots", "dotted-value"); + + List array = Arrays.asList("first", "second", "third"); + + Map properties = new HashMap<>(); + properties.put("map", nestedMap); + properties.put("array", array); + + TestMetadataItem item = createTestItem("test3", properties); + persistenceService.save(item); + + // Test accessing nested properties + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + Map loadedProps = loaded.getProperties(); + Map loadedMap = (Map) loadedProps.get("map"); + List loadedArray = (List) loadedProps.get("array"); + + assertEquals("dotted-value", loadedMap.get("key.with.dots")); + assertEquals("second", loadedArray.get(1)); + } + + @Test + void shouldHandleNullAndNonExistentFields() { + // Setup test data + Map properties = new HashMap<>(); + properties.put("nullValue", null); + + TestMetadataItem item = createTestItem("test4", properties); + persistenceService.save(item); + + // Test null and non-existent fields + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + Map loadedProps = loaded.getProperties(); + + assertNull(loadedProps.get("nullValue")); + assertNull(loadedProps.get("nonexistent.field")); + } + + @Test + void shouldHandleArrayAccess() { + // Setup test data + List> array = new ArrayList<>(); + Map element = new HashMap<>(); + element.put("key.with.dots", "array-value"); + array.add(element); + + Map properties = new HashMap<>(); + properties.put("array", array); + + TestMetadataItem item = createTestItem("test5", properties); + persistenceService.save(item); + + // Test array access + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + Map loadedProps = loaded.getProperties(); + List> loadedArray = (List>) loadedProps.get("array"); + Map loadedElement = loadedArray.get(0); + + assertEquals("array-value", loadedElement.get("key.with.dots")); + } + } + + @Nested + class SortingOperationsTests { + @Test + void shouldSortQueryResultsBySimpleProperty() { + // given + List items = new ArrayList<>(); + for (int i = 3; i >= 1; i--) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setProperty("simple", "value"); + item.setName("Name" + i); + items.add(item); + persistenceService.save(item); + } + + // when - ascending order (retry until items are available) + List ascResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "name:asc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, ascResults.size()); + assertEquals("Name1", ascResults.get(0).getName()); + assertEquals("Name2", ascResults.get(1).getName()); + assertEquals("Name3", ascResults.get(2).getName()); + + // when - descending order (retry until items are available) + List descResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "name:desc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, descResults.size()); + assertEquals("Name3", descResults.get(0).getName()); + assertEquals("Name2", descResults.get(1).getName()); + assertEquals("Name1", descResults.get(2).getName()); + } + + @Test + void shouldSortQueryResultsByNumericProperty() { + // given + List items = new ArrayList<>(); + for (int i = 3; i >= 1; i--) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setProperty("simple", "value"); + item.setNumericValue((double) i); + items.add(item); + persistenceService.save(item); + } + + // when - ascending order (retry until items are available) + List ascResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "numericValue:asc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, ascResults.size()); + assertEquals(1.0, ascResults.get(0).getNumericValue()); + assertEquals(2.0, ascResults.get(1).getNumericValue()); + assertEquals(3.0, ascResults.get(2).getNumericValue()); + + // when - descending order (retry until items are available) + List descResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "numericValue:desc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, descResults.size()); + assertEquals(3.0, descResults.get(0).getNumericValue()); + assertEquals(2.0, descResults.get(1).getNumericValue()); + assertEquals(1.0, descResults.get(2).getNumericValue()); + } + + @Test + void shouldHandleNullValuesInSorting() { + // given + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setName("Name1"); + item1.setProperty("simple", "value"); + item1.setNumericValue(1.0); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setName("Name2"); + item2.setProperty("simple", "value"); + item2.setNumericValue(null); + persistenceService.save(item2); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setName("Name3"); + item3.setProperty("simple", "value"); + item3.setNumericValue(3.0); + persistenceService.save(item3); + + // when - ascending order (retry until items are available) + List ascResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "numericValue:asc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, ascResults.size()); + assertNull(ascResults.get(0).getNumericValue()); + assertEquals(1.0, ascResults.get(1).getNumericValue()); + assertEquals(3.0, ascResults.get(2).getNumericValue()); + + // when - descending order (retry until items are available) + List descResults = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "numericValue:desc", TestMetadataItem.class), + 3 + ); + + // then + assertEquals(3, descResults.size()); + assertEquals(3.0, descResults.get(0).getNumericValue()); + assertEquals(1.0, descResults.get(1).getNumericValue()); + assertNull(descResults.get(2).getNumericValue()); + } + + @Test + void shouldSortQueryResultsWithCondition() { + // given + List items = new ArrayList<>(); + for (int i = 3; i >= 1; i--) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setName("Name" + i); + item.setProperty("active", i % 2 == 0); + items.add(item); + persistenceService.save(item); + } + + // Create condition for active items + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.active"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", true); + + // when - retry until items are available + List results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query(condition, "name:asc", TestMetadataItem.class), + 1 + ); + + // then + assertEquals(1, results.size()); + assertEquals("Name2", results.get(0).getName()); + } + + @Test + void shouldSortPaginatedQueryResults() { + // given + List items = new ArrayList<>(); + for (int i = 5; i >= 1; i--) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setProperty("simple", "value"); + item.setName("Name" + i); + items.add(item); + persistenceService.save(item); + } + + // when - first page (retry until items are available) + PartialList page1 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "name:asc", TestMetadataItem.class, 0, 2), + 2 + ); + + // then + assertEquals(2, page1.getList().size()); + assertEquals("Name1", page1.getList().get(0).getName()); + assertEquals("Name2", page1.getList().get(1).getName()); + + // when - second page (retry until items are available) + PartialList page2 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "name:asc", TestMetadataItem.class, 2, 2), + 2 + ); + + // then + assertEquals(2, page2.getList().size()); + assertEquals("Name3", page2.getList().get(0).getName()); + assertEquals("Name4", page2.getList().get(1).getName()); + + // when - last page (retry until items are available) + PartialList page3 = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.query("properties.simple", "value", "name:asc", TestMetadataItem.class, 4, 2), + 1 + ); + + // then + assertEquals(1, page3.getList().size()); + assertEquals("Name5", page3.getList().get(0).getName()); + } + } + + @Nested + class IndexAndPurgeOperations { + + @Test + void shouldPurgeItemsByDate() { + // Create items with different creation dates + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setScope("scope1"); + Calendar cal1 = Calendar.getInstance(); + cal1.add(Calendar.DAY_OF_YEAR, -10); // 10 days ago + item1.setCreationDate(cal1.getTime()); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setScope("scope1"); + Calendar cal2 = Calendar.getInstance(); + cal2.add(Calendar.DAY_OF_YEAR, -5); // 5 days ago + item2.setCreationDate(cal2.getTime()); + persistenceService.save(item2); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setScope("scope1"); + Calendar cal3 = Calendar.getInstance(); + cal3.add(Calendar.DAY_OF_YEAR, -1); // 1 day ago + item3.setCreationDate(cal3.getTime()); + persistenceService.save(item3); + + // Purge items older than 7 days + Calendar purgeDate = Calendar.getInstance(); + purgeDate.add(Calendar.DAY_OF_YEAR, -7); + persistenceService.purge(purgeDate.getTime()); + + // Check that only item1 was purged + assertNull(persistenceService.load("item1", TestMetadataItem.class)); + assertNotNull(persistenceService.load("item2", TestMetadataItem.class)); + assertNotNull(persistenceService.load("item3", TestMetadataItem.class)); + } + + @Test + void shouldPurgeItemsByScope() { + // Create items with different scopes + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setScope("scope1"); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setScope("scope2"); + persistenceService.save(item2); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setScope("scope1"); + persistenceService.save(item3); + + // Purge items with scope1 + persistenceService.purge("scope1"); + + // Check that only scope1 items were purged + assertNull(persistenceService.load("item1", TestMetadataItem.class)); + assertNotNull(persistenceService.load("item2", TestMetadataItem.class)); + assertNull(persistenceService.load("item3", TestMetadataItem.class)); + } + + @Test + void shouldCreateAndRemoveIndex() { + // Create an index + boolean created = persistenceService.createIndex(TestMetadataItem.ITEM_TYPE); + + // Verify the index was created + assertTrue(created); + + // Create items with the test item type + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + persistenceService.save(item1); + + // Remove the index + boolean removed = persistenceService.removeIndex(TestMetadataItem.ITEM_TYPE); + + // Verify the index was removed + assertTrue(removed); + + // Check that only the item with the specified item type was removed + assertNull(persistenceService.load("item1", TestMetadataItem.class)); + } + + @Test + void shouldCreateMapping() { + // Create a mapping + String itemType = "testItemType"; + String mappingConfig = "{\"properties\":{\"field1\":{\"type\":\"keyword\"},\"field2\":{\"type\":\"text\"}}}"; + + // Verify no exception is thrown + assertDoesNotThrow(() -> persistenceService.createMapping(itemType, mappingConfig)); + + // Verify the mapping was stored + Map> mapping = persistenceService.getPropertiesMapping(itemType); + assertNotNull(mapping, "Mapping should not be null"); + + // Verify mapping contains expected properties structure + assertTrue(mapping.containsKey("properties"), "Mapping should contain 'properties' key"); + } + + @Test + void shouldHandleNullArgumentsGracefully() { + // purge(null) is a full reset: save an item, call purge(null), verify the item is gone + TestMetadataItem survivorItem = new TestMetadataItem(); + survivorItem.setItemId("purge-null-test"); + persistenceService.save(survivorItem); + persistenceService.refresh(); + assertDoesNotThrow(() -> persistenceService.purge((Date) null)); + assertNull(persistenceService.load("purge-null-test", TestMetadataItem.class), + "purge(null) should purge all items (used for test isolation reset)"); + + // Test purge with null scope — also a no-op + assertDoesNotThrow(() -> persistenceService.purge((String) null)); + + // Test refresh index with null class + assertDoesNotThrow(() -> persistenceService.refreshIndex(null, null)); + + // Test create index with null item type + assertFalse(persistenceService.createIndex(null)); + + // Test remove index with null item type + assertFalse(persistenceService.removeIndex(null)); + + // Test create mapping with null arguments + assertThrows(IllegalArgumentException.class, () -> persistenceService.createMapping(null, "config")); + assertThrows(IllegalArgumentException.class, () -> persistenceService.createMapping("type", null)); + } + + @Test + void shouldPurgeTimeBasedItems() { + // Create items with different creation dates + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + Calendar cal1 = Calendar.getInstance(); + cal1.add(Calendar.DAY_OF_YEAR, -30); // 30 days ago + item1.setCreationDate(cal1.getTime()); + persistenceService.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + Calendar cal2 = Calendar.getInstance(); + cal2.add(Calendar.DAY_OF_YEAR, -15); // 15 days ago + item2.setCreationDate(cal2.getTime()); + persistenceService.save(item2); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + Calendar cal3 = Calendar.getInstance(); + cal3.add(Calendar.DAY_OF_YEAR, -5); // 5 days ago + item3.setCreationDate(cal3.getTime()); + persistenceService.save(item3); + + // Purge items older than 10 days + persistenceService.purgeTimeBasedItems(10, TestMetadataItem.class); + + // Check that items older than 10 days were purged + assertNull(persistenceService.load("item1", TestMetadataItem.class)); + assertNull(persistenceService.load("item2", TestMetadataItem.class)); + assertNotNull(persistenceService.load("item3", TestMetadataItem.class)); + } + + @Test + void shouldHandleRefreshOperationsSafely() { + // Test refresh + assertDoesNotThrow(() -> persistenceService.refresh()); + + // Test refresh index with a specific class + assertDoesNotThrow(() -> persistenceService.refreshIndex(TestMetadataItem.class, new Date())); + } + + @Test + void shouldRespectTenantIsolationInPurgeOperations() throws Exception { + // Create items for different tenants + TestMetadataItem itemTenant1 = executionContextManager.executeAsTenant("tenant1", () -> { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setScope("scope1"); + persistenceService.save(item); + return item; + }); + + TestMetadataItem itemTenant2 = executionContextManager.executeAsTenant("tenant2", () -> { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item2"); + item.setScope("scope1"); + persistenceService.save(item); + return item; + }); + + // Verify both items were saved + executionContextManager.executeAsTenant("tenant1", () -> { + assertNotNull(persistenceService.load(itemTenant1.getItemId(), TestMetadataItem.class)); + return null; + }); + + executionContextManager.executeAsTenant("tenant2", () -> { + assertNotNull(persistenceService.load(itemTenant2.getItemId(), TestMetadataItem.class)); + return null; + }); + + // Purge items with scope1 but only in tenant1's context + executionContextManager.executeAsTenant("tenant1", () -> { + persistenceService.purge("scope1"); + return null; + }); + + // Verify item from tenant1 is gone but tenant2's item is still there + executionContextManager.executeAsTenant("tenant1", () -> { + assertNull(persistenceService.load(itemTenant1.getItemId(), TestMetadataItem.class)); + return null; + }); + + executionContextManager.executeAsTenant("tenant2", () -> { + assertNotNull(persistenceService.load(itemTenant2.getItemId(), TestMetadataItem.class)); + return null; + }); + } + + @Test + void shouldRespectTenantIsolationInTimeBasedPurge() throws Exception { + // Create items for different tenants with dates in the past + Calendar pastCal = Calendar.getInstance(); + pastCal.add(Calendar.DAY_OF_YEAR, -10); + Date pastDate = pastCal.getTime(); + + TestMetadataItem itemTenant1 = executionContextManager.executeAsTenant("tenant1", () -> { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setCreationDate(pastDate); + persistenceService.save(item); + return item; + }); + + TestMetadataItem itemTenant2 = executionContextManager.executeAsTenant("tenant2", () -> { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item2"); + item.setCreationDate(pastDate); + persistenceService.save(item); + return item; + }); + + // Verify both items were saved + executionContextManager.executeAsTenant("tenant1", () -> { + assertNotNull(persistenceService.load(itemTenant1.getItemId(), TestMetadataItem.class)); + return null; + }); + + executionContextManager.executeAsTenant("tenant2", () -> { + assertNotNull(persistenceService.load(itemTenant2.getItemId(), TestMetadataItem.class)); + return null; + }); + + // Purge items older than 5 days but only in tenant1's context + executionContextManager.executeAsTenant("tenant1", () -> { + persistenceService.purgeTimeBasedItems(5, TestMetadataItem.class); + return null; + }); + + // Verify item from tenant1 is gone but tenant2's item is still there + executionContextManager.executeAsTenant("tenant1", () -> { + assertNull(persistenceService.load(itemTenant1.getItemId(), TestMetadataItem.class)); + return null; + }); + + executionContextManager.executeAsTenant("tenant2", () -> { + assertNotNull(persistenceService.load(itemTenant2.getItemId(), TestMetadataItem.class)); + return null; + }); + } + } + + @Nested + class CustomItemOperations { + + private CustomItem createCustomItem(String id, String itemType, Map properties) { + CustomItem item = new CustomItem(id, itemType); + if (properties != null) { + item.getProperties().putAll(properties); + } + return item; + } + + @Test + void shouldSaveAndLoadCustomItem() { + // Given + String itemType = "testCustomType"; + String itemId = "customItem1"; + Map properties = new HashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", 42); + + CustomItem item = createCustomItem(itemId, itemType, properties); + + // When + persistenceService.save(item); + CustomItem loaded = persistenceService.loadCustomItem(itemId, itemType); + + // Then + assertNotNull(loaded); + assertEquals(itemId, loaded.getItemId()); + assertEquals(itemType, loaded.getItemType()); + assertEquals("value1", loaded.getProperties().get("prop1")); + assertEquals(42, loaded.getProperties().get("prop2")); + } + + @Test + void shouldRespectTenantIsolationInLoadCustomItem() { + // Given + String itemType = "testCustomType"; + String itemId = "customItemTenant"; + Map properties = new HashMap<>(); + properties.put("prop1", "tenant1Value"); + + final CustomItem itemTenant1 = createCustomItem(itemId, itemType, properties); + + // First save in tenant1 context + executionContextManager.executeAsTenant("tenant1", () -> { + persistenceService.save(itemTenant1); + return null; + }); + + // Then try to load in tenant2 context + CustomItem loadedInOtherTenant = executionContextManager.executeAsTenant("tenant2", () -> { + return persistenceService.loadCustomItem(itemId, itemType); + }); + + // And load in original tenant context + CustomItem loadedInOriginalTenant = executionContextManager.executeAsTenant("tenant1", () -> { + return persistenceService.loadCustomItem(itemId, itemType); + }); + + // Then + assertNull(loadedInOtherTenant, "Item should not be accessible in different tenant"); + assertNotNull(loadedInOriginalTenant, "Item should be accessible in original tenant"); + assertEquals("tenant1Value", loadedInOriginalTenant.getProperties().get("prop1")); + } + + @Test + void shouldRemoveCustomItem() { + // Given + String itemType = "testCustomType"; + String itemId = "customItemToRemove"; + + CustomItem item = createCustomItem(itemId, itemType, null); + persistenceService.save(item); + + // When + boolean removeResult = persistenceService.removeCustomItem(itemId, itemType); + CustomItem afterRemove = persistenceService.loadCustomItem(itemId, itemType); + + // Then + assertTrue(removeResult); + assertNull(afterRemove); + } + + @Test + void shouldRespectTenantIsolationInRemoveCustomItem() { + // Given + String itemType = "testCustomType"; + String itemId = "customItemMultiTenant"; + + final CustomItem itemTenant1 = createCustomItem(itemId, itemType, null); + + // Save in tenant1 context + executionContextManager.executeAsTenant("tenant1", () -> { + persistenceService.save(itemTenant1); + return null; + }); + + // Try to remove in tenant2 context + boolean removeResultWrongTenant = executionContextManager.executeAsTenant("tenant2", () -> { + return persistenceService.removeCustomItem(itemId, itemType); + }); + + // Verify item still exists in tenant1 + CustomItem stillExistsInTenant1 = executionContextManager.executeAsTenant("tenant1", () -> { + return persistenceService.loadCustomItem(itemId, itemType); + }); + + // Then remove in correct tenant + boolean removeResultCorrectTenant = executionContextManager.executeAsTenant("tenant1", () -> { + return persistenceService.removeCustomItem(itemId, itemType); + }); + + // Then + assertFalse(removeResultWrongTenant, "Should not be able to remove item from different tenant"); + assertNotNull(stillExistsInTenant1, "Item should still exist in original tenant"); + assertTrue(removeResultCorrectTenant, "Should be able to remove item in original tenant"); + + // Verify item is gone in tenant1 + CustomItem afterRemoveInTenant1 = executionContextManager.executeAsTenant("tenant1", () -> { + return persistenceService.loadCustomItem(itemId, itemType); + }); + assertNull(afterRemoveInTenant1); + } + + @Test + void shouldQueryCustomItems() { + // Given + String itemType = "testQueryCustomType"; + + // Create multiple items + for (int i = 0; i < 10; i++) { + Map props = new HashMap<>(); + props.put("index", i); + props.put("even", i % 2 == 0); + + CustomItem item = createCustomItem("queryItem" + i, itemType, props); + persistenceService.save(item); + } + + // Create a condition to match only even items + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("propertyCondition")); + condition.setParameter("propertyName", "properties.even"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", true); + + // When - retry query until items are available (handles refresh delay) + PartialList results = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(condition, null, itemType, 1, 3, null), + 3 + ); + + // Then + assertEquals(5, results.getTotalSize(), "Should find 5 items with even index"); + assertEquals(3, results.getList().size(), "Should return 3 items with offset 1"); + assertEquals(1, results.getOffset()); + + // Verify the returned items have the expected property values + assertTrue((Boolean) results.getList().get(0).getProperties().get("even")); + assertTrue((Boolean) results.getList().get(1).getProperties().get("even")); + assertTrue((Boolean) results.getList().get(2).getProperties().get("even")); + } + + @Test + void shouldSupportCustomItemScrollQueries() { + // Given + String itemType = "testScrollCustomType"; + + // Create multiple items + for (int i = 0; i < 20; i++) { + Map props = new HashMap<>(); + props.put("index", i); + + CustomItem item = createCustomItem("scrollItem" + i, itemType, props); + persistenceService.save(item); + } + + // When - Start a scroll query (retry until items are available) + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(null, "index:asc", itemType, 0, 5, "1m"), + 5 + ); + String scrollId = firstPage.getScrollIdentifier(); + + assertNotNull(scrollId, "Should have a scroll identifier"); + assertEquals(5, firstPage.getList().size()); + + // When - Continue the scroll query + PartialList secondPage = persistenceService.continueCustomItemScrollQuery(itemType, scrollId, "1m"); + String scrollId2 = secondPage.getScrollIdentifier(); + + // Then + assertNotNull(scrollId2, "Should have a scroll identifier for second page"); + assertEquals(5, secondPage.getList().size()); + + // Verify these are different items than the first page + Set firstPageIds = firstPage.getList().stream() + .map(Item::getItemId) + .collect(Collectors.toSet()); + + Set secondPageIds = secondPage.getList().stream() + .map(Item::getItemId) + .collect(Collectors.toSet()); + + assertTrue(Collections.disjoint(firstPageIds, secondPageIds), + "First and second page should contain different items"); + + // Complete the scroll + PartialList thirdPage = persistenceService.continueCustomItemScrollQuery(itemType, scrollId2, "1m"); + PartialList fourthPage = persistenceService.continueCustomItemScrollQuery(itemType, thirdPage.getScrollIdentifier(), "1m"); + + // Final page, should be no more scroll ID + assertNull(fourthPage.getScrollIdentifier(), "Last page should not have a scroll identifier"); + } + + @Test + void shouldRespectTenantIsolationInQueryCustomItem() { + // Given - items for two different tenants + String itemType = "testMultiTenantQueryType"; + + // Create items in tenant1 + executionContextManager.executeAsTenant("tenant1", () -> { + for (int i = 0; i < 5; i++) { + Map props = new HashMap<>(); + props.put("tenant", "tenant1"); + props.put("index", i); + + CustomItem item = createCustomItem("tenant1Item" + i, itemType, props); + persistenceService.save(item); + } + return null; + }); + + // Create items in tenant2 + executionContextManager.executeAsTenant("tenant2", () -> { + for (int i = 0; i < 7; i++) { + Map props = new HashMap<>(); + props.put("tenant", "tenant2"); + props.put("index", i); + + CustomItem item = createCustomItem("tenant2Item" + i, itemType, props); + persistenceService.save(item); + } + return null; + }); + + // When - query from tenant1 (retry until items are available) + PartialList tenant1Results = executionContextManager.executeAsTenant("tenant1", () -> { + return TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(null, null, itemType, 0, 100, null), + 5 + ); + }); + + // When - query from tenant2 (retry until items are available) + PartialList tenant2Results = executionContextManager.executeAsTenant("tenant2", () -> { + return TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(null, null, itemType, 0, 100, null), + 7 + ); + }); + + // Then + assertEquals(5, tenant1Results.getTotalSize(), "Tenant1 should only see its 5 items"); + assertEquals(7, tenant2Results.getTotalSize(), "Tenant2 should only see its 7 items"); + + // Verify tenant isolation in scroll queries (retry until items are available) + PartialList tenant1ScrollResults = executionContextManager.executeAsTenant("tenant1", () -> { + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(null, "index:asc", itemType, 0, 2, "1m"), + 2 + ); + String scrollId = firstPage.getScrollIdentifier(); + return persistenceService.continueCustomItemScrollQuery(itemType, scrollId, "1m"); + }); + + assertEquals(2, tenant1ScrollResults.getList().size(), "Tenant1 should get correct page size in scroll query"); + + for (CustomItem item : tenant1ScrollResults.getList()) { + assertEquals("tenant1", item.getProperties().get("tenant"), "Items should belong to tenant1"); + } + } + + @Test + void shouldRespectScrollTimeValidity() throws InterruptedException { + // Given + String itemType = "testScrollTimeValidityType"; + + // Create multiple items + for (int i = 0; i < 10; i++) { + Map props = new HashMap<>(); + props.put("index", i); + + CustomItem item = createCustomItem("validityItem" + i, itemType, props); + persistenceService.save(item); + } + + // When - Start a scroll query with very short validity (100ms) (retry until items are available) + Condition matchAllCondition = new Condition(TestConditionEvaluators.getConditionType("matchAllCondition")); + PartialList firstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(matchAllCondition, "index:asc", itemType, 0, 3, "100ms"), + 3 + ); + String scrollId = firstPage.getScrollIdentifier(); + + assertNotNull(scrollId, "Should have a scroll identifier"); + assertEquals(3, firstPage.getList().size()); + + // Wait for scroll to expire (retry until scroll is expired) + // Scroll validity is 100ms, so we check if it has expired by calling continueCustomItemScrollQuery + TestHelper.retryUntil( + () -> persistenceService.continueCustomItemScrollQuery(itemType, scrollId, "100ms"), + result -> result.getList().isEmpty() && result.getScrollIdentifier() == null + ); + + // When - Try to continue the expired scroll query + PartialList secondPage = persistenceService.continueCustomItemScrollQuery(itemType, scrollId, "100ms"); + + // Then - Should return empty result as scroll has expired + assertEquals(0, secondPage.getList().size(), "Should return empty list for expired scroll"); + assertNull(secondPage.getScrollIdentifier(), "Should not have a scroll identifier for expired scroll"); + + // When - Start a new scroll query with longer validity (retry until items are available) + PartialList newFirstPage = TestHelper.retryQueryUntilAvailable( + () -> persistenceService.queryCustomItem(null, "index:asc", itemType, 0, 3, "10s"), + 3 + ); + String newScrollId = newFirstPage.getScrollIdentifier(); + + // Then - Continue the scroll immediately should work + PartialList newSecondPage = persistenceService.continueCustomItemScrollQuery(itemType, newScrollId, "10s"); + assertNotNull(newSecondPage.getScrollIdentifier(), "Should have a scroll identifier for valid scroll"); + assertEquals(3, newSecondPage.getList().size(), "Should return items for valid scroll"); + } + } + + @Nested + class VersioningAndConcurrencyTests { + + @Test + void shouldHandleVersioning() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-version"); + item.setName("Test Version"); + + // Initial save should set version to 1 + persistenceService.save(item); + assertEquals(1L, item.getVersion()); + + // Subsequent saves should increment version + persistenceService.save(item); + assertEquals(2L, item.getVersion()); + + // Load and verify version persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(2L, loaded.getVersion()); + } + + @Test + void shouldHandleVersioningWithExplicitVersion() { + // Create a test item with explicit version + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-explicit-version"); + item.setName("Test Explicit Version"); + item.setVersion(5L); + + // Save should increment existing version + persistenceService.save(item); + assertEquals(6L, item.getVersion()); + + // Load and verify version persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(6L, loaded.getVersion()); + } + + @Test + void shouldGenerateAndIncrementSequenceNumber() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-seq-no"); + item.setName("Test Sequence Number"); + + // Initial save should set sequence number to 1 + persistenceService.save(item); + assertNotNull(item.getSystemMetadata("_seq_no")); + assertEquals(1L, ((Number) item.getSystemMetadata("_seq_no")).longValue()); + + // Each save should increment sequence number + persistenceService.save(item); + assertEquals(2L, ((Number) item.getSystemMetadata("_seq_no")).longValue()); + + persistenceService.save(item); + assertEquals(3L, ((Number) item.getSystemMetadata("_seq_no")).longValue()); + + // Load and verify sequence number persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(3L, ((Number) loaded.getSystemMetadata("_seq_no")).longValue()); + } + + @Test + void shouldSetPrimaryTerm() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-primary-term"); + item.setName("Test Primary Term"); + + // Initial save should set primary term to 1 + persistenceService.save(item); + assertNotNull(item.getSystemMetadata("_primary_term")); + assertEquals(1L, ((Number) item.getSystemMetadata("_primary_term")).longValue()); + + // Primary term shouldn't change on regular updates + persistenceService.save(item); + assertEquals(1L, ((Number) item.getSystemMetadata("_primary_term")).longValue()); + + // Load and verify primary term persisted + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(1L, ((Number) loaded.getSystemMetadata("_primary_term")).longValue()); + } + + @Test + void shouldRejectUpdateWithIncorrectSequenceNumber() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-seq-conflict"); + item.setName("Test Sequence Conflict"); + + // Save the item to get a sequence number + persistenceService.save(item); + Long initialSeqNo = ((Number) item.getSystemMetadata("_seq_no")).longValue(); + Long initialPrimaryTerm = ((Number) item.getSystemMetadata("_primary_term")).longValue(); + + // Create a different instance with the same ID but wrong sequence number + TestMetadataItem conflictItem = new TestMetadataItem(); + conflictItem.setItemId(item.getItemId()); + conflictItem.setName("Conflicting Update"); + conflictItem.setSystemMetadata("_seq_no", initialSeqNo - 1); // Use wrong sequence number + conflictItem.setSystemMetadata("_primary_term", initialPrimaryTerm); + + // Try to save with incorrect sequence number, should fail + boolean saveResult = persistenceService.save(conflictItem); + assertFalse(saveResult, "Save should fail with incorrect sequence number"); + + // Original item should still be there unchanged + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(item.getName(), loaded.getName()); + assertEquals(initialSeqNo, ((Number) loaded.getSystemMetadata("_seq_no")).longValue()); + } + + @Test + void shouldRejectUpdateWithIncorrectPrimaryTerm() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-term-conflict"); + item.setName("Test Primary Term Conflict"); + + // Save the item to get a primary term + persistenceService.save(item); + Long initialSeqNo = ((Number) item.getSystemMetadata("_seq_no")).longValue(); + Long initialPrimaryTerm = ((Number) item.getSystemMetadata("_primary_term")).longValue(); + + // Create a different instance with the same ID but wrong primary term + TestMetadataItem conflictItem = new TestMetadataItem(); + conflictItem.setItemId(item.getItemId()); + conflictItem.setName("Conflicting Term Update"); + conflictItem.setSystemMetadata("_seq_no", initialSeqNo); + conflictItem.setSystemMetadata("_primary_term", initialPrimaryTerm + 1); // Use wrong primary term + + // Try to save with incorrect primary term, should fail + boolean saveResult = persistenceService.save(conflictItem); + assertFalse(saveResult, "Save should fail with incorrect primary term"); + + // Original item should still be there unchanged + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(item.getName(), loaded.getName()); + assertEquals(initialPrimaryTerm, ((Number) loaded.getSystemMetadata("_primary_term")).longValue()); + } + + @Test + void shouldAllowUpdateWithCorrectSequenceNumberAndPrimaryTerm() { + // Create a test item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-correct-seq"); + item.setName("Test Correct Sequence"); + + // Save the item to get a sequence number and primary term + persistenceService.save(item); + Long initialSeqNo = ((Number) item.getSystemMetadata("_seq_no")).longValue(); + Long initialPrimaryTerm = ((Number) item.getSystemMetadata("_primary_term")).longValue(); + + // Create a different instance with the same ID and correct sequence number + TestMetadataItem updateItem = new TestMetadataItem(); + updateItem.setItemId(item.getItemId()); + updateItem.setName("Updated Name"); + updateItem.setSystemMetadata("_seq_no", initialSeqNo); + updateItem.setSystemMetadata("_primary_term", initialPrimaryTerm); + + // Try to save with correct sequence number and primary term, should succeed + boolean saveResult = persistenceService.save(updateItem); + assertTrue(saveResult, "Save should succeed with correct sequence number and primary term"); + + // Item should be updated with new name and incremented sequence number + TestMetadataItem loaded = persistenceService.load(item.getItemId(), TestMetadataItem.class); + assertEquals(updateItem.getName(), loaded.getName()); + assertEquals(initialSeqNo + 1, ((Number) loaded.getSystemMetadata("_seq_no")).longValue()); + assertEquals(initialPrimaryTerm, ((Number) loaded.getSystemMetadata("_primary_term")).longValue()); + } + } + + @Nested + class RefreshDelaySimulationTests { + @Test + void shouldNotReturnItemsImmediatelyAfterSaveWhenRefreshDelayEnabled() throws InterruptedException { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save an item + Profile profile = new Profile(); + profile.setItemId("test-profile"); + profile.setProperty("firstName", "John"); + serviceWithDelay.save(profile); + + // then - item should not be immediately available in queries (simulating Elasticsearch behavior) + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Item should not be immediately available in queries after save"); + + // but load by ID should work immediately (Elasticsearch get by ID works immediately) + Profile loaded = serviceWithDelay.load("test-profile", Profile.class); + assertNotNull(loaded, "Load by ID should work immediately even with refresh delay"); + assertEquals("John", loaded.getProperty("firstName")); + } + + @Test + void shouldReturnItemsAfterRefreshInterval() throws InterruptedException { + // given - create persistence service with short refresh interval for testing + // File storage disabled for performance (not needed for refresh delay tests) + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save an item + Profile profile = new Profile(); + profile.setItemId("test-profile"); + serviceWithDelay.save(profile); + + // then - item should not be immediately available + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Item should not be immediately available"); + + // Wait for item to be available after refresh interval (retry until available) + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item should be available after refresh interval"); + assertEquals("test-profile", queryResults.get(0).getItemId()); + } + + @Test + void shouldReturnItemsImmediatelyAfterExplicitRefresh() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save an item + Profile profile = new Profile(); + profile.setItemId("test-profile"); + serviceWithDelay.save(profile); + + // then - item should not be immediately available + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Item should not be immediately available"); + + // when - explicitly refresh + serviceWithDelay.refresh(); + + // then - item should now be available (retry until available) + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item should be available after explicit refresh"); + assertEquals("test-profile", queryResults.get(0).getItemId()); + } + + @Test + void shouldReturnItemsImmediatelyAfterRefreshIndex() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save an item + Profile profile = new Profile(); + profile.setItemId("test-profile"); + serviceWithDelay.save(profile); + + // then - item should not be immediately available + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Item should not be immediately available"); + + // when - explicitly refresh index + serviceWithDelay.refreshIndex(Profile.class, null); + + // then - item should now be available (retry until available) + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item should be available after refreshIndex"); + assertEquals("test-profile", queryResults.get(0).getItemId()); + } + + @Test + void shouldReturnItemsImmediatelyWhenRefreshDelayDisabled() { + // given - create persistence service with refresh delay disabled + InMemoryPersistenceServiceImpl serviceWithoutDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, true, true, true, false, 1000L); + + // when - save an item + Profile profile = new Profile(); + profile.setItemId("test-profile"); + serviceWithoutDelay.save(profile); + + // then - item should be immediately available (no delay simulation) + List queryResults = serviceWithoutDelay.query(null, null, Profile.class); + assertEquals(1, queryResults.size(), "Item should be immediately available when refresh delay is disabled"); + assertEquals("test-profile", queryResults.get(0).getItemId()); + } + + @Test + void shouldFilterMultipleItemsByRefreshStatus() throws InterruptedException { + // given - create persistence service with fast refresh interval for testing + // File storage disabled for performance + long testRefreshInterval = FAST_REFRESH_INTERVAL_MS; + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, testRefreshInterval); + + // when - save multiple items + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + serviceWithDelay.save(profile1); + + // Wait for first item to be refreshed (retry until available) + TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1, + testRefreshInterval + 100 + ); + + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + serviceWithDelay.save(profile2); + + // then - only first item should be available (second item not yet refreshed) + // Check immediately after saving profile2 - profile2 should not be available yet + // profile1 should be available since we waited for its refresh interval + List queryResults = serviceWithDelay.query(null, null, Profile.class); + // With fast refresh, there's a small race condition window, so we check that: + // - We have at most 1 item (profile1 might not be ready yet, but profile2 definitely shouldn't be) + // - If we have 1 item, it must be profile1 + assertTrue(queryResults.size() <= 1, + "Profile2 was just saved and should not be available yet, got " + queryResults.size() + " items"); + if (queryResults.size() == 1) { + assertEquals("profile1", queryResults.get(0).getItemId(), + "Only profile1 should be available if any"); + } + // Ensure profile1 is available by retrying if needed + if (queryResults.isEmpty()) { + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1, + testRefreshInterval + 50 + ); + assertEquals(1, queryResults.size(), "Profile1 should be available after refresh"); + assertEquals("profile1", queryResults.get(0).getItemId()); + } + + // Wait for second item to be refreshed (retry until both items are available) + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 2, + testRefreshInterval + 100 + ); + assertEquals(2, queryResults.size(), "Both items should be available after refresh"); + } + + @Test + void shouldRespectRefreshDelayInQueryCount() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save items + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + serviceWithDelay.save(profile1); + + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + serviceWithDelay.save(profile2); + + // then - count should be 0 (items not yet refreshed) + long count = serviceWithDelay.queryCount(null, Profile.ITEM_TYPE); + assertEquals(0, count, "Count should not include unrefreshed items"); + + // when - refresh + serviceWithDelay.refresh(); + + // then - count should include all items (retry until available) + count = TestHelper.retryUntil( + () -> serviceWithDelay.queryCount(null, Profile.ITEM_TYPE), + c -> c == 2L + ); + assertEquals(2, count, "Count should include all refreshed items"); + } + + @Test + void shouldRespectRefreshDelayInGetAllItemsCount() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save items + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + serviceWithDelay.save(profile1); + + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + serviceWithDelay.save(profile2); + + // then - count should be 0 (items not yet refreshed) + long count = serviceWithDelay.getAllItemsCount(Profile.ITEM_TYPE); + assertEquals(0, count, "Count should not include unrefreshed items"); + + // when - refresh + serviceWithDelay.refresh(); + + // then - count should include all items (retry until available) + count = TestHelper.retryUntil( + () -> serviceWithDelay.getAllItemsCount(Profile.ITEM_TYPE), + c -> c == 2L + ); + assertEquals(2, count, "Count should include all refreshed items"); + } + + @Test + void shouldShutdownRefreshThread() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - shutdown + serviceWithDelay.shutdown(); + + // then - shutdown should complete without error, and calling it a second time + // must be idempotent (no exception, no deadlock), which is the primary observable + // contract for a shutdown method in a test harness + assertDoesNotThrow(() -> serviceWithDelay.shutdown(), "Second shutdown call should be idempotent"); + } + + @Test + void shouldDeleteItemsImmediatelyRegardlessOfRefreshStatus() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save items + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + profile1.setProperty("name", "Profile 1"); + serviceWithDelay.save(profile1); + + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + profile2.setProperty("name", "Profile 2"); + serviceWithDelay.save(profile2); + + // then - items should not be immediately available in queries + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Items should not be immediately available in queries"); + + // when - delete an item (should work immediately, regardless of refresh status) + boolean deleted = serviceWithDelay.remove("profile1", Profile.class); + assertTrue(deleted, "Delete should succeed immediately"); + + // then - deleted item should not be loadable + Profile loaded = serviceWithDelay.load("profile1", Profile.class); + assertNull(loaded, "Deleted item should not be loadable"); + + // and - after refresh, deleted item should still not appear in queries (retry until available) + serviceWithDelay.refresh(); + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Only non-deleted item should be available"); + assertEquals("profile2", queryResults.get(0).getItemId()); + } + + @Test + void shouldDeleteByQueryAllMatchingItemsRegardlessOfRefreshStatus() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save multiple items + Profile profile1 = new Profile(); + profile1.setItemId("profile1"); + profile1.setProperty("category", "test"); + serviceWithDelay.save(profile1); + + Profile profile2 = new Profile(); + profile2.setItemId("profile2"); + profile2.setProperty("category", "test"); + serviceWithDelay.save(profile2); + + Profile profile3 = new Profile(); + profile3.setItemId("profile3"); + profile3.setProperty("category", "other"); + serviceWithDelay.save(profile3); + + // then - items should not be immediately available in queries + List queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Items should not be immediately available"); + + // when - delete by query (should work on all matching items, not just refreshed ones) + Condition condition = new Condition(); + condition.setConditionType(TestConditionEvaluators.getConditionType("profilePropertyCondition")); + condition.setParameter("propertyName", "properties.category"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", "test"); + + // Note: removeByQuery should work on all items regardless of refresh status + // In Elasticsearch, deleteByQuery works on all matching documents + boolean deleted = serviceWithDelay.removeByQuery(condition, Profile.class); + assertTrue(deleted, "Delete by query should succeed"); + + // then - deleted items should not be loadable + assertNull(serviceWithDelay.load("profile1", Profile.class), "Deleted item should not be loadable"); + assertNull(serviceWithDelay.load("profile2", Profile.class), "Deleted item should not be loadable"); + assertNotNull(serviceWithDelay.load("profile3", Profile.class), "Non-matching item should still be loadable"); + + // and - after refresh, deleted items should still not appear (retry until available) + serviceWithDelay.refresh(); + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Only non-deleted item should be available"); + assertEquals("profile3", queryResults.get(0).getItemId()); + } + + @Test + void shouldCleanupPendingRefreshItemsOnDelete() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save and immediately delete an item + Profile profile = new Profile(); + profile.setItemId("profile1"); + serviceWithDelay.save(profile); + serviceWithDelay.remove("profile1", Profile.class); + + // then - item should be deleted and not in pending refresh list + // (this prevents memory leaks and ensures deleted items don't appear after refresh) + serviceWithDelay.refresh(); + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 0 + ); + assertTrue(queryResults.isEmpty(), "Deleted item should not appear even after refresh"); + } + + @Test + void shouldPurgeItemsWithRefreshDelay() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save items with different creation dates + Calendar oldDate = Calendar.getInstance(); + oldDate.add(Calendar.DAY_OF_YEAR, -10); + + TestMetadataItem oldItem = new TestMetadataItem(); + oldItem.setItemId("old-item"); + oldItem.setCreationDate(oldDate.getTime()); + serviceWithDelay.save(oldItem); + + TestMetadataItem newItem = new TestMetadataItem(); + newItem.setItemId("new-item"); + newItem.setCreationDate(new Date()); + serviceWithDelay.save(newItem); + + // then - items should not be immediately available + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertTrue(queryResults.isEmpty(), "Items should not be immediately available"); + + // when - purge items older than 7 days + Calendar purgeDate = Calendar.getInstance(); + purgeDate.add(Calendar.DAY_OF_YEAR, -7); + serviceWithDelay.purge(purgeDate.getTime()); + + // then - old item should be deleted (even though not refreshed) + assertNull(serviceWithDelay.load("old-item", TestMetadataItem.class), "Old item should be purged"); + assertNotNull(serviceWithDelay.load("new-item", TestMetadataItem.class), "New item should not be purged"); + + // and - after refresh, only new item should appear (retry until available) + serviceWithDelay.refresh(); + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, queryResults.size(), "Only new item should be available after refresh"); + assertEquals("new-item", queryResults.get(0).getItemId()); + } + + @Test + void shouldRemoveIndexWithRefreshDelay() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save items + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + serviceWithDelay.save(item1); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + serviceWithDelay.save(item2); + + // then - items should not be immediately available + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertTrue(queryResults.isEmpty(), "Items should not be immediately available"); + + // when - remove index + boolean removed = serviceWithDelay.removeIndex(TestMetadataItem.ITEM_TYPE); + assertTrue(removed, "Index should be removed"); + + // then - all items should be deleted (even though not refreshed) + assertNull(serviceWithDelay.load("item1", TestMetadataItem.class), "Item should be deleted"); + assertNull(serviceWithDelay.load("item2", TestMetadataItem.class), "Item should be deleted"); + + // and - after refresh, no items should appear (retry until available) + serviceWithDelay.refresh(); + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(queryResults.isEmpty(), "No items should be available after index removal"); + } + + @Test + void shouldRemoveCustomItemWithRefreshDelay() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save custom item + CustomItem customItem = new CustomItem(); + customItem.setItemId("custom1"); + customItem.setItemType("testCustomType"); + serviceWithDelay.save(customItem); + + // then - item should not be immediately available in queries + PartialList queryResults = serviceWithDelay.queryCustomItem(null, null, "testCustomType", 0, 10, null); + assertEquals(0, queryResults.getTotalSize(), "Item should not be immediately available"); + + // but - load by ID should work + CustomItem loaded = serviceWithDelay.loadCustomItem("custom1", "testCustomType"); + assertNotNull(loaded, "Load by ID should work immediately"); + + // when - remove custom item + boolean removed = serviceWithDelay.removeCustomItem("custom1", "testCustomType"); + assertTrue(removed, "Custom item should be removed"); + + // then - item should not be loadable + assertNull(serviceWithDelay.loadCustomItem("custom1", "testCustomType"), "Deleted item should not be loadable"); + + // and - after refresh, item should still not appear (retry until available) + serviceWithDelay.refresh(); + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.queryCustomItem(null, null, "testCustomType", 0, 10, null), + 0 + ); + assertEquals(0, queryResults.getTotalSize(), "Deleted item should not appear after refresh"); + } + + @Test + void shouldHandleUpdateOfAlreadyRefreshedItem() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - save and refresh an item + Profile profile = new Profile(); + profile.setItemId("profile1"); + profile.setProperty("name", "Original"); + serviceWithDelay.save(profile); + serviceWithDelay.refresh(); + + // then - item should be available (retry until available) + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item should be available after refresh"); + assertEquals("Original", queryResults.get(0).getProperty("name")); + + // when - update the item (this adds it back to pendingRefreshItems) + profile.setProperty("name", "Updated"); + serviceWithDelay.save(profile); + + // then - updated item should not be immediately available in queries + // The item was removed from refreshedIndexes when we saved it again, + // and added back to pendingRefreshItems, so it needs refresh again + queryResults = serviceWithDelay.query(null, null, Profile.class); + assertTrue(queryResults.isEmpty(), "Updated item should not be immediately available until refreshed"); + + // when - refresh again + serviceWithDelay.refresh(); + + // then - updated item should be available with new value (retry until available) + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, Profile.class), + 1 + ); + assertEquals(1, queryResults.size(), "Updated item should be available after refresh"); + assertEquals("Updated", queryResults.get(0).getProperty("name"), "Updated value should be visible"); + } + } + + @Nested + class RefreshPolicyTests { + @Test + void shouldRespectFalseRefreshPolicy() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - set FALSE refresh policy for a custom item type + serviceWithDelay.setRefreshPolicy("testItem", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save an item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testItem"); + serviceWithDelay.save(item); + + // then - item should not be immediately available (FALSE = wait for automatic refresh) + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertTrue(queryResults.isEmpty(), "Item with FALSE refresh policy should not be immediately available"); + + // but - load by ID should work + TestMetadataItem loaded = serviceWithDelay.load("test-item", TestMetadataItem.class); + assertNotNull(loaded, "Load by ID should work immediately even with FALSE refresh policy"); + } + + @Test + void shouldRespectTrueRefreshPolicy() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - set TRUE refresh policy for a custom item type + serviceWithDelay.setRefreshPolicy("testItem", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + + // and - save an item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testItem"); + serviceWithDelay.save(item); + + // then - item should be immediately available (TRUE = immediate refresh) - retry until available + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item with TRUE refresh policy should be immediately available"); + assertEquals("test-item", queryResults.get(0).getItemId()); + } + + @Test + void shouldRespectWaitForRefreshPolicy() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - set WAIT_FOR refresh policy for a custom item type + serviceWithDelay.setRefreshPolicy("testItem", InMemoryPersistenceServiceImpl.RefreshPolicy.WAIT_FOR); + + // and - save an item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testItem"); + serviceWithDelay.save(item); + + // then - item should be immediately available (WAIT_FOR = wait for refresh, which completes immediately in-memory) - retry until available + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item with WAIT_FOR refresh policy should be immediately available"); + assertEquals("test-item", queryResults.get(0).getItemId()); + } + + @Test + void shouldSetRefreshPolicyFromJson() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - set refresh policies from JSON (Elasticsearch/OpenSearch format) + String json = "{\"event\":\"WAIT_FOR\",\"rule\":\"FALSE\",\"scheduledTask\":\"TRUE\"}"; + serviceWithDelay.setItemTypeToRefreshPolicy(json); + + // then - policies should be set correctly + TestMetadataItem eventItem = new TestMetadataItem(); + eventItem.setItemId("event1"); + eventItem.setItemType("event"); + serviceWithDelay.save(eventItem); + + // Event with WAIT_FOR should be immediately available (retry until available) + List eventResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, eventResults.size(), "Event with WAIT_FOR policy should be immediately available"); + + TestMetadataItem ruleItem = new TestMetadataItem(); + ruleItem.setItemId("rule1"); + ruleItem.setItemType("rule"); + serviceWithDelay.save(ruleItem); + + // Rule with FALSE should not be immediately available (retry to ensure only event is visible) + List ruleResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + // Should still only see the event, not the rule + assertEquals(1, ruleResults.size(), "Rule with FALSE policy should not be immediately available"); + assertEquals("event1", ruleResults.get(0).getItemId()); + } + + @Test + void shouldParseElasticsearchRefreshPolicyValues() { + // given - create persistence service with refresh delay explicitly enabled + // File storage disabled for performance (not needed for refresh policy tests) + // Fast refresh interval (50ms) for faster test execution + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + + // when - set refresh policies using Elasticsearch string values + String json = "{\"item1\":\"NONE\",\"item2\":\"IMMEDIATE\",\"item3\":\"WAIT_UNTIL\"}"; + serviceWithDelay.setItemTypeToRefreshPolicy(json); + + // then - policies should be parsed correctly + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("item1"); + serviceWithDelay.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "NONE policy should not make item immediately available"); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("item2"); + serviceWithDelay.save(item2); + // After saving item2, we should see at least 1 item (item2 with IMMEDIATE policy) + List results2 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "item2".equals(i.getItemId())) + ); + assertTrue(results2.size() >= 1, + "IMMEDIATE policy should make item immediately available"); + assertTrue(results2.stream().anyMatch(i -> "item2".equals(i.getItemId())), + "Item2 should be in results"); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("item3"); + serviceWithDelay.save(item3); + // After saving item3, we should see at least 2 items (item2 and item3 with WAIT_UNTIL policy) + List results3 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 2 && r.stream().anyMatch(i -> "item3".equals(i.getItemId())) + ); + assertTrue(results3.size() >= 2, + "WAIT_UNTIL policy should make item immediately available"); + assertTrue(results3.stream().anyMatch(i -> "item3".equals(i.getItemId())), + "Item3 should be in results"); + } + + @Test + void shouldParseOpenSearchRefreshPolicyValues() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set refresh policies using OpenSearch string values + String json = "{\"item1\":\"False\",\"item2\":\"True\",\"item3\":\"WaitFor\"}"; + serviceWithDelay.setItemTypeToRefreshPolicy(json); + + // then - policies should be parsed correctly + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("item1"); + serviceWithDelay.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "False policy should not make item immediately available"); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("item2"); + serviceWithDelay.save(item2); + // After saving item2, we should see at least 1 item (item2 with True policy) + List results2 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "item2".equals(i.getItemId())) + ); + assertTrue(results2.size() >= 1, + "True policy should make item immediately available"); + assertTrue(results2.stream().anyMatch(i -> "item2".equals(i.getItemId())), + "Item2 should be in results"); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("item3"); + serviceWithDelay.save(item3); + // After saving item3, we should see at least 2 items (item2 and item3 with WaitFor policy) + List results3 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 2 && r.stream().anyMatch(i -> "item3".equals(i.getItemId())) + ); + assertTrue(results3.size() >= 2, + "WaitFor policy should make item immediately available"); + assertTrue(results3.stream().anyMatch(i -> "item3".equals(i.getItemId())), + "Item3 should be in results"); + } + + @Test + void shouldDefaultToFalseRefreshPolicy() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - save an item without setting a refresh policy + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + serviceWithDelay.save(item); + + // then - item should not be immediately available (defaults to FALSE) - retry to ensure it's not available + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(queryResults.isEmpty(), "Item with default refresh policy (FALSE) should not be immediately available"); + } + + @Test + void shouldHandleMixedRefreshPolicies() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set different refresh policies for different item types + serviceWithDelay.setRefreshPolicy("immediateType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + serviceWithDelay.setRefreshPolicy("waitType", InMemoryPersistenceServiceImpl.RefreshPolicy.WAIT_FOR); + // falseType uses default FALSE policy + + // and - save items with different policies + TestMetadataItem immediateItem = new TestMetadataItem(); + immediateItem.setItemId("immediate1"); + immediateItem.setItemType("immediateType"); + serviceWithDelay.save(immediateItem); + + TestMetadataItem waitItem = new TestMetadataItem(); + waitItem.setItemId("wait1"); + waitItem.setItemType("waitType"); + serviceWithDelay.save(waitItem); + + TestMetadataItem falseItem = new TestMetadataItem(); + falseItem.setItemId("false1"); + falseItem.setItemType("falseType"); + serviceWithDelay.save(falseItem); + + // then - only items with TRUE or WAIT_FOR should be immediately available (retry until available) + // Use retryQueryUntilAvailable with expected count, then verify specific items + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 2 + ); + assertEquals(2, queryResults.size(), "Only items with TRUE or WAIT_FOR policies should be immediately available"); + assertTrue(queryResults.stream().anyMatch(i -> "immediate1".equals(i.getItemId())), + "Item with TRUE policy should be available"); + assertTrue(queryResults.stream().anyMatch(i -> "wait1".equals(i.getItemId())), + "Item with WAIT_FOR policy should be available"); + assertFalse(queryResults.stream().anyMatch(i -> "false1".equals(i.getItemId())), + "Item with FALSE policy should not be available"); + } + + @Test + void shouldReturnCorrectIsConsistentValue() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set different refresh policies + serviceWithDelay.setRefreshPolicy("consistentType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + serviceWithDelay.setRefreshPolicy("inconsistentType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - create items + TestMetadataItem consistentItem = new TestMetadataItem(); + consistentItem.setItemId("consistent1"); + consistentItem.setItemType("consistentType"); + serviceWithDelay.save(consistentItem); + + TestMetadataItem inconsistentItem = new TestMetadataItem(); + inconsistentItem.setItemId("inconsistent1"); + inconsistentItem.setItemType("inconsistentType"); + serviceWithDelay.save(inconsistentItem); + + // then - isConsistent should return true for TRUE policy, false for FALSE policy + assertTrue(serviceWithDelay.isConsistent(consistentItem), + "Item with TRUE refresh policy should be consistent (immediately visible)"); + assertFalse(serviceWithDelay.isConsistent(inconsistentItem), + "Item with FALSE refresh policy should not be consistent (not immediately visible)"); + } + + @Test + void shouldHandleRefreshPolicyForCustomItems() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set refresh policy for custom item type + serviceWithDelay.setRefreshPolicy("customType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + + // and - save custom item + CustomItem customItem = new CustomItem(); + customItem.setItemId("custom1"); + customItem.setItemType("customType"); + customItem.setCustomItemType("customType"); + serviceWithDelay.save(customItem); + + // then - custom item should be immediately available + PartialList queryResults = serviceWithDelay.queryCustomItem(null, null, "customType", 0, 10, null); + assertEquals(1, queryResults.getTotalSize(), "Custom item with TRUE refresh policy should be immediately available"); + } + + @Test + void shouldUpdateRefreshPolicyDynamically() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - save item with default FALSE policy + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + serviceWithDelay.save(item); + + // then - item should not be immediately available - retry to ensure it's not available + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(queryResults.isEmpty(), "Item with FALSE policy should not be immediately available"); + + // when - change refresh policy to TRUE + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + + // and - save another item + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("test-item2"); + item2.setItemType("testType"); + serviceWithDelay.save(item2); + + // then - new item should be immediately available, but old item still needs refresh - retry until available + // Use retryUntil to check for at least item2, allowing for item1 to potentially be refreshed by background thread + queryResults = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "test-item2".equals(i.getItemId())) + ); + assertTrue(queryResults.size() >= 1, "New item with TRUE policy should be immediately available"); + assertTrue(queryResults.stream().anyMatch(i -> "test-item2".equals(i.getItemId())), + "Item2 should be in results"); + } + + @Test + void shouldRespectRefreshPolicyOnUpdate() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set TRUE refresh policy + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + + // and - save and update an item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setName("Original"); + serviceWithDelay.save(item); + + // Update the item + Map updates = new HashMap<>(); + updates.put("name", "Updated"); + serviceWithDelay.update(item, null, TestMetadataItem.class, updates); + + // then - updated item should be immediately available with new value - retry until available + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, queryResults.size(), "Updated item with TRUE refresh policy should be immediately available"); + assertEquals("Updated", queryResults.get(0).getName(), "Updated value should be visible"); + } + + @Test + void shouldRespectRefreshPolicyOnUpdateWithFalsePolicy() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save and update an item + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setName("Original"); + serviceWithDelay.save(item); + serviceWithDelay.refresh(); // Make initial item available + + // Update the item + Map updates = new HashMap<>(); + updates.put("name", "Updated"); + serviceWithDelay.update(item, null, TestMetadataItem.class, updates); + + // then - updated item should not be immediately available (back in pendingRefreshItems) + List queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(queryResults.isEmpty(), "Updated item should not be immediately available until refresh"); + + // when - refresh + serviceWithDelay.refresh(); + + // then - updated value should be visible - retry until available + queryResults = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, queryResults.size(), "Item should be available after refresh"); + assertEquals("Updated", queryResults.get(0).getName(), "Updated value should be visible after refresh"); + } + + @Test + void shouldSupportRequestBasedRefreshPolicyOverride() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy for item type (default behavior) + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save item with request-based override to TRUE + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + serviceWithDelay.save(item); + + // then - item should be immediately available (request override takes precedence) + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertEquals(1, queryResults.size(), "Item with request-based TRUE override should be immediately available"); + } + + @Test + void shouldSupportRequestBasedRefreshPolicyOverrideAsString() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy for item type + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save item with request-based override as string (Elasticsearch format) + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setSystemMetadata("refresh", "IMMEDIATE"); // Elasticsearch format + serviceWithDelay.save(item); + + // then - item should be immediately available + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertEquals(1, queryResults.size(), "Item with request-based IMMEDIATE override should be immediately available"); + } + + @Test + void shouldSupportRequestBasedRefreshPolicyOverrideAsBoolean() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy for item type + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save item with request-based override as boolean + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setSystemMetadata("refresh", true); // Boolean true + serviceWithDelay.save(item); + + // then - item should be immediately available + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertEquals(1, queryResults.size(), "Item with request-based boolean true override should be immediately available"); + } + + @Test + void shouldSupportRequestBasedRefreshPolicyOverrideWaitFor() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy for item type + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save item with request-based override to WAIT_FOR + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setSystemMetadata("refresh", "wait_for"); // OpenSearch/Elasticsearch format + serviceWithDelay.save(item); + + // then - item should be immediately available (WAIT_FOR behaves like TRUE in-memory) + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertEquals(1, queryResults.size(), "Item with request-based wait_for override should be immediately available"); + } + + @Test + void shouldOverridePerItemTypePolicyWithRequestBasedOverride() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set TRUE refresh policy for item type + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + + // and - save item with request-based override to FALSE + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + serviceWithDelay.save(item); + + // then - item should NOT be immediately available (request override takes precedence) + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertTrue(queryResults.isEmpty(), "Item with request-based FALSE override should not be immediately available"); + } + + @Test + void shouldSupportRequestBasedRefreshPolicyOverrideOnUpdate() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // when - set FALSE refresh policy for item type + serviceWithDelay.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // and - save and update item with request-based override + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("test-item"); + item.setItemType("testType"); + item.setName("Original"); + serviceWithDelay.save(item); + serviceWithDelay.refresh(); // Make initial item available + + // Update with request-based refresh override + item.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + Map updates = new HashMap<>(); + updates.put("name", "Updated"); + serviceWithDelay.update(item, null, TestMetadataItem.class, updates); + + // then - updated item should be immediately available + List queryResults = serviceWithDelay.query(null, null, TestMetadataItem.class); + assertEquals(1, queryResults.size(), "Updated item with request-based TRUE override should be immediately available"); + assertEquals("Updated", queryResults.get(0).getName(), "Updated value should be visible"); + } + + @Test + void shouldSupportElasticsearchRefreshParameterValues() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Test Elasticsearch refresh parameter values: true, false, wait_for + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("testType"); + item1.setSystemMetadata("refresh", "true"); + serviceWithDelay.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results1.size(), + "refresh=true should make item immediately available"); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("testType"); + item2.setSystemMetadata("refresh", "false"); + serviceWithDelay.save(item2); + // After saving item2 with false, we should still only see item1 (item2 not immediately available) + List results2 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "item1".equals(i.getItemId())) && + !r.stream().anyMatch(i -> "item2".equals(i.getItemId())) + ); + assertTrue(results2.size() >= 1, + "refresh=false should not make item immediately available (only item1 visible)"); + assertTrue(results2.stream().anyMatch(i -> "item1".equals(i.getItemId())), + "Item1 should still be visible"); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("testType"); + item3.setSystemMetadata("refresh", "wait_for"); + serviceWithDelay.save(item3); + // After saving item3, we should see at least item1 and item3 (item2 still not available) + List results3 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 2 && r.stream().anyMatch(i -> "item3".equals(i.getItemId())) + ); + assertTrue(results3.size() >= 2, + "refresh=wait_for should make item immediately available"); + assertTrue(results3.stream().anyMatch(i -> "item3".equals(i.getItemId())), + "Item3 should be visible"); + } + + @Test + void shouldSupportOpenSearchRefreshParameterValues() { + // given - create persistence service with refresh delay enabled + InMemoryPersistenceServiceImpl serviceWithDelay = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Test OpenSearch refresh parameter values: True, False, WaitFor + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("testType"); + item1.setSystemMetadata("refresh", "True"); + serviceWithDelay.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results1.size(), + "refresh=True should make item immediately available"); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("testType"); + item2.setSystemMetadata("refresh", "False"); + serviceWithDelay.save(item2); + // After saving item2 with False, we should still only see item1 (item2 not immediately available) + List results2 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "item1".equals(i.getItemId())) && + !r.stream().anyMatch(i -> "item2".equals(i.getItemId())) + ); + assertTrue(results2.size() >= 1, + "refresh=False should not make item immediately available"); + assertTrue(results2.stream().anyMatch(i -> "item1".equals(i.getItemId())), + "Item1 should still be visible"); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("testType"); + item3.setSystemMetadata("refresh", "WaitFor"); + serviceWithDelay.save(item3); + // After saving item3, we should see at least item1 and item3 (item2 still not available) + List results3 = TestHelper.retryUntil( + () -> serviceWithDelay.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 2 && r.stream().anyMatch(i -> "item3".equals(i.getItemId())) + ); + assertTrue(results3.size() >= 2, + "refresh=WaitFor should make item immediately available"); + assertTrue(results3.stream().anyMatch(i -> "item3".equals(i.getItemId())), + "Item3 should be visible"); + } + + @Test + void shouldHandleAllRefreshPolicyCombinationsWithDelayEnabled() { + // Test all combinations: per-item-type policy × request override × operation type + + // Combination 1: FALSE policy, no override, save + // File storage disabled and fast refresh for performance + InMemoryPersistenceServiceImpl service1 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + service1.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + service1.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "FALSE policy, no override, save: should not be immediately available"); + + // Combination 2: FALSE policy, TRUE override, save + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("type1"); + item2.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + service1.save(item2); + // After saving item2 with TRUE override, we should see at least item2 (item1 might be refreshed by now) + List results2 = TestHelper.retryUntil( + () -> service1.query(null, null, TestMetadataItem.class), + r -> r != null && r.size() >= 1 && r.stream().anyMatch(i -> "item2".equals(i.getItemId())) + ); + assertTrue(results2.size() >= 1, + "FALSE policy, TRUE override, save: should be immediately available"); + assertTrue(results2.stream().anyMatch(i -> "item2".equals(i.getItemId())), + "Item2 should be visible"); + + // Combination 3: TRUE policy, no override, save + // File storage disabled and fast refresh for performance + InMemoryPersistenceServiceImpl service2 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + service2.setRefreshPolicy("type2", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("type2"); + service2.save(item3); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> service2.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results3.size(), + "TRUE policy, no override, save: should be immediately available"); + + // Combination 4: TRUE policy, FALSE override, save + TestMetadataItem item4 = new TestMetadataItem(); + item4.setItemId("item4"); + item4.setItemType("type2"); + item4.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + service2.save(item4); + List results4 = TestHelper.retryQueryUntilAvailable( + () -> service2.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results4.size(), + "TRUE policy, FALSE override, save: override should take precedence (not available)"); + + // Combination 5: WAIT_FOR policy, no override, save + // File storage disabled and fast refresh for performance + InMemoryPersistenceServiceImpl service3 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + service3.setRefreshPolicy("type3", InMemoryPersistenceServiceImpl.RefreshPolicy.WAIT_FOR); + TestMetadataItem item5 = new TestMetadataItem(); + item5.setItemId("item5"); + item5.setItemType("type3"); + service3.save(item5); + List results5 = TestHelper.retryQueryUntilAvailable( + () -> service3.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results5.size(), + "WAIT_FOR policy, no override, save: should be immediately available"); + + // Combination 6: WAIT_FOR policy, FALSE override, save + TestMetadataItem item6 = new TestMetadataItem(); + item6.setItemId("item6"); + item6.setItemType("type3"); + item6.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + service3.save(item6); + List results6 = TestHelper.retryQueryUntilAvailable( + () -> service3.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results6.size(), + "WAIT_FOR policy, FALSE override, save: override should take precedence (not available)"); + } + + @Test + void shouldHandleAllRefreshPolicyCombinationsOnUpdate() { + // Test all combinations for update operations + + // Combination 1: FALSE policy, no override, update + // File storage disabled and fast refresh for performance + InMemoryPersistenceServiceImpl service1 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + service1.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + item1.setName("Original"); + service1.save(item1); + service1.refresh(); // Make initial item available + + Map updates1 = new HashMap<>(); + updates1.put("name", "Updated1"); + service1.update(item1, null, TestMetadataItem.class, updates1); + // After update with FALSE policy, item should not be immediately available + // (it's back in pendingRefreshItems with future refresh time) + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "FALSE policy, no override, update: should not be immediately available"); + + // Combination 2: FALSE policy, TRUE override, update + // First refresh to make item available again + service1.refresh(); + item1.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + Map updates2 = new HashMap<>(); + updates2.put("name", "Updated2"); + service1.update(item1, null, TestMetadataItem.class, updates2); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals("Updated2", results2.get(0).getName(), + "FALSE policy, TRUE override, update: should be immediately available"); + + // Combination 3: TRUE policy, no override, update + // File storage disabled and fast refresh for performance + InMemoryPersistenceServiceImpl service2 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, false, true, true, true, FAST_REFRESH_INTERVAL_MS); + service2.setRefreshPolicy("type2", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("type2"); + item2.setName("Original"); + service2.save(item2); + + Map updates3 = new HashMap<>(); + updates3.put("name", "Updated3"); + service2.update(item2, null, TestMetadataItem.class, updates3); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> service2.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals("Updated3", results3.get(0).getName(), + "TRUE policy, no override, update: should be immediately available"); + } + + @Test + void shouldHandleRefreshPolicyWithExplicitRefresh() { + // Test that explicit refresh works regardless of policy + + // FALSE policy + explicit refresh + InMemoryPersistenceServiceImpl service1 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + service1.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + service1.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "FALSE policy: should not be immediately available"); + + service1.refresh(); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results2.size(), + "FALSE policy + explicit refresh: should be available after refresh"); + + // TRUE policy + explicit refresh (should still work) + InMemoryPersistenceServiceImpl service2 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + service2.setRefreshPolicy("type2", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("type2"); + service2.save(item2); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> service2.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results3.size(), + "TRUE policy: should be immediately available"); + + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("type2"); + service2.save(item3); + service2.refresh(); // Explicit refresh should work + List results4 = TestHelper.retryQueryUntilAvailable( + () -> service2.query(null, null, TestMetadataItem.class), + 2 + ); + assertEquals(2, results4.size(), + "TRUE policy + explicit refresh: should still work"); + } + + @Test + void shouldHandleRefreshPolicyWithRefreshIndex() { + // Test that refreshIndex works regardless of policy + + // FALSE policy + refreshIndex + InMemoryPersistenceServiceImpl service1 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + service1.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + service1.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "FALSE policy: should not be immediately available"); + + service1.refreshIndex(TestMetadataItem.class, null); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results2.size(), + "FALSE policy + refreshIndex: should be available after refreshIndex"); + } + + @Test + void shouldHandleRefreshPolicyWithAutomaticRefresh() throws InterruptedException { + // Test that automatic refresh works with different policies + + // FALSE policy + automatic refresh + InMemoryPersistenceServiceImpl service1 = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, true, true, true, true, 100L); + service1.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + service1.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 0 + ); + assertTrue(results1.isEmpty(), + "FALSE policy: should not be immediately available"); + + // Wait for automatic refresh (retry until item is available) + List results2 = TestHelper.retryQueryUntilAvailable( + () -> service1.query(null, null, TestMetadataItem.class), + 1, + FAST_REFRESH_INTERVAL_MS + 100 + ); + assertEquals(1, results2.size(), + "FALSE policy + automatic refresh: should be available after interval"); + } + + @Test + void shouldHandleRefreshPolicyWhenDelayDisabled() { + // Test that refresh policies are ignored when delay simulation is disabled + + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, true, true, true, false, 1000L); + + // Set FALSE policy, but delay is disabled + service.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setItemType("type1"); + service.save(item); + + // Should be immediately available regardless of policy when delay is disabled + List results = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results.size(), + "When delay disabled: should be immediately available regardless of policy"); + } + + @Test + void shouldHandleRequestOverrideWhenDelayDisabled() { + // Test that request overrides are ignored when delay simulation is disabled + + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, true, true, true, false, 1000L); + + // Set request override, but delay is disabled + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setItemType("type1"); + item.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + service.save(item); + + // Should be immediately available regardless of override when delay is disabled + List results = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results.size(), + "When delay disabled: should be immediately available regardless of override"); + } + + @Test + void shouldHandleAllRequestOverrideFormats() { + // Test all possible request override formats + + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + service.setRefreshPolicy("testType", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + + // Format 1: Enum value + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("testType"); + item1.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + service.save(item1); + List results1 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 1 + ); + assertEquals(1, results1.size(), + "Enum override: should work"); + + // Format 2: String "true" + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("testType"); + item2.setSystemMetadata("refresh", "true"); + service.save(item2); + List results2 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 2 + ); + assertEquals(2, results2.size(), + "String 'true' override: should work"); + + // Format 3: String "IMMEDIATE" + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("testType"); + item3.setSystemMetadata("refresh", "IMMEDIATE"); + service.save(item3); + List results3 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 3 + ); + assertEquals(3, results3.size(), + "String 'IMMEDIATE' override: should work"); + + // Format 4: Boolean true + TestMetadataItem item4 = new TestMetadataItem(); + item4.setItemId("item4"); + item4.setItemType("testType"); + item4.setSystemMetadata("refresh", true); + service.save(item4); + List results4 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 4 + ); + assertEquals(4, results4.size(), + "Boolean true override: should work"); + + // Format 5: String "wait_for" + TestMetadataItem item5 = new TestMetadataItem(); + item5.setItemId("item5"); + item5.setItemType("testType"); + item5.setSystemMetadata("refresh", "wait_for"); + service.save(item5); + List results5 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 5 + ); + assertEquals(5, results5.size(), + "String 'wait_for' override: should work"); + + // Format 6: String "WaitFor" + TestMetadataItem item6 = new TestMetadataItem(); + item6.setItemId("item6"); + item6.setItemType("testType"); + item6.setSystemMetadata("refresh", "WaitFor"); + service.save(item6); + List results6 = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class), + 6 + ); + assertEquals(6, results6.size(), + "String 'WaitFor' override: should work"); + } + + @Test + void shouldHandleIsConsistentWithAllCombinations() { + // Test isConsistent() with all policy combinations + + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // FALSE policy, no override + service.setRefreshPolicy("type1", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + TestMetadataItem item1 = new TestMetadataItem(); + item1.setItemId("item1"); + item1.setItemType("type1"); + assertFalse(service.isConsistent(item1), + "FALSE policy, no override: should not be consistent"); + + // FALSE policy, TRUE override + item1.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + assertTrue(service.isConsistent(item1), + "FALSE policy, TRUE override: should be consistent"); + + // TRUE policy, no override + service.setRefreshPolicy("type2", InMemoryPersistenceServiceImpl.RefreshPolicy.TRUE); + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setItemType("type2"); + assertTrue(service.isConsistent(item2), + "TRUE policy, no override: should be consistent"); + + // TRUE policy, FALSE override + item2.setSystemMetadata("refresh", InMemoryPersistenceServiceImpl.RefreshPolicy.FALSE); + assertFalse(service.isConsistent(item2), + "TRUE policy, FALSE override: should not be consistent"); + + // WAIT_FOR policy, no override + service.setRefreshPolicy("type3", InMemoryPersistenceServiceImpl.RefreshPolicy.WAIT_FOR); + TestMetadataItem item3 = new TestMetadataItem(); + item3.setItemId("item3"); + item3.setItemType("type3"); + assertTrue(service.isConsistent(item3), + "WAIT_FOR policy, no override: should be consistent"); + } + } + + @Nested + class DefaultQueryLimitTests { + @Test + void shouldApplyDefaultLimitWhenSizeIsNegative() { + // Create service with default limit of 10 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Save 20 items + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + service.save(item); + } + + // Wait for all 20 items to be available before checking totalSize + // The totalSize is calculated from items that are currently queryable (refreshed) + // So we need to ensure all items are refreshed before verifying totalSize + PartialList result = TestHelper.retryUntil( + () -> service.query(null, null, TestMetadataItem.class, 0, -1), + r -> r != null && r.getTotalSize() == 20 && r.getList().size() == 10 + ); + assertEquals(10, result.getList().size(), "Query with size=-1 should return default limit of 10"); + assertEquals(20, result.getTotalSize(), "Total size should be 20"); + } + + @Test + void shouldApplyDefaultLimitWhenSizeIsNotSpecified() { + // Create service with default limit of 10 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Save 20 items + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + service.save(item); + } + + // Wait for all 20 items to be available before checking totalSize + // The totalSize is calculated from items that are currently queryable (refreshed) + // So we need to ensure all items are refreshed before verifying totalSize + PartialList result = TestHelper.retryUntil( + () -> service.query(null, null, TestMetadataItem.class, 0, -1, null), + r -> r != null && r.getTotalSize() == 20 && r.getList().size() == 10 + ); + assertEquals(10, result.getList().size(), "Query with size=-1 should return default limit of 10"); + assertEquals(20, result.getTotalSize(), "Total size should be 20"); + } + + @Test + void shouldRespectExplicitSizeWhenProvided() { + // Create service with default limit of 10 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Save 20 items + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + service.save(item); + } + + // Query with explicit size should respect it - retry until items are available + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class, 0, 5), + 5 + ); + assertEquals(5, result.getList().size(), "Query with explicit size=5 should return 5 items"); + assertEquals(20, result.getTotalSize(), "Total size should be 20"); + } + + @Test + void shouldAllowCustomDefaultLimit() { + // Create service with custom default limit of 5 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR, true, true, true, true, 1000L, 5); + + // Save 20 items + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + service.save(item); + } + + // Query with size = -1 should return custom default limit (5) - retry until items are available + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> service.query(null, null, TestMetadataItem.class, 0, -1), + 5 + ); + assertEquals(5, result.getList().size(), "Query with size=-1 should return custom default limit of 5"); + assertEquals(20, result.getTotalSize(), "Total size should be 20"); + } + + @Test + void shouldApplyDefaultLimitInCreatePartialList() { + // Create service with default limit of 10 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Save 20 items with name property set + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setName("test"); // Set name so the query will match + service.save(item); + } + + // Query with field matching and size = -1 - retry until items are available + PartialList result = TestHelper.retryQueryUntilAvailable( + () -> service.query("name", "test", null, TestMetadataItem.class, 0, -1), + 10 + ); + assertEquals(10, result.getList().size(), "Query with size=-1 should return default limit of 10"); + } + + @Test + void shouldApplyDefaultLimitInRangeQuery() { + // Create service with default limit of 10 + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Save 20 items with numeric values + for (int i = 0; i < 20; i++) { + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item" + i); + item.setNumericValue((double) i); + service.save(item); + } + + // Wait for all 20 items to be available before checking totalSize + // The totalSize is calculated from items that are currently queryable (refreshed) + // So we need to ensure all items are refreshed before verifying totalSize + PartialList result = TestHelper.retryUntil( + () -> service.rangeQuery("numericValue", "0", "20", null, TestMetadataItem.class, 0, -1), + r -> r != null && r.getTotalSize() == 20 && r.getList().size() == 10 + ); + assertEquals(10, result.getList().size(), "Range query with size=-1 should return default limit of 10"); + assertEquals(20, result.getTotalSize(), "Total size should be 20"); + } + + @Test + void shouldGetAndSetDefaultQueryLimit() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + assertEquals(10, service.getDefaultQueryLimit(), "Default limit should be 10"); + + service.setDefaultQueryLimit(15); + assertEquals(15, service.getDefaultQueryLimit(), "Default limit should be 15 after setting"); + + // Setting invalid value should default to 10 + service.setDefaultQueryLimit(0); + assertEquals(10, service.getDefaultQueryLimit(), "Invalid limit should default to 10"); + + service.setDefaultQueryLimit(-5); + assertEquals(10, service.getDefaultQueryLimit(), "Negative limit should default to 10"); + } + } + + @Nested + class TenantTransformationTests { + // Mock transformation listener for testing + static class TestTransformationListener implements TenantTransformationListener { + private final String transformationType; + private final boolean enabled; + private final int priority; + private boolean transformCalled = false; + private boolean reverseTransformCalled = false; + + TestTransformationListener(String transformationType, boolean enabled, int priority) { + this.transformationType = transformationType; + this.enabled = enabled; + this.priority = priority; + } + + @Override + public Item transformItem(Item item, String tenantId) { + transformCalled = true; + if (item instanceof TestMetadataItem) { + TestMetadataItem testItem = (TestMetadataItem) item; + testItem.setName(testItem.getName() + "_transformed"); + testItem.setSystemMetadata("transformed", true); + } + return item; + } + + @Override + public boolean isTransformationEnabled() { + return enabled; + } + + @Override + public Item reverseTransformItem(Item item, String tenantId) { + reverseTransformCalled = true; + if (item instanceof TestMetadataItem) { + TestMetadataItem testItem = (TestMetadataItem) item; + String name = testItem.getName(); + if (name != null && name.endsWith("_transformed")) { + testItem.setName(name.substring(0, name.length() - "_transformed".length())); + testItem.setSystemMetadata("transformed", false); + } + } + return item; + } + + @Override + public String getTransformationType() { + return transformationType; + } + + @Override + public int getPriority() { + return priority; + } + + boolean wasTransformCalled() { + return transformCalled; + } + + boolean wasReverseTransformCalled() { + return reverseTransformCalled; + } + + void reset() { + transformCalled = false; + reverseTransformCalled = false; + } + } + + @Test + void shouldTransformItemBeforeSave() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", true, 0); + service.addTransformationListener(listener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + + service.save(item); + + assertTrue(listener.wasTransformCalled(), "Transform should be called before save"); + + // Load the item - reverse transformation should undo the transformation + TestMetadataItem loaded = service.load("item1", TestMetadataItem.class); + assertNotNull(loaded, "Item should be loaded"); + assertTrue(listener.wasReverseTransformCalled(), "Reverse transform should be called after load"); + // After reverse transformation, the name should be back to original + assertEquals("original", loaded.getName(), "Item should be reverse transformed after load"); + // But the item should have been transformed when stored (check system metadata) + // Note: The transformation is applied before save, so the stored item has "_transformed" suffix + // The reverse transformation on load removes it, which is the expected behavior + } + + @Test + void shouldReverseTransformItemAfterLoad() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", true, 0); + service.addTransformationListener(listener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + item.setSystemMetadata("transformed", true); + + // Save transformed item directly (simulating what would be stored) + service.save(item); + listener.reset(); + + // Load the item and verify reverse transformation is called + TestMetadataItem loaded = service.load("item1", TestMetadataItem.class); + assertNotNull(loaded, "Item should be loaded"); + assertTrue(listener.wasReverseTransformCalled(), "Reverse transform should be called after load"); + } + + @Test + void shouldNotTransformWhenListenerIsDisabled() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", false, 0); + service.addTransformationListener(listener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + + service.save(item); + + assertFalse(listener.wasTransformCalled(), "Transform should not be called when listener is disabled"); + + TestMetadataItem loaded = service.load("item1", TestMetadataItem.class); + assertNotNull(loaded, "Item should be loaded"); + assertEquals("original", loaded.getName(), "Item should not be transformed"); + } + + @Test + void shouldApplyMultipleTransformationsInPriorityOrder() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener1 = new TestTransformationListener("test1", true, 10); + TestTransformationListener listener2 = new TestTransformationListener("test2", true, 5); + TestTransformationListener listener3 = new TestTransformationListener("test3", true, 1); + + service.addTransformationListener(listener1); + service.addTransformationListener(listener2); + service.addTransformationListener(listener3); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + + service.save(item); + + // All listeners should be called + assertTrue(listener1.wasTransformCalled(), "Listener 1 should be called"); + assertTrue(listener2.wasTransformCalled(), "Listener 2 should be called"); + assertTrue(listener3.wasTransformCalled(), "Listener 3 should be called"); + + // Item should be transformed multiple times before save, then reverse transformed on load + TestMetadataItem loaded = service.load("item1", TestMetadataItem.class); + assertNotNull(loaded, "Item should be loaded"); + // Reverse transformation should undo all transformations, so we get back to original + assertEquals("original", loaded.getName(), + "Item should be reverse transformed back to original after load"); + // All reverse transforms should have been called + assertTrue(listener1.wasReverseTransformCalled(), "Reverse transform 1 should be called"); + assertTrue(listener2.wasReverseTransformCalled(), "Reverse transform 2 should be called"); + assertTrue(listener3.wasReverseTransformCalled(), "Reverse transform 3 should be called"); + } + + @Test + void shouldTransformItemBeforeUpdate() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", true, 0); + service.addTransformationListener(listener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + service.save(item); + listener.reset(); + + // Update the item + Map updates = new HashMap<>(); + updates.put("name", "updated"); + service.update(item, null, TestMetadataItem.class, updates); + + assertTrue(listener.wasTransformCalled(), "Transform should be called before update"); + } + + @Test + void shouldHandleTransformationErrorsGracefully() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + // Capture WARN logs from InMemoryPersistenceServiceImpl so the intentional + // RuntimeException stack traces don't pollute the test output, while still + // asserting that errors are actually logged (not silently swallowed). + ch.qos.logback.classic.Logger implLogger = (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger(InMemoryPersistenceServiceImpl.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + implLogger.addAppender(appender); + // Prevent events from propagating to the root appender so stack traces + // from intentional errors don't pollute the test output. + implLogger.setAdditive(false); + + try { + TenantTransformationListener errorListener = + new TenantTransformationListener() { + @Override + public Item transformItem(Item item, String tenantId) { + throw new RuntimeException("Transformation error"); + } + + @Override + public boolean isTransformationEnabled() { + return true; + } + + @Override + public Item reverseTransformItem(Item item, String tenantId) { + throw new RuntimeException("Reverse transformation error"); + } + + @Override + public String getTransformationType() { + return "error"; + } + }; + + service.addTransformationListener(errorListener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + + assertDoesNotThrow(() -> service.save(item), "Should handle transformation errors gracefully"); + + // Item should still be saved despite the transformation error + TestMetadataItem loaded = service.load("item1", TestMetadataItem.class); + assertNotNull(loaded, "Item should still be saved despite transformation error"); + + // Both errors must have been logged as WARN (not silently swallowed) + long warnCount = appender.list.stream() + .filter(e -> e.getLevel() == Level.WARN) + .count(); + assertTrue(warnCount >= 2, + "Expected WARN for transform + reverse-transform errors, got " + warnCount); + } finally { + implLogger.detachAppender(appender); + implLogger.setAdditive(true); + } + } + + @Test + void shouldRemoveTransformationListener() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", true, 0); + service.addTransformationListener(listener); + + TestMetadataItem item = new TestMetadataItem(); + item.setItemId("item1"); + item.setName("original"); + service.save(item); + assertTrue(listener.wasTransformCalled(), "Transform should be called"); + + // Remove listener + service.removeTransformationListener(listener); + listener.reset(); + + TestMetadataItem item2 = new TestMetadataItem(); + item2.setItemId("item2"); + item2.setName("original2"); + service.save(item2); + + assertFalse(listener.wasTransformCalled(), "Transform should not be called after removal"); + } + + @Test + void shouldTransformCustomItems() { + InMemoryPersistenceServiceImpl service = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher); + + TestTransformationListener listener = new TestTransformationListener("test", true, 0); + service.addTransformationListener(listener); + + CustomItem customItem = new CustomItem(); + customItem.setItemId("custom1"); + customItem.setItemType("customType"); + Map properties = new HashMap<>(); + properties.put("name", "original"); + customItem.setProperties(properties); + + service.save(customItem); + + assertTrue(listener.wasTransformCalled(), "Transform should be called for custom items"); + } + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/NestedItem.java b/services/src/test/java/org/apache/unomi/services/impl/NestedItem.java new file mode 100644 index 000000000..422bf8e12 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/NestedItem.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.Item; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class NestedItem extends Item { + private static final long serialVersionUID = 1L; + public static final String ITEM_TYPE = "nestedItem"; + private Map nestedMap; + private List stringList; + private Set> complexSet; + + public NestedItem() { + super(); + setItemType(ITEM_TYPE); + setScope("testScope"); + } + + public Map getNestedMap() { + return nestedMap; + } + + public void setNestedMap(Map nestedMap) { + this.nestedMap = nestedMap; + } + + public List getStringList() { + return stringList; + } + + public void setStringList(List stringList) { + this.stringList = stringList; + } + + public Set> getComplexSet() { + return complexSet; + } + + public void setComplexSet(Set> complexSet) { + this.complexSet = complexSet; + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/SimpleItem.java b/services/src/test/java/org/apache/unomi/services/impl/SimpleItem.java new file mode 100644 index 000000000..6b3be7e43 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/SimpleItem.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.Item; + +public class SimpleItem extends Item { + private static final long serialVersionUID = 1L; + public static final String ITEM_TYPE = "simpleItem"; + private String simpleProperty; + private int numericProperty; + private boolean booleanProperty; + + public SimpleItem() { + super(); + setItemType(ITEM_TYPE); + setScope("testScope"); + } + + public String getSimpleProperty() { + return simpleProperty; + } + + public void setSimpleProperty(String simpleProperty) { + this.simpleProperty = simpleProperty; + } + + public int getNumericProperty() { + return numericProperty; + } + + public void setNumericProperty(int numericProperty) { + this.numericProperty = numericProperty; + } + + public boolean isBooleanProperty() { + return booleanProperty; + } + + public void setBooleanProperty(boolean booleanProperty) { + this.booleanProperty = booleanProperty; + } +} \ No newline at end of file diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestBundleContext.java b/services/src/test/java/org/apache/unomi/services/impl/TestBundleContext.java new file mode 100644 index 000000000..938e8672e --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestBundleContext.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.osgi.framework.*; + +import java.io.File; +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.Dictionary; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TestBundleContext implements BundleContext { + private final Map bundles = new HashMap<>(); + private final Bundle bundle; + + public TestBundleContext() { + this.bundle = mock(Bundle.class); + when(bundle.getBundleContext()).thenReturn(this); + } + + public void addBundle(Bundle bundle) { + bundles.put(bundle.getBundleId(), bundle); + } + + @Override + public Bundle getBundle() { + return bundle; + } + + @Override + public Bundle[] getBundles() { + return bundles.values().toArray(new Bundle[0]); + } + + @Override + public Bundle getBundle(long id) { + return bundles.get(id); + } + + @Override + public Bundle getBundle(String location) { + return null; + } + + // Unimplemented methods below - we only implement what we need for tests + + @Override + public String getProperty(String key) { + return null; + } + + @Override + public Bundle installBundle(String location, InputStream input) { + return null; + } + + @Override + public Bundle installBundle(String location) { + return null; + } + + @Override + public void addServiceListener(ServiceListener listener, String filter) { + } + + @Override + public void addServiceListener(ServiceListener listener) { + } + + @Override + public void removeServiceListener(ServiceListener listener) { + } + + @Override + public void addBundleListener(BundleListener listener) { + } + + @Override + public void removeBundleListener(BundleListener listener) { + } + + @Override + public void addFrameworkListener(FrameworkListener listener) { + } + + @Override + public void removeFrameworkListener(FrameworkListener listener) { + } + + @Override + public ServiceRegistration registerService(String[] clazzes, Object service, Dictionary properties) { + return null; + } + + @Override + public ServiceRegistration registerService(String clazz, Object service, Dictionary properties) { + return null; + } + + @Override + public ServiceRegistration registerService(Class clazz, S service, Dictionary properties) { + return null; + } + + @Override + public ServiceRegistration registerService(Class clazz, ServiceFactory factory, Dictionary properties) { + return null; + } + + @Override + public ServiceReference[] getServiceReferences(String clazz, String filter) { + return new ServiceReference[0]; + } + + @Override + public ServiceReference[] getAllServiceReferences(String clazz, String filter) { + return new ServiceReference[0]; + } + + @Override + public ServiceReference getServiceReference(String clazz) { + return null; + } + + @Override + public ServiceReference getServiceReference(Class clazz) { + return null; + } + + @Override + public Collection> getServiceReferences(Class clazz, String filter) { + return Collections.emptyList(); + } + + @Override + public S getService(ServiceReference reference) { + return null; + } + + @Override + public boolean ungetService(ServiceReference reference) { + return false; + } + + @Override + public ServiceObjects getServiceObjects(ServiceReference reference) { + return null; + } + + @Override + public File getDataFile(String filename) { + return null; + } + + @Override + public Filter createFilter(String filter) { + return null; + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java b/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java new file mode 100644 index 000000000..9d09d3c13 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java @@ -0,0 +1,937 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.unomi.api.*; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.persistence.spi.PropertyHelper; +import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; +import org.apache.unomi.persistence.spi.conditions.DateUtils; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluator; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.persistence.spi.conditions.evaluator.impl.ConditionEvaluatorDispatcherImpl; +import org.apache.unomi.persistence.spi.conditions.geo.DistanceUnit; +import org.osgi.framework.BundleContext; + +import java.lang.reflect.Method; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Test condition evaluators for use in unit tests. + */ +public class TestConditionEvaluators { + + private static Map conditionTypes = new ConcurrentHashMap<>(); + private static final DateTimeFormatter ISO_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter YEAR_MONTH_DAY_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + private static EventService eventService; + private static BundleContext bundleContext; + private static TestRequestTracer tracer = new TestRequestTracer(false); + private static Map evaluators = new HashMap<>(); + + public static void setEventService(EventService service) { + eventService = service; + } + + public static void setBundleContext(BundleContext bundleContext) { + TestConditionEvaluators.bundleContext = bundleContext; + } + + public static TestRequestTracer getTracer() { + return tracer; + } + + public static ConditionEvaluatorDispatcher createDispatcher() { + ConditionEvaluatorDispatcherImpl dispatcher = new ConditionEvaluatorDispatcherImpl(); + dispatcher.addEvaluator("booleanConditionEvaluator", createBooleanConditionEvaluator()); + dispatcher.addEvaluator("propertyConditionEvaluator", createPropertyConditionEvaluator()); + dispatcher.addEvaluator("matchAllConditionEvaluator", createMatchAllConditionEvaluator()); + dispatcher.addEvaluator("pastEventConditionEvaluator", createPastEventConditionEvaluator()); + dispatcher.addEvaluator("notConditionEvaluator", createNotConditionEvaluator()); + dispatcher.addEvaluator("nestedConditionEvaluator", createNestedConditionEvaluator()); + dispatcher.addEvaluator("idsConditionEvaluator", createIdsConditionEvaluator()); + initializeConditionTypes(); + return dispatcher; + } + + private static ConditionEvaluator createBooleanConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startOperation("boolean", "Evaluating boolean condition with operator: " + condition.getParameter("operator"), condition); + String operator = (String) condition.getParameter("operator"); + Object subConditionsParam = condition.getParameter("subConditions"); + if (!(subConditionsParam instanceof List)) { + tracer.endOperation(true, "No subconditions found, returning true"); + return true; + } + @SuppressWarnings("unchecked") + List subConditions = (List) subConditionsParam; + + if (subConditions.isEmpty()) { + tracer.endOperation(true, "No subconditions found, returning true"); + return true; + } + + boolean isAnd = "and".equalsIgnoreCase(operator); + tracer.trace("Using " + (isAnd ? "AND" : "OR") + " operator for " + subConditions.size() + " subconditions", condition); + + for (int i = 0; i < subConditions.size(); i++) { + Condition subCondition = subConditions.get(i); + boolean result = dispatcher.eval(subCondition, item, context); + if (isAnd && !result) { + tracer.endOperation(false, "AND condition failed on subcondition"); + return false; + } else if (!isAnd && result) { + tracer.endOperation(true, "OR condition succeeded on subcondition"); + return true; + } + } + + boolean finalResult = isAnd; + tracer.endOperation(finalResult, "All subconditions processed, returning " + finalResult); + return finalResult; + }; + } + + private static boolean compareValues(Object expectedValue, Object actualValue, String comparisonOperator) { + if (comparisonOperator == null) { + return false; + } + + switch (comparisonOperator) { + case "equals": + return Objects.equals(expectedValue, actualValue); + case "notEquals": + return !Objects.equals(expectedValue, actualValue); + case "exists": + return actualValue != null; + case "missing": + return actualValue == null; + case "contains": + if (actualValue instanceof Collection) { + return ((Collection) actualValue).contains(expectedValue); + } + return actualValue != null && actualValue.toString().contains(expectedValue.toString()); + case "startsWith": + return actualValue != null && actualValue.toString().startsWith(expectedValue.toString()); + case "endsWith": + return actualValue != null && actualValue.toString().endsWith(expectedValue.toString()); + default: + return false; + } + } + + private static Object getPropertyValue(Item item, String propertyName) { + if (item == null || propertyName == null) { + return null; + } + + try { + String[] path = propertyName.split("\\."); + Object current = item; + + for (String field : path) { + if (current == null) { + return null; + } + + // Handle Map-based access + if (current instanceof Map) { + current = ((Map) current).get(field); + continue; + } + + // Handle special cases for known types + if (current instanceof Event) { + Event currentEvent = (Event) current; + if ("profile".equals(field)) { + current = currentEvent.getProfile(); + continue; + } else if ("session".equals(field)) { + current = currentEvent.getSession(); + continue; + } else { + // For Event, try getProperty() for custom properties + if (currentEvent.getProperties() != null) { + Object currentProperty = currentEvent.getProperty(field); + if (currentProperty != null) { + current = currentProperty; + continue; + } + } + // If getProperty returns null, it might not exist, but continue to try other methods + } + } + + // Try getter method + try { + Method getter = current.getClass().getMethod("get" + field.substring(0, 1).toUpperCase() + field.substring(1)); + current = getter.invoke(current); + } catch (Exception e) { + try { + Method getter = current.getClass().getMethod("is" + field.substring(0, 1).toUpperCase() + field.substring(1)); + current = getter.invoke(current); + } catch (Exception e2) { + // If getter fails, try direct field access + try { + current = current.getClass().getField(field).get(current); + } catch (Exception ex) { + return null; + } + } + } + } + return current; + } catch (Exception e) { + return null; + } + } + + private static ConditionEvaluator createPropertyConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startOperation("property", "Evaluating property condition", condition); + String propertyName = (String) condition.getParameter("propertyName"); + String comparisonOperator = (String) condition.getParameter("comparisonOperator"); + + Object actualValue = getPropertyValue(item, propertyName); + boolean result = evaluateCondition(actualValue, comparisonOperator, condition); + + tracer.endOperation(result, "Property condition evaluation completed"); + return result; + }; + } + + private static boolean evaluateCondition(Object actualValue, String operator, Condition condition) { + // Handle null cases first + if (operator == null) { + return false; + } + if (actualValue == null) { + return operator.equals("missing") || operator.equals("notIn") || + operator.equals("notEquals") || operator.equals("hasNoneOf"); + } + if (operator.equals("exists")) { + return !(actualValue instanceof List) || !((List) actualValue).isEmpty(); + } + + // Get all possible expected values + String expectedValue = condition.getParameter("propertyValue") == null ? null : + ConditionContextHelper.foldToASCII(condition.getParameter("propertyValue").toString()); + Object expectedValueInteger = condition.getParameter("propertyValueInteger"); + Object expectedValueDouble = condition.getParameter("propertyValueDouble"); + Object expectedValueDate = condition.getParameter("propertyValueDate"); + Object expectedValueDateExpr = condition.getParameter("propertyValueDateExpr"); + + // Convert string values to ASCII folded form + if (actualValue instanceof String || expectedValue != null) { + actualValue = ConditionContextHelper.foldToASCII(actualValue.toString()); + } + + // Handle comparison operators + switch (operator) { + case "equals": + case "notEquals": + case "greaterThan": + case "greaterThanOrEqualTo": + case "lessThan": + case "lessThanOrEqualTo": + // For relational comparisons, ensure values are comparable (avoid NPE on invalid/non-numeric actual values) + if ("greaterThan".equals(operator) || "greaterThanOrEqualTo".equals(operator) || + "lessThan".equals(operator) || "lessThanOrEqualTo".equals(operator)) { + if (expectedValueInteger != null) { + Integer actualInt = PropertyHelper.getInteger(actualValue); + Integer expectedInt = PropertyHelper.getInteger(expectedValueInteger); + if (actualInt == null || expectedInt == null) { + return false; + } + return evaluateComparison(operator, actualInt.compareTo(expectedInt)); + } + if (expectedValueDouble != null) { + Double actualDouble = PropertyHelper.getDouble(actualValue); + Double expectedDouble = PropertyHelper.getDouble(expectedValueDouble); + if (actualDouble == null || expectedDouble == null) { + return false; + } + return evaluateComparison(operator, actualDouble.compareTo(expectedDouble)); + } + if (expectedValueDate != null || expectedValueDateExpr != null) { + Date actualDate = getDate(actualValue); + Object expectedDateObj = expectedValueDate != null ? expectedValueDate : expectedValueDateExpr; + Date expectedDateParsed = getDate(expectedDateObj); + if (actualDate == null || expectedDateParsed == null) { + return false; + } + return evaluateComparison(operator, actualDate.compareTo(expectedDateParsed)); + } + } + + int comparisonResult = compareValues(actualValue, expectedValue, expectedValueDate, + expectedValueInteger, expectedValueDateExpr, expectedValueDouble); + return evaluateComparison(operator, comparisonResult); + + case "between": + return evaluateBetweenCondition(actualValue, condition); + + case "contains": + case "notContains": + case "startsWith": + case "endsWith": + case "matchesRegex": + return evaluateStringCondition(actualValue.toString(), expectedValue, operator); + + case "in": + case "inContains": + case "notIn": + case "hasSomeOf": + case "hasNoneOf": + case "all": + return evaluateCollectionCondition(actualValue, condition, operator); + + case "isDay": + case "isNotDay": + return evaluateDateCondition(actualValue, expectedValueDate, expectedValueDateExpr, operator); + + case "distance": + return evaluateDistanceCondition(actualValue, condition); + + default: + return false; + } + } + + private static boolean evaluateComparison(String operator, int comparisonResult) { + switch (operator) { + case "equals": return comparisonResult == 0; + case "notEquals": return comparisonResult != 0; + case "greaterThan": return comparisonResult > 0; + case "greaterThanOrEqualTo": return comparisonResult >= 0; + case "lessThan": return comparisonResult < 0; + case "lessThanOrEqualTo": return comparisonResult <= 0; + default: return false; + } + } + + private static boolean evaluateStringCondition(String actualValue, String expectedValue, String operator) { + if (expectedValue == null) { + return false; + } + switch (operator) { + case "contains": return actualValue.contains(expectedValue); + case "notContains": return !actualValue.contains(expectedValue); + case "startsWith": return actualValue.startsWith(expectedValue); + case "endsWith": return actualValue.endsWith(expectedValue); + case "matchesRegex": return Pattern.compile(expectedValue).matcher(actualValue).matches(); + default: return false; + } + } + + private static boolean evaluateBetweenCondition(Object actualValue, Condition condition) { + Collection expectedValuesInteger = (Collection) condition.getParameter("propertyValuesInteger"); + Collection expectedValuesDouble = (Collection) condition.getParameter("propertyValuesDouble"); + Collection expectedValuesDate = (Collection) condition.getParameter("propertyValuesDate"); + Collection expectedValuesDateExpr = (Collection) condition.getParameter("propertyValuesDateExpr"); + + int lowerBoundComparison = compareValues(actualValue, null, + getDate(getFirst(expectedValuesDate)), + getFirst(expectedValuesInteger), + getFirst(expectedValuesDateExpr), + getFirst(expectedValuesDouble)); + + int upperBoundComparison = compareValues(actualValue, null, + getDate(getSecond(expectedValuesDate)), + getSecond(expectedValuesInteger), + getSecond(expectedValuesDateExpr), + getSecond(expectedValuesDouble)); + + return lowerBoundComparison >= 0 && upperBoundComparison <= 0; + } + + private static boolean evaluateDateCondition(Object actualValue, Object expectedValueDate, + Object expectedValueDateExpr, String operator) { + Object expectedDate = expectedValueDate == null ? expectedValueDateExpr : expectedValueDate; + Date actualDateVal = getDate(actualValue); + Date expectedDateVal = getDate(expectedDate); + if (actualDateVal == null || expectedDateVal == null) { + return false; + } + boolean isSameDay = YEAR_MONTH_DAY_FORMAT.format(actualDateVal.toInstant()) + .equals(YEAR_MONTH_DAY_FORMAT.format(expectedDateVal.toInstant())); + return operator.equals("isDay") ? isSameDay : !isSameDay; + } + + private static boolean evaluateDistanceCondition(Object actualValue, Condition condition) { + GeoPoint actualCenter = null; + if (actualValue instanceof GeoPoint) { + actualCenter = (GeoPoint) actualValue; + } else if (actualValue instanceof Map) { + actualCenter = GeoPoint.fromMap((Map) actualValue); + } else if (actualValue instanceof String) { + actualCenter = GeoPoint.fromString((String) actualValue); + } + if (actualCenter == null) { + return false; + } + + String unitString = (String) condition.getParameter("unit"); + String centerString = (String) condition.getParameter("center"); + Double distance = (Double) condition.getParameter("distance"); + if (centerString == null || distance == null) { + return false; + } + + GeoPoint expectedCenter = GeoPoint.fromString(centerString); + DistanceUnit expectedUnit = unitString != null ? DistanceUnit.fromString(unitString) : DistanceUnit.METERS; + return expectedCenter.distanceTo(actualCenter) <= expectedUnit.toMeters(distance); + } + + private static boolean evaluateCollectionCondition(Object actualValue, Condition condition, String operator) { + Collection expectedValues = ConditionContextHelper.foldToASCII((Collection) condition.getParameter("propertyValues")); + Collection expectedValuesInteger = (Collection) condition.getParameter("propertyValuesInteger"); + Collection expectedValuesDate = (Collection) condition.getParameter("propertyValuesDate"); + Collection expectedValuesDateExpr = (Collection) condition.getParameter("propertyValuesDateExpr"); + Collection expectedValuesDouble = (Collection) condition.getParameter("propertyValuesDouble"); + + Collection expectedDateExpr = expectedValuesDateExpr != null ? + expectedValuesDateExpr.stream().map(DateUtils::getDate).collect(Collectors.toList()) : null; + + @SuppressWarnings("unchecked") + Collection expected = ObjectUtils.firstNonNull(expectedValues, expectedValuesDate, + expectedValuesInteger, expectedValuesDouble, expectedDateExpr); + + if (expected == null) { + return actualValue == null; + } + + Collection actual = ConditionContextHelper.foldToASCII(getValueSet(actualValue)); + + switch (operator) { + case "in": return actual.stream().anyMatch(expected::contains); + case "inContains": return actual.stream().anyMatch(a -> + (a instanceof String) && expected.stream().anyMatch(b -> (b instanceof String) && ((String) a).contains((String) b))); + case "notIn": return actual.stream().noneMatch(expected::contains); + case "all": return expected.stream().allMatch(actual::contains); + case "hasNoneOf": return Collections.disjoint(actual, expected); + case "hasSomeOf": return !Collections.disjoint(actual, expected); + default: return false; + } + } + + private static int compareValues(Object actualValue, String expectedValue, Object expectedValueDate, + Object expectedValueInteger, Object expectedValueDateExpr, Object expectedValueDouble) { + if (expectedValue == null && expectedValueDate == null && expectedValueInteger == null && + getDate(expectedValueDateExpr) == null && expectedValueDouble == null) { + return actualValue == null ? 0 : 1; + } else if (actualValue == null) { + return -1; + } + + if (expectedValueInteger != null) { + Integer actualInt = PropertyHelper.getInteger(actualValue); + Integer expectedInt = PropertyHelper.getInteger(expectedValueInteger); + if (actualInt == null || expectedInt == null) { + return 1; + } + return actualInt.compareTo(expectedInt); + } else if (expectedValueDouble != null) { + Double actualDouble = PropertyHelper.getDouble(actualValue); + Double expectedDouble = PropertyHelper.getDouble(expectedValueDouble); + if (actualDouble == null || expectedDouble == null) { + return 1; + } + return actualDouble.compareTo(expectedDouble); + } else if (expectedValueDate != null) { + Date actualDate = getDate(actualValue); + Date expectedDate = getDate(expectedValueDate); + if (actualDate == null || expectedDate == null) { + return 1; + } + return actualDate.compareTo(expectedDate); + } else if (expectedValueDateExpr != null) { + Date actualDate = getDate(actualValue); + Date expectedDate = getDate(expectedValueDateExpr); + if (actualDate == null || expectedDate == null) { + return 1; + } + return actualDate.compareTo(expectedDate); + } else { + return actualValue.toString().toLowerCase().compareTo(expectedValue); + } + } + + private static List getValueSet(Object value) { + if (value instanceof List) { + return (List) value; + } else if (value instanceof Collection) { + return new ArrayList<>((Collection) value); + } else { + return Collections.singletonList(value); + } + } + + private static Object getFirst(Collection collection) { + if (collection == null || collection.isEmpty()) { + return null; + } + return collection.iterator().next(); + } + + private static Object getSecond(Collection collection) { + if (collection == null || collection.size() < 2) { + return null; + } + Iterator iterator = collection.iterator(); + iterator.next(); + return iterator.next(); + } + + private static ConditionEvaluator createMatchAllConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startEvaluation(condition, "Evaluating matchAll condition"); + tracer.endEvaluation(condition, true, "MatchAll condition always returns true"); + return true; + }; + } + + private static ConditionEvaluator createPastEventConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startOperation("pastEvent", "Evaluating past event condition", condition); + if (!(item instanceof Profile)) { + tracer.endOperation(false, "Item is not a profile"); + return false; + } + + Profile profile = (Profile) item; + Map parameters = condition.getParameterValues(); + long count; + + if (parameters.containsKey("generatedPropertyKey")) { + String key = (String) parameters.get("generatedPropertyKey"); + tracer.trace(condition, "Using generated property key: " + key); + + Object pastEventsObj = profile.getSystemProperties().get("pastEvents"); + if (!(pastEventsObj instanceof List)) { + tracer.trace(condition, "No pastEvents found in profile system properties"); + count = 0; + } else { + @SuppressWarnings("unchecked") + List> pastEvents = (List>) pastEventsObj; + tracer.trace(condition, "Found pastEvents in profile system properties"); + Number l = (Number) pastEvents + .stream() + .filter(pastEvent -> pastEvent.get("key").equals(key)) + .findFirst() + .map(pastEvent -> pastEvent.get("count")).orElse(0L); + count = l.longValue(); + tracer.trace(condition, "Found count=" + count + " for key=" + key); + } + } else { + tracer.trace(condition, "No generatedPropertyKey found, querying events directly"); + Condition eventCondition = (Condition) condition.getParameter("eventCondition"); + count = eventService.searchEvents(eventCondition, 0, 1).size(); + tracer.trace(condition, "Direct event query returned count=" + count); + } + + // Match the behavior of getStrategyFromOperator in real evaluators + String operator = (String) condition.getParameter("operator"); + boolean eventsOccurred; + if (operator != null && !operator.equals("eventsOccurred") && !operator.equals("eventsNotOccurred")) { + tracer.trace(condition, "Warning: Unsupported operator: " + operator + ", defaulting to eventsOccurred behavior"); + eventsOccurred = true; // Default behavior for invalid values (matches null handling) + } else { + eventsOccurred = operator == null || operator.equals("eventsOccurred"); + } + + if (eventsOccurred) { + int minimumEventCount = parameters.get("minimumEventCount") == null ? 0 : (Integer) parameters.get("minimumEventCount"); + int maximumEventCount = parameters.get("maximumEventCount") == null ? Integer.MAX_VALUE : (Integer) parameters.get("maximumEventCount"); + boolean result = count > 0 && (count >= minimumEventCount && count <= maximumEventCount); + tracer.endEvaluation(condition, result, + String.format("Events occurred check: count=%d, min=%d, max=%d", count, minimumEventCount, maximumEventCount)); + return result; + } else { + boolean result = count == 0; + tracer.endEvaluation(condition, result, "Events not occurred check: count=" + count); + return result; + } + }; + } + + private static ConditionEvaluator createNotConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + Condition subCondition = (Condition) condition.getParameter("subCondition"); + if (subCondition == null) { + return false; + } + return !dispatcher.eval(subCondition, item, context); + }; + } + + + private static ConditionEvaluator createNestedConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startEvaluation(condition, "Evaluating nested condition"); + + String path = (String) condition.getParameter("path"); + Condition subCondition = (Condition) condition.getParameter("subCondition"); + + if (subCondition == null || path == null) { + tracer.endEvaluation(condition, false, "Missing required parameters: subCondition or path is null"); + return false; + } + + tracer.trace(condition, "Evaluating nested condition with path: " + path); + + try { + // Get list of nested items to be evaluated + Object nestedItems = getPropertyValue(item, path); + if (nestedItems instanceof List) { + tracer.trace(condition, "Found list of nested items at path: " + path + ", size: " + ((List) nestedItems).size()); + + // Evaluate each nested item until one matches the nested condition + for (Object nestedItem : (List) nestedItems) { + if (nestedItem instanceof Map) { + Map flattenedNestedItem = flattenNestedItem(path, (Map) nestedItem); + Item finalNestedItem = createFinalNestedItemForEvaluation(item, path, flattenedNestedItem); + + if (finalNestedItem != null) { + tracer.trace(condition, "Evaluating subcondition on nested item"); + boolean result = dispatcher.eval(subCondition, finalNestedItem, context); + if (result) { + tracer.endEvaluation(condition, true, "Found matching nested item"); + return true; + } + } + } + } + tracer.endEvaluation(condition, false, "No matching nested items found"); + } else { + tracer.endEvaluation(condition, false, "Property at path is not a list: " + path); + } + } catch (Exception e) { + tracer.trace(condition, "Error evaluating nested condition: " + e.getMessage()); + tracer.endEvaluation(condition, false, "Failed to evaluate nested condition: " + e.getMessage()); + return false; + } + return false; + }; + } + + private static Map flattenNestedItem(String path, Map nestedItem) { + Map flattenedNestedItem = new HashMap<>(); + + if (path != null && !path.isEmpty()) { + String propertyPath = path.contains(".") ? path.substring(path.indexOf(".") + 1) : path; + if (!propertyPath.isEmpty()) { + String[] propertyKeys = propertyPath.split("\\."); + Map currentLevel = flattenedNestedItem; + + for (int i = 0; i < propertyKeys.length; i++) { + String key = propertyKeys[i]; + if (i == propertyKeys.length - 1) { + currentLevel.put(key, nestedItem); + } else { + Map nextLevel = new HashMap<>(); + currentLevel.put(key, nextLevel); + currentLevel = nextLevel; + } + } + } + } + + return flattenedNestedItem; + } + + private static Item createFinalNestedItemForEvaluation(Item parentItem, String path, Map flattenedNestedItem) { + if (parentItem instanceof Profile) { + Profile profile = new Profile(parentItem.getItemId()); + if (path.startsWith("properties.")) { + profile.setProperties(flattenedNestedItem); + } else if (path.startsWith("systemProperties.")) { + profile.setSystemProperties(flattenedNestedItem); + } + return profile; + } else if (parentItem instanceof Session) { + Session session = new Session(); + if (path.startsWith("properties.")) { + session.setProperties(flattenedNestedItem); + } else if (path.startsWith("systemProperties.")) { + session.setSystemProperties(flattenedNestedItem); + } + return session; + } + return null; + } + + private static void initializeConditionTypes() { + + ConditionType propertyConditionType = createConditionType("propertyCondition", "propertyConditionEvaluator", "propertyConditionQueryBuilder", null); + conditionTypes.put("propertyCondition", propertyConditionType); + + // Create boolean condition type + ConditionType booleanConditionType = createConditionType("booleanCondition", + "booleanConditionEvaluator", + "booleanConditionQueryBuilder", Set.of("profileTags", + "logical", + "condition", + "profileCondition", + "eventCondition", + "sessionCondition", + "sourceEventCondition")); + conditionTypes.put("booleanCondition", booleanConditionType); + + // Create matchAll condition type + ConditionType matchAllConditionType = createConditionType("matchAllCondition", "matchAllConditionEvaluator", + "matchAllConditionQueryBuilder", Set.of("profileTags", "logical", "condition", "profileCondition", + "eventCondition", "sessionCondition", "sourceEventCondition")); + conditionTypes.put("matchAllCondition", matchAllConditionType); + + // Create eventProperty condition type first (needed for eventTypeCondition parent) + ConditionType eventPropertyConditionType = createConditionType("eventPropertyCondition", "propertyConditionEvaluator", + "propertyConditionQueryBuilder", Set.of("profileTags", "demographic", "condition", "eventCondition")); + conditionTypes.put("eventPropertyCondition", eventPropertyConditionType); + + // Create eventType condition type + // eventTypeCondition uses parentCondition (eventPropertyCondition) instead of direct evaluator or queryBuilder + ConditionType eventTypeConditionType = createConditionType("eventTypeCondition", null, + null, Set.of("profileTags", "event", "condition", "eventCondition")); + + // Set up parent condition: eventPropertyCondition with propertyName="eventType", propertyValue="parameter::eventTypeId" + // The parent condition uses propertyConditionEvaluator (same as eventPropertyCondition) + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("eventPropertyCondition"); + Map parentParameterValues = new HashMap<>(); + parentParameterValues.put("propertyName", "eventType"); + parentParameterValues.put("propertyValue", "parameter::eventTypeId"); + parentParameterValues.put("comparisonOperator", "equals"); + parentCondition.setParameterValues(parentParameterValues); + eventTypeConditionType.setParentCondition(parentCondition); + + conditionTypes.put("eventTypeCondition", eventTypeConditionType); + + + // Create sessionProperty condition type + ConditionType sessionPropertyConditionType = createConditionType("sessionPropertyCondition", "propertyConditionEvaluator", + "propertyConditionQueryBuilder", Set.of("availableToEndUser", "sessionBased", "profileTags", "event", "condition", "sessionCondition")); + conditionTypes.put("sessionPropertyCondition", sessionPropertyConditionType); + + // Create profileProperty condition type + ConditionType profilePropertyConditionType = createConditionType("profilePropertyCondition", "propertyConditionEvaluator", + "propertyConditionQueryBuilder", Set.of("availableToEndUser", "profileTags", "demographic", "condition", "profileCondition")); + conditionTypes.put("profilePropertyCondition", profilePropertyConditionType); + + // Create pastEvent condition type + ConditionType pastEventConditionType = createConditionType("pastEventCondition", "pastEventConditionEvaluator", + "pastEventConditionQueryBuilder", Set.of("profileTags", "event", "condition", "pastEventCondition")); + conditionTypes.put("pastEventCondition", pastEventConditionType); + + // Create not condition type + ConditionType notConditionType = createConditionType("notCondition", "notConditionEvaluator", + "notConditionQueryBuilder", Set.of("profileTags", "logical", "condition", "profileCondition", + "eventCondition", "sessionCondition", "sourceEventCondition")); + conditionTypes.put("notCondition", notConditionType); + + // Create profileUpdatedEvent condition type + // profileUpdatedEventCondition uses parentCondition (eventTypeCondition) instead of direct evaluator or queryBuilder + ConditionType profileUpdatedEventConditionType = createConditionType("profileUpdatedEventCondition", + null, + null, + Set.of("profileTags", "event", "condition", "eventCondition")); + + // Set up parent condition: eventTypeCondition with eventTypeId="profileUpdated" + Condition profileUpdatedParentCondition = new Condition(); + profileUpdatedParentCondition.setConditionTypeId("eventTypeCondition"); + Map profileUpdatedParentParameterValues = new HashMap<>(); + profileUpdatedParentParameterValues.put("eventTypeId", "profileUpdated"); + profileUpdatedParentCondition.setParameterValues(profileUpdatedParentParameterValues); + profileUpdatedEventConditionType.setParentCondition(profileUpdatedParentCondition); + + conditionTypes.put("profileUpdatedEventCondition", profileUpdatedEventConditionType); + + // Create nested condition type + ConditionType nestedConditionType = createConditionType("nestedCondition", + "nestedConditionEvaluator", + "nestedConditionQueryBuilder", + Set.of("profileTags", "logical", "condition", "profileCondition", "sessionCondition")); + conditionTypes.put("nestedCondition", nestedConditionType); + + // Create ids condition type + ConditionType idsConditionType = createConditionType("idsCondition", "idsConditionEvaluator", + "idsConditionQueryBuilder", Set.of("profileTags", "logical", "condition", "profileCondition", + "eventCondition", "sessionCondition", "sourceEventCondition")); + conditionTypes.put("idsCondition", idsConditionType); + } + + private static ConditionType createConditionType(String typeId, String conditionEvaluatorId, String queryBuilderId, Set systemTags) { + ConditionType conditionType = new ConditionType(); + conditionType.setItemId(typeId); + + Metadata metadata = new Metadata(); + metadata.setId(typeId); + metadata.setEnabled(true); + if (systemTags != null) { + metadata.setSystemTags(new HashSet<>(systemTags)); + } + + conditionType.setMetadata(metadata); + conditionType.setConditionEvaluator(conditionEvaluatorId); + conditionType.setQueryBuilder(queryBuilderId); + + // Add parameter validation requirements based on condition type + switch (typeId) { + case "profilePropertyCondition": + case "sessionPropertyCondition": + case "eventPropertyCondition": + // Property conditions require propertyName, comparisonOperator, and one of the propertyValue* parameters + conditionType.setParameters(Arrays.asList( + createParameter("propertyName", "string", true, null, false), + createParameter("comparisonOperator", "string", true, null, false), + createParameter("propertyValue", "string", false, "propertyValue", false), + createParameter("propertyValueInteger", "integer", false, "propertyValue", false), + createParameter("propertyValueDouble", "double", false, "propertyValue", false), + createParameter("propertyValueDate", "date", false, "propertyValue", false) + )); + break; + case "booleanCondition": + // Boolean conditions require operator and subConditions (which is multivalued) + conditionType.setParameters(Arrays.asList( + createParameter("operator", "string", true, null, false), + createParameter("subConditions", "Condition", true, null, true) + )); + break; + case "pastEventCondition": + // Past event conditions require eventCondition, operator is recommended + conditionType.setParameters(Arrays.asList( + createParameter("eventCondition", "Condition", true, null, false), + createParameterRecommended("operator", "string", null, false), + createParameter("numberOfDays", "integer", false, null, false), + createParameter("minimumEventCount", "integer", false, null, false), + createParameter("maximumEventCount", "integer", false, null, false) + )); + break; + case "eventTypeCondition": + // Event type conditions require eventTypeId + conditionType.setParameters(Arrays.asList( + createParameter("eventTypeId", "string", true, null, false) + )); + break; + case "notCondition": + // Not conditions require subCondition (single condition, not multivalued) + conditionType.setParameters(Arrays.asList( + createParameter("subCondition", "Condition", true, null, false) + )); + break; + case "nestedCondition": + // Nested conditions require path and subCondition (single condition, not multivalued) + conditionType.setParameters(Arrays.asList( + createParameter("path", "string", true, null, false), + createParameter("subCondition", "Condition", true, null, false) + )); + break; + case "idsCondition": + // Ids conditions require ids collection (which is multivalued) + conditionType.setParameters(Arrays.asList( + createParameter("ids", "string", true, null, true) + )); + break; + case "matchAllCondition": + // Match all doesn't require any parameters + break; + case "profileUpdatedEventCondition": + // Profile updated event doesn't require any parameters + break; + } + + return conditionType; + } + + private static Parameter createParameter(String name, String type, boolean required, String exclusiveGroup, boolean multivalued) { + Parameter parameter = new Parameter(); + parameter.setId(name); + parameter.setType(type); + parameter.setMultivalued(multivalued); + + return parameter; + } + + private static Parameter createParameterRecommended(String name, String type, String exclusiveGroup, boolean multivalued) { + Parameter parameter = new Parameter(); + parameter.setId(name); + parameter.setType(type); + parameter.setMultivalued(multivalued); + + return parameter; + } + + public static Map getConditionTypes() { + return Collections.unmodifiableMap(conditionTypes); + } + + public static ConditionType getConditionType(String conditionTypeId) { + return conditionTypes.get(conditionTypeId); + } + + private static Date getDate(Object value) { + return DateUtils.getDate(value); + } + + private static ConditionEvaluator createIdsConditionEvaluator() { + return (condition, item, context, dispatcher) -> { + tracer.startEvaluation(condition, "Evaluating ids condition"); + + if (item == null) { + tracer.endEvaluation(condition, false, "Item is null"); + return false; + } + + Object idsObj = condition.getParameter("ids"); + if (idsObj == null) { + tracer.endEvaluation(condition, false, "No ids provided in condition"); + return false; + } + + Collection ids; + if (idsObj instanceof Collection) { + @SuppressWarnings("unchecked") + Collection temp = (Collection) idsObj; + ids = temp; + } else { + tracer.endEvaluation(condition, false, "Ids parameter is not a collection"); + return false; + } + + if (ids.isEmpty()) { + tracer.endEvaluation(condition, false, "Empty ids collection"); + return false; + } + + tracer.trace(condition, "Checking if item id " + item.getItemId() + " is in collection: " + ids); + boolean result = ids.contains(item.getItemId()); + tracer.endEvaluation(condition, result, "Item id " + (result ? "found" : "not found") + " in collection"); + return result; + }; + } +} + diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestEventAdmin.java b/services/src/test/java/org/apache/unomi/services/impl/TestEventAdmin.java new file mode 100644 index 000000000..e8298d863 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestEventAdmin.java @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.osgi.service.event.Event; +import org.osgi.service.event.EventAdmin; +import org.osgi.service.event.EventHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Mock implementation of OSGi EventAdmin for unit tests. + * + * This implementation complies with the OSGi EventAdmin Service Specification (OSGi Compendium 8.1+): + * + * 1. postEvent(Event event): Asynchronous, ordered delivery + * - Returns immediately (non-blocking) per spec: "returns to the caller before delivery is complete" + * - Events are delivered to handlers in the order they were posted per spec: "Events are delivered in the order posted" + * - Each handler receives events in the order they were posted + * - Null events are ignored (early return) + * - Events with null topics are ignored (logged and skipped) + * + * 2. sendEvent(Event event): Synchronous delivery + * - Does not return until all handlers have processed the event per spec: "does not return until all event handlers have been called" + * - Handlers are called directly in the current thread (synchronous) + * - Null events are ignored (early return) + * - Events with null topics are ignored (logged and skipped) + * + * 3. Exception handling: Exceptions from handlers do not stop delivery to other handlers + * - Per spec: exceptions should be caught and logged (using LogService if available, SLF4J otherwise) + * - Delivery continues to remaining handlers + * + * 4. Topic matching: Supports OSGi hierarchical topic matching + * - EVENT_TOPIC service property: array of topic patterns + * - Empty EVENT_TOPIC matches all topics (defaults to "*") + * - Wildcard "*" matches all topics + * - Wildcard "**" at end matches all subtopics (e.g., "org/apache/unomi/**") + * - Single-level wildcard "*" at end matches one level (e.g., "org/apache/unomi/*") + * - Exact topic matching + * - Topics are hierarchical, separated by '/' character + * + * 5. Handler registration: Manual registration for tests (in real OSGi, handlers are registered via service registry) + * - Handlers specify topics via EVENT_TOPIC property (mapped to topics parameter) + * - EVENT_FILTER property is optional and not implemented (minimal compliance) + * + * Threading model: + * - postEvent(): Each handler has a dedicated daemon thread (from a cached thread pool) consuming + * events from its own queue, guaranteeing per-handler ordered delivery + * - sendEvent(): Calls handlers directly in the current thread (synchronous) + * + * Note: Security (TopicPermission) is not enforced in this test mock, as it's not required for minimal compliance + * in a test environment. Real OSGi implementations must enforce TopicPermission checks. + */ +public class TestEventAdmin implements EventAdmin { + + private static final Logger LOGGER = LoggerFactory.getLogger(TestEventAdmin.class); + + /** + * Registry of event handlers with their topic filters. + * Key: EventHandler instance + * Value: Set of topic patterns (from EVENT_TOPIC service property) + */ + private final Map> handlers = new ConcurrentHashMap<>(); + + /** + * Cached thread pool providing one daemon thread per registered handler. + * A single-thread executor cannot be shared across handlers because each handler's + * worker blocks on queue.take(), starving all subsequent handler submissions. + */ + private final ExecutorService asyncExecutor; + + /** + * Number of events currently being processed (taken from queue but not yet finished). + * Used by waitForEventProcessing to avoid a race where the queue appears empty before + * handleEvent has actually completed. + */ + private final AtomicInteger inFlightCount = new AtomicInteger(0); + + /** + * Queue per handler to guarantee event sequencing. + * Each handler has its own queue, ensuring events are processed in order. + */ + private final Map> handlerQueues = new ConcurrentHashMap<>(); + + /** + * Worker threads per handler to process events from their queues sequentially. + */ + private final Map> handlerWorkers = new ConcurrentHashMap<>(); + + /** + * Counter for tracking posted events (for test verification). + */ + private final AtomicInteger postedEventCount = new AtomicInteger(0); + + /** + * Counter for tracking sent events (for test verification). + */ + private final AtomicInteger sentEventCount = new AtomicInteger(0); + + /** + * List of all events posted (for test verification). + */ + private final List postedEvents = new CopyOnWriteArrayList<>(); + + /** + * List of all events sent (for test verification). + */ + private final List sentEvents = new CopyOnWriteArrayList<>(); + + public TestEventAdmin() { + this.asyncExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "TestEventAdmin-Handler-" + System.identityHashCode(this)); + t.setDaemon(true); + return t; + }); + } + + /** + * Registers an event handler with topic filters. + * In real OSGi, handlers are registered via the service registry with EVENT_TOPIC property. + * For tests, we allow manual registration. + * + * @param handler the event handler to register + * @param topics topic patterns (supports OSGi hierarchical wildcards) + */ + public void registerHandler(EventHandler handler, String... topics) { + if (handler == null) { + return; + } + + Set topicFilters = new HashSet<>(); + if (topics != null && topics.length > 0) { + topicFilters.addAll(Arrays.asList(topics)); + } else { + // No filter means match all topics (OSGi spec: empty EVENT_TOPIC matches all) + topicFilters.add("*"); + } + + handlers.put(handler, topicFilters); + + // Create a dedicated queue for this handler to guarantee sequencing + BlockingQueue queue = new LinkedBlockingQueue<>(); + handlerQueues.put(handler, queue); + + // Start a worker thread for this handler to process events sequentially + Future worker = asyncExecutor.submit(() -> { + try { + while (!Thread.currentThread().isInterrupted()) { + Event event = queue.take(); // Blocks until event is available + inFlightCount.incrementAndGet(); + try { + handler.handleEvent(event); + } catch (Exception e) { + // OSGi spec: catch exceptions, log them, and continue + LOGGER.warn("Exception in event handler {} while processing event {}: {}", + handler.getClass().getName(), event.getTopic(), e.getMessage(), e); + } finally { + inFlightCount.decrementAndGet(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + handlerWorkers.put(handler, worker); + + LOGGER.debug("Registered event handler: {} with topics: {}", handler.getClass().getName(), topicFilters); + } + + /** + * Unregisters an event handler. + * + * @param handler the event handler to unregister + */ + public void unregisterHandler(EventHandler handler) { + if (handler == null) { + return; + } + + handlers.remove(handler); + BlockingQueue queue = handlerQueues.remove(handler); + Future worker = handlerWorkers.remove(handler); + + if (worker != null) { + worker.cancel(true); + } + + // Drain remaining events from queue + if (queue != null) { + queue.clear(); + } + + LOGGER.debug("Unregistered event handler: {}", handler.getClass().getName()); + } + + /** + * Posts an event asynchronously (non-blocking). + * + * OSGi spec: "Initiates asynchronous, ordered delivery of an event. This method returns + * to the caller before delivery is complete. Events are delivered in the order posted." + * + * @param event the event to post + */ + @Override + public void postEvent(Event event) { + if (event == null) { + return; + } + + String topic = event.getTopic(); + if (topic == null) { + // OSGi spec: Events must have a topic. Skip delivery if topic is null. + LOGGER.warn("Event has null topic, skipping delivery"); + return; + } + + postedEventCount.incrementAndGet(); + postedEvents.add(event); + + LOGGER.debug("Posting event asynchronously: {}", topic); + + // Deliver to all matching handlers asynchronously + // Each handler gets events in order via its dedicated queue + for (Map.Entry> entry : handlers.entrySet()) { + EventHandler handler = entry.getKey(); + Set topicFilters = entry.getValue(); + + if (matchesTopic(topic, topicFilters)) { + BlockingQueue queue = handlerQueues.get(handler); + if (queue != null) { + // Add to handler's queue (non-blocking, will be processed by handler's worker thread) + queue.offer(event); + } + } + } + } + + /** + * Sends an event synchronously (blocking until all handlers complete). + * + * OSGi spec: "Synchronously sends an event. This method does not return to the caller + * until all event handlers have been called." + * + * @param event the event to send + */ + @Override + public void sendEvent(Event event) { + if (event == null) { + return; + } + + String topic = event.getTopic(); + if (topic == null) { + // OSGi spec: Events must have a topic. Skip delivery if topic is null. + LOGGER.warn("Event has null topic, skipping delivery"); + return; + } + + sentEventCount.incrementAndGet(); + sentEvents.add(event); + + LOGGER.debug("Sending event synchronously: {}", topic); + + // Collect all matching handlers + List matchingHandlers = new ArrayList<>(); + for (Map.Entry> entry : handlers.entrySet()) { + EventHandler handler = entry.getKey(); + Set topicFilters = entry.getValue(); + + if (matchesTopic(topic, topicFilters)) { + matchingHandlers.add(handler); + } + } + + // Deliver to all matching handlers synchronously in the current thread + // OSGi spec: "does not return until all event handlers have been called" + for (EventHandler handler : matchingHandlers) { + try { + handler.handleEvent(event); + } catch (Exception e) { + // OSGi spec: catch exceptions, log them, and continue + // If LogService is available, it should be used (we use SLF4J) + LOGGER.warn("Exception in event handler {} while processing event {}: {}", + handler.getClass().getName(), topic, e.getMessage(), e); + } + } + } + + /** + * Checks if an event topic matches any of the topic filters. + * + * OSGi spec topic matching rules: + * - Topics are hierarchical, separated by '/' (e.g., "org/apache/unomi/definitions/conditionType/ADDED") + * - Wildcard "*" matches all topics + * - Wildcard "**" matches all subtopics (e.g., "org/apache/unomi/**" matches all topics starting with that prefix) + * - Exact match for specific topics + * + * @param topic the event topic + * @param topicFilters the topic filters to check against + * @return true if the topic matches any filter + */ + private boolean matchesTopic(String topic, Set topicFilters) { + if (topic == null) { + return false; + } + + for (String filter : topicFilters) { + if ("*".equals(filter)) { + return true; + } + + if ("**".equals(filter)) { + return true; + } + + // Support wildcard pattern: "org/apache/unomi/**" + // Matches all topics starting with the prefix (including the prefix itself) + if (filter.endsWith("/**")) { + String prefix = filter.substring(0, filter.length() - 3); + if (topic.startsWith(prefix + "/") || topic.equals(prefix)) { + return true; + } + } + + // Exact match + if (topic.equals(filter)) { + return true; + } + + // Support single-level wildcard: "org/apache/unomi/*" + // Matches exactly one level (no nested slashes after the prefix) + if (filter.endsWith("/*")) { + String prefix = filter.substring(0, filter.length() - 2); + if (topic.startsWith(prefix + "/")) { + String remainder = topic.substring(prefix.length() + 1); + // Remainder should not contain '/' (single level only) + if (!remainder.contains("/")) { + return true; + } + } + } + } + + return false; + } + + /** + * Gets all events that were posted (asynchronously). + * + * @return a list of posted events + */ + public List getPostedEvents() { + return new ArrayList<>(postedEvents); + } + + /** + * Gets all events that were sent (synchronously). + * + * @return a list of sent events + */ + public List getSentEvents() { + return new ArrayList<>(sentEvents); + } + + /** + * Clears all stored events. + */ + public void clearEvents() { + postedEvents.clear(); + sentEvents.clear(); + postedEventCount.set(0); + sentEventCount.set(0); + } + + /** + * Gets the count of posted events. + * + * @return the number of posted events + */ + public int getPostedEventCount() { + return postedEventCount.get(); + } + + /** + * Gets the count of sent events. + * + * @return the number of sent events + */ + public int getSentEventCount() { + return sentEventCount.get(); + } + + /** + * Gets the number of registered handlers. + * + * @return the number of registered handlers + */ + public int getHandlerCount() { + return handlers.size(); + } + + /** + * Waits for all pending asynchronous events to be processed. + * This is useful in tests to ensure events have been delivered before assertions. + * + * @param timeoutMs maximum time to wait in milliseconds + * @return true if all events were processed, false if timeout occurred + */ + public boolean waitForEventProcessing(long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + + // Wait until all queues are drained AND all in-flight handleEvent calls have returned. + // Checking only queue.isEmpty() is insufficient: workers remove events from the queue + // before calling handleEvent, so the queue can appear empty while processing is ongoing. + while (System.currentTimeMillis() < deadline) { + boolean allQueuesEmpty = handlerQueues.values().stream().allMatch(BlockingQueue::isEmpty); + if (allQueuesEmpty && inFlightCount.get() == 0) { + return true; + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + return false; + } + + /** + * Shuts down the executor service and cleans up resources. + * Should be called when the test EventAdmin is no longer needed. + */ + public void shutdown() { + // Cancel all handler workers + for (Future worker : handlerWorkers.values()) { + worker.cancel(true); + } + handlerWorkers.clear(); + + // Shutdown executor service + asyncExecutor.shutdown(); + try { + if (!asyncExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + asyncExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + asyncExecutor.shutdownNow(); + } + + handlers.clear(); + handlerQueues.clear(); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestRequestTracer.java b/services/src/test/java/org/apache/unomi/services/impl/TestRequestTracer.java new file mode 100644 index 000000000..5ce792a79 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestRequestTracer.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.conditions.Condition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Lightweight no-op tracer for unit tests. Does not depend on the tracing modules + * (added in UNOMI-873); sufficient for {@link TestConditionEvaluators}. + */ +public class TestRequestTracer { + private static final Logger logger = LoggerFactory.getLogger(TestRequestTracer.class); + + private final List traces = Collections.synchronizedList(new ArrayList<>()); + private final boolean logToConsole; + private volatile boolean enabled; + + public TestRequestTracer(boolean logToConsole) { + this.logToConsole = logToConsole; + this.enabled = logToConsole; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public void reset() { + traces.clear(); + } + + public List getTraces() { + return Collections.unmodifiableList(new ArrayList<>(traces)); + } + + public void clear() { + traces.clear(); + } + + public void startOperation(String operationType, String message, Condition condition) { + if (!enabled) { + return; + } + record("START " + operationType + ": " + message, condition); + } + + public void endOperation(boolean result, String message) { + if (!enabled) { + return; + } + record("END result=" + result + ": " + message, null); + } + + public void trace(String message, Condition condition) { + if (!enabled) { + return; + } + record(message, condition); + } + + public void trace(Condition condition, String message) { + trace(message, condition); + } + + public void startEvaluation(Condition condition, String message) { + Objects.requireNonNull(condition, "Condition cannot be null"); + if (enabled) { + record("Starting evaluation: " + message, condition); + } + } + + public void endEvaluation(Condition condition, boolean result, String message) { + Objects.requireNonNull(condition, "Condition cannot be null"); + if (enabled) { + record("Evaluation completed: " + message + " - Result: " + result, condition); + } + } + + private void record(String message, Condition condition) { + String line = condition != null ? message + " [" + condition.getConditionTypeId() + "]" : message; + traces.add(line); + if (logToConsole) { + logger.debug(line); + } + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java new file mode 100644 index 000000000..82af4f434 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.tenants.ApiKey; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.api.tenants.TenantStatus; + +import javax.xml.bind.DatatypeConverter; +import java.security.SecureRandom; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +// Custom TenantService implementation for testing +public class TestTenantService implements TenantService { + private ThreadLocal currentTenantId = new ThreadLocal<>(); + private Map tenants = new ConcurrentHashMap<>(); + private ThreadLocal inSystemOperation = new ThreadLocal<>(); + private static final SecureRandom secureRandom = new SecureRandom(); + + public void setInSystemOperation(boolean inSystemOperation) { + this.inSystemOperation.set(inSystemOperation); + } + + public void clearInSystemOperation() { + this.inSystemOperation.remove(); + } + + public void setCurrentTenantId(String tenantId) { + currentTenantId.set(tenantId); + } + + public void clearCurrentTenantId() { + currentTenantId.remove(); + } + + @Override + public List getAllTenants() { + return new ArrayList<>(tenants.values()); + } + + @Override + public Tenant getTenant(String tenantId) { + return tenants.get(tenantId); + } + + @Override + public void saveTenant(Tenant tenant) { + if (tenant != null && tenant.getItemId() != null) { + tenants.put(tenant.getItemId(), tenant); + } + } + + @Override + public void deleteTenant(String tenantId) { + tenants.remove(tenantId); + } + + @Override + public boolean validateApiKey(String tenantId, String key) { + return validateApiKeyWithType(tenantId, key, null); + } + + @Override + public boolean validateApiKeyWithType(String tenantId, String key, ApiKey.ApiKeyType requiredType) { + Tenant tenant = getTenant(tenantId); + if (tenant == null) { + return false; + } + if (tenant.getApiKeys() == null) { + return false; + } + return tenant.getApiKeys().stream() + .anyMatch(apiKey -> apiKey.getKey().equals(key) && + !apiKey.isRevoked() && + (requiredType == null || apiKey.getKeyType() == requiredType) && + (apiKey.getExpirationDate() == null || apiKey.getExpirationDate().after(new Date()))); + } + + @Override + public Tenant createTenant(String tenantId, Map properties) { + Tenant tenant = new Tenant(); + tenant.setItemId(tenantId); + tenant.setProperties(properties != null ? properties : new HashMap<>()); + tenant.setStatus(TenantStatus.ACTIVE); + tenant.setCreationDate(new Date()); + tenant.setLastModificationDate(new Date()); + + saveTenant(tenant); + + // Generate both public and private API keys (consistent with TenantServiceImpl) + generateApiKeyWithType(tenant.getItemId(), ApiKey.ApiKeyType.PUBLIC, null); + generateApiKeyWithType(tenant.getItemId(), ApiKey.ApiKeyType.PRIVATE, null); + + // Return the updated tenant with API keys + return getTenant(tenant.getItemId()); + } + + @Override + public ApiKey generateApiKey(String tenantId, Long validityPeriod) { + return generateApiKeyWithType(tenantId, ApiKey.ApiKeyType.PUBLIC, validityPeriod); + } + + @Override + public ApiKey generateApiKeyWithType(String tenantId, ApiKey.ApiKeyType keyType, Long validityPeriod) { + ApiKey apiKey = new ApiKey(); + apiKey.setItemId(UUID.randomUUID().toString()); + String key = generateSecureKey(); + apiKey.setKey(key); + apiKey.setKeyType(keyType); + apiKey.setCreationDate(new Date()); + if (validityPeriod != null) { + apiKey.setExpirationDate(new Date(System.currentTimeMillis() + validityPeriod)); + } + + Tenant tenant = getTenant(tenantId); + if (tenant != null) { + // Remove any existing key of the same type + if (tenant.getApiKeys() == null) { + tenant.setApiKeys(new ArrayList<>()); + } + tenant.getApiKeys().removeIf(existingKey -> existingKey.getKeyType() == keyType); + tenant.getApiKeys().add(apiKey); + saveTenant(tenant); + } + + return apiKey; + } + + @Override + public Tenant getTenantByApiKey(String apiKey) { + return tenants.values().stream() + .filter(tenant -> tenant.getApiKeys() != null && + tenant.getApiKeys().stream() + .anyMatch(key -> key.getKey().equals(apiKey))) + .findFirst() + .orElse(null); + } + + @Override + public Tenant getTenantByApiKey(String apiKey, ApiKey.ApiKeyType keyType) { + return tenants.values().stream() + .filter(tenant -> tenant.getApiKeys() != null && + tenant.getApiKeys().stream() + .anyMatch(key -> key.getKey().equals(apiKey) && key.getKeyType() == keyType)) + .findFirst() + .orElse(null); + } + + @Override + public ApiKey getApiKey(String tenantId, ApiKey.ApiKeyType keyType) { + Tenant tenant = getTenant(tenantId); + if (tenant != null && tenant.getApiKeys() != null) { + return tenant.getApiKeys().stream() + .filter(key -> key.getKeyType() == keyType) + .findFirst() + .orElse(null); + } + return null; + } + + private String generateSecureKey() { + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + return DatatypeConverter.printHexBinary(randomBytes); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestTenantServiceTest.java b/services/src/test/java/org/apache/unomi/services/impl/TestTenantServiceTest.java new file mode 100644 index 000000000..8f10752b7 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TestTenantServiceTest.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.tenants.ApiKey; +import org.apache.unomi.api.tenants.Tenant; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Collections; +import java.util.List; + +/** + * Test for the TestTenantService to verify it works correctly with API key functionality. + */ +public class TestTenantServiceTest { + + @Test + public void testCreateTenantWithApiKeys() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant + Tenant tenant = tenantService.createTenant("test-tenant", Collections.emptyMap()); + + // Verify tenant was created + assertNotNull(tenant, "Tenant should not be null"); + assertEquals("test-tenant", tenant.getItemId(), "Tenant ID should match"); + + // Verify API keys were generated + assertNotNull(tenant.getApiKeys(), "API keys should not be null"); + assertEquals(2, tenant.getApiKeys().size(), "Should have 2 API keys (public and private)"); + + // Verify both public and private keys exist + List publicKeys = tenant.getActivePublicApiKeys(); + List privateKeys = tenant.getActivePrivateApiKeys(); + + assertEquals(1, publicKeys.size(), "Should have 1 public key"); + assertEquals(1, privateKeys.size(), "Should have 1 private key"); + + // Verify getters work + assertNotNull(tenant.getPublicApiKey(), "Public API key should be accessible"); + assertNotNull(tenant.getPrivateApiKey(), "Private API key should be accessible"); + } + + @Test + public void testGenerateApiKeyWithType() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant first + Tenant tenant = tenantService.createTenant("test-tenant", Collections.emptyMap()); + + // Generate a new public API key + ApiKey newPublicKey = tenantService.generateApiKeyWithType("test-tenant", ApiKey.ApiKeyType.PUBLIC, null); + + // Verify the new key was generated + assertNotNull(newPublicKey, "New API key should not be null"); + assertEquals(ApiKey.ApiKeyType.PUBLIC, newPublicKey.getKeyType(), "Key type should be PUBLIC"); + assertNotNull(newPublicKey.getKey(), "Key value should not be null"); + + // Reload tenant and verify the new key is there + Tenant reloadedTenant = tenantService.getTenant("test-tenant"); + assertEquals(1, reloadedTenant.getActivePublicApiKeys().size(), "Should still have 1 public key (replaced the old one)"); + assertEquals(1, reloadedTenant.getActivePrivateApiKeys().size(), "Should have 1 private key"); + } + + @Test + public void testValidateApiKey() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant + Tenant tenant = tenantService.createTenant("test-tenant", Collections.emptyMap()); + String publicKey = tenant.getPublicApiKey(); + String privateKey = tenant.getPrivateApiKey(); + + // Verify API key validation works + assertTrue(tenantService.validateApiKey("test-tenant", publicKey), "Public API key should be valid"); + assertTrue(tenantService.validateApiKey("test-tenant", privateKey), "Private API key should be valid"); + assertFalse(tenantService.validateApiKey("test-tenant", "invalid-key"), "Invalid API key should not be valid"); + assertFalse(tenantService.validateApiKey("non-existent", publicKey), "Non-existent tenant should not be valid"); + } + + @Test + public void testValidateApiKeyWithType() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant + Tenant tenant = tenantService.createTenant("test-tenant", Collections.emptyMap()); + String publicKey = tenant.getPublicApiKey(); + String privateKey = tenant.getPrivateApiKey(); + + // Verify type-specific validation works + assertTrue(tenantService.validateApiKeyWithType("test-tenant", publicKey, ApiKey.ApiKeyType.PUBLIC), + "Public API key should be valid for PUBLIC type"); + assertTrue(tenantService.validateApiKeyWithType("test-tenant", privateKey, ApiKey.ApiKeyType.PRIVATE), + "Private API key should be valid for PRIVATE type"); + assertFalse(tenantService.validateApiKeyWithType("test-tenant", publicKey, ApiKey.ApiKeyType.PRIVATE), + "Public API key should not be valid for PRIVATE type"); + assertFalse(tenantService.validateApiKeyWithType("test-tenant", privateKey, ApiKey.ApiKeyType.PUBLIC), + "Private API key should not be valid for PUBLIC type"); + } + + @Test + public void testGetTenantByApiKey() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant + Tenant tenant = tenantService.createTenant("test-tenant", Collections.emptyMap()); + String publicKey = tenant.getPublicApiKey(); + + // Verify tenant lookup by API key works + Tenant foundTenant = tenantService.getTenantByApiKey(publicKey); + assertNotNull(foundTenant, "Should find tenant by API key"); + assertEquals("test-tenant", foundTenant.getItemId(), "Found tenant should match"); + + // Verify type-specific lookup works + Tenant foundTenantByType = tenantService.getTenantByApiKey(publicKey, ApiKey.ApiKeyType.PUBLIC); + assertNotNull(foundTenantByType, "Should find tenant by API key and type"); + assertEquals("test-tenant", foundTenantByType.getItemId(), "Found tenant should match"); + + // Verify non-existent key returns null + assertNull(tenantService.getTenantByApiKey("invalid-key"), "Non-existent key should return null"); + } + + @Test + public void testGetApiKey() { + TestTenantService tenantService = new TestTenantService(); + + // Create a tenant + tenantService.createTenant("test-tenant", Collections.emptyMap()); + + // Verify API key retrieval works + ApiKey publicKey = tenantService.getApiKey("test-tenant", ApiKey.ApiKeyType.PUBLIC); + ApiKey privateKey = tenantService.getApiKey("test-tenant", ApiKey.ApiKeyType.PRIVATE); + + assertNotNull(publicKey, "Should retrieve public API key"); + assertNotNull(privateKey, "Should retrieve private API key"); + assertEquals(ApiKey.ApiKeyType.PUBLIC, publicKey.getKeyType(), "Public key type should be PUBLIC"); + assertEquals(ApiKey.ApiKeyType.PRIVATE, privateKey.getKeyType(), "Private key type should be PRIVATE"); + + // Verify non-existent tenant returns null + assertNull( + tenantService.getApiKey("non-existent", ApiKey.ApiKeyType.PUBLIC), + "Non-existent tenant should return null"); + } +} \ No newline at end of file diff --git a/services/src/test/java/org/apache/unomi/services/impl/cache/MultiTypeCacheServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/cache/MultiTypeCacheServiceImplTest.java new file mode 100644 index 000000000..49e97b40e --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/cache/MultiTypeCacheServiceImplTest.java @@ -0,0 +1,663 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.cache; + +import org.apache.unomi.api.services.cache.CacheableTypeConfig; +import org.apache.unomi.api.services.cache.MultiTypeCacheService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.Serializable; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class MultiTypeCacheServiceImplTest { + + private static final String SYSTEM_TENANT = "system"; + private static final String TEST_TENANT = "test-tenant"; + private static final String TEST_TYPE = "test-type"; + private static final String TEST_ID = "test-id"; + + private MultiTypeCacheServiceImpl cacheService; + + private static class TestSerializable implements Serializable { + private String id; + + public TestSerializable(String id) { + this.id = id; + } + + public String getId() { + return id; + } + } + + private static class OtherTestSerializable implements Serializable { + private String id; + + public OtherTestSerializable(String id) { + this.id = id; + } + + public String getId() { + return id; + } + } + + @BeforeEach + public void setUp() { + cacheService = new MultiTypeCacheServiceImpl(); + } + + @Test + public void testRegisterType() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + + // Register type + cacheService.registerType(config); + + // Put a value and verify it's cached + TestSerializable value = new TestSerializable(TEST_ID); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, value); + + // Verify value can be retrieved + TestSerializable retrieved = cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + assertNotNull(retrieved, "Retrieved value should not be null"); + assertEquals(value.getId(), retrieved.getId(), "Retrieved value should match original"); + } + + @Test + public void testInheritanceFromSystemTenant() { + // Create test configuration with inheritance enabled + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put a value in system tenant + TestSerializable systemValue = new TestSerializable("system-value"); + cacheService.put(TEST_TYPE, TEST_ID, SYSTEM_TENANT, systemValue); + + // Verify value can be retrieved from another tenant + TestSerializable retrieved = cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + assertNotNull(retrieved, "Retrieved value should not be null"); + assertEquals(systemValue.getId(), retrieved.getId(), "Retrieved value should match system tenant value"); + + // Put a tenant-specific value + TestSerializable tenantValue = new TestSerializable("tenant-value"); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, tenantValue); + + // Verify tenant-specific value overrides system tenant + retrieved = cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + assertNotNull(retrieved, "Retrieved value should not be null"); + assertEquals(tenantValue.getId(), retrieved.getId(), "Retrieved value should match tenant value"); + } + + @Test + public void testGetValuesByPredicateWithInheritance() { + // Create test configuration with inheritance enabled + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put values in system tenant + TestSerializable systemValue1 = new TestSerializable("system1"); + TestSerializable systemValue2 = new TestSerializable("system2"); + cacheService.put(TEST_TYPE, "system1", SYSTEM_TENANT, systemValue1); + cacheService.put(TEST_TYPE, "system2", SYSTEM_TENANT, systemValue2); + + // Put values in test tenant + TestSerializable tenantValue1 = new TestSerializable("tenant1"); + TestSerializable tenantValue2 = new TestSerializable("tenant2"); + cacheService.put(TEST_TYPE, "tenant1", TEST_TENANT, tenantValue1); + cacheService.put(TEST_TYPE, "tenant2", TEST_TENANT, tenantValue2); + + // Get values by predicate + Set values = cacheService.getValuesByPredicateWithInheritance( + TEST_TENANT, + TestSerializable.class, + value -> value.getId().startsWith("system") + ); + + assertEquals(2, values.size(), "Should find 2 values matching predicate"); + assertTrue(values.contains(systemValue1), "Should contain system values"); + assertTrue(values.contains(systemValue2), "Should contain system values"); + } + + @Test + public void testStatistics() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put a value + TestSerializable value = new TestSerializable(TEST_ID); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, value); + + // Get the value (hit) + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + + // Try to get non-existent value (miss) + cacheService.getWithInheritance("non-existent", TEST_TENANT, TestSerializable.class); + + // Verify statistics + MultiTypeCacheService.CacheStatistics stats = cacheService.getStatistics(); + MultiTypeCacheService.CacheStatistics.TypeStatistics typeStats = stats.getAllStats().get(TEST_TYPE); + + assertNotNull(typeStats, "Type statistics should exist"); + assertEquals(1, typeStats.getHits(), "Should have 1 hit"); + assertEquals(1, typeStats.getMisses(), "Should have 1 miss"); + assertEquals(1, typeStats.getUpdates(), "Should have 1 update"); + + // Reset statistics + stats.reset(); + assertTrue(stats.getAllStats().isEmpty(), "Statistics should be empty after reset"); + } + + @Test + public void testClearTenantCache() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put values in different tenants + TestSerializable value1 = new TestSerializable("value1"); + TestSerializable value2 = new TestSerializable("value2"); + cacheService.put(TEST_TYPE, "value1", TEST_TENANT, value1); + cacheService.put(TEST_TYPE, "value2", SYSTEM_TENANT, value2); + + // Clear test tenant cache + cacheService.clear(TEST_TENANT); + + // Verify test tenant value is gone but system tenant value remains + assertNull( + cacheService.getWithInheritance("value1", TEST_TENANT, TestSerializable.class), + "Test tenant value should be cleared"); + assertNotNull( + cacheService.getWithInheritance("value2", SYSTEM_TENANT, TestSerializable.class), + "System tenant value should remain"); + } + + @Test + public void testRemoveValue() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put a value + TestSerializable value = new TestSerializable(TEST_ID); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, value); + + // Remove the value + cacheService.remove(TEST_TYPE, TEST_ID, TEST_TENANT, TestSerializable.class); + + // Verify value is removed + assertNull( + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class), + "Value should be removed"); + } + + @Test + public void testNullParameters() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Test null parameters for put + cacheService.put(null, TEST_ID, TEST_TENANT, new TestSerializable(TEST_ID)); + cacheService.put(TEST_TYPE, null, TEST_TENANT, new TestSerializable(TEST_ID)); + cacheService.put(TEST_TYPE, TEST_ID, null, new TestSerializable(TEST_ID)); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, null); + + // Verify no values were cached + assertNull( + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class), + "No value should be cached with null parameters"); + + // Test null parameters for get + assertNull( + cacheService.getWithInheritance(null, TEST_TENANT, TestSerializable.class), + "Get with null ID should return null"); + assertNull( + cacheService.getWithInheritance(TEST_ID, null, TestSerializable.class), + "Get with null tenant should return null"); + assertNull( + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, null), + "Get with null type should return null"); + } + + @Test + public void testGetTenantCache() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put multiple values + TestSerializable value1 = new TestSerializable("value1"); + TestSerializable value2 = new TestSerializable("value2"); + cacheService.put(TEST_TYPE, "value1", TEST_TENANT, value1); + cacheService.put(TEST_TYPE, "value2", TEST_TENANT, value2); + + // Get tenant cache + Map tenantCache = cacheService.getTenantCache(TEST_TENANT, TestSerializable.class); + assertNotNull(tenantCache, "Tenant cache should not be null"); + assertEquals(2, tenantCache.size(), "Tenant cache should contain 2 values"); + assertEquals(value1.getId(), tenantCache.get("value1").getId(), "Value1 should match"); + assertEquals(value2.getId(), tenantCache.get("value2").getId(), "Value2 should match"); + + // Verify cache is unmodifiable + try { + tenantCache.put("value3", new TestSerializable("value3")); + fail("Tenant cache should be unmodifiable"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void testUnregisteredType() { + // Try to get value for unregistered type + assertNull( + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class), + "Get with unregistered type should return null"); + + // Try to put value for unregistered type + TestSerializable value = new TestSerializable(TEST_ID); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, value); + + // Verify value was not cached + assertNull( + cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class), + "Value should not be cached for unregistered type"); + } + + @Test + public void testRefreshTypeCache() { + // Create test configuration with refresh required + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put a value + TestSerializable value = new TestSerializable(TEST_ID); + cacheService.put(TEST_TYPE, TEST_ID, TEST_TENANT, value); + + // Refresh cache + cacheService.refreshTypeCache(config); + + // Verify statistics + MultiTypeCacheService.CacheStatistics.TypeStatistics typeStats = + cacheService.getStatistics().getAllStats().get(TEST_TYPE); + assertEquals(0, typeStats.getIndexingErrors(), "Should have no indexing errors"); + } + + @Test + public void testInvalidRefreshTypeCache() { + // Create test configuration with refresh disabled + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(false) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + + // Try to refresh with disabled config + cacheService.refreshTypeCache(config); + + // Verify no statistics were created + assertTrue( + cacheService.getStatistics().getAllStats().isEmpty(), + "No statistics should be created"); + } + + @Test + public void testInheritanceDisabled() { + // Create test configuration with inheritance disabled + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(false) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put a value in system tenant + TestSerializable systemValue = new TestSerializable("system-value"); + cacheService.put(TEST_TYPE, TEST_ID, SYSTEM_TENANT, systemValue); + + // Verify value cannot be retrieved from another tenant when inheritance is disabled + TestSerializable retrieved = cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + assertNull(retrieved, "Should not inherit from system tenant when inheritance is disabled"); + + // Verify statistics + MultiTypeCacheService.CacheStatistics.TypeStatistics typeStats = + cacheService.getStatistics().getAllStats().get(TEST_TYPE); + assertEquals(1, typeStats.getMisses(), "Should have 1 miss"); + assertEquals(0, typeStats.getHits(), "Should have no hits"); + } + + @Test + public void testMultipleTypeConfigurations() { + // typeConfigs is keyed by Java class, so two different Java types can coexist independently. + // Using the same Java class for two configs would overwrite the first (tested in testOverrideTypeConfiguration). + CacheableTypeConfig config1 = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + + CacheableTypeConfig config2 = CacheableTypeConfig.builder( + OtherTestSerializable.class, + "other-type", + "/other/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(OtherTestSerializable::getId) + .build(); + + cacheService.registerType(config1); + cacheService.registerType(config2); + + // Put values using the correct registered type names + TestSerializable value1 = new TestSerializable("value1"); + OtherTestSerializable value2 = new OtherTestSerializable("value2"); + cacheService.put(TEST_TYPE, "id1", TEST_TENANT, value1); + cacheService.put("other-type", "id2", TEST_TENANT, value2); + + // Each type's values are retrievable and isolated from the other type + TestSerializable retrieved1 = cacheService.getWithInheritance("id1", TEST_TENANT, TestSerializable.class); + OtherTestSerializable retrieved2 = cacheService.getWithInheritance("id2", TEST_TENANT, OtherTestSerializable.class); + + assertNotNull(retrieved1, "TestSerializable value should be cached"); + assertEquals("value1", retrieved1.getId(), "TestSerializable value should match"); + assertNotNull(retrieved2, "OtherTestSerializable value should be cached"); + assertEquals("value2", retrieved2.getId(), "OtherTestSerializable value should match"); + + // id2 is not accessible via TestSerializable, and id1 is not accessible via OtherTestSerializable + assertNull(cacheService.getWithInheritance("id2", TEST_TENANT, TestSerializable.class), + "id2 should not be accessible via TestSerializable"); + assertNull(cacheService.getWithInheritance("id1", TEST_TENANT, OtherTestSerializable.class), + "id1 should not be accessible via OtherTestSerializable"); + } + + @Test + public void testPredicateWithNullValues() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put some values including null IDs + TestSerializable value1 = new TestSerializable(null); // null ID + TestSerializable value2 = new TestSerializable("value2"); + cacheService.put(TEST_TYPE, "id1", TEST_TENANT, value1); + cacheService.put(TEST_TYPE, "id2", TEST_TENANT, value2); + + // Get values with predicate that handles null + Set values = cacheService.getValuesByPredicateWithInheritance( + TEST_TENANT, + TestSerializable.class, + value -> value.getId() == null || value.getId().equals("value2") + ); + + assertEquals(2, values.size(), "Should find 2 values matching predicate"); + } + + @Test + public void testConcurrentAccess() throws InterruptedException { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Create multiple threads to access cache concurrently + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + threads[i] = new Thread(() -> { + // Each thread puts and gets its own values + TestSerializable value = new TestSerializable("value" + threadId); + cacheService.put(TEST_TYPE, "id" + threadId, TEST_TENANT, value); + cacheService.getWithInheritance("id" + threadId, TEST_TENANT, TestSerializable.class); + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // Verify statistics + MultiTypeCacheService.CacheStatistics.TypeStatistics typeStats = + cacheService.getStatistics().getAllStats().get(TEST_TYPE); + assertEquals(threadCount, typeStats.getUpdates(), "Should have correct number of updates"); + assertEquals(threadCount, typeStats.getHits(), "Should have correct number of hits"); + } + + @Test + public void testOverrideTypeConfiguration() { + // Original configuration + CacheableTypeConfig config1 = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + + // Override configuration + CacheableTypeConfig config2 = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/other/path") + .withInheritFromSystemTenant(false) + .withRequiresRefresh(false) + .withRefreshInterval(2000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config1); + cacheService.registerType(config2); + + // Put a value in system tenant + TestSerializable systemValue = new TestSerializable("system-value"); + cacheService.put(TEST_TYPE, TEST_ID, SYSTEM_TENANT, systemValue); + + // Verify inheritance is disabled as per new configuration + TestSerializable retrieved = cacheService.getWithInheritance(TEST_ID, TEST_TENANT, TestSerializable.class); + assertNull(retrieved, "Should not inherit from system tenant with new configuration"); + } + + @Test + public void testEmptyPredicateResults() { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Put some values + TestSerializable value1 = new TestSerializable("value1"); + TestSerializable value2 = new TestSerializable("value2"); + cacheService.put(TEST_TYPE, "id1", TEST_TENANT, value1); + cacheService.put(TEST_TYPE, "id2", TEST_TENANT, value2); + + // Get values with predicate that matches nothing + Set values = cacheService.getValuesByPredicateWithInheritance( + TEST_TENANT, + TestSerializable.class, + value -> false + ); + + assertTrue(values.isEmpty(), "Should return empty set when no values match predicate"); + } + + @Test + public void testStatisticsThreadSafety() throws InterruptedException { + // Create test configuration + CacheableTypeConfig config = CacheableTypeConfig.builder( + TestSerializable.class, + TEST_TYPE, + "/test/path") + .withInheritFromSystemTenant(true) + .withRequiresRefresh(true) + .withRefreshInterval(1000L) + .withIdExtractor(TestSerializable::getId) + .build(); + cacheService.registerType(config); + + // Create threads to update statistics concurrently + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + threads[i] = new Thread(() -> { + for (int j = 0; j < 100; j++) { + TestSerializable value = new TestSerializable("value"); + cacheService.put(TEST_TYPE, "id", TEST_TENANT, value); + cacheService.getWithInheritance("id", TEST_TENANT, TestSerializable.class); + cacheService.getWithInheritance("nonexistent", TEST_TENANT, TestSerializable.class); + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // Verify statistics + MultiTypeCacheService.CacheStatistics.TypeStatistics typeStats = + cacheService.getStatistics().getAllStats().get(TEST_TYPE); + assertEquals(threadCount * 100, typeStats.getUpdates(), "Should have correct number of updates"); + assertEquals(threadCount * 100, typeStats.getHits(), "Should have correct number of hits"); + assertEquals(threadCount * 100, typeStats.getMisses(), "Should have correct number of misses"); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java new file mode 100644 index 000000000..4836f6a81 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java @@ -0,0 +1,431 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.cluster; + +import org.apache.unomi.api.ClusterNode; +import org.apache.unomi.api.ServerInfo; +import org.apache.unomi.lifecycle.BundleWatcher; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl; +import org.apache.unomi.services.impl.TestConditionEvaluators; +import org.apache.unomi.services.impl.TestTenantService; +import org.apache.unomi.services.impl.scheduler.SchedulerServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.osgi.framework.BundleContext; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.*; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ClusterServiceImplTest { + + private ClusterServiceImpl clusterService; + private TestTenantService tenantService; + private PersistenceService persistenceService; + private ExecutionContextManagerImpl executionContextManager; + private KarafSecurityService securityService; + private SchedulerServiceImpl schedulerService; + + @Mock + private BundleContext bundleContext; + + // Add mock for BundleWatcher + @Mock + private BundleWatcher bundleWatcher; + + private static final String TEST_NODE_ID = "test-node-1"; + private static final String PUBLIC_ADDRESS = "http://localhost:8181"; + private static final String INTERNAL_ADDRESS = "https://localhost:9443"; + private static final long NODE_STATISTICS_UPDATE_FREQUENCY = 10000; + + @BeforeEach + public void setUp() { + // Initialize tenant service + tenantService = new TestTenantService(); + + // Create tenants using TestHelper + TestHelper.setupCommonTestData(tenantService); + + // Set up condition evaluator dispatcher + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + + // Set up bundle context using TestHelper + bundleContext = TestHelper.createMockBundleContext(); + + // Set up security service and context manager + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + + // Set up persistence service — no refresh-delay simulation: cluster tests verify + // cluster logic, not persistence refresh semantics (tested in InMemoryPersistenceServiceImplTest). + persistenceService = new InMemoryPersistenceServiceImpl( + executionContextManager, conditionEvaluatorDispatcher, + System.getProperty("java.io.tmpdir"), false, false, false, false, 0L); + + // Create cluster service using TestHelper + clusterService = TestHelper.createClusterService(persistenceService, TEST_NODE_ID, PUBLIC_ADDRESS, INTERNAL_ADDRESS, bundleContext); + + // Configure cluster service (additional configurations not covered by helper method) + clusterService.setNodeStatisticsUpdateFrequency(NODE_STATISTICS_UPDATE_FREQUENCY); + + // Create scheduler service using TestHelper + schedulerService = TestHelper.createSchedulerService( + "cluster-scheduler-node", + persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + + // Set scheduler in cluster service - this would normally be done by OSGi but we need to do it manually in tests + clusterService.setSchedulerService(schedulerService); + + // Explicitly initialize scheduled tasks to handle the circular dependency properly + clusterService.initializeScheduledTasks(); + } + + @Test + public void testInitRegistersNodeInPersistence() { + // Setup mock BundleWatcher to return a ServerInfo + ServerInfo mockServerInfo = new ServerInfo(); + mockServerInfo.setServerIdentifier("test-server"); + mockServerInfo.setServerVersion("1.0.0"); + mockServerInfo.setServerBuildNumber("123"); + mockServerInfo.setServerBuildDate(new Date()); + mockServerInfo.setServerTimestamp("20250314120000"); + mockServerInfo.setServerScmBranch("main"); + + when(bundleWatcher.getServerInfos()).thenReturn(Collections.singletonList(mockServerInfo)); + + // Set the BundleWatcher in the ClusterService + clusterService.setBundleWatcher(bundleWatcher); + + // Execute + clusterService.init(); + + // Verify node was saved in persistence + ClusterNode savedNode = persistenceService.load(TEST_NODE_ID, ClusterNode.class); + assertNotNull(savedNode); + assertEquals(TEST_NODE_ID, savedNode.getItemId()); + assertEquals(PUBLIC_ADDRESS, savedNode.getPublicHostAddress()); + assertEquals(INTERNAL_ADDRESS, savedNode.getInternalHostAddress()); + assertNotNull(savedNode.getStartTime()); + assertNotNull(savedNode.getLastHeartbeat()); + assertNotNull(savedNode.getCpuLoad()); + assertNotNull(savedNode.getUptime()); + assertNotNull(savedNode.getLoadAverage()); + + // Verify ServerInfo was set correctly + assertNotNull(savedNode.getServerInfo(), "ServerInfo should be set from BundleWatcher"); + assertEquals(mockServerInfo.getServerIdentifier(), savedNode.getServerInfo().getServerIdentifier()); + assertEquals(mockServerInfo.getServerVersion(), savedNode.getServerInfo().getServerVersion()); + assertEquals(mockServerInfo.getServerBuildNumber(), savedNode.getServerInfo().getServerBuildNumber()); + assertEquals(mockServerInfo.getServerBuildDate(), savedNode.getServerInfo().getServerBuildDate()); + assertEquals(mockServerInfo.getServerTimestamp(), savedNode.getServerInfo().getServerTimestamp()); + assertEquals(mockServerInfo.getServerScmBranch(), savedNode.getServerInfo().getServerScmBranch()); + } + + // Add a new test to verify behavior when BundleWatcher is not available + @Test + public void testInitRegistersNodeInPersistenceWithoutBundleWatcher() { + // Execute without setting a BundleWatcher + clusterService.init(); + + // Verify node was saved in persistence + ClusterNode savedNode = persistenceService.load(TEST_NODE_ID, ClusterNode.class); + assertNotNull(savedNode); + assertEquals(TEST_NODE_ID, savedNode.getItemId()); + + // Server info should be null since we don't have a BundleWatcher set + assertNull(savedNode.getServerInfo(), "ServerInfo should be null when BundleWatcher is not available"); + } + + @Test + public void testInitSchedulesStatisticsUpdateTask() { + // We need to use a mock scheduler service for this test to verify the task creation + SchedulerServiceImpl mockSchedulerService = mock(SchedulerServiceImpl.class); + clusterService.setSchedulerService(mockSchedulerService); + + // Execute + clusterService.init(); + + // Verify + verify(mockSchedulerService).createRecurringTask( + eq("clusterNodeStatisticsUpdate"), + eq(NODE_STATISTICS_UPDATE_FREQUENCY), + eq(TimeUnit.MILLISECONDS), + any(TimerTask.class), + eq(false) + ); + } + + @Test + public void testInitSchedulesStaleNodesCleanupTask() { + // We need to use a mock scheduler service for this test to verify the task creation + SchedulerServiceImpl mockSchedulerService = mock(SchedulerServiceImpl.class); + clusterService.setSchedulerService(mockSchedulerService); + + // Execute + clusterService.init(); + + // Verify + verify(mockSchedulerService).createRecurringTask( + eq("clusterStaleNodesCleanup"), + eq(60000L), + eq(TimeUnit.MILLISECONDS), + any(TimerTask.class), + eq(false) + ); + } + + @Test + public void testInitWithoutNodeIdThrowsException() { + // Setup + ClusterServiceImpl serviceWithoutNodeId = new ClusterServiceImpl(); + serviceWithoutNodeId.setPersistenceService(persistenceService); + serviceWithoutNodeId.setPublicAddress(PUBLIC_ADDRESS); + serviceWithoutNodeId.setInternalAddress(INTERNAL_ADDRESS); + serviceWithoutNodeId.setSchedulerService(schedulerService); + + // Execute and verify + IllegalStateException exception = assertThrows(IllegalStateException.class, + serviceWithoutNodeId::init); + assertTrue(exception.getMessage().contains("nodeId is not set")); + } + + @Test + public void testDestroyRemovesNodeFromPersistence() { + // Setup - first initialize to create the node + clusterService.init(); + + // Verify node exists + assertNotNull(persistenceService.load(TEST_NODE_ID, ClusterNode.class)); + + // Execute + clusterService.destroy(); + + // Verify node was removed - retry to handle race condition with scheduled tasks + // The updateSystemStats scheduled task might re-register the node if it's running + // concurrently with destroy(), so we retry until the node is actually removed + ClusterNode node = TestHelper.retryUntil( + () -> persistenceService.load(TEST_NODE_ID, ClusterNode.class), + n -> n == null + ); + + assertNull(node, "Node should be removed from persistence after destroy(), nodeId=" + TEST_NODE_ID); + } + + @Test + public void testGetClusterNodesReturnsAllNodes() { + // Setup - create multiple nodes + ClusterNode node1 = new ClusterNode(); + node1.setItemId(TEST_NODE_ID); + node1.setPublicHostAddress(PUBLIC_ADDRESS); + node1.setInternalHostAddress(INTERNAL_ADDRESS); + node1.setStartTime(System.currentTimeMillis()); + node1.setLastHeartbeat(System.currentTimeMillis()); + persistenceService.save(node1); + + ClusterNode node2 = new ClusterNode(); + node2.setItemId("test-node-2"); + node2.setPublicHostAddress("http://localhost:8182"); + node2.setInternalHostAddress("https://localhost:9444"); + node2.setStartTime(System.currentTimeMillis()); + node2.setLastHeartbeat(System.currentTimeMillis()); + persistenceService.save(node2); + + // Refresh persistence to ensure nodes are available for querying (handles refresh delay) + persistenceService.refresh(); + + // Force a cache refresh: updateSystemStats() queries persistence and populates + // cachedClusterNodes. The scheduled task runs every 10 s, so we invoke it directly. + try { + Method updateSystemStats = ClusterServiceImpl.class.getDeclaredMethod("updateSystemStats"); + updateSystemStats.setAccessible(true); + updateSystemStats.invoke(clusterService); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to invoke updateSystemStats via reflection", e); + } + + List result = clusterService.getClusterNodes(); + + // Verify + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(node -> node.getItemId().equals(TEST_NODE_ID))); + assertTrue(result.stream().anyMatch(node -> node.getItemId().equals("test-node-2"))); + } + + @Test + public void testUpdateSystemStats() { + // Setup - initialize the service to create the node (this also records an initial heartbeat) + clusterService.init(); + + // Get the initial heartbeat timestamp recorded at init time + ClusterNode initialNode = persistenceService.load(TEST_NODE_ID, ClusterNode.class); + assertNotNull(initialNode, "Node should be registered after init"); + long initialHeartbeat = initialNode.getLastHeartbeat(); + + // Wait for the scheduler's statistics update task to run and write a newer heartbeat. + // The task runs on the configured nodeStatisticsUpdateFrequency; retryUntil polls until + // the persisted heartbeat is strictly greater than the one captured at init. + ClusterNode updatedNode = TestHelper.retryUntil( + () -> persistenceService.load(TEST_NODE_ID, ClusterNode.class), + node -> node != null && node.getLastHeartbeat() > initialHeartbeat + ); + + assertNotNull(updatedNode); + assertTrue(updatedNode.getLastHeartbeat() > initialHeartbeat, + "Heartbeat should be updated: initial=" + initialHeartbeat + ", updated=" + updatedNode.getLastHeartbeat()); + + // Verify node statistics are updated in memory + Map> nodeStats = clusterService.getNodeSystemStatistics(); + assertNotNull(nodeStats); + assertTrue(nodeStats.containsKey(TEST_NODE_ID)); + + Map stats = nodeStats.get(TEST_NODE_ID); + assertNotNull(stats.get("systemCpuLoad")); + assertNotNull(stats.get("uptime")); + assertNotNull(stats.get("systemLoadAverage")); + } + + @Test + public void testCleanupStaleNodes() { + // Setup - create a stale node + long cutoffTime = System.currentTimeMillis() - (NODE_STATISTICS_UPDATE_FREQUENCY * 3); + + // Save nodes in system context to ensure proper tenant handling + executionContextManager.executeAsSystem(() -> { + ClusterNode staleNode = new ClusterNode(); + staleNode.setItemId("stale-node"); + staleNode.setPublicHostAddress("http://stale:8181"); + staleNode.setInternalHostAddress("https://stale:9443"); + staleNode.setStartTime(cutoffTime - 60000); + staleNode.setLastHeartbeat(cutoffTime - 10000); // Older than cutoff + persistenceService.save(staleNode); + + // Create a fresh node + ClusterNode freshNode = new ClusterNode(); + freshNode.setItemId("fresh-node"); + freshNode.setPublicHostAddress("http://fresh:8181"); + freshNode.setInternalHostAddress("https://fresh:9443"); + freshNode.setStartTime(System.currentTimeMillis() - 60000); + freshNode.setLastHeartbeat(System.currentTimeMillis()); // Recent heartbeat + persistenceService.save(freshNode); + return null; + }); + + // Refresh persistence to ensure nodes are available for querying (handles refresh delay) + persistenceService.refresh(); + + // Verify both nodes exist - use retry to handle potential race conditions with scheduled cleanup task + ClusterNode staleNodeBeforeCleanup = TestHelper.retryUntil( + () -> persistenceService.load("stale-node", ClusterNode.class), + node -> node != null + ); + assertNotNull(staleNodeBeforeCleanup, "Stale node should exist before cleanup, nodeId=stale-node"); + + ClusterNode freshNodeBeforeCleanup = TestHelper.retryUntil( + () -> persistenceService.load("fresh-node", ClusterNode.class), + node -> node != null + ); + assertNotNull(freshNodeBeforeCleanup, "Fresh node should exist before cleanup, nodeId=fresh-node"); + + // Trigger the cleanup by running the scheduled task's logic directly. + // The cleanup scheduled task runs every 60 s (hardcoded) which is impractical in a unit test; + // we therefore invoke the private method synchronously to observe the same end-to-end + // persistence behaviour (query by lastHeartbeat, remove stale, keep fresh) without the wait. + assertDoesNotThrow(() -> { + Method cleanupMethod = ClusterServiceImpl.class.getDeclaredMethod("cleanupStaleNodes"); + cleanupMethod.setAccessible(true); + cleanupMethod.invoke(clusterService); + }, "cleanupStaleNodes should run without throwing"); + + // Verify stale node was removed but fresh node remains + assertNull(persistenceService.load("stale-node", ClusterNode.class), "Stale node should be removed"); + assertNotNull(persistenceService.load("fresh-node", ClusterNode.class), "Fresh node should remain"); + } + + @Test + public void testPurgeByDateDelegatesToPersistenceService() { + // Setup: create items with different creation dates in real persistence + executionContextManager.executeAsSystem(() -> { + ClusterNode oldNode = new ClusterNode(); + oldNode.setItemId("old-node"); + oldNode.setPublicHostAddress("http://old:8181"); + oldNode.setInternalHostAddress("https://old:9443"); + oldNode.setCreationDate(new Date(System.currentTimeMillis() - 7L * 24 * 3600 * 1000)); // 7 days ago + persistenceService.save(oldNode); + + ClusterNode recentNode = new ClusterNode(); + recentNode.setItemId("recent-node"); + recentNode.setPublicHostAddress("http://recent:8181"); + recentNode.setInternalHostAddress("https://recent:9443"); + recentNode.setCreationDate(new Date()); + persistenceService.save(recentNode); + }); + + // Purge items older than cutoff (between old and recent) + Date cutoff = new Date(System.currentTimeMillis() - 3L * 24 * 3600 * 1000); // 3 days ago + clusterService.purge(cutoff); + + // Verify: old node removed, recent node remains + assertNull(persistenceService.load("old-node", ClusterNode.class)); + assertNotNull(persistenceService.load("recent-node", ClusterNode.class)); + } + + @Test + public void testPurgeByScopeDelegatesToPersistenceService() { + // Setup: create two nodes with different scopes in real persistence + executionContextManager.executeAsSystem(() -> { + ClusterNode scopedNode = new ClusterNode(); + scopedNode.setItemId("scoped-node"); + scopedNode.setPublicHostAddress("http://scoped:8181"); + scopedNode.setInternalHostAddress("https://scoped:9443"); + scopedNode.setScope("testScope"); + persistenceService.save(scopedNode); + + ClusterNode otherNode = new ClusterNode(); + otherNode.setItemId("other-node"); + otherNode.setPublicHostAddress("http://other:8181"); + otherNode.setInternalHostAddress("https://other:9443"); + otherNode.setScope("otherScope"); + persistenceService.save(otherNode); + }); + + // Execute purge by scope + clusterService.purge("testScope"); + + // Verify: scoped node removed, other node remains + assertNull(persistenceService.load("scoped-node", ClusterNode.class)); + assertNotNull(persistenceService.load("other-node", ClusterNode.class)); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java new file mode 100644 index 000000000..1585e69c6 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/profiles/ProfileServiceImplTest.java @@ -0,0 +1,674 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.profiles; + +import org.apache.unomi.api.*; +import org.apache.unomi.api.services.SchedulerService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.AuditServiceImpl; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl; +import org.apache.unomi.services.impl.TestBundleContext; +import org.apache.unomi.services.impl.TestConditionEvaluators; +import org.apache.unomi.services.impl.TestTenantService; +import org.apache.unomi.services.impl.cache.MultiTypeCacheServiceImpl; +import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleEvent; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ProfileServiceImplTest { + + private ProfileServiceImpl profileService; + private TestTenantService tenantService; + private PersistenceService persistenceService; + private DefinitionsServiceImpl definitionsService; + private TestBundleContext bundleContext; + private ExecutionContextManagerImpl executionContextManager; + private MultiTypeCacheServiceImpl multiTypeCacheService; + private KarafSecurityService securityService; + private AuditServiceImpl auditService; + private SchedulerService schedulerService; + + private static final String TENANT_1 = "tenant1"; + private static final String SYSTEM_TENANT = "system"; + + @BeforeEach + public void setUp() { + bundleContext = new TestBundleContext(); + tenantService = new TestTenantService(); + + + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + + // Create tenants using TestHelper + TestHelper.setupCommonTestData(tenantService); + + // Set up condition evaluator dispatcher + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + + multiTypeCacheService = new MultiTypeCacheServiceImpl(); + + // Set up persistence service + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher); + + // Set up bundle context with predefined data + bundleContext = new TestBundleContext(); + Bundle systemBundle = mock(Bundle.class); + when(systemBundle.getBundleContext()).thenReturn(bundleContext); + when(systemBundle.getBundleId()).thenReturn(0L); + when(systemBundle.getSymbolicName()).thenReturn("org.apache.unomi.predefined"); + bundleContext.addBundle(systemBundle); + + // Create scheduler service using TestHelper + schedulerService = TestHelper.createSchedulerService("profile-service-scheduler-node", persistenceService, executionContextManager, bundleContext, null, -1, true, true); + + // Set up definitions service + definitionsService = TestHelper.createDefinitionService(persistenceService, bundleContext, schedulerService, multiTypeCacheService, executionContextManager, tenantService); + // Inject definitionsService into the dispatcher + TestHelper.injectDefinitionsServiceIntoDispatcher(conditionEvaluatorDispatcher, definitionsService); + + // Set up value types + ValueType stringType = new ValueType(); + stringType.setId("string"); + definitionsService.setValueType(stringType); + + // Create predefined property types + URL propertyTypesUrl = getClass().getResource("/META-INF/cxs/properties/predefined-properties.json"); + when(bundleContext.getBundle().findEntries("META-INF/cxs/properties", "*.json", true)) + .thenReturn(Collections.enumeration(Arrays.asList(propertyTypesUrl))); + + // Create predefined personas + URL personasUrl = getClass().getResource("/META-INF/cxs/personas/predefined-personas.json"); + when(bundleContext.getBundle().findEntries("META-INF/cxs/personas", "*.json", true)) + .thenReturn(Collections.enumeration(Arrays.asList(personasUrl))); + + // Set up profile service + profileService = new ProfileServiceImpl(); + profileService.setBundleContext(bundleContext); + profileService.setPersistenceService(persistenceService); + profileService.setDefinitionsService(definitionsService); + profileService.setContextManager(executionContextManager); + profileService.setSchedulerService(schedulerService); + profileService.setCacheService(multiTypeCacheService); + // Ensure tenantService is available for initial data loading + profileService.setTenantService(tenantService); + + + profileService.postConstruct(); + + // Load predefined data + profileService.bundleChanged(new BundleEvent(BundleEvent.STARTED, systemBundle)); + } + + @AfterEach + public void tearDown() throws Exception { + // Use the common tearDown method from TestHelper + TestHelper.tearDown( + schedulerService, + multiTypeCacheService, + persistenceService, + tenantService, + TENANT_1, SYSTEM_TENANT + ); + + // Clean up references using the helper method + TestHelper.cleanupReferences( + tenantService, securityService, executionContextManager, profileService, + persistenceService, definitionsService, bundleContext, schedulerService, + multiTypeCacheService, auditService + ); + } + + @Test + public void testPredefinedPropertyTypes() { + // Test + Collection result = profileService.getTargetPropertyTypes("profiles"); + + // Verify predefined properties exist + assertNotNull(result, "Predefined properties should be loaded for target=profiles (bundle=org.apache.unomi.predefined)"); + assertFalse(result.isEmpty(), "Predefined properties should not be empty (target=profiles)"); + + // Verify specific predefined property + Optional firstNameProp = result.stream() + .filter(p -> p.getItemId().equals("firstName")) + .findFirst(); + assertTrue(firstNameProp.isPresent(), "firstName property should exist in predefined properties (target=profiles)"); + assertEquals("string", firstNameProp.get().getValueTypeId(), "firstName should use string valueType"); + assertEquals("profiles", firstNameProp.get().getTarget(), "firstName should target 'profiles'"); + assertEquals(SYSTEM_TENANT, firstNameProp.get().getTenantId(), "firstName should belong to system tenant"); + } + + @Test + public void testPropertyTypeByTag_CurrentTenant() { + executionContextManager.executeAsTenant(TENANT_1, () -> { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.singleton("tag1"), Collections.emptySet()); + profileService.setPropertyType(propertyType); + + // Test + Set result = profileService.getPropertyTypeByTag("tag1"); + + // Verify + assertNotNull(result, "Property types by tag should return results (tenant=" + TENANT_1 + ", tag=tag1)"); + assertEquals(1, result.size(), "Exactly one property type should be returned for tag1 (tenant=" + TENANT_1 + ")"); + assertEquals(TENANT_1, result.iterator().next().getTenantId(), "Returned property should belong to current tenant"); + }); + } + + @Test + public void testPropertyTypeByTag_SystemTenant() { + // Setup + executionContextManager.executeAsSystem(() -> { + PropertyType systemPropertyType = createPropertyType("systemProp", "test", Collections.singleton("systemTag"), Collections.singleton("systemTag")); + profileService.setPropertyType(systemPropertyType); + return null; + }); + + // Test from tenant context + executionContextManager.executeAsTenant(TENANT_1, () -> { + Collection result = profileService.getPropertyTypeByTag("systemTag"); + + // Verify + assertNotNull(result, "System-tagged property should be visible from tenant context (tag=systemTag)"); + assertFalse(result.isEmpty(), "System-tagged property list should not be empty (tag=systemTag)"); + PropertyType foundType = result.iterator().next(); + assertEquals(SYSTEM_TENANT, foundType.getTenantId(), "Found property should belong to system tenant (tag=systemTag)"); + assertEquals("systemProp", foundType.getItemId(), "Found property id should match system property id"); + return null; + }); + } + + @Test + public void testPropertyTypeByTag_TenantOverride() { + // Setup system tenant property + executionContextManager.executeAsSystem(() -> { + PropertyType systemProperty = createPropertyType("prop1", "system-version", Collections.singleton("tag1"), Collections.emptySet()); + profileService.setPropertyType(systemProperty); + return null; + }); + + // Setup tenant property + executionContextManager.executeAsTenant(TENANT_1, () -> { + PropertyType tenantProperty = createPropertyType("prop1", "tenant-version", Collections.singleton("tag1"), Collections.emptySet()); + profileService.setPropertyType(tenantProperty); + + // Test + Set result = profileService.getPropertyTypeByTag("tag1"); + + // Verify + assertNotNull(result, "Property types by tag should include tenant override (tag=tag1, tenant=" + TENANT_1 + ")"); + assertEquals(1, result.size(), "Exactly one property type should be returned for tag1 after override"); + PropertyType resultProp = result.iterator().next(); + assertEquals(TENANT_1, resultProp.getTenantId(), "Overridden property should belong to tenant"); + assertEquals("tenant-version", resultProp.getMetadata().getName(), "Overridden property name should reflect tenant version"); + return null; + }); + } + + @Test + public void testPropertyTypeBySystemTag_CurrentTenant() { + executionContextManager.executeAsTenant(TENANT_1, () -> { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.emptySet(), Collections.singleton("systag1")); + profileService.setPropertyType(propertyType); + + // Test + Set result = profileService.getPropertyTypeBySystemTag("systag1"); + + // Verify + assertNotNull(result, "System tag lookup (systag1) should yield tenant-scoped property (tenant=" + TENANT_1 + ")"); + assertEquals(1, result.size(), "Exactly one property type should match system tag systag1 (tenant=" + TENANT_1 + ")"); + assertEquals(TENANT_1, result.iterator().next().getTenantId(), "Resolved property should belong to tenant"); + }); + } + + @Test + public void testPropertyTypeBySystemTag_SystemTenant() { + // Setup system tenant property + executionContextManager.executeAsSystem(() -> { + PropertyType systemProperty = createPropertyType("prop1", "test", Collections.emptySet(), Collections.singleton("systag1")); + profileService.setPropertyType(systemProperty); + return null; + }); + + // Test from tenant1 + executionContextManager.executeAsTenant(TENANT_1, () -> { + Set result = profileService.getPropertyTypeBySystemTag("systag1"); + + // Verify + assertNotNull(result, "System tag lookup (systag1) from tenant should include system property"); + assertEquals(1, result.size(), "Exactly one system property should match systag1"); + assertEquals(SYSTEM_TENANT, result.iterator().next().getTenantId(), "Resolved property should belong to system tenant"); + return null; + }); + } + + @Test + public void testTargetPropertyTypes_CurrentTenant() { + executionContextManager.executeAsTenant(TENANT_1, () -> { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.emptySet(), Collections.emptySet()); + propertyType.setTarget("profile"); + profileService.setPropertyType(propertyType); + + // Test + Collection result = profileService.getTargetPropertyTypes("profile"); + + // Verify + assertNotNull(result, "Target property types for 'profile' should include tenant property (tenant=" + TENANT_1 + ")"); + assertEquals(1, result.size(), "Exactly one tenant property should target 'profile'"); + assertEquals(TENANT_1, result.iterator().next().getTenantId(), "Returned property should belong to tenant"); + }); + } + + @Test + public void testTargetPropertyTypes_SystemTenant() { + // Setup system tenant property + executionContextManager.executeAsSystem(() -> { + PropertyType systemProperty = createPropertyType("prop1", "test", Collections.emptySet(), Collections.emptySet()); + systemProperty.setTarget("profile"); + profileService.setPropertyType(systemProperty); + return null; + }); + + // Test from tenant1 + executionContextManager.executeAsTenant(TENANT_1, () -> { + Collection result = profileService.getTargetPropertyTypes("profile"); + + // Verify + assertNotNull(result, "Target property types for 'profile' should include system property from tenant context"); + assertEquals(1, result.size(), "Exactly one system property should target 'profile'"); + assertEquals(SYSTEM_TENANT, result.iterator().next().getTenantId(), "Returned property should belong to system tenant"); + return null; + }); + } + + @Test + public void testTargetPropertyTypes_TenantOverride() { + // Setup system tenant property + executionContextManager.executeAsSystem(() -> { + PropertyType systemProperty = createPropertyType("prop1", "system-version", Collections.emptySet(), Collections.emptySet()); + systemProperty.setTarget("profile"); + profileService.setPropertyType(systemProperty); + return null; + }); + + // Setup tenant property and test + executionContextManager.executeAsTenant(TENANT_1, () -> { + PropertyType tenantProperty = createPropertyType("prop1", "tenant-version", Collections.emptySet(), Collections.emptySet()); + tenantProperty.setTarget("profile"); + profileService.setPropertyType(tenantProperty); + + // Test + Collection result = profileService.getTargetPropertyTypes("profile"); + + // Verify + assertNotNull(result, "Target property types for 'profile' should resolve tenant override"); + assertEquals(1, result.size(), "Exactly one property should target 'profile' for tenant override"); + PropertyType resultProp = result.iterator().next(); + assertEquals(TENANT_1, resultProp.getTenantId(), "Overridden property should belong to tenant"); + assertEquals("tenant-version", resultProp.getMetadata().getName(), "Overridden property name should reflect tenant version"); + return null; + }); + } + + @Test + public void testExistingProperties_WithTag() { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.singleton("tag1"), Collections.emptySet()); + propertyType.setTarget("profiles"); + profileService.setPropertyType(propertyType); + + // Add mapping + persistenceService.setPropertyMapping(propertyType, Profile.ITEM_TYPE); + + // Test + Set result = profileService.getExistingProperties("tag1", Profile.ITEM_TYPE); + + // Verify + assertNotNull(result, "Existing properties should include tag-matching mapping (tag=tag1, itemType=profile)"); + assertEquals(1, result.size(), "Exactly one existing property should be mapped for tag1"); + assertEquals("prop1", result.iterator().next().getItemId(), "Mapped property id should be 'prop1'"); + } + + @Test + public void testExistingProperties_WithSystemTag() { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.emptySet(), Collections.singleton("systag1")); + propertyType.setTarget("profiles"); + profileService.setPropertyType(propertyType); + + // Add mapping + Map> mapping = new HashMap<>(); + Map properties = new HashMap<>(); + properties.put("properties", Collections.singletonMap("prop1", Collections.emptyMap())); + mapping.put("properties", properties); + persistenceService.setPropertyMapping(propertyType, Profile.ITEM_TYPE); + + // Test + Set result = profileService.getExistingProperties("systag1", Profile.ITEM_TYPE, true); + + // Verify + assertNotNull(result, "Existing properties should include system-tag mapping when includeSystem=true"); + assertEquals(1, result.size(), "Exactly one existing property should be mapped for system tag"); + assertEquals("prop1", result.iterator().next().getItemId(), "Mapped property id should be 'prop1'"); + } + + @Test + public void testDeletePropertyType() { + // Setup + PropertyType propertyType = createPropertyType("prop1", "test", Collections.emptySet(), Collections.emptySet()); + profileService.setPropertyType(propertyType); + + // Test delete + boolean result = profileService.deletePropertyType("prop1"); + + // Verify + assertTrue(result, "deletePropertyType should return true for existing property id (prop1)"); + assertNull(persistenceService.load("prop1", PropertyType.class), "Property type should be removed from persistence (prop1)"); + } + + private PropertyType createPropertyType(String id, String target, Set tags, Set systemTags) { + PropertyType propertyType = new PropertyType(); + Metadata metadata = new Metadata(); + metadata.setId(id); + metadata.setName(target); + metadata.setTags(tags); + metadata.setSystemTags(systemTags); + propertyType.setMetadata(metadata); + propertyType.setTarget(target); + propertyType.setValueTypeId("string"); + return propertyType; + } + + @Test + public void testPersonaInheritance_CurrentTenant() { + executionContextManager.executeAsTenant(TENANT_1, () -> { + Persona persona = new Persona("test-persona"); + persistenceService.save(persona); + + // Test + Persona result = profileService.loadPersona("test-persona"); + + // Verify + assertNotNull(result); + assertEquals(TENANT_1, result.getTenantId()); + return null; + }); + } + + @Test + public void testPersonaInheritance_SystemTenant() { + // Setup + executionContextManager.executeAsSystem(() -> { + Persona persona = new Persona("test-persona"); + persistenceService.save(persona); + return null; + }); + + // Switch to tenant1 and test + executionContextManager.executeAsTenant(TENANT_1, () -> { + Persona result = profileService.loadPersona("test-persona"); + + // Verify + assertNotNull(result); + assertEquals(SYSTEM_TENANT, result.getTenantId()); + return null; + }); + } + + @Test + public void testPersonaInheritance_TenantOverride() { + // Setup system persona + executionContextManager.executeAsSystem(() -> { + Persona systemPersona = new Persona("test-persona"); + systemPersona.setProperty("version", "system"); + persistenceService.save(systemPersona); + return null; + }); + + // Setup tenant persona and test + executionContextManager.executeAsTenant(TENANT_1, () -> { + Persona tenantPersona = new Persona("test-persona"); + tenantPersona.setProperty("version", "tenant"); + persistenceService.save(tenantPersona); + + // Test + Persona result = profileService.loadPersona("test-persona"); + + // Verify + assertNotNull(result); + assertEquals(TENANT_1, result.getTenantId()); + assertEquals("tenant", result.getProperty("version")); + return null; + }); + } + + @Test + public void testPropertyTypeByTagInheritance_MergeResults() { + // Setup system properties + executionContextManager.executeAsSystem(() -> { + PropertyType systemOnlyProp = new PropertyType(); + systemOnlyProp.setMetadata(new Metadata("system-only-prop")); + systemOnlyProp.setTarget("profiles"); + profileService.setPropertyType(systemOnlyProp); + + PropertyType systemOverrideProp = new PropertyType(); + systemOverrideProp.setMetadata(new Metadata("override-prop")); + systemOverrideProp.setTarget("profiles"); + systemOverrideProp.getMetadata().setSystemTags(Collections.singleton("system")); + profileService.setPropertyType(systemOverrideProp); + return null; + }); + + // Setup tenant properties and test + executionContextManager.executeAsTenant(TENANT_1, () -> { + PropertyType tenantOverrideProp = new PropertyType(); + tenantOverrideProp.setMetadata(new Metadata("override-prop")); + tenantOverrideProp.setTarget("profiles"); + tenantOverrideProp.getMetadata().setSystemTags(Collections.singleton("tenant")); + profileService.setPropertyType(tenantOverrideProp); + + PropertyType tenantOnlyProp = new PropertyType(); + tenantOnlyProp.setMetadata(new Metadata("tenant-only-prop")); + tenantOnlyProp.setTarget("profiles"); + profileService.setPropertyType(tenantOnlyProp); + + // Test + Collection result = profileService.getTargetPropertyTypes("profiles"); + + // Verify + assertNotNull(result); + assertEquals(4, result.size()); // Should have 4 unique properties (3 test properties + firstName) + + Map resultMap = new HashMap<>(); + for (PropertyType prop : result) { + resultMap.put(prop.getMetadata().getId(), prop); + } + + assertTrue(resultMap.containsKey("system-only-prop")); + assertTrue(resultMap.containsKey("override-prop")); + assertTrue(resultMap.containsKey("tenant-only-prop")); + assertTrue(resultMap.containsKey("firstName")); // Predefined property + + // Verify the overridden property has tenant version + assertTrue(resultMap.get("override-prop").getMetadata().getSystemTags().contains("tenant")); + return null; + }); + } + + @Test + public void testPredefinedPersonas() { + executionContextManager.executeAsTenant(TENANT_1, () -> { + Persona result = profileService.loadPersona("testPersona"); + + // Verify predefined persona exists + assertNotNull(result); + assertEquals(SYSTEM_TENANT, result.getTenantId()); + assertEquals("Test", result.getProperty("firstName")); + assertEquals("Persona", result.getProperty("lastName")); + assertEquals(30, result.getProperty("age")); + assertTrue(((List)result.getSystemProperties().get("systemTags")).contains("predefinedPersona")); + return null; + }); + } + + @Test + public void testPersonaInheritance_SystemFallback() { + // Setup system persona only + executionContextManager.executeAsSystem(() -> { + Persona systemPersona = new Persona(); + systemPersona.setItemId("systemOnlyPersona"); + systemPersona.setProperties(Collections.singletonMap("role", "system")); + persistenceService.save(systemPersona); + return null; + }); + + // Test from tenant context + executionContextManager.executeAsTenant(TENANT_1, () -> { + Persona result = profileService.loadPersona("systemOnlyPersona"); + + // Verify system persona is returned + assertNotNull(result); + assertEquals(SYSTEM_TENANT, result.getTenantId()); + assertEquals("system", result.getProperty("role")); + return null; + }); + } + + @Test + public void testPropertyTypeBySystemTag_TenantOverride() { + // Setup system property type + executionContextManager.executeAsSystem(() -> { + PropertyType systemPropertyType = createPropertyType("sharedProp", "test", Collections.singleton("sharedTag"), Collections.singleton("systemTag")); + profileService.setPropertyType(systemPropertyType); + return null; + }); + + // Setup tenant property type and test + executionContextManager.executeAsTenant(TENANT_1, () -> { + PropertyType tenantPropertyType = createPropertyType("sharedProp", "test", Collections.singleton("tenantTag"), Collections.singleton("systemTag")); + profileService.setPropertyType(tenantPropertyType); + + // Test + Collection result = profileService.getPropertyTypeBySystemTag("systemTag"); + + // Verify tenant property type overrides system one + assertNotNull(result); + assertFalse(result.isEmpty()); + PropertyType foundType = result.iterator().next(); + assertEquals(TENANT_1, foundType.getTenantId()); + assertTrue(foundType.getMetadata().getTags().contains("tenantTag")); + return null; + }); + } + + @Test + public void testPersonaWithSessions_SystemTenant() { + // Setup system persona + executionContextManager.executeAsSystem(() -> { + Persona systemPersona = new Persona(); + systemPersona.setItemId("personaWithSessions"); + systemPersona.setProperties(Collections.singletonMap("role", "system")); + persistenceService.save(systemPersona); + return null; + }); + + // Test from tenant context + executionContextManager.executeAsTenant(TENANT_1, () -> { + PersonaWithSessions result = profileService.loadPersonaWithSessions("personaWithSessions"); + + // Verify system persona is returned with sessions + assertNotNull(result); + assertNotNull(result.getPersona()); + assertEquals(SYSTEM_TENANT, result.getPersona().getTenantId()); + assertEquals("system", result.getPersona().getProperty("role")); + return null; + }); + } + + @Test + public void testLoadPredefinedPropertyTypes() { + // Setup a specific test bundle for this test + Bundle testBundle = mock(Bundle.class); + when(testBundle.getBundleContext()).thenReturn(bundleContext); + when(testBundle.getBundleId()).thenReturn(123L); + when(testBundle.getSymbolicName()).thenReturn("org.apache.unomi.test.properties"); + bundleContext.addBundle(testBundle); + + // Create a test property type JSON URL with a custom target in the path + URL propertyTypeUrl = getClass().getResource("/META-INF/cxs/properties/predefined-properties.json"); + + // Reset and set up the mock to return our test URL + reset(bundleContext.getBundle()); + when(bundleContext.getBundle().findEntries("META-INF/cxs/properties", "*.json", true)) + .thenReturn(Collections.enumeration(Arrays.asList(propertyTypeUrl))); + + // Trigger the bundle event to load property types via the CacheableTypeConfig system + profileService.bundleChanged(new BundleEvent(BundleEvent.STARTED, testBundle)); + + // Verify property types were loaded correctly + Collection result = profileService.getTargetPropertyTypes("profiles"); + + // Verify that the predefined property exists and has the correct target + Optional firstNameProp = result.stream() + .filter(p -> p.getItemId().equals("firstName")) + .findFirst(); + + assertTrue(firstNameProp.isPresent()); + assertEquals("profiles", firstNameProp.get().getTarget()); + assertEquals("string", firstNameProp.get().getValueTypeId()); + + // Direct test of the setPropertyTypeTarget method that's used by the URL-aware processor + URL mockUrl; + try { + mockUrl = new URL("file:/path/to/META-INF/cxs/properties/customTarget/test-property.json"); + PropertyType testPropertyType = new PropertyType(); + testPropertyType.setMetadata(new Metadata("test-property")); + testPropertyType.setTarget(""); // Empty target + + // Call the method directly to test target setting logic + profileService.setPropertyTypeTarget(mockUrl, testPropertyType); + + // Verify the target was set correctly from the path + assertEquals("customTarget", testPropertyType.getTarget()); + } catch (MalformedURLException e) { + fail("Failed to create test URL: " + e.getMessage()); + } + } + +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/rules/TestEvaluateProfileSegmentsAction.java b/services/src/test/java/org/apache/unomi/services/impl/rules/TestEvaluateProfileSegmentsAction.java new file mode 100644 index 000000000..5895c6cc4 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/rules/TestEvaluateProfileSegmentsAction.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.rules; + +import org.apache.unomi.api.Event; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.actions.ActionExecutor; +import org.apache.unomi.api.segments.SegmentsAndScores; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.api.services.SegmentService; + +public class TestEvaluateProfileSegmentsAction implements ActionExecutor { + + private final SegmentService segmentService; + + public TestEvaluateProfileSegmentsAction(SegmentService segmentService) { + this.segmentService = segmentService; + } + + @Override + public int execute(Action action, Event event) { + if (event.getProfile().isAnonymousProfile()) { + return EventService.NO_CHANGE; + } + boolean updated = false; + SegmentsAndScores segmentsAndScoringForProfile = segmentService.getSegmentsAndScoresForProfile(event.getProfile()); + if (!segmentsAndScoringForProfile.getSegments().equals(event.getProfile().getSegments())) { + event.getProfile().setSegments(segmentsAndScoringForProfile.getSegments()); + updated = true; + } + if (!segmentsAndScoringForProfile.getScores().equals(event.getProfile().getScores())) { + event.getProfile().setScores(segmentsAndScoringForProfile.getScores()); + updated = true; + } + return updated ? EventService.PROFILE_UPDATED : EventService.NO_CHANGE; + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java b/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java new file mode 100644 index 000000000..d239a8671 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.rules; + +import org.apache.unomi.api.Event; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.actions.ActionExecutor; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.EventService; +import org.apache.unomi.persistence.spi.PersistenceService; + +import javax.xml.bind.DatatypeConverter; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; + +/** + * Test implementation of the SetEventOccurrenceCountAction. + * TODO: This is a duplicate of the SetEventOccurrenceCountAction in the unomi-plugins-base project to avoid introducing a dependency on it but should be cleaned up. + */ +public class TestSetEventOccurrenceCountAction implements ActionExecutor { + + private final DefinitionsService definitionsService; + private final PersistenceService persistenceService; + + public TestSetEventOccurrenceCountAction(DefinitionsService definitionsService, PersistenceService persistenceService) { + this.definitionsService = definitionsService; + this.persistenceService = persistenceService; + } + + @Override + public int execute(Action action, Event event) { + final Condition pastEventCondition = (Condition) action.getParameterValues().get("pastEventCondition"); + + Condition andCondition = new Condition(definitionsService.getConditionType("booleanCondition")); + andCondition.setParameter("operator", "and"); + ArrayList conditions = new ArrayList(); + + Condition eventCondition = (Condition) pastEventCondition.getParameter("eventCondition"); + conditions.add(eventCondition); + + Condition c = new Condition(definitionsService.getConditionType("eventPropertyCondition")); + c.setParameter("propertyName", "profileId"); + c.setParameter("comparisonOperator", "equals"); + c.setParameter("propertyValue", event.getProfileId()); + conditions.add(c); + + // may be current event is already persisted and indexed, in that case we filter it from the count to increment it manually at the end + Condition eventIdFilter = new Condition(definitionsService.getConditionType("eventPropertyCondition")); + eventIdFilter.setParameter("propertyName", "itemId"); + eventIdFilter.setParameter("comparisonOperator", "notEquals"); + eventIdFilter.setParameter("propertyValue", event.getItemId()); + conditions.add(eventIdFilter); + + Integer numberOfDays = (Integer) pastEventCondition.getParameter("numberOfDays"); + String fromDate = (String) pastEventCondition.getParameter("fromDate"); + String toDate = (String) pastEventCondition.getParameter("toDate"); + + if (numberOfDays != null) { + Condition numberOfDaysCondition = new Condition(definitionsService.getConditionType("eventPropertyCondition")); + numberOfDaysCondition.setParameter("propertyName", "timeStamp"); + numberOfDaysCondition.setParameter("comparisonOperator", "greaterThan"); + numberOfDaysCondition.setParameter("propertyValueDateExpr", "now-" + numberOfDays + "d"); + conditions.add(numberOfDaysCondition); + } + if (fromDate != null) { + Condition startDateCondition = new Condition(); + startDateCondition.setConditionType(definitionsService.getConditionType("eventPropertyCondition")); + startDateCondition.setParameter("propertyName", "timeStamp"); + startDateCondition.setParameter("comparisonOperator", "greaterThanOrEqualTo"); + startDateCondition.setParameter("propertyValueDate", fromDate); + conditions.add(startDateCondition); + } + if (toDate != null) { + Condition endDateCondition = new Condition(); + endDateCondition.setConditionType(definitionsService.getConditionType("eventPropertyCondition")); + endDateCondition.setParameter("propertyName", "timeStamp"); + endDateCondition.setParameter("comparisonOperator", "lessThanOrEqualTo"); + endDateCondition.setParameter("propertyValueDate", toDate); + conditions.add(endDateCondition); + } + + andCondition.setParameter("subConditions", conditions); + + long count = persistenceService.queryCount(andCondition, Event.ITEM_TYPE); + + LocalDateTime fromDateTime = null; + if (fromDate != null) { + Calendar fromDateCalendar = DatatypeConverter.parseDateTime(fromDate); + fromDateTime = LocalDateTime.ofInstant(fromDateCalendar.toInstant(), ZoneId.of("UTC")); + } + LocalDateTime toDateTime = null; + if (toDate != null) { + Calendar toDateCalendar = DatatypeConverter.parseDateTime(toDate); + toDateTime = LocalDateTime.ofInstant(toDateCalendar.toInstant(), ZoneId.of("UTC")); + } + + LocalDateTime eventTime = LocalDateTime.ofInstant(event.getTimeStamp().toInstant(),ZoneId.of("UTC")); + + if (inTimeRange(eventTime, numberOfDays, fromDateTime, toDateTime)) { + count++; + } + + if (updatePastEvents(event, (String) pastEventCondition.getParameter("generatedPropertyKey"), count)) { + return EventService.PROFILE_UPDATED; + } + + return EventService.NO_CHANGE; + } + + private boolean updatePastEvents(Event event, String generatedPropertyKey, long count) { + List> existingPastEvents = (List>) event.getProfile().getSystemProperties().get("pastEvents"); + if (existingPastEvents == null) { + existingPastEvents = new ArrayList<>(); + event.getProfile().getSystemProperties().put("pastEvents", existingPastEvents); + } + + for (Map pastEvent : existingPastEvents) { + if (generatedPropertyKey.equals(pastEvent.get("key"))) { + if (pastEvent.containsKey("count") && pastEvent.get("count").equals(count)) { + return false; + } + pastEvent.put("count", count); + return true; + } + } + + Map newPastEvent = new HashMap<>(); + newPastEvent.put("key", generatedPropertyKey); + newPastEvent.put("count", count); + existingPastEvents.add(newPastEvent); + return true; + } + + private boolean inTimeRange(LocalDateTime eventTime, Integer numberOfDays, LocalDateTime fromDate, LocalDateTime toDate) { + boolean inTimeRange = true; + + if (numberOfDays != null) { + LocalDateTime now = LocalDateTime.now(ZoneId.of("UTC")); + if (eventTime.isAfter(now)) { + inTimeRange = false; + } + long daysDiff = Duration.between(eventTime, now).toDays(); + if (daysDiff > numberOfDays) { + inTimeRange = false; + } + } + if (fromDate != null && fromDate.isAfter(eventTime)) { + inTimeRange = false; + } + if (toDate != null && toDate.isBefore(eventTime)) { + inTimeRange = false; + } + + return inTimeRange; + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java new file mode 100644 index 000000000..0e705d353 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java @@ -0,0 +1,2479 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.scheduler; + +import org.apache.unomi.api.PartialList; +import org.apache.unomi.api.services.SchedulerService.TaskBuilder; +import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tasks.TaskExecutor; +import org.apache.unomi.persistence.spi.CustomObjectMapper; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl; +import org.apache.unomi.services.impl.TestConditionEvaluators; +import org.apache.unomi.services.impl.cluster.ClusterServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test suite for the SchedulerServiceImpl class. + * Tests cover task lifecycle, state transitions, clustering, recovery, and metrics. + * + * The test suite is organized into categories: + * - BasicTests: Core task creation, execution, and completion + * - StateTests: Task state transitions and validation + * - DependencyTests: Task dependency management + * - ClusterTests: Multi-node execution and lock management + * - RecoveryTests: Crash recovery and task resumption + * - MetricsTests: Metrics collection and history tracking + * - ValidationTests: Input validation and error handling + * - RetryTests: Task retry behavior and delay + * - MaintenanceTests: Task cleanup and maintenance + * - QueryTests: Task querying and filtering + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SchedulerServiceImplTest { + private static final Logger LOGGER = LoggerFactory.getLogger(SchedulerServiceImplTest.class); + + + + // Test configuration constants + /** Maximum number of retries for storage operations */ + private static final int MAX_RETRIES = 10; + /** Default timeout for test assertions */ + private static final long TEST_TIMEOUT = 5000; // 5 seconds + /** Time unit for test timeouts */ + private static final TimeUnit TEST_TIME_UNIT = TimeUnit.MILLISECONDS; + /** Lock timeout for testing lock expiration */ + private static final long TEST_LOCK_TIMEOUT = 1000; // 1 second + /** Thread pool size for parallel execution */ + private static final int TEST_THREAD_POOL_SIZE = 4; + /** Delay between task retries */ + private static final long TEST_RETRY_DELAY = 500; // 500 milliseconds + /** Maximum number of retries for failed tasks */ + private static final int TEST_MAX_RETRIES = 3; + /** Short sleep duration for timing-sensitive tests */ + private static final long TEST_SLEEP = 100; // 100 milliseconds + /** Default wait timeout for task status transitions */ + private static final long TEST_WAIT_TIMEOUT = 2000; // 2 seconds + + private SchedulerServiceImpl schedulerService; + private PersistenceService persistenceService; + private ExecutionContextManagerImpl executionContextManager; + private KarafSecurityService securityService; + private ClusterServiceImpl clusterService; + + @Mock + private BundleContext bundleContext; + + // Test categories with documentation + // JUnit 5 provides tags; marker interfaces removed + + private static void configureDebugLogging() { + // Enable debug logging for scheduler package + System.setProperty("org.slf4j.simpleLogger.log.org.apache.unomi.services.impl.scheduler", "DEBUG"); + System.setProperty("org.slf4j.simpleLogger.showDateTime", "true"); + System.setProperty("org.slf4j.simpleLogger.dateTimeFormat", "yyyy-MM-dd HH:mm:ss.SSS"); + System.setProperty("org.slf4j.simpleLogger.showThreadName", "true"); + } + + @BeforeEach + public void setUp() throws IOException { + configureDebugLogging(); + CustomObjectMapper.getCustomInstance().registerBuiltInItemTypeClass(ScheduledTask.ITEM_TYPE, ScheduledTask.class); + + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + + Bundle bundle = mock(Bundle.class); + when(bundleContext.getBundle()).thenReturn(bundle); + when(bundle.getBundleContext()).thenReturn(bundleContext); + when(bundleContext.getBundle().findEntries(anyString(), anyString(), anyBoolean())).thenReturn(null); + when(bundleContext.getBundles()).thenReturn(new Bundle[0]); + + TestHelper.cleanDefaultStorageDirectory(MAX_RETRIES); + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher); + + // Create and configure cluster service using the helper method + clusterService = TestHelper.createClusterService(persistenceService, "test-scheduler-node", bundleContext); + + // Create scheduler service with cluster service + schedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( + "test-scheduler-node", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + -1, + true, + false, + 0); // Set TTL to 0 for immediate purging in tests + + // Configure scheduler for testing + schedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE); + schedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + schedulerService.postConstruct(); + } + + @AfterEach + public void tearDown() { + if (schedulerService != null) { + try { + // Cancel any running or retrying tasks + List allTasks = schedulerService.getAllTasks(); + for (ScheduledTask task : allTasks) { + try { + if (task.getStatus() == ScheduledTask.TaskStatus.RUNNING || + task.getStatus() == ScheduledTask.TaskStatus.SCHEDULED) { + schedulerService.cancelTask(task.getItemId()); + } + } catch (Exception e) { + // Log but continue with other tasks + LOGGER.warn("Error cancelling task {} during teardown: {}", task.getItemId(), e.getMessage()); + } + } + // Wait for running tasks to stop before shutdown + try { + TestHelper.retryUntil( + () -> schedulerService.getAllTasks().stream() + .noneMatch(t -> t.getStatus() == ScheduledTask.TaskStatus.RUNNING), + done -> done + ); + } catch (Exception e) { + LOGGER.debug("Timeout waiting for tasks to stop during teardown, proceeding with preDestroy"); + } + + schedulerService.preDestroy(); + schedulerService = null; + } catch (Exception e) { + LOGGER.warn("Error during test teardown: {}", e.getMessage()); + } + } + persistenceService = null; + executionContextManager = null; + securityService = null; + } + + // Basic task lifecycle tests + @Test + @Tag("BasicTests") + public void testBasicTaskLifecycle() throws Exception { + // Test task creation, execution, and completion + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicBoolean executed = new AtomicBoolean(false); + + ScheduledTask task = schedulerService.newTask("test-type") + .withSimpleExecutor(() -> { + executed.set(true); + executionLatch.countDown(); + }) + .schedule(); + + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + assertTrue(executed.get(), "Task should have executed"); + + ScheduledTask completedTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.COMPLETED, TEST_WAIT_TIMEOUT, 50); + assertEquals(ScheduledTask.TaskStatus.COMPLETED, completedTask.getStatus(), "Task should be completed"); + assertNotNull(completedTask.getStatusDetails().get("executionHistory"), "Task should have execution history"); + } + + @Test + @Tag("BasicTests") + public void testFixedRateExecution() throws Exception { + CountDownLatch executionLatch = new CountDownLatch(3); + AtomicInteger executionCount = new AtomicInteger(0); + long period = 100; + + ScheduledTask task = schedulerService.newTask("fixed-rate-test") + .withPeriod(period, TimeUnit.MILLISECONDS) + .withFixedRate() + .withSimpleExecutor(() -> { + executionCount.incrementAndGet(); + executionLatch.countDown(); + try { + Thread.sleep(period * 2); // Sleep longer than period + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .schedule(); + + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute three times"); + assertTrue( + executionCount.get() >= 3, + "Task should execute at fixed rate despite long execution"); + } + + @Test + @Tag("BasicTests") + public void testFixedDelayExecution() throws Exception { + CountDownLatch executionLatch = new CountDownLatch(3); + AtomicInteger executionCount = new AtomicInteger(0); + AtomicLong lastExecutionTime = new AtomicLong(0); + AtomicReference workerError = new AtomicReference<>(); + long period = 100; + + ScheduledTask task = schedulerService.newTask("fixed-delay-test") + .withPeriod(period, TimeUnit.MILLISECONDS) + .withFixedDelay() + .withSimpleExecutor(() -> { + long now = System.currentTimeMillis(); + if (lastExecutionTime.get() > 0) { + long delay = now - lastExecutionTime.get(); + try { + assertTrue(delay >= period, "Delay should be at least the period"); + } catch (AssertionError e) { + workerError.set(e); + } + } + lastExecutionTime.set(now); + executionCount.incrementAndGet(); + executionLatch.countDown(); + try { + Thread.sleep(50); // Add some execution time + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .schedule(); + + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute three times"); + assertEquals(3, executionCount.get(), "Task should execute exactly three times"); + if (workerError.get() != null) { + throw new AssertionError("Assertion failed in worker thread", workerError.get()); + } + } + + // Task state transition tests + @Test + @Tag("StateTests") + public void testTaskStateTransitions() throws Exception { + // Test all possible task state transitions + CountDownLatch transitionLatch = new CountDownLatch(1); + AtomicReference currentStatus = new AtomicReference<>(); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "transition-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + currentStatus.set(task.getStatus()); + callback.updateStep("test-step", Collections.emptyMap()); + callback.checkpoint(Collections.singletonMap("test", "data")); + callback.complete(); + transitionLatch.countDown(); + } + }; + + schedulerService.registerTaskExecutor(executor); + + ScheduledTask task = schedulerService.newTask("transition-test") + .disallowParallelExecution() + .schedule(); + + assertTrue(transitionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should complete transition"); + assertEquals( + ScheduledTask.TaskStatus.RUNNING, + currentStatus.get(), + "Task should be in RUNNING state during execution"); + + ScheduledTask finalTask = schedulerService.getTask(task.getItemId()); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + finalTask.getStatus(), + "Task should be in COMPLETED state"); + assertNotNull(finalTask.getCheckpointData(), "Task should have checkpoint data"); + } + + @Test + @Tag("StateTests") + public void testInMemoryTaskStateTransitions() throws Exception { + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completionLatch = new CountDownLatch(1); + AtomicReference executionStatus = new AtomicReference<>(); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "in-memory-state-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionStatus.set(task.getStatus()); + startLatch.countDown(); + try { + Thread.sleep(100); // Give time to check status + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + callback.complete(); + completionLatch.countDown(); + } + }; + + schedulerService.registerTaskExecutor(executor); + + // Create in-memory task + ScheduledTask task = schedulerService.newTask("in-memory-state-test") + .nonPersistent() + .schedule(); + + // Wait for execution to start + assertTrue(startLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should start executing"); + assertEquals( + ScheduledTask.TaskStatus.RUNNING, + executionStatus.get(), + "Task should be in RUNNING state during execution"); + + // Wait for completion + assertTrue(completionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should complete"); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + task.getStatus(), + "Task should be in COMPLETED state after execution"); + } + + // Task dependency tests + @Test + @Tag("DependencyTests") + public void testTaskDependencies() throws Exception { + // Test task dependencies and waiting behavior + CountDownLatch dep1Latch = new CountDownLatch(1); + CountDownLatch dep2Latch = new CountDownLatch(1); + + // Create first dependency + ScheduledTask dep1 = schedulerService.newTask("dep-test") + .withSimpleExecutor(() -> dep1Latch.countDown()) + .schedule(); + + // Create second dependency + ScheduledTask dep2 = schedulerService.newTask("dep-test") + .withSimpleExecutor(() -> dep2Latch.countDown()) + .schedule(); + + // Create dependent task + AtomicBoolean dependentExecuted = new AtomicBoolean(false); + ScheduledTask dependentTask = schedulerService.createTask( + "dep-test", + null, + 0, + 0, + TimeUnit.MILLISECONDS, + true, + true, + true, + true + ); + dependentTask.setDependsOn(new HashSet<>(Arrays.asList(dep1.getItemId(), dep2.getItemId()))); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "dep-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + if (task == dependentTask) { + dependentExecuted.set(true); + } + callback.complete(); + } + }; + + schedulerService.registerTaskExecutor(executor); + schedulerService.scheduleTask(dependentTask); + + // Wait for dependencies to complete + assertTrue( + dep1Latch.await(TEST_TIMEOUT, TEST_TIME_UNIT) && + dep2Latch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Dependencies should complete"); + + // Verify dependent task execution + Thread.sleep(100); // Give time for dependent task to execute + assertTrue(dependentExecuted.get(), "Dependent task should execute after dependencies"); + } + + // Clustering support tests + @Test + @Tag("ClusterTests") + public void testClusteringSupport() throws Exception { + // Test clustering behavior with multiple nodes + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl nonExecutorNode = TestHelper.createSchedulerService("node3", persistenceService, executionContextManager, bundleContext, clusterService, -1, false, true); + + try { + // Instead of mock cluster nodes, create persistent tasks with locks + // to ensure nodes are detected via the fallback mechanism + createAndPersistTaskWithLock("node1-cluster-detection", "node1"); + createAndPersistTaskWithLock("node2-cluster-detection", "node2"); + createAndPersistTaskWithLock("node3-cluster-detection", "node3"); + + // Refresh the index to ensure changes are visible + persistenceService.refreshIndex(ScheduledTask.class); + // Also refresh all indexes to ensure tasks are available for querying (handles refresh delay) + persistenceService.refresh(); + + CountDownLatch executionLatch = new CountDownLatch(2); + Set executingNodes = ConcurrentHashMap.newKeySet(); + + TaskExecutor clusterTestExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return "cluster-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executingNodes.add(task.getExecutingNodeId()); + executionLatch.countDown(); + callback.complete(); + } + }; + + // Register executor on all nodes + node1.registerTaskExecutor(clusterTestExecutor); + node2.registerTaskExecutor(clusterTestExecutor); + nonExecutorNode.registerTaskExecutor(clusterTestExecutor); + + // Create tasks with different distribution requirements + ScheduledTask normalTask = node1.newTask("cluster-test") + .withPeriod(100, TimeUnit.MILLISECONDS) + .schedule(); + + ScheduledTask allNodesTask = node1.newTask("cluster-test") + .runOnAllNodes() + .withPeriod(100, TimeUnit.MILLISECONDS) + .schedule(); + + // Wait for executions + assertTrue( + executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Tasks should execute on cluster nodes"); + + // Verify distribution + assertTrue( + executingNodes.contains("node1") || executingNodes.contains("node2"), + "Task should execute on executor nodes"); + assertFalse( + executingNodes.contains("node3"), + "Task should not execute on non-executor node"); + + TaskExecutor clusterLockTestExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return "cluster-lock-test"; + } + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + try { + Thread.sleep(5000); + callback.complete(); + } catch (InterruptedException e) { + callback.fail(e.getMessage()); + } + } + }; + + schedulerService.registerTaskExecutor(clusterLockTestExecutor); + + // Test lock management + ScheduledTask lockTask = node1.newTask("cluster-lock-test") + .disallowParallelExecution() + .schedule(); + + // Refresh persistence to ensure task updates are available (handles refresh delay) + persistenceService.refresh(); + // Retry until task has lock owner (handles refresh delay for updates) + ScheduledTask lockedTask = TestHelper.retryUntil( + () -> persistenceService.load(lockTask.getItemId(), ScheduledTask.class), + t -> t != null && t.getLockOwner() != null + ); + assertNotNull(lockedTask.getLockOwner(), "Task should have lock owner"); + assertNotNull(lockedTask.getLockDate(), "Task should have lock date"); + + // Test lock release - directly update task in persistence + lockedTask.setLockOwner(null); + lockedTask.setLockDate(null); + persistenceService.save(lockedTask); + + // Refresh index to ensure changes are visible + persistenceService.refreshIndex(ScheduledTask.class); + + // Get latest state and verify lock release + ScheduledTask releasedTask = persistenceService.load(lockTask.getItemId(), ScheduledTask.class); + assertNull(releasedTask.getLockOwner(), "Lock should be released"); + + } finally { + node1.preDestroy(); + node2.preDestroy(); + nonExecutorNode.preDestroy(); + } + } + + // Task recovery tests + @Test + @Tag("RecoveryTests") + public void testTaskRecovery() throws Exception { + // Set a very short lock timeout for the test + schedulerService.setLockTimeout(100); // 100 ms + schedulerService.getLockManager().setLockTimeout(100); // Ensure lockManager uses the same timeout + + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicBoolean recovered = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "recovery-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) throws Exception { + if (task.getStatusDetails() == null || task.getStatusDetails().get("crashTime") == null) { + throw new RuntimeException("Simulated crash"); + } + recovered.set(true); + callback.complete(); + executionLatch.countDown(); + } + + @Override + public boolean canResume(ScheduledTask task) { + return true; + } + }; + + schedulerService.registerTaskExecutor(executor); + + ScheduledTask task = schedulerService.newTask("recovery-test") + .disallowParallelExecution() + .schedule(); + + // Wait for the task to be scheduled, then simulate a node crash (RUNNING + expired lock) + ScheduledTask persistedTask = TestHelper.retryUntil( + () -> schedulerService.getTask(task.getItemId()), + Objects::nonNull + ); + persistedTask.setStatus(ScheduledTask.TaskStatus.RUNNING); + persistedTask.setLockOwner("dead-node"); + persistedTask.setLockDate(new Date(System.currentTimeMillis() - 10_000)); + if (persistedTask.getStatusDetails() == null) { + persistedTask.setStatusDetails(new HashMap<>()); + } + persistedTask.getStatusDetails().remove("crashTime"); + schedulerService.saveTask(persistedTask); + + schedulerService.recoverCrashedTasks(); + + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should be recovered"); + assertTrue(recovered.get(), "Task should be recovered and executed"); + } + + // Metrics and history tests + @Test + @Tag("MetricsTests") + public void testMetricsAndHistory() throws Exception { + // Test metrics collection and history tracking + CountDownLatch successLatch = new CountDownLatch(2); + CountDownLatch failureLatch = new CountDownLatch(1); + + TaskExecutor executor = new TaskExecutor() { + private int execCount = 0; + + @Override + public String getTaskType() { + return "metrics-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + LOGGER.info("Executing task type " + task.getTaskType() + " with status " + task.getStatus()); + execCount++; + if (execCount <= 2) { + callback.complete(); + successLatch.countDown(); + } else { + callback.fail("Simulated failure"); + failureLatch.countDown(); + } + } + }; + + schedulerService.registerTaskExecutor(executor); + + ScheduledTask task = schedulerService.newTask("metrics-test") + .withPeriod(100, TimeUnit.MILLISECONDS) + .schedule(); + + assertTrue( + successLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task should execute successfully twice"); + assertTrue( + failureLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task should fail once"); + + // Verify metrics and history + ScheduledTask finalTask = schedulerService.getTask(task.getItemId()); + @SuppressWarnings("unchecked") + List> history = + (List>) finalTask.getStatusDetails().get("executionHistory"); + + assertNotNull(history, "Should have execution history"); + assertEquals(3, history.size(), "Should have 3 history entries"); + assertEquals(2, finalTask.getSuccessCount(), "Should have 2 successful executions"); + assertEquals(1, finalTask.getFailureCount(), "Should have 1 failed execution"); + assertEquals(3, finalTask.getSuccessCount() + finalTask.getFailureCount(), "Total executions should be 3"); + + // Verify history entries + int successEntries = 0; + int failureEntries = 0; + for (Map entry : history) { + String status = (String) entry.get("status"); + if ("SUCCESS".equals(status)) { + successEntries++; + } else if ("FAILED".equals(status)) { + failureEntries++; + assertNotNull(entry.get("error"), "Failed entry should have error"); + } + } + + assertEquals(2, successEntries, "Should have 2 successful executions"); + assertEquals(1, failureEntries, "Should have 1 failed execution"); + + // Verify metrics + assertTrue(schedulerService.getMetric("tasks.completed") > 0, "Should have completed tasks metric"); + assertTrue(schedulerService.getMetric("tasks.failed") > 0, "Should have failed tasks metric"); + + // Test metric reset + schedulerService.resetMetrics(); + assertEquals(0, schedulerService.getMetric("tasks.completed"), "Metrics should be reset"); + } + + // Task validation tests + @Test + @Tag("ValidationTests") + public void testTaskValidation() { + // Test invalid configurations + try { + schedulerService.newTask(null).schedule(); + fail("Should throw IllegalArgumentException for null task type"); + } catch (IllegalArgumentException expected) { + } + + try { + schedulerService.newTask("test") + .withPeriod(-1, TimeUnit.MILLISECONDS) + .schedule(); + fail("Should throw IllegalArgumentException for negative period"); + } catch (IllegalArgumentException expected) { + } + + try { + schedulerService.newTask("test") + .asOneShot() + .withPeriod(1000, TimeUnit.MILLISECONDS) + .schedule(); + fail("Should throw IllegalArgumentException for oneShot with period"); + } catch (IllegalArgumentException expected) { + } + + try { + ScheduledTask task = schedulerService.newTask("test").asOneShot().withMaxRetries(-1).withRetryDelay(1000, TimeUnit.MILLISECONDS).schedule(); + fail("Should throw IllegalArgumentException for negative maxRetries"); + } catch (IllegalArgumentException expected) { + } + } + + // Retry tests + @Test + @Tag("RetryTests") + public void testOneShotTaskRetryScenarios() throws Exception { + // Test both persistent and in-memory tasks + testOneShotRetryBehavior(true); // persistent + testOneShotRetryBehavior(false); // in-memory + } + + /** + * Helper method that waits for a task to reach a specific status. + * + * @param taskId The ID of the task to check + * @param targetStatus The status to wait for + * @param maxWaitTimeMs Maximum time to wait in milliseconds + * @param sleepIntervalMs Time to sleep between status checks in milliseconds + * @return The task with its current status (which may not be targetStatus if timeout occurred) + * @throws InterruptedException If the waiting is interrupted + */ + private ScheduledTask waitForTaskStatus(String taskId, ScheduledTask.TaskStatus targetStatus, + long maxWaitTimeMs, long sleepIntervalMs) throws InterruptedException { + + final long startTime = System.currentTimeMillis(); + ScheduledTask task = null; + + while (System.currentTimeMillis() - startTime < maxWaitTimeMs) { + task = schedulerService.getTask(taskId); + if (targetStatus.equals(task.getStatus())) { + LOGGER.info("Task {} reached target status: {}", taskId, targetStatus); + break; + } + LOGGER.debug("Waiting for task {} to transition from {} to {}", + taskId, task.getStatus(), targetStatus); + Thread.sleep(sleepIntervalMs); + } + + if (task != null && !targetStatus.equals(task.getStatus())) { + LOGGER.warn("Timeout waiting for task {} to reach status {}. Current status: {}", + taskId, targetStatus, task.getStatus()); + } + + return task; + } + + private ScheduledTask waitForTaskStatusWithScheduler(String taskId, ScheduledTask.TaskStatus targetStatus, + long maxWaitTimeMs, long sleepIntervalMs, SchedulerServiceImpl scheduler) throws InterruptedException { + + final long startTime = System.currentTimeMillis(); + ScheduledTask task = null; + + while (System.currentTimeMillis() - startTime < maxWaitTimeMs) { + task = scheduler.getTask(taskId); + if (targetStatus.equals(task.getStatus())) { + LOGGER.info("Task {} reached target status: {}", taskId, targetStatus); + break; + } + LOGGER.debug("Waiting for task {} to transition from {} to {}", + taskId, task.getStatus(), targetStatus); + Thread.sleep(sleepIntervalMs); + } + + if (task != null && !targetStatus.equals(task.getStatus())) { + LOGGER.warn("Timeout waiting for task {} to reach status {}. Current status: {}", + taskId, targetStatus, task.getStatus()); + } + + return task; + } + + private void testOneShotRetryBehavior(boolean persistent) throws Exception { + CountDownLatch executionLatch = new CountDownLatch(TEST_MAX_RETRIES+1); + AtomicInteger executionCount = new AtomicInteger(0); + List executionTimes = Collections.synchronizedList(new ArrayList<>()); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "one-shot-retry-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionTimes.add(System.currentTimeMillis()); + int count = executionCount.incrementAndGet(); + LOGGER.info("Execution #{} of task type {}", count, task.getTaskType()); + executionLatch.countDown(); + + if (count == TEST_MAX_RETRIES+1) { + callback.complete(); // Succeed on last retry + } else { + callback.fail("Simulated failure #" + count); + } + } + }; + + // Create task with retry configuration + TaskBuilder builder = schedulerService.newTask("one-shot-retry-test") + .withMaxRetries(TEST_MAX_RETRIES) + .withRetryDelay(TEST_RETRY_DELAY, TimeUnit.MILLISECONDS) + .withExecutor(executor) + .asOneShot(); + + if (!persistent) { + builder.nonPersistent(); + } + + ScheduledTask task = builder.schedule(); + + assertTrue( + executionLatch.await(TEST_TIMEOUT, TimeUnit.MILLISECONDS), + "Task should complete all executions"); + + // Verify retry delays + for (int i = 1; i < executionTimes.size(); i++) { + long delay = executionTimes.get(i) - executionTimes.get(i-1); + assertTrue( + delay >= TEST_RETRY_DELAY, + "Retry delay should be at least " + TEST_RETRY_DELAY + "ms"); + } + + // Wait for the task to transition from RUNNING to COMPLETED state + // This is necessary because the state transition happens asynchronously after the callback.complete() is called + ScheduledTask finalTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.COMPLETED, TEST_WAIT_TIMEOUT, 50); + + LOGGER.info("Task status={}", finalTask.getStatus()); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + finalTask.getStatus(), + "Task should be completed"); + assertEquals( + TEST_MAX_RETRIES+1, + executionCount.get(), + "Should have executed expected number of times"); + } + + @Test + @Tag("RetryTests") + public void testPeriodicTaskRetryScenarios() throws Exception { + // Test both fixed-rate and fixed-delay + testPeriodicRetryBehavior(true); // fixed-rate + testPeriodicRetryBehavior(false); // fixed-delay + } + + private void testPeriodicRetryBehavior(boolean fixedRate) throws Exception { + LOGGER.info("Testing periodic task retry scenarios with fixed rate: {}", fixedRate); + CountDownLatch firstPeriodLatch = new CountDownLatch(TEST_MAX_RETRIES+1); + CountDownLatch secondPeriodLatch = new CountDownLatch(1); + AtomicInteger periodCount = new AtomicInteger(0); + AtomicInteger executionCount = new AtomicInteger(0); + List executionTimes = Collections.synchronizedList(new ArrayList<>()); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "periodic-retry-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionTimes.add(System.currentTimeMillis()); + int count = executionCount.incrementAndGet(); + LOGGER.info("Execution #{} of task type {} with period {}", count, task.getTaskType(), periodCount.get()); + + // Transition to next period after exhausting retries + if (count == TEST_MAX_RETRIES+2) { + periodCount.incrementAndGet(); + } + + if (periodCount.get() == 0) { + firstPeriodLatch.countDown(); + callback.fail("First period failure #" + count); + } else { + secondPeriodLatch.countDown(); + callback.complete(); + } + + } + }; + + // Create periodic task with retry configuration + TaskBuilder builder = schedulerService.newTask("periodic-retry-test") + .withMaxRetries(TEST_MAX_RETRIES) + .withRetryDelay(TEST_RETRY_DELAY, TimeUnit.MILLISECONDS) + .withPeriod(2000, TimeUnit.MILLISECONDS); + + if (fixedRate) { + builder.withFixedRate(); + } else { + builder.withFixedDelay(); + } + + ScheduledTask task = builder.withExecutor(executor).schedule(); + + // Wait for first period retries + assertTrue( + firstPeriodLatch.await(TEST_TIMEOUT, TimeUnit.MILLISECONDS), + "First period should exhaust retries"); + + // Verify retry delays in first period + for (int i = 1; i <= TEST_MAX_RETRIES; i++) { + long delay = executionTimes.get(i) - executionTimes.get(i-1); + assertTrue( + delay >= TEST_RETRY_DELAY, + "Retry delay should be at least " + TEST_RETRY_DELAY + "ms"); + } + + // Wait for successful execution in second period + assertTrue( + secondPeriodLatch.await(TEST_TIMEOUT, TimeUnit.MILLISECONDS), + "Second period should succeed"); + + // Verify period transition + long periodTransitionDelay = executionTimes.get(TEST_MAX_RETRIES+1) - + executionTimes.get(TEST_MAX_RETRIES); + assertTrue( + periodTransitionDelay >= 2000, + "Period transition delay should be at least 2000ms"); + + // Verify task state + ScheduledTask scheduledTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.SCHEDULED, TEST_WAIT_TIMEOUT, 100); + LOGGER.info("Task status={}", scheduledTask.getStatus()); + assertEquals( + ScheduledTask.TaskStatus.SCHEDULED, + scheduledTask.getStatus(), + "Task should be in scheduled state"); + assertEquals(0, scheduledTask.getFailureCount(), "Failure count should be reset"); + + schedulerService.cancelTask(task.getItemId()); + } + + @Test + @Tag("RetryTests") + public void testManualRetryAfterExhaustion() throws Exception { + AtomicInteger executionCount = new AtomicInteger(0); + CountDownLatch exhaustionLatch = new CountDownLatch(TEST_MAX_RETRIES+1); + CountDownLatch manualRetryLatch = new CountDownLatch(1); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "manual-retry-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + int count = executionCount.incrementAndGet(); + LOGGER.info("Execution #{} of task type {}", count, task.getTaskType()); + + if (count <= TEST_MAX_RETRIES+1) { + exhaustionLatch.countDown(); + callback.fail("Initial failures"); + } else { + manualRetryLatch.countDown(); + callback.complete(); + } + } + }; + + // Create one-shot task + ScheduledTask task = schedulerService.newTask("manual-retry-test") + .withMaxRetries(TEST_MAX_RETRIES) + .withRetryDelay(TEST_RETRY_DELAY, TimeUnit.MILLISECONDS) + .withExecutor(executor) + .asOneShot() + .schedule(); + + // Wait for automatic retries to be exhausted + assertTrue( + exhaustionLatch.await(TEST_TIMEOUT, TimeUnit.MILLISECONDS), + "Should exhaust automatic retries"); + + // Verify failed state + ScheduledTask failedTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.FAILED, TEST_WAIT_TIMEOUT, 50); + assertEquals( + ScheduledTask.TaskStatus.FAILED, + failedTask.getStatus(), + "Task should be in failed state"); + assertEquals( + TEST_MAX_RETRIES+1, + failedTask.getFailureCount(), + "Should have correct failure count"); + + // Manually retry with reset + schedulerService.retryTask(task.getItemId(), true); + + // Wait for manual retry to complete + assertTrue( + manualRetryLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Manual retry should succeed"); + + // Verify final state + ScheduledTask completedTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.COMPLETED, TEST_WAIT_TIMEOUT, 50); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + completedTask.getStatus(), + "Task should be completed after manual retry"); + assertEquals(0, completedTask.getFailureCount(), "Failure count should be reset"); + } + + // Task purging tests + @Test + @Tag("MaintenanceTests") + public void testTaskPurging() throws Exception { + // Test task purging functionality + schedulerService.setCompletedTaskTtlDays(0); + schedulerService.setPurgeTaskEnabled(true); + + // Create and complete some tasks + for (int i = 0; i < 3; i++) { + ScheduledTask task = schedulerService.newTask("task-to-purge") + .withSimpleExecutor(() -> {}) + .schedule(); + + // Wait for completion + TestHelper.retryUntil( + () -> schedulerService.getTask(task.getItemId()), + t -> t != null && t.getStatus() == ScheduledTask.TaskStatus.COMPLETED + ); + + // Verify task completion + ScheduledTask completedTask = schedulerService.getTask(task.getItemId()); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + completedTask.getStatus(), + "Task should be completed"); + } + + // Force purge + schedulerService.purgeOldTasks(); + + // Wait for purge to complete + TestHelper.retryUntil( + () -> schedulerService.getAllTasks(), + tasks -> tasks.isEmpty() + ); + + // Verify purged tasks + List remainingTasks = schedulerService.getAllTasks(); + assertEquals(0, remainingTasks.size(), "Should have purged completed tasks"); + } + + /** + * Tests task builder pattern completeness. + * Verifies all builder methods work correctly. + */ + @Test + @Tag("ValidationTests") + public void testTaskBuilder() { + Map params = Collections.singletonMap("test", "value"); + + ScheduledTask task = schedulerService.newTask("builder-test") + .withParameters(params) + .withInitialDelay(100, TimeUnit.MILLISECONDS) + .withPeriod(200, TimeUnit.MILLISECONDS) + .withFixedDelay() + .nonPersistent() + .runOnAllNodes() + .schedule(); + + assertEquals(params, task.getParameters(), "Parameters should be set"); + assertEquals(100, task.getInitialDelay(), "Initial delay should be set"); + assertEquals(200, task.getPeriod(), "Period should be set"); + assertFalse(task.isFixedRate(), "Should be fixed delay"); + assertFalse(task.isPersistent(), "Should be non-persistent"); + assertTrue(task.isRunOnAllNodes(), "Should run on all nodes"); + } + + /** + * Tests lock timeout expiration and recovery. + */ + @Test + @Tag("ClusterTests") + public void testLockTimeout() throws Exception { + // Explicitly set a very short lock timeout for this test + schedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicBoolean taskStarted = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "lock-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + try { + // Signal that the task has started + taskStarted.set(true); + executionLatch.countDown(); + + // Hold the lock longer than timeout + Thread.sleep(TEST_LOCK_TIMEOUT * 2); + callback.complete(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + + schedulerService.registerTaskExecutor(executor); + + ScheduledTask task = schedulerService.newTask("lock-test") + .disallowParallelExecution() + .schedule(); + + // Wait for task to start + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should start executing"); + + // Get the running task instance + ScheduledTask runningTask = persistenceService.load(task.getItemId(), ScheduledTask.class); + + // Directly update task to simulate lock expiration + runningTask.setLockOwner(null); + runningTask.setLockDate(null); + persistenceService.save(runningTask); + persistenceService.refreshIndex(ScheduledTask.class); + + // Check lock status after manual release + ScheduledTask updatedTask = persistenceService.load(task.getItemId(), ScheduledTask.class); + assertNull(updatedTask.getLockOwner(), "Lock should be released after manual update"); + } + + /** + * Tests task cancellation during execution. + */ + @Test + @Tag("StateTests") + public void testTaskCancellation() throws Exception { + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch cancelLatch = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "cancel-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + LOGGER.info("Starting task " + task.getTaskType()); + startLatch.countDown(); + try { + LOGGER.info("Task {} waiting on cancel latch...", task.getTaskType()); + if (!cancelLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT)) { + LOGGER.warn("Task {} timeout waiting for cancel latch", task.getTaskType()); + } + LOGGER.info("Task {} completing", task.getTaskType()); + completed.set(true); + callback.complete(); + LOGGER.info("Task {} completed", task.getTaskType()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + + schedulerService.registerTaskExecutor(executor); + + ScheduledTask task = schedulerService.newTask("cancel-test") + .schedule(); + + // Wait for task to start + assertTrue(startLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should start"); + + // Cancel the task + schedulerService.cancelTask(task.getItemId()); + + // Wait for cancellation to be reflected in task status + TestHelper.retryUntil( + () -> schedulerService.getTask(task.getItemId()), + t -> t != null && t.getStatus() == ScheduledTask.TaskStatus.CANCELLED + ); + // Allow any blocked task executor thread to unblock and finish + cancelLatch.countDown(); + + ScheduledTask cancelledTask = schedulerService.getTask(task.getItemId()); + assertEquals( + ScheduledTask.TaskStatus.CANCELLED, + cancelledTask.getStatus(), + "Task should be cancelled"); + assertFalse(completed.get(), "Task should not complete after cancellation"); + } + + /** + * Tests task querying and filtering capabilities. + */ + @Test + @Tag("QueryTests") + public void testTaskQuerying() throws Exception { + // Create tasks with different states + ScheduledTask runningTask = createTestTask("query-test", ScheduledTask.TaskStatus.RUNNING); + ScheduledTask completedTask = createTestTask("query-test", ScheduledTask.TaskStatus.COMPLETED); + ScheduledTask failedTask = createTestTask("query-test", ScheduledTask.TaskStatus.FAILED); + ScheduledTask waitingTask = createTestTask("query-test", ScheduledTask.TaskStatus.WAITING); + + // Refresh persistence to ensure tasks are available for querying (handles refresh delay) + persistenceService.refresh(); + + // Test querying by status - retry until task is available + PartialList completedTasks = TestHelper.retryQueryUntilAvailable( + () -> schedulerService.getTasksByStatus(ScheduledTask.TaskStatus.COMPLETED, 0, 10, null), + 1 + ); + assertEquals(1, completedTasks.getList().size(), "Should find completed task"); + assertEquals( + completedTask.getItemId(), + completedTasks.getList().get(0).getItemId(), + "Should return correct task"); + + // Test querying by type - retry until all tasks are available + PartialList typeTasks = TestHelper.retryQueryUntilAvailable( + () -> schedulerService.getTasksByType("query-test", 0, 10, null), + 4 + ); + assertEquals(4, typeTasks.getList().size(), "Should find all tasks of type"); + + // Test pagination + PartialList pagedTasks = + schedulerService.getTasksByType("query-test", 0, 2, null); + assertEquals(2, pagedTasks.getList().size(), "Should respect page size"); + } + + /** + * Helper method to create a test task with specified status. + */ + private ScheduledTask createTestTask(String type, ScheduledTask.TaskStatus status) { + ScheduledTask task = new ScheduledTask(); + task.setItemId(UUID.randomUUID().toString()); + task.setTaskType(type); + task.setStatus(status); + persistenceService.save(task); + return task; + } + + /** + * Tests node failure scenarios in clustering. + * Verifies that tasks are properly recovered when nodes fail during execution. + */ + @Test + @Tag("ClusterTests") + public void testNodeFailure() throws Exception { + schedulerService.preDestroy(); + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, TEST_LOCK_TIMEOUT, true, true); + SchedulerServiceImpl node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, TEST_LOCK_TIMEOUT, true, true); + + try { + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completionLatch = new CountDownLatch(1); + AtomicReference executingNode = new AtomicReference<>(); + AtomicBoolean taskRecovered = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "node-failure-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + LOGGER.info("Executing task {} id={} on node {}", task.getTaskType(), task.getItemId(), task.getExecutingNodeId()); + executingNode.set(task.getExecutingNodeId()); + startLatch.countDown(); + + if (task.getStatusDetails().get("crashTime") != null) { + LOGGER.info("Task {} has been crashed and is recovered", task.getTaskType()); + taskRecovered.set(true); + callback.complete(); + completionLatch.countDown(); + } + } + + @Override + public boolean canResume(ScheduledTask task) { + return true; + } + }; + + // Register executor on both nodes + node1.registerTaskExecutor(executor); + node2.registerTaskExecutor(executor); + + // Schedule task on node1 + ScheduledTask task = node1.newTask("node-failure-test") + .disallowParallelExecution() + .schedule(); + + // Wait for task to start + assertTrue(startLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should start"); + String originalNode = executingNode.get(); + assertNotNull(originalNode, "Task should have executing node"); + + // Simulate node failure by destroying the executing node + if (originalNode.equals("node1")) { + node1.simulateCrash(); + } else { + node2.simulateCrash(); + } + + // Wait for lock to expire: TEST_LOCK_TIMEOUT plus buffer for the surviving node to detect it + Thread.sleep(TEST_LOCK_TIMEOUT * 2); + + // Trigger recovery on surviving node + if (originalNode.equals("node1")) { + node2.recoverCrashedTasks(); + } else { + node1.recoverCrashedTasks(); + } + + assertTrue(completionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should be recovered"); + assertTrue(taskRecovered.get(), "Task should be recovered by other node"); + + // Verify final state + ScheduledTask recoveredTask = persistenceService.load(task.getItemId(), ScheduledTask.class); + assertEquals( + ScheduledTask.TaskStatus.COMPLETED, + recoveredTask.getStatus(), + "Task should be completed"); + assertNotEquals( + originalNode, + recoveredTask.getLockOwner(), + "Task should be recovered by different node"); + } finally { + node1.preDestroy(); + node2.preDestroy(); + } + } + + /** + * Tests concurrent lock acquisition in a cluster. + * Verifies that tasks don't execute concurrently even across different nodes. + */ + @Test + @Tag("ClusterTests") + public void testConcurrentLockAcquisition() throws Exception { + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + + try { + CountDownLatch completionLatch = new CountDownLatch(2); // We expect 2 executions + Map executions = new ConcurrentHashMap<>(); + AtomicInteger concurrentExecutions = new AtomicInteger(0); + AtomicInteger maxConcurrentExecutions = new AtomicInteger(0); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "testConcurrentLock"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + try { + String executionId = UUID.randomUUID().toString(); + ExecutionInfo info = new ExecutionInfo(System.currentTimeMillis(), task.getLockOwner()); + executions.put(executionId, info); + + int current = concurrentExecutions.incrementAndGet(); + maxConcurrentExecutions.updateAndGet(max -> Math.max(max, current)); + LOGGER.info("Task execution started on node {} with concurrent count: {}", task.getExecutingNodeId(), current); + + Thread.sleep(500); // Simulate some work + + info.endTime = System.currentTimeMillis(); + concurrentExecutions.decrementAndGet(); + completionLatch.countDown(); + LOGGER.info("Task execution completed on node {}", task.getExecutingNodeId()); + callback.complete(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + callback.fail("Execution interrupted"); + } + } + }; + + // Register executor on both nodes + node1.registerTaskExecutor(executor); + node2.registerTaskExecutor(executor); + + // Create task that disallows parallel execution + ScheduledTask task = node1.newTask("testConcurrentLock") + .disallowParallelExecution() + .withPeriod(1000, TimeUnit.MILLISECONDS) + .schedule(); + + // Wait for both executions to complete + assertTrue( + completionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task executions should complete"); + + // Verify no concurrent execution by checking time overlaps + List> sortedExecutions = new ArrayList<>(executions.entrySet()); + sortedExecutions.sort((a, b) -> Long.compare(a.getValue().startTime, b.getValue().startTime)); + + assertEquals(1, maxConcurrentExecutions.get(), "Should have exactly one task executing at a time"); + + // Verify sequential execution + ExecutionInfo current = sortedExecutions.get(0).getValue(); + ExecutionInfo next = sortedExecutions.get(1).getValue(); + + assertTrue( + current.endTime <= next.startTime, + "Task executions should not overlap in time"); + + LOGGER.info("Execution 1 on node {} ran from {} to {}", + current.lockOwner, + current.startTime, + current.endTime); + + LOGGER.info("Execution 2 on node {} ran from {} to {}", + next.lockOwner, + next.startTime, + next.endTime); + + } finally { + node1.preDestroy(); + node2.preDestroy(); + } + } + + /** + * Helper class to track execution timing and lock ownership + */ + private static class ExecutionInfo { + final long startTime; + final String lockOwner; + long endTime; + + ExecutionInfo(long startTime, String lockOwner) { + this.startTime = startTime; + this.lockOwner = lockOwner; + this.endTime = 0; + } + } + + /** + * Tests task rebalancing when cluster nodes join/leave. + * Verifies that tasks are properly redistributed. + */ + @Test + @Tag("ClusterTests") + public void testTaskRebalancing() throws Exception { + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl node2 = null; + try { + CountDownLatch node1Latch = new CountDownLatch(1); + CountDownLatch node2Latch = new CountDownLatch(1); + Set executingNodes = ConcurrentHashMap.newKeySet(); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "rebalance-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + LOGGER.info("Task execution started on node {}", task.getExecutingNodeId()); + executingNodes.add(task.getExecutingNodeId()); + if (task.getExecutingNodeId().equals("node1")) { + node1Latch.countDown(); + } else if (task.getExecutingNodeId().equals("node2")) { + node2Latch.countDown(); + } + callback.complete(); + } + }; + + // Register executor on first node + node1.registerTaskExecutor(executor); + + // Create periodic task with shorter period for faster test execution + ScheduledTask task = node1.newTask("rebalance-test") + .withPeriod(1000, TimeUnit.MILLISECONDS) + .schedule(); + + // Wait for execution on node1 + assertTrue( + node1Latch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task should execute on node1"); + assertTrue(executingNodes.contains("node1"), "Node1 should execute task"); + + // Refresh persistence to ensure task is available for node2 + persistenceService.refreshIndex(ScheduledTask.class); + persistenceService.refresh(); + + // Add second node + node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + node2.registerTaskExecutor(executor); + + // Refresh persistence again after node2 is added to ensure it can see the task + // Note: refresh() and refreshIndex() update immediately, so items are available right away + persistenceService.refreshIndex(ScheduledTask.class); + persistenceService.refresh(); + + // Wait for node2 to discover the task before waiting for execution + // This ensures the task checker has run and the task has been rebalanced + // The task checker runs every 1000ms, so we need to wait for at least one cycle + // Use retryUntil to wait for task discovery with retries + final SchedulerServiceImpl node2Final = node2; // Make effectively final for lambda + final String taskId = task.getItemId(); // Make effectively final for lambda + ScheduledTask discoveredTask = TestHelper.retryUntil( + () -> { + try { + return node2Final.getTask(taskId); + } catch (Exception e) { + return null; + } + }, + discovered -> discovered != null + ); + assertNotNull(discoveredTask, "Node2 should discover the task after initialization"); + + // Now wait for execution on node2 + // The task has a 1000ms period, and the task checker runs every 1000ms + // After discovery, we need to wait for: + // 1. The next execution time to arrive (up to 1000ms) + // 2. The task checker to run and pick up the task (up to 1000ms) + // 3. The task to actually execute + // So we need at least 2000ms + some buffer, but use retry pattern for reliability + // Now wait for execution on node2 + // The task has a 1000ms period, and the task checker runs every 1000ms + // After discovery, we need to wait for: + // 1. The next execution time to arrive (up to 1000ms) + // 2. The task checker to run and pick up the task (up to 1000ms) + // 3. The task to actually execute + // So we need at least 2000ms + some buffer + // Use retry pattern to wait for execution - check if node2 has executed + // retryUntil will retry up to MAX_RETRIES (20) times with 100ms delay = up to 2000ms + boolean node2Executed = false; + try { + TestHelper.retryUntil( + () -> executingNodes.contains("node2"), + result -> result == true + ); + node2Executed = true; + } catch (RuntimeException e) { + // retryUntil failed, try the latch with extended timeout as fallback + long extendedTimeout = Math.max(TEST_TIMEOUT, 3000); // At least 3 seconds + node2Executed = node2Latch.await(extendedTimeout, TEST_TIME_UNIT); + } + + assertTrue( + node2Executed, + "Task should execute on node2 within timeout after discovery (allowing for task period and checker cycles)"); + assertTrue(executingNodes.contains("node2"), "Node2 should execute task"); + + // Verify task distribution + assertEquals(2, executingNodes.size(), "Task should execute on both nodes"); + + } finally { + if (node2 != null) { + node2.preDestroy(); + } + node1.preDestroy(); + } + } + + /** + * Tests lock stealing prevention and recovery. + * Verifies that locks cannot be stolen and are properly recovered. + */ + @Test + @Tag("ClusterTests") + public void testLockStealing() throws Exception { + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + + try { + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicReference lockOwner = new AtomicReference<>(); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "lock-steal-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + lockOwner.set(task.getLockOwner()); + try { + Thread.sleep(TEST_LOCK_TIMEOUT / 2); // Hold lock for half timeout + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + callback.complete(); + executionLatch.countDown(); + } + }; + + // Register executor on both nodes + node1.registerTaskExecutor(executor); + node2.registerTaskExecutor(executor); + + // Create task with short lock timeout + ScheduledTask task = node1.newTask("lock-steal-test") + .disallowParallelExecution() + .schedule(); + + // Wait for first execution + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + String originalOwner = lockOwner.get(); + assertNotNull(originalOwner, "Task should have lock owner"); + + // Attempt immediate re-execution (potential lock stealing) + node1.recoverCrashedTasks(); + node2.recoverCrashedTasks(); + + // After recovery, the task may have completed and released its lock. + // Only assert lock ownership if the lock is still held. + ScheduledTask lockedTask = persistenceService.load(task.getItemId(), ScheduledTask.class); + assertNotNull(lockedTask, "Task should exist"); + if (lockedTask.getLockOwner() != null) { + assertEquals(originalOwner, lockedTask.getLockOwner(), "Lock should not be stolen"); + } + } finally { + node1.preDestroy(); + node2.preDestroy(); + } + } + + @Test + public void testNodeAffinity() throws Exception { + // Create test nodes with cluster service + SchedulerServiceImpl node1 = TestHelper.createSchedulerService("node1", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl node2 = TestHelper.createSchedulerService("node2", persistenceService, executionContextManager, bundleContext, clusterService, -1, true, true); + SchedulerServiceImpl nonExecutorNode = TestHelper.createSchedulerService("node3", persistenceService, executionContextManager, bundleContext, clusterService, -1, false, true); + + try { + // Instead of trying to mock the ClusterService, create persistent tasks with locks + // to ensure nodes are detected via the fallback mechanism in getActiveNodes() + + // Create and persist a task for each node with active locks + createAndPersistTaskWithLock("node1-detection-task", "node1"); + createAndPersistTaskWithLock("node2-detection-task", "node2"); + createAndPersistTaskWithLock("node3-detection-task", "node3"); + + // Refresh the index to ensure changes are visible + persistenceService.refreshIndex(ScheduledTask.class); + + // Register test executors + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicReference executingNodeId = new AtomicReference<>(); + + TaskExecutor testExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return "test-affinity"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executingNodeId.set(task.getExecutingNodeId()); + executionLatch.countDown(); + callback.complete(); + } + }; + + node1.registerTaskExecutor(testExecutor); + node2.registerTaskExecutor(testExecutor); + nonExecutorNode.registerTaskExecutor(testExecutor); + + // Create and schedule a task + ScheduledTask task = new ScheduledTask(); + task.setItemId("affinity-test-task"); + task.setTaskType("test-affinity"); + task.setEnabled(true); + task.setPersistent(true); + + // Schedule the task on node1 + node1.scheduleTask(task); + + // Wait for execution + assertTrue(executionLatch.await(5, TimeUnit.SECONDS), "Task should execute within timeout"); + + // Verify that the task was executed by the expected node + assertNotNull(executingNodeId.get(), "Task should have an executing node ID"); + + // Verify that the cluster service was used to determine active nodes + List activeNodes = node1.getActiveNodes(); + assertTrue(activeNodes.contains("node1"), "Node1 should be in active nodes list"); + assertTrue(activeNodes.contains("node2"), "Node2 should be in active nodes list"); + assertTrue(activeNodes.contains("node3"), "Node3 should be in active nodes list"); + + } finally { + // Clean up + node1.preDestroy(); + node2.preDestroy(); + nonExecutorNode.preDestroy(); + } + } + + // Helper method to create a task with an active lock for a specific node + private void createAndPersistTaskWithLock(String taskId, String nodeId) { + ScheduledTask task = new ScheduledTask(); + task.setItemId(taskId); + task.setTaskType("node-detection"); + task.setEnabled(true); + task.setPersistent(true); + task.setLockOwner(nodeId); + task.setLockDate(new Date()); // Current time + task.setStatus(ScheduledTask.TaskStatus.RUNNING); + persistenceService.save(task); + } + + // removed JUnit 4 marker interfaces + + /** + * Tests that the scheduler service works correctly without a PersistenceSchedulerProvider. + * This verifies that the dependency is truly optional. + */ + @Test + @Tag("OptionalDependencyTests") + public void testSchedulerServiceWithoutPersistenceProvider() throws Exception { + // Create a scheduler service without persistence provider using the helper method + SchedulerServiceImpl schedulerWithoutProvider = TestHelper.createSchedulerServiceWithoutPersistenceProvider( + "test-node-no-provider", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + TEST_LOCK_TIMEOUT, + true, + true); + + try { + // Verify that the service starts without errors + assertTrue(schedulerWithoutProvider.isExecutorNode(), "Scheduler service should be running"); + + // Test that in-memory tasks work correctly + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicBoolean executed = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "no-provider-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executed.set(true); + executionLatch.countDown(); + callback.complete(); + } + }; + + schedulerWithoutProvider.registerTaskExecutor(executor); + + // Create a non-persistent task using the registered executor + ScheduledTask task = schedulerWithoutProvider.newTask("no-provider-test") + .nonPersistent() + .withExecutor(executor) + .schedule(); + + // Wait for execution + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + assertTrue(executed.get(), "Task should have executed"); + + // Wait for task status to be properly updated + // This is necessary because the state transition happens asynchronously after the callback.complete() is called + ScheduledTask completedTask = waitForTaskStatusWithScheduler(task.getItemId(), ScheduledTask.TaskStatus.COMPLETED, TEST_TIMEOUT, TEST_SLEEP, schedulerWithoutProvider); + assertNotNull(completedTask, "Task should be found"); + assertEquals(ScheduledTask.TaskStatus.COMPLETED, completedTask.getStatus(), "Task should be completed"); + + // Test that persistent tasks are handled gracefully (should not fail) + try { + List persistentTasks = schedulerWithoutProvider.getPersistentTasks(); + assertNotNull(persistentTasks, "Should return empty list for persistent tasks"); + assertEquals(0, persistentTasks.size(), "Should have no persistent tasks"); + } catch (Exception e) { + fail("Should handle persistent task queries gracefully: " + e.getMessage()); + } + + } finally { + schedulerWithoutProvider.preDestroy(); + } + } + + /** + * Tests that the scheduler service works correctly when PersistenceSchedulerProvider is added later. + * This simulates the OSGi service binding scenario. + */ + @Test + @Tag("OptionalDependencyTests") + public void testSchedulerServiceWithLatePersistenceProviderBinding() throws Exception { + // Create a scheduler service without persistence provider initially + SchedulerServiceImpl schedulerWithLateProvider = TestHelper.createSchedulerServiceWithoutPersistenceProvider( + "test-node-late-provider", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + TEST_LOCK_TIMEOUT, + true, + true); + + try { + // Verify service starts without persistence provider + assertTrue(schedulerWithLateProvider.isExecutorNode(), "Scheduler service should be running"); + + // Create a persistence provider and bind it later + PersistenceSchedulerProvider lateProvider = new PersistenceSchedulerProvider(); + lateProvider.setPersistenceService(persistenceService); + lateProvider.setExecutorNode(true); + lateProvider.setNodeId("test-node-late-provider"); + lateProvider.setCompletedTaskTtlDays(30); + lateProvider.setLockManager(schedulerWithLateProvider.getLockManager()); + lateProvider.postConstruct(); + + // Bind the provider (simulating OSGi service binding) + schedulerWithLateProvider.setPersistenceProvider(lateProvider); + + // Test that persistent tasks now work + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicBoolean executed = new AtomicBoolean(false); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return "late-provider-test"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executed.set(true); + executionLatch.countDown(); + callback.complete(); + } + }; + + schedulerWithLateProvider.registerTaskExecutor(executor); + + // Create a persistent task using the registered executor + ScheduledTask task = schedulerWithLateProvider.newTask("late-provider-test") + .withExecutor(executor) + .schedule(); + + // Wait for execution + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + assertTrue(executed.get(), "Task should have executed"); + + // Wait for task status to be properly updated and persisted + ScheduledTask completedTask = waitForTaskStatus(task.getItemId(), ScheduledTask.TaskStatus.COMPLETED, TEST_TIMEOUT, TEST_SLEEP); + assertNotNull(completedTask, "Task should be found"); + assertEquals(ScheduledTask.TaskStatus.COMPLETED, completedTask.getStatus(), "Task should be completed"); + + // Verify persistent tasks are now available - retry until task is available (handles refresh delay) + List persistentTasks = TestHelper.retryUntil( + () -> schedulerWithLateProvider.getPersistentTasks(), + r -> r != null && r.size() >= 1 + ); + assertNotNull(persistentTasks, "Should return list for persistent tasks"); + assertTrue(persistentTasks.size() >= 1, "Should have at least one persistent task"); + + // Unbind the provider (simulating OSGi service unbinding) + schedulerWithLateProvider.unsetPersistenceProvider(lateProvider); + + // Verify that persistent task queries are handled gracefully again + try { + List persistentTasksAfterUnbind = schedulerWithLateProvider.getPersistentTasks(); + assertNotNull(persistentTasksAfterUnbind, "Should return empty list after unbinding"); + assertEquals(0, persistentTasksAfterUnbind.size(), "Should have no persistent tasks after unbinding"); + } catch (Exception e) { + fail("Should handle persistent task queries gracefully after unbinding: " + e.getMessage()); + } + + // Clean up the provider + lateProvider.preDestroy(); + + } finally { + schedulerWithLateProvider.preDestroy(); + } + } + + /** + * Tests that the scheduler service handles PersistenceSchedulerProvider binding/unbinding correctly + * in a multi-threaded environment. + */ + @Test + @Tag("OptionalDependencyTests") + public void testConcurrentPersistenceProviderBinding() throws Exception { + SchedulerServiceImpl schedulerConcurrent = TestHelper.createSchedulerServiceWithoutPersistenceProvider( + "test-node-concurrent", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + TEST_LOCK_TIMEOUT, + true, + true); + + try { + // Create multiple persistence providers + List providers = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + PersistenceSchedulerProvider provider = new PersistenceSchedulerProvider(); + provider.setPersistenceService(persistenceService); + provider.setExecutorNode(true); + provider.setNodeId("test-node-concurrent-" + i); + provider.setCompletedTaskTtlDays(30); + provider.setLockManager(schedulerConcurrent.getLockManager()); + provider.postConstruct(); + providers.add(provider); + } + + // Test concurrent binding/unbinding + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completionLatch = new CountDownLatch(3); + AtomicInteger bindingCount = new AtomicInteger(0); + AtomicInteger unbindingCount = new AtomicInteger(0); + + // Start threads that bind/unbind providers concurrently + for (int i = 0; i < 3; i++) { + final int index = i; + new Thread(() -> { + try { + startLatch.await(); + + // Bind provider + schedulerConcurrent.setPersistenceProvider(providers.get(index)); + bindingCount.incrementAndGet(); + + // Simulate some work + Thread.sleep(100); + + // Unbind provider + schedulerConcurrent.unsetPersistenceProvider(providers.get(index)); + unbindingCount.incrementAndGet(); + + completionLatch.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + + // Start all threads + startLatch.countDown(); + + // Wait for completion + assertTrue( + completionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "All binding/unbinding operations should complete"); + + // Verify all operations completed + assertEquals(3, bindingCount.get(), "All bindings should complete"); + assertEquals(3, unbindingCount.get(), "All unbindings should complete"); + + // Clean up providers + for (PersistenceSchedulerProvider provider : providers) { + provider.preDestroy(); + } + + } finally { + schedulerConcurrent.preDestroy(); + } + } + + /** + * Tests that system tasks are properly handled when the scheduler restarts. + * System tasks should be reused rather than duplicated. + */ + @Test + @Tag("SystemTaskTests") + public void testSystemTaskReuse() throws Exception { + String taskType = "system-task-reuse-test"; + AtomicInteger executionCount = new AtomicInteger(0); + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicReference taskId = new AtomicReference<>(); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionCount.incrementAndGet(); + executionLatch.countDown(); + callback.complete(); + } + }; + + schedulerService.registerTaskExecutor(executor); + + // Schedule a system task + ScheduledTask systemTask = schedulerService.newTask(taskType) + .withPeriod(1, TimeUnit.DAYS) + .withFixedRate() + .asSystemTask() + .schedule(); + + taskId.set(systemTask.getItemId()); + + // Wait for task to execute once + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + assertEquals(1, executionCount.get(), "Task should have executed once"); + + // Force persistence to ensure the task is saved + persistenceService.refreshIndex(ScheduledTask.class); + + // Now create a new task with the same type + ScheduledTask secondTask = schedulerService.newTask(taskType) + .withPeriod(1, TimeUnit.DAYS) + .withFixedRate() + .asSystemTask() + .schedule(); + + // The task ID should be the same as the first one since it's reused + assertEquals( + systemTask.getItemId(), + secondTask.getItemId(), + "System task should be reused, not duplicated"); + + // Check that there's only one task of this type BEFORE teardown + persistenceService.refreshIndex(ScheduledTask.class); + List tasks = schedulerService.getTasksByType(taskType, 0, -1, null).getList(); + assertEquals(1, tasks.size(), "Should only be one system task of this type"); + + // Clean up the system task manually to avoid teardown issues + schedulerService.cancelTask(taskId.get()); + } + + /** + * Tests that scheduler correctly handles tasks across shutdown and restart. + * Persistent tasks should be properly reloaded and continue execution. + */ + @Test + @Tag("RestartTests") + public void testSchedulerRestartWithPersistentTasks() throws Exception { + // Create a persistent task + String taskType = "restart-test"; + AtomicInteger executionCount = new AtomicInteger(0); + CountDownLatch firstExecutionLatch = new CountDownLatch(1); + CountDownLatch secondExecutionLatch = new CountDownLatch(1); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + int count = executionCount.incrementAndGet(); + if (count == 1) { + firstExecutionLatch.countDown(); + } else if (count == 2) { + secondExecutionLatch.countDown(); + } + callback.complete(); + } + }; + + schedulerService.registerTaskExecutor(executor); + + // Schedule a persistent task with short period + ScheduledTask persistentTask = schedulerService.newTask(taskType) + .withPeriod(500, TimeUnit.MILLISECONDS) + .withFixedRate() + .schedule(); + + // Wait for first execution to complete + assertTrue( + firstExecutionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task should execute first time"); + + // Force refresh to ensure the task is properly saved + persistenceService.refreshIndex(ScheduledTask.class); + + // Shutdown the scheduler service + schedulerService.preDestroy(); + + // Create a new scheduler service that will reload existing tasks + SchedulerServiceImpl newSchedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( + "restart-scheduler-node", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + -1, + true, + false); + + newSchedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE); + newSchedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + + // Initialize first, then register executor + newSchedulerService.postConstruct(); + newSchedulerService.registerTaskExecutor(executor); + + // Wait for second execution to complete on restarted scheduler + boolean executed = secondExecutionLatch.await(TEST_TIMEOUT * 2, TEST_TIME_UNIT); + + // Clean up the new scheduler service + newSchedulerService.preDestroy(); + + assertTrue(executed, "Task should execute after scheduler restart"); + assertEquals(2, executionCount.get(), "Task should have executed twice"); + + // Verify the reloaded task has same ID + ScheduledTask reloadedTask = persistenceService.load(persistentTask.getItemId(), ScheduledTask.class); + assertNotNull(reloadedTask, "Persistent task should still exist"); + assertEquals(persistentTask.getItemId(), reloadedTask.getItemId(), "Task ID should be preserved after restart"); + } + + /** + * Tests that system tasks properly preserve their state and configuration + * across scheduler restarts. + */ + @Test + @Tag("SystemTaskTests") + @Tag("RestartTests") + public void testSystemTaskAcrossRestart() throws Exception { + // Create a system task with specific configuration + String taskType = "system-task-restart-test"; + Map taskParams = new HashMap<>(); + taskParams.put("configParam", "testValue"); + taskParams.put("numericParam", 123); + + AtomicInteger executionCount = new AtomicInteger(0); + AtomicReference> executedParams = new AtomicReference<>(); + CountDownLatch executionLatch = new CountDownLatch(1); + + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionCount.incrementAndGet(); + executedParams.set(new HashMap<>(task.getParameters())); + executionLatch.countDown(); + callback.complete(); + } + }; + + schedulerService.registerTaskExecutor(executor); + + // Schedule the system task with parameters + ScheduledTask systemTask = schedulerService.newTask(taskType) + .withParameters(taskParams) + .withPeriod(1, TimeUnit.DAYS) + .withFixedRate() + .asSystemTask() + .schedule(); + + // Force refresh to ensure the task is properly saved + persistenceService.refreshIndex(ScheduledTask.class); + + // Shutdown the scheduler service + schedulerService.preDestroy(); + + // Create a new scheduler service + SchedulerServiceImpl newSchedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( + "system-task-restart-scheduler-node", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + -1, + true, + false); + + newSchedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE); + newSchedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + newSchedulerService.postConstruct(); + + // Create a duplicate task during scheduler initialization + // This simulates code that always creates its task during initialization + TaskExecutor newExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionCount.incrementAndGet(); + executedParams.set(new HashMap<>(task.getParameters())); + executionLatch.countDown(); + callback.complete(); + } + }; + + newSchedulerService.registerTaskExecutor(newExecutor); + + // Force refresh to ensure tasks are properly loaded + persistenceService.refreshIndex(ScheduledTask.class); + + // Attempt to create the same system task (should reuse existing one) + Map newTaskParams = new HashMap<>(); + newTaskParams.put("configParam", "newValue"); // Different param value + + ScheduledTask recreatedTask = newSchedulerService.newTask(taskType) + .withParameters(newTaskParams) + .withPeriod(2, TimeUnit.DAYS) // Different period + .withFixedRate() + .asSystemTask() + .schedule(); + + // Verify task was reused, not recreated + assertEquals( + systemTask.getItemId(), + recreatedTask.getItemId(), + "System task ID should be preserved"); + + // Verify original parameters were preserved + assertEquals( + "testValue", + recreatedTask.getParameters().get("configParam"), + "Original parameter should be preserved"); + assertEquals( + 123, + recreatedTask.getParameters().get("numericParam"), + "Original parameter should be preserved"); + + // Clean up + newSchedulerService.preDestroy(); + } + + /** + * Tests that system purge tasks are properly handled and can be reused. + * This simulates the behavior in SchedulerServiceImpl and ProfileServiceImpl. + */ + @Test + @Tag("SystemTaskTests") + @Tag("MaintenanceTests") + public void testSystemPurgeTasks() throws Exception { + CountDownLatch executionLatch = new CountDownLatch(1); + AtomicInteger executionCount = new AtomicInteger(0); + + // Create a TaskExecutor for "task-purge" (similar to SchedulerServiceImpl's purge task) + TaskExecutor taskPurgeExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return "task-purge"; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + executionCount.incrementAndGet(); + executionLatch.countDown(); + callback.complete(); + } + }; + + // Register the task executor + schedulerService.registerTaskExecutor(taskPurgeExecutor); + + // Create the initial task-purge task + ScheduledTask taskPurgeTask = schedulerService.newTask("task-purge") + .withPeriod(1, TimeUnit.DAYS) + .withFixedRate() + .asSystemTask() + .schedule(); + + // Verify the task is correctly created and scheduled + assertNotNull(taskPurgeTask, "Task should be created"); + assertTrue(taskPurgeTask.isSystemTask(), "Task should be a system task"); + + // Wait for the task to execute + assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task should execute"); + assertEquals(1, executionCount.get(), "Task should have executed once"); + + // Force refresh to ensure the task is properly saved + persistenceService.refreshIndex(ScheduledTask.class); + + // Now simulate the scheduler restart by destroying and recreating it + schedulerService.preDestroy(); + + // Create a new scheduler service + SchedulerServiceImpl newSchedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( + "system-purge-scheduler-node", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + -1, + true, + false); + + newSchedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE); + newSchedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + newSchedulerService.postConstruct(); + + // Register the executor + newSchedulerService.registerTaskExecutor(taskPurgeExecutor); + + // Force refresh to ensure tasks are properly loaded + persistenceService.refreshIndex(ScheduledTask.class); + + // Now simulate the initializeTaskPurge method in SchedulerServiceImpl + // This checks for existing system tasks of type "task-purge" + List existingTasks = newSchedulerService.getTasksByType("task-purge", 0, 1, null).getList(); + ScheduledTask reuseTask = null; + + // Check if there's an existing system task + if (!existingTasks.isEmpty() && existingTasks.get(0).isSystemTask()) { + // Reuse the existing task + reuseTask = existingTasks.get(0); + // Update task configuration + reuseTask.setPeriod(1); + reuseTask.setTimeUnit(TimeUnit.DAYS); + reuseTask.setFixedRate(true); + reuseTask.setEnabled(true); + newSchedulerService.saveTask(reuseTask); + } else { + // Create a new task if none exists or existing one isn't a system task + reuseTask = newSchedulerService.newTask("task-purge") + .withPeriod(1, TimeUnit.DAYS) + .withFixedRate() + .asSystemTask() + .schedule(); + } + + // Verify task was reused, not recreated + assertEquals(taskPurgeTask.getItemId(), reuseTask.getItemId(), "System task ID should be preserved"); + + // Finally, verify the task state is preserved after all operations + ScheduledTask finalTask = persistenceService.load(taskPurgeTask.getItemId(), ScheduledTask.class); + assertNotNull(finalTask, "Task should still exist in persistence"); + assertTrue(finalTask.isSystemTask(), "Task should still be a system task"); + assertEquals("task-purge", finalTask.getTaskType(), "Task type should be preserved"); + + // Clean up + newSchedulerService.preDestroy(); + } + + /** + * Tests that a dedicated TaskExecutor for system tasks works properly + * and is reused after scheduler restart. This simulates the pattern we've + * implemented for previously using withSimpleExecutor() in system tasks. + */ + @Test + @Tag("SystemTaskTests") + @Tag("RestartTests") + public void testDedicatedTaskExecutorForSystemTasks() throws Exception { + String taskType = "dedicated-executor-test"; + CountDownLatch firstExecutionLatch = new CountDownLatch(1); + CountDownLatch secondExecutionLatch = new CountDownLatch(1); + AtomicInteger executionCount = new AtomicInteger(0); + + // Create a dedicated TaskExecutor that counts executions and signals via latches + TaskExecutor dedicatedExecutor = new TaskExecutor() { + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + try { + int count = executionCount.incrementAndGet(); + LOGGER.info("Executing task {} (execution #{})", task.getItemId(), count); + + // Get parameter from task + Map params = task.getParameters(); + String testParam = (String) params.get("testParam"); + assertNotNull(testParam, "Task should have testParam in parameters map"); + + if (count == 1) { + firstExecutionLatch.countDown(); + } else if (count == 2) { + secondExecutionLatch.countDown(); + } + + callback.complete(); + } catch (Exception e) { + LOGGER.error("Error executing task", e); + callback.fail(e.getMessage()); + } + } + }; + + // Register the executor and create the system task + schedulerService.registerTaskExecutor(dedicatedExecutor); + + // Setup parameters for the task + Map taskParams = new HashMap<>(); + taskParams.put("testParam", "initialValue"); + + // Create a system task using the dedicated executor + ScheduledTask systemTask = schedulerService.newTask(taskType) + .withParameters(taskParams) + .withPeriod(100, TimeUnit.MILLISECONDS) // Short period for testing + .withFixedRate() + .asSystemTask() + .schedule(); + + // Verify the task executes with the dedicated executor + assertTrue( + firstExecutionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), + "Task should execute with dedicated executor"); + assertEquals(1, executionCount.get(), "Task should execute once"); + + // Force refresh to ensure the task is properly saved + persistenceService.refreshIndex(ScheduledTask.class); + + // Now shut down and restart the scheduler + schedulerService.preDestroy(); + + // Create a new scheduler service + SchedulerServiceImpl newSchedulerService = (SchedulerServiceImpl) TestHelper.createSchedulerService( + "dedicated-executor-scheduler-node", + persistenceService, + executionContextManager, + bundleContext, + clusterService, + -1, + true, + false); + + newSchedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE); + newSchedulerService.setLockTimeout(TEST_LOCK_TIMEOUT); + + // Initialize the scheduler before registering the executor + newSchedulerService.postConstruct(); + newSchedulerService.registerTaskExecutor(dedicatedExecutor); + + // Force refresh to ensure tasks are properly loaded + persistenceService.refreshIndex(ScheduledTask.class); + + // The task should automatically resume and execute with the dedicated executor + assertTrue( + secondExecutionLatch.await(TEST_TIMEOUT * 2, TEST_TIME_UNIT), + "Task should execute after restart with dedicated executor"); + assertEquals(2, executionCount.get(), "Task should execute twice"); + + // Clean up + newSchedulerService.preDestroy(); + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutorRegistryTest.java b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutorRegistryTest.java new file mode 100644 index 000000000..0ce0c71d8 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutorRegistryTest.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.services.impl.scheduler; + +import org.apache.unomi.api.tasks.ScheduledTask; +import org.apache.unomi.api.tasks.TaskExecutor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for TaskExecutorRegistry + */ +public class TaskExecutorRegistryTest { + + private TaskExecutorRegistry registry; + + @BeforeEach + public void setUp() { + registry = new TaskExecutorRegistry(); + } + + @Test + public void testRegisterExecutor() { + TestTaskExecutor executor = new TestTaskExecutor("test-type"); + + assertFalse(registry.hasExecutor("test-type")); + assertEquals(0, registry.getExecutorCount()); + + registry.registerExecutor(executor); + + assertTrue(registry.hasExecutor("test-type")); + assertEquals(1, registry.getExecutorCount()); + assertEquals(executor, registry.getExecutor("test-type")); + } + + @Test + public void testUnregisterExecutor() { + TestTaskExecutor executor = new TestTaskExecutor("test-type"); + + registry.registerExecutor(executor); + assertTrue(registry.hasExecutor("test-type")); + + registry.unregisterExecutor(executor); + + assertFalse(registry.hasExecutor("test-type")); + assertEquals(0, registry.getExecutorCount()); + assertNull(registry.getExecutor("test-type")); + } + + @Test + public void testMultipleExecutors() { + TestTaskExecutor executor1 = new TestTaskExecutor("type1"); + TestTaskExecutor executor2 = new TestTaskExecutor("type2"); + + registry.registerExecutor(executor1); + registry.registerExecutor(executor2); + + assertEquals(2, registry.getExecutorCount()); + assertEquals(2, registry.getRegisteredTaskTypes().size()); + assertTrue(registry.getRegisteredTaskTypes().contains("type1")); + assertTrue(registry.getRegisteredTaskTypes().contains("type2")); + + assertEquals(executor1, registry.getExecutor("type1")); + assertEquals(executor2, registry.getExecutor("type2")); + } + + @Test + public void testReplaceExecutor() { + TestTaskExecutor executor1 = new TestTaskExecutor("test-type"); + TestTaskExecutor executor2 = new TestTaskExecutor("test-type"); + + registry.registerExecutor(executor1); + assertEquals(executor1, registry.getExecutor("test-type")); + + // Registering another executor for the same type should replace it + registry.registerExecutor(executor2); + assertEquals(executor2, registry.getExecutor("test-type")); + assertEquals(1, registry.getExecutorCount()); + } + + @Test + public void testClear() { + registry.registerExecutor(new TestTaskExecutor("type1")); + registry.registerExecutor(new TestTaskExecutor("type2")); + + assertEquals(2, registry.getExecutorCount()); + + registry.clear(); + + assertEquals(0, registry.getExecutorCount()); + assertFalse(registry.hasExecutor("type1")); + assertFalse(registry.hasExecutor("type2")); + } + + @Test + public void testGetAllExecutors() { + TestTaskExecutor executor1 = new TestTaskExecutor("type1"); + TestTaskExecutor executor2 = new TestTaskExecutor("type2"); + + registry.registerExecutor(executor1); + registry.registerExecutor(executor2); + + assertEquals(2, registry.getAllExecutors().size()); + assertTrue(registry.getAllExecutors().containsValue(executor1)); + assertTrue(registry.getAllExecutors().containsValue(executor2)); + + // Verify returned map is unmodifiable + try { + registry.getAllExecutors().put("test", executor1); + fail("Should throw UnsupportedOperationException"); + } catch (UnsupportedOperationException e) { + // Expected + } + } + + @Test + public void testRegisterNullExecutor() { + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> registry.registerExecutor(null)); + } + + @Test + public void testRegisterExecutorWithNullTaskType() { + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return null; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + // No-op + } + }; + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> registry.registerExecutor(executor)); + } + + @Test + public void testRegisterExecutorWithEmptyTaskType() { + TaskExecutor executor = new TaskExecutor() { + @Override + public String getTaskType() { + return ""; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + // No-op + } + }; + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> registry.registerExecutor(executor)); + } + + @Test + public void testUnregisterNullExecutor() { + // Should not throw exception + registry.unregisterExecutor(null); + } + + @Test + public void testGetNonExistentExecutor() { + assertNull(registry.getExecutor("non-existent")); + assertNull(registry.getExecutor(null)); + assertFalse(registry.hasExecutor("non-existent")); + assertFalse(registry.hasExecutor(null)); + } + + /** + * Test helper class + */ + private static class TestTaskExecutor implements TaskExecutor { + private final String taskType; + + public TestTaskExecutor(String taskType) { + this.taskType = taskType; + } + + @Override + public String getTaskType() { + return taskType; + } + + @Override + public void execute(ScheduledTask task, TaskStatusCallback callback) { + // No-op for testing + } + } +} \ No newline at end of file diff --git a/services/src/test/resources/META-INF/cxs/personas/predefined-personas.json b/services/src/test/resources/META-INF/cxs/personas/predefined-personas.json new file mode 100644 index 000000000..95f597524 --- /dev/null +++ b/services/src/test/resources/META-INF/cxs/personas/predefined-personas.json @@ -0,0 +1,16 @@ +{ + "persona": { + "itemId": "testPersona", + "itemType": "persona", + "version": null, + "properties": { + "firstName": "Test", + "lastName": "Persona", + "age": 30 + }, + "systemProperties": { + "systemTags": ["predefinedPersona"] + } + }, + "sessions": [] +} diff --git a/services/src/test/resources/META-INF/cxs/properties/predefined-properties.json b/services/src/test/resources/META-INF/cxs/properties/predefined-properties.json new file mode 100644 index 000000000..507597358 --- /dev/null +++ b/services/src/test/resources/META-INF/cxs/properties/predefined-properties.json @@ -0,0 +1,12 @@ +{ + "metadata": { + "id": "firstName", + "name": "First Name", + "description": "The first name of the profile", + "systemTags": ["profileProperties", "personalProfileProperties"] + }, + "target": "profiles", + "type": "string", + "defaultValue": "", + "automaticMappingsFrom": ["j:firstName"] +} \ No newline at end of file diff --git a/services/src/test/resources/logback-test.xml b/services/src/test/resources/logback-test.xml new file mode 100644 index 000000000..425ffabec --- /dev/null +++ b/services/src/test/resources/logback-test.xml @@ -0,0 +1,37 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/services/src/test/resources/test-segments/test-segment.json b/services/src/test/resources/test-segments/test-segment.json new file mode 100644 index 000000000..b6f4ec4f1 --- /dev/null +++ b/services/src/test/resources/test-segments/test-segment.json @@ -0,0 +1,18 @@ +{ + "itemId": "test-segment", + "tenantId": "system", + "metadata": { + "id": "test-segment", + "name": "Test Segment", + "scope": "systemscope", + "enabled": true + }, + "condition": { + "type": "profilePropertyCondition", + "parameterValues": { + "propertyName": "properties.testProperty", + "comparisonOperator": "equals", + "propertyValue": "testValue" + } + } +}