diff --git a/src/main/java/com/yashdotdev/distributed_traffic_control/config/TrafficControlConfiguration.java b/src/main/java/com/yashdotdev/distributed_traffic_control/config/TrafficControlConfiguration.java index c679a8f..25968ed 100644 --- a/src/main/java/com/yashdotdev/distributed_traffic_control/config/TrafficControlConfiguration.java +++ b/src/main/java/com/yashdotdev/distributed_traffic_control/config/TrafficControlConfiguration.java @@ -11,13 +11,7 @@ import com.yashdotdev.distributed_traffic_control.lease.LeaseStore; import com.yashdotdev.distributed_traffic_control.lease.RedisLeaseCoordinator; import com.yashdotdev.distributed_traffic_control.monitoring.TrafficControlMetrics; -import com.yashdotdev.distributed_traffic_control.policy.DefaultPolicyManagementService; -import com.yashdotdev.distributed_traffic_control.policy.InMemoryPolicyProvider; -import com.yashdotdev.distributed_traffic_control.policy.PolicyManagementService; -import com.yashdotdev.distributed_traffic_control.policy.PolicyProvider; -import com.yashdotdev.distributed_traffic_control.policy.TrafficPolicy; -import com.yashdotdev.distributed_traffic_control.policy.TrafficPolicyType; -import com.yashdotdev.distributed_traffic_control.policy.PolicyStatus; +import com.yashdotdev.distributed_traffic_control.policy.*; import com.yashdotdev.distributed_traffic_control.quota.InMemoryQuotaCoordinator; import com.yashdotdev.distributed_traffic_control.quota.QuotaCoordinator; import com.yashdotdev.distributed_traffic_control.traffic.DefaultTrafficControlService; @@ -47,13 +41,19 @@ public TrafficPolicy defaultTrafficPolicy(Clock clock) { } @Bean - public InMemoryPolicyProvider policyProvider(TrafficPolicy defaultTrafficPolicy) { - return new InMemoryPolicyProvider(defaultTrafficPolicy); + public RedisPolicyStore policyStore( + StringRedisTemplate redisTemplate, + TrafficPolicy defaultTrafficPolicy) { + + RedisPolicyStore policyStore = new RedisPolicyStore(redisTemplate); + policyStore.initializeDefaultPolicy(defaultTrafficPolicy); + + return policyStore; } @Bean - public PolicyManagementService policyManagementService(InMemoryPolicyProvider policyProvider) { - return new DefaultPolicyManagementService(policyProvider); + public PolicyManagementService policyManagementService(PolicyStore policyStore) { + return new DefaultPolicyManagementService(policyStore); } diff --git a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/DefaultPolicyManagementService.java b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/DefaultPolicyManagementService.java index 5d24b28..95e61d4 100644 --- a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/DefaultPolicyManagementService.java +++ b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/DefaultPolicyManagementService.java @@ -6,34 +6,42 @@ public class DefaultPolicyManagementService implements PolicyManagementService { - private final InMemoryPolicyProvider policyProvider; + private final PolicyStore policyStore; - public DefaultPolicyManagementService(InMemoryPolicyProvider policyProvider) { - this.policyProvider = Objects.requireNonNull(policyProvider, "policyProvider must not be null"); + public DefaultPolicyManagementService(PolicyStore policyStore) { + this.policyStore = Objects.requireNonNull( + policyStore, + "policyStore must not be null" + ); } @Override public TrafficPolicy registerPolicy(String resource, TrafficPolicy policy) { - policyProvider.registerPolicy(resource, policy); + policyStore.registerPolicy(resource, policy); return policy; } @Override public Optional findPolicy(String resource) { - return policyProvider.findPolicy(resource); + return policyStore.findPolicy(resource); } + @Override public List findAllPolicies() { - return policyProvider.findAllPolicies().stream().map(entry -> new PolicyRegistration(entry.getKey(), entry.getValue())).toList(); + return policyStore.findAllPolicies(); } @Override public TrafficPolicy updatePolicy(String resource, TrafficPolicy policy) { - Optional existingPolicy = policyProvider.findPolicy(resource); + Optional existingPolicy = policyStore.findPolicy(resource); + if (existingPolicy.isEmpty()) { - throw new IllegalArgumentException("No policy registered for resource: " + resource); + throw new IllegalArgumentException( + "No policy registered for resource: " + resource + ); } - policyProvider.registerPolicy(resource, policy); + + policyStore.registerPolicy(resource, policy); return policy; } @@ -48,22 +56,22 @@ public TrafficPolicy deactivatePolicy(String resource) { } private TrafficPolicy changePolicyStatus(String resource, PolicyStatus status) { - TrafficPolicy policy = policyProvider.findPolicy(resource) + TrafficPolicy policy = policyStore.findPolicy(resource) .orElseThrow(() -> new PolicyNotFoundException(resource)); TrafficPolicy updatedPolicy = policy.withStatus(status); - policyProvider.registerPolicy(resource, updatedPolicy); + policyStore.registerPolicy(resource, updatedPolicy); return updatedPolicy; } @Override public void removePolicy(String resource) { - policyProvider.removePolicy(resource); + policyStore.removePolicy(resource); } @Override public void setDefaultPolicy(TrafficPolicy policy) { - policyProvider.setDefaultPolicy(policy); + policyStore.setDefaultPolicy(policy); } } \ No newline at end of file diff --git a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/InMemoryPolicyProvider.java b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/InMemoryPolicyProvider.java index ab07002..7e2469e 100644 --- a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/InMemoryPolicyProvider.java +++ b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/InMemoryPolicyProvider.java @@ -9,21 +9,28 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -public class InMemoryPolicyProvider implements PolicyProvider { +public class InMemoryPolicyProvider implements PolicyProvider, PolicyStore { private final Map policiesByResource = new ConcurrentHashMap<>(); private final Map policiesBySubjectAndResource = new ConcurrentHashMap<>(); + private volatile TrafficPolicy defaultPolicy; public InMemoryPolicyProvider() { } + public InMemoryPolicyProvider(TrafficPolicy defaultPolicy) { - this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy must not be null"); + this.defaultPolicy = Objects.requireNonNull( + defaultPolicy, + "defaultPolicy must not be null" + ); } - public void registerPolicy(String resource, TrafficPolicy policy) { + @Override + public void registerPolicy(String resource, TrafficPolicy policy) { validateResource(resource); Objects.requireNonNull(policy, "policy must not be null"); + policiesByResource.put(resource, policy); } @@ -31,39 +38,62 @@ public void registerPolicy(TrafficSubject subject, String resource, TrafficPolic Objects.requireNonNull(subject, "subject must not be null"); validateResource(resource); Objects.requireNonNull(policy, "policy must not be null"); - policiesBySubjectAndResource.put(new PolicyMatchKey(subject, resource), policy); + + policiesBySubjectAndResource.put( + new PolicyMatchKey(subject, resource), + policy + ); } public Optional findPolicy(String resource) { validateResource(resource); + return Optional.ofNullable(policiesByResource.get(resource)); } - public List> findAllPolicies() { - return List.copyOf(policiesByResource.entrySet()); + @Override + public List findAllPolicies() { + return policiesByResource.entrySet() + .stream() + .map(entry -> new PolicyRegistration(entry.getKey(), entry.getValue())) + .toList(); } + @Override public void removePolicy(String resource) { validateResource(resource); + policiesByResource.remove(resource); } public void removePolicy(TrafficSubject subject, String resource) { Objects.requireNonNull(subject, "subject must not be null"); - validateResource(resource); + policiesBySubjectAndResource.remove(new PolicyMatchKey(subject, resource)); } + @Override public void setDefaultPolicy(TrafficPolicy policy) { - this.defaultPolicy = Objects.requireNonNull(policy, "policy must not be null"); + this.defaultPolicy = Objects.requireNonNull( + policy, + "policy must not be null" + ); + } + + @Override + public Optional findDefaultPolicy() { + return Optional.ofNullable(defaultPolicy); } @Override public Optional findPolicy(TrafficRequest request) { Objects.requireNonNull(request, "request must not be null"); - TrafficPolicy subjectPolicy = policiesBySubjectAndResource.get(new PolicyMatchKey(request.getSubject(), request.getResource())); + TrafficPolicy subjectPolicy = policiesBySubjectAndResource.get( + new PolicyMatchKey(request.getSubject(), request.getResource()) + ); + if (subjectPolicy != null) { return Optional.of(subjectPolicy); } @@ -73,6 +103,7 @@ public Optional findPolicy(TrafficRequest request) { if (resourcePolicy != null) { return Optional.of(resourcePolicy); } + return Optional.ofNullable(defaultPolicy); } diff --git a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/PolicyStore.java b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/PolicyStore.java new file mode 100644 index 0000000..1d2dbce --- /dev/null +++ b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/PolicyStore.java @@ -0,0 +1,24 @@ +package com.yashdotdev.distributed_traffic_control.policy; + +import java.util.List; +import java.util.Optional; + +/** + * Defines storage operations for traffic policies. + * + * Implementations may use in-memory or persistent storage. + */ +public interface PolicyStore { + + void registerPolicy(String resource, TrafficPolicy policy); + + Optional findPolicy(String resource); + + List findAllPolicies(); + + void removePolicy(String resource); + + void setDefaultPolicy(TrafficPolicy policy); + + Optional findDefaultPolicy(); +} \ No newline at end of file diff --git a/src/main/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStore.java b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStore.java new file mode 100644 index 0000000..148a5ea --- /dev/null +++ b/src/main/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStore.java @@ -0,0 +1,286 @@ +package com.yashdotdev.distributed_traffic_control.policy; + +import com.yashdotdev.distributed_traffic_control.traffic.TrafficRequest; +import com.yashdotdev.distributed_traffic_control.traffic.TrafficSubject; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public class RedisPolicyStore implements PolicyStore, PolicyProvider { + + private static final String RESOURCE_PREFIX = + "traffic-control:policies:resource:"; + + private static final String SUBJECT_PREFIX = + "traffic-control:policies:subject:"; + + private static final String DEFAULT_POLICY_KEY = + "traffic-control:policies:default"; + + private static final String RESOURCE_INDEX_KEY = + "traffic-control:policies:resources"; + + private final StringRedisTemplate redisTemplate; + + public RedisPolicyStore(StringRedisTemplate redisTemplate) { + this.redisTemplate = Objects.requireNonNull( + redisTemplate, + "redisTemplate must not be null" + ); + } + + @Override + public void registerPolicy(String resource, TrafficPolicy policy) { + validateResource(resource); + Objects.requireNonNull(policy, "policy must not be null"); + + redisTemplate.opsForValue().set(resourceKey(resource), serialize(policy)); + redisTemplate.opsForSet().add(RESOURCE_INDEX_KEY, resource); + } + + @Override + public Optional findPolicy(String resource) { + validateResource(resource); + return readPolicy(resourceKey(resource)); + } + + @Override + public List findAllPolicies() { + Set resources = + redisTemplate.opsForSet().members(RESOURCE_INDEX_KEY); + + if (resources == null || resources.isEmpty()) { + return List.of(); + } + + return resources.stream() + .map(resource -> findPolicy(resource) + .map(policy -> new PolicyRegistration(resource, policy))) + .flatMap(Optional::stream) + .toList(); + } + + @Override + public void removePolicy(String resource) { + validateResource(resource); + + redisTemplate.delete(resourceKey(resource)); + redisTemplate.opsForSet().remove(RESOURCE_INDEX_KEY, resource); + } + + @Override + public void setDefaultPolicy(TrafficPolicy policy) { + Objects.requireNonNull(policy, "policy must not be null"); + + redisTemplate.opsForValue().set( + DEFAULT_POLICY_KEY, + serialize(policy) + ); + } + + /** + * Initializes the default policy only if Redis does not already contain one. + * Existing configuration is therefore preserved across application restarts. + */ + public void initializeDefaultPolicy(TrafficPolicy policy) { + Objects.requireNonNull(policy, "policy must not be null"); + + redisTemplate.opsForValue().setIfAbsent( + DEFAULT_POLICY_KEY, + serialize(policy) + ); + } + + @Override + public Optional findDefaultPolicy() { + return readPolicy(DEFAULT_POLICY_KEY); + } + + public void registerPolicy( + TrafficSubject subject, + String resource, + TrafficPolicy policy) { + + Objects.requireNonNull(subject, "subject must not be null"); + validateResource(resource); + Objects.requireNonNull(policy, "policy must not be null"); + + redisTemplate.opsForValue().set( + subjectKey(subject, resource), + serialize(policy) + ); + } + + public Optional findPolicy( + TrafficSubject subject, + String resource) { + + Objects.requireNonNull(subject, "subject must not be null"); + validateResource(resource); + + return readPolicy(subjectKey(subject, resource)); + } + + public void removePolicy(TrafficSubject subject, String resource) { + Objects.requireNonNull(subject, "subject must not be null"); + validateResource(resource); + + redisTemplate.delete(subjectKey(subject, resource)); + } + + @Override + public Optional findPolicy(TrafficRequest request) { + Objects.requireNonNull(request, "request must not be null"); + + Optional subjectPolicy = + findPolicy(request.getSubject(), request.getResource()); + + if (subjectPolicy.isPresent()) { + return subjectPolicy; + } + + Optional resourcePolicy = + findPolicy(request.getResource()); + + if (resourcePolicy.isPresent()) { + return resourcePolicy; + } + + return findDefaultPolicy(); + } + + private Optional readPolicy(String key) { + String value = redisTemplate.opsForValue().get(key); + + return value == null + ? Optional.empty() + : Optional.of(deserialize(value)); + } + + private String resourceKey(String resource) { + return RESOURCE_PREFIX + encode(resource); + } + + private String subjectKey(TrafficSubject subject, String resource) { + return SUBJECT_PREFIX + + encode(subject.getType().name()) + + ":" + + encode(subject.getSubjectId()) + + ":" + + encode(resource); + } + + private String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private String serialize(TrafficPolicy policy) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(policy.getPolicyId()); + out.writeUTF(policy.getName()); + out.writeUTF(policy.getType().name()); + out.writeUTF(policy.getStatus().name()); + out.writeLong(policy.getCapacity()); + out.writeLong(policy.getRefillRate()); + + Duration window = policy.getWindowDuration(); + out.writeBoolean(window != null); + + if (window != null) { + out.writeLong(window.getSeconds()); + out.writeInt(window.getNano()); + } + + Instant createdAt = policy.getCreatedAt(); + out.writeLong(createdAt.getEpochSecond()); + out.writeInt(createdAt.getNano()); + } + + return Base64.getEncoder().encodeToString(bytes.toByteArray()); + } catch (IOException exception) { + throw new IllegalStateException( + "Could not serialize traffic policy", + exception + ); + } + } + + private TrafficPolicy deserialize(String value) { + try { + byte[] bytes = Base64.getDecoder().decode(value); + + try (DataInputStream in = + new DataInputStream(new ByteArrayInputStream(bytes))) { + + String policyId = in.readUTF(); + String name = in.readUTF(); + TrafficPolicyType type = + TrafficPolicyType.valueOf(in.readUTF()); + PolicyStatus status = + PolicyStatus.valueOf(in.readUTF()); + long capacity = in.readLong(); + long refillRate = in.readLong(); + + Duration window = null; + + if (in.readBoolean()) { + window = Duration.ofSeconds( + in.readLong(), + in.readInt() + ); + } + + Instant createdAt = Instant.ofEpochSecond( + in.readLong(), + in.readInt() + ); + + if (in.available() != 0) { + throw new IllegalStateException( + "Unexpected trailing data in stored traffic policy" + ); + } + + return new TrafficPolicy( + policyId, + name, + type, + status, + capacity, + refillRate, + window, + createdAt + ); + } + } catch (IOException | IllegalArgumentException exception) { + throw new IllegalStateException( + "Could not deserialize stored traffic policy", + exception + ); + } + } + + private void validateResource(String resource) { + if (resource == null || resource.isBlank()) { + throw new IllegalArgumentException( + "resource must not be null or blank" + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStoreIntegrationTest.java b/src/test/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStoreIntegrationTest.java new file mode 100644 index 0000000..918ec58 --- /dev/null +++ b/src/test/java/com/yashdotdev/distributed_traffic_control/policy/RedisPolicyStoreIntegrationTest.java @@ -0,0 +1,246 @@ +package com.yashdotdev.distributed_traffic_control.policy; + +import com.yashdotdev.distributed_traffic_control.traffic.TrafficSubject; +import com.yashdotdev.distributed_traffic_control.traffic.TrafficSubjectType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Instant; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@Testcontainers +@SpringBootTest +class RedisPolicyStoreIntegrationTest { + + private static final String DEFAULT_POLICY_KEY = + "traffic-control:policies:default"; + + private static final String RESOURCE_INDEX_KEY = + "traffic-control:policies:resources"; + + @Container + static final GenericContainer REDIS = + new GenericContainer<>("redis:7-alpine") + .withExposedPorts(6379); + + @Autowired + private StringRedisTemplate redisTemplate; + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS::getHost); + registry.add( + "spring.data.redis.port", + () -> REDIS.getMappedPort(6379) + ); + } + + @BeforeEach + void cleanRedis() { + redisTemplate.delete(DEFAULT_POLICY_KEY); + redisTemplate.delete(RESOURCE_INDEX_KEY); + + var keys = redisTemplate.keys("traffic-control:policies:resource:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + + keys = redisTemplate.keys("traffic-control:policies:subject:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @Test + void shouldPersistResourcePolicyAcrossStoreInstances() { + RedisPolicyStore firstStore = new RedisPolicyStore(redisTemplate); + RedisPolicyStore secondStore = new RedisPolicyStore(redisTemplate); + + String resource = "/api/orders/" + UUID.randomUUID(); + + TrafficPolicy policy = new TrafficPolicy( + "policy-" + UUID.randomUUID(), + "Orders Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 200, + 20, + Instant.parse("2026-01-01T10:00:00Z") + ); + + firstStore.registerPolicy(resource, policy); + + TrafficPolicy storedPolicy = secondStore.findPolicy(resource) + .orElseThrow(() -> new AssertionError( + "Expected the resource policy to be persisted" + )); + + assertEquals(policy.getPolicyId(), storedPolicy.getPolicyId()); + assertEquals(policy.getName(), storedPolicy.getName()); + assertEquals(policy.getType(), storedPolicy.getType()); + assertEquals(policy.getStatus(), storedPolicy.getStatus()); + assertEquals(policy.getCapacity(), storedPolicy.getCapacity()); + assertEquals(policy.getRefillRate(), storedPolicy.getRefillRate()); + assertEquals(policy.getWindowDuration(), storedPolicy.getWindowDuration()); + assertEquals(policy.getCreatedAt(), storedPolicy.getCreatedAt()); + } + + @Test + void shouldPersistDefaultPolicyAcrossStoreInstances() { + RedisPolicyStore firstStore = new RedisPolicyStore(redisTemplate); + RedisPolicyStore secondStore = new RedisPolicyStore(redisTemplate); + + TrafficPolicy policy = new TrafficPolicy( + "default-" + UUID.randomUUID(), + "Default Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 100, + 10, + Instant.parse("2026-01-01T12:00:00Z") + ); + + firstStore.setDefaultPolicy(policy); + + TrafficPolicy storedPolicy = secondStore.findDefaultPolicy() + .orElseThrow(() -> new AssertionError( + "Expected the default policy to be persisted" + )); + + assertEquals(policy.getPolicyId(), storedPolicy.getPolicyId()); + assertEquals(policy.getName(), storedPolicy.getName()); + assertEquals(policy.getType(), storedPolicy.getType()); + assertEquals(policy.getStatus(), storedPolicy.getStatus()); + assertEquals(policy.getCapacity(), storedPolicy.getCapacity()); + assertEquals(policy.getRefillRate(), storedPolicy.getRefillRate()); + assertEquals(policy.getWindowDuration(), storedPolicy.getWindowDuration()); + assertEquals(policy.getCreatedAt(), storedPolicy.getCreatedAt()); + } + + @Test + void shouldPreserveExistingDefaultPolicyDuringInitialization() { + RedisPolicyStore store = new RedisPolicyStore(redisTemplate); + + TrafficPolicy existingPolicy = new TrafficPolicy( + "existing-" + UUID.randomUUID(), + "Existing Default Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 150, + 15, + Instant.parse("2026-01-01T08:00:00Z") + ); + + TrafficPolicy initialPolicy = new TrafficPolicy( + "initial-" + UUID.randomUUID(), + "Initial Default Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 100, + 10, + Instant.parse("2026-01-01T09:00:00Z") + ); + + store.setDefaultPolicy(existingPolicy); + store.initializeDefaultPolicy(initialPolicy); + + TrafficPolicy storedPolicy = store.findDefaultPolicy() + .orElseThrow(() -> new AssertionError( + "Expected the default policy to exist" + )); + + assertEquals( + existingPolicy.getPolicyId(), + storedPolicy.getPolicyId() + ); + assertEquals( + existingPolicy.getName(), + storedPolicy.getName() + ); + assertEquals( + existingPolicy.getCapacity(), + storedPolicy.getCapacity() + ); + } + + @Test + void shouldPersistSubjectSpecificPolicyAcrossStoreInstances() { + RedisPolicyStore firstStore = new RedisPolicyStore(redisTemplate); + RedisPolicyStore secondStore = new RedisPolicyStore(redisTemplate); + + TrafficSubject originalSubject = new TrafficSubject( + "user-" + UUID.randomUUID(), + TrafficSubjectType.USER + ); + + TrafficSubject equivalentSubject = new TrafficSubject( + originalSubject.getSubjectId(), + originalSubject.getType() + ); + + String resource = "/api/payments"; + + TrafficPolicy policy = new TrafficPolicy( + "subject-" + UUID.randomUUID(), + "User Payment Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 50, + 5, + Instant.parse("2026-01-01T11:00:00Z") + ); + + firstStore.registerPolicy(originalSubject, resource, policy); + + TrafficPolicy storedPolicy = secondStore + .findPolicy(equivalentSubject, resource) + .orElseThrow(() -> new AssertionError( + "Expected the subject-specific policy to be persisted" + )); + + assertEquals(policy.getPolicyId(), storedPolicy.getPolicyId()); + assertEquals(policy.getName(), storedPolicy.getName()); + assertEquals(policy.getCapacity(), storedPolicy.getCapacity()); + assertEquals(policy.getRefillRate(), storedPolicy.getRefillRate()); + } + + @Test + void shouldListRegisteredResourcePolicies() { + RedisPolicyStore store = new RedisPolicyStore(redisTemplate); + + String resource = "/api/products/" + UUID.randomUUID(); + + TrafficPolicy policy = new TrafficPolicy( + "list-" + UUID.randomUUID(), + "Products Policy", + TrafficPolicyType.TOKEN_BUCKET, + PolicyStatus.ACTIVE, + 300, + 30, + Instant.parse("2026-01-01T13:00:00Z") + ); + + store.registerPolicy(resource, policy); + + PolicyRegistration registration = store.findAllPolicies() + .stream() + .filter(item -> item.resource().equals(resource)) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Expected the registered resource policy to be listed" + )); + + assertEquals(resource, registration.resource()); + assertEquals(policy.getPolicyId(), registration.policy().getPolicyId()); + } +} \ No newline at end of file