package com.njcn.middle.cache.template; import com.alibaba.fastjson.JSON; import com.njcn.middle.cache.autoconfig.RedisCacheProperties; import com.njcn.middle.cache.constant.CacheConstant; import com.njcn.middle.cache.support.CacheKeyBuilder; import com.njcn.middle.cache.support.CacheValueCodec; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.script.RedisScript; import java.time.Duration; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class RedisCacheEnhanceTemplateTest { @Mock StringRedisTemplate redis; @Mock ValueOperations valueOps; CacheValueCodec codec = new CacheValueCodec(); CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, ""); RedisCacheProperties props = new RedisCacheProperties(); RedisCacheEnhanceTemplate template; @Data @AllArgsConstructor @NoArgsConstructor static class User { private Long id; private String name; } @BeforeEach void init() { MockitoAnnotations.initMocks(this); lenient().when(redis.opsForValue()).thenReturn(valueOps); template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props); } @Test void setSerializesWithExactTtlWhenNoJitter() { props.setJitterRatio(0); User u = new User(1L, "a"); template.set("user:1", u, 100); verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS); } @Test void getHitDeserializes() { when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); User got = template.get("user:1", User.class); assertEquals("a", got.getName()); } @Test void getMissReturnsNull() { when(valueOps.get("cache:user:1")).thenReturn(null); assertNull(template.get("user:1", User.class)); } @Test void getNullSentinelReturnsNull() { when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); assertNull(template.get("user:1", User.class)); } @Test void getDirtyDataThrowsIllegalState() { when(valueOps.get("cache:user:1")).thenReturn("{not-json"); assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class)); } @Test void expireDelegatesWithJitterTtl() { props.setJitterRatio(0); when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true); assertTrue(template.expire("user:1", 200)); verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS); } @Test void applyJitterWithinBounds() { props.setJitterRatio(0.1); long bound = (long) (100 * 0.1); for (int i = 0; i < 50; i++) { long ttl = template.applyJitter(100); assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl); } props.setJitterRatio(0); assertEquals(100, template.applyJitter(100)); } @Test void slidingOffDoesNotRenewOnGet() { when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); template.get("user:1", User.class); verify(redis, never()).expire(any(), anyLong(), any()); } @Test void slidingOnRenewsRealValueOnGet() { props.setSlidingExpireEnabled(true); props.setJitterRatio(0); when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true); template.get("user:1", User.class); verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS); } @Test void slidingOnDoesNotRenewNullSentinel() { props.setSlidingExpireEnabled(true); when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); assertNull(template.get("user:1", User.class)); verify(redis, never()).expire(any(), anyLong(), any()); } @Test void getOrLoadHitDoesNotCallLoader() { when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); AtomicInteger calls = new AtomicInteger(); Supplier loader = () -> { calls.incrementAndGet(); return new User(9L, "x"); }; User got = template.getOrLoad("user:1", User.class, 60, loader); assertEquals("a", got.getName()); assertEquals(0, calls.get()); } @Test void getOrLoadNullSentinelHitReturnsNullNoLoader() { when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); AtomicInteger calls = new AtomicInteger(); assertNull(template.getOrLoad("user:1", User.class, 60, () -> { calls.incrementAndGet(); return new User(1L, "a"); })); assertEquals(0, calls.get()); } @Test void getOrLoadMissAcquiresLockLoadsAndCaches() { props.setJitterRatio(0); when(valueOps.get("cache:user:1")).thenReturn(null); when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) .thenReturn(true); User loaded = new User(1L, "a"); User got = template.getOrLoad("user:1", User.class, 60, () -> loaded); assertEquals("a", got.getName()); verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS); // 释放锁:执行了 Lua 脚本 verify(redis).execute(any(RedisScript.class), anyList(), any()); } @Test void getOrLoadLoaderReturnsNullWritesSentinel() { when(valueOps.get("cache:user:1")).thenReturn(null); when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) .thenReturn(true); assertNull(template.getOrLoad("user:1", User.class, 60, () -> null)); verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL, props.getNullTtlSeconds(), TimeUnit.SECONDS); } @Test void getOrLoadNotLockedWaitsThenReadsValue() { props.getBreakdown().setWaitIntervalMs(1); props.getBreakdown().setWaitTimeoutMs(1000); // 初始 tryGet 返回 null,未抢到锁,随后等待中读到值 when(valueOps.get("cache:user:1")) .thenReturn(null) .thenReturn(null) .thenReturn(JSON.toJSONString(new User(1L, "a"))); when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); AtomicInteger calls = new AtomicInteger(); User got = template.getOrLoad("user:1", User.class, 60, () -> { calls.incrementAndGet(); return new User(9L, "x"); }); assertEquals("a", got.getName()); assertEquals(0, calls.get()); } @Test void getOrLoadWaitTimeoutFallsBackToLoader() { props.getBreakdown().setWaitIntervalMs(1); props.getBreakdown().setWaitTimeoutMs(5); when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中 when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); AtomicInteger calls = new AtomicInteger(); User got = template.getOrLoad("user:1", User.class, 60, () -> { calls.incrementAndGet(); return new User(7L, "fallback"); }); assertEquals("fallback", got.getName()); assertEquals(1, calls.get()); } @Test void getOrLoadBreakdownDisabledLoadsWithoutLock() { props.getBreakdown().setEnabled(false); when(valueOps.get("cache:user:1")).thenReturn(null); User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a")); assertEquals("a", got.getName()); verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class)); } @Test void getOrLoadInternalDecodeFailureFallsBackToReload() { // 初始读到脏数据(当未命中降级),抢到锁后回源重建 when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty"); when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class))) .thenReturn(true); User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh")); assertEquals("fresh", got.getName()); } }