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.Consumer; import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; 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.atMost; 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()); } // ---- #1+#2: ttl<=0 入口校验(fail-fast,不下发非法过期) ---- @Test void setRejectsNonPositiveTtl() { User u = new User(1L, "a"); assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, 0)); assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, -1)); verify(valueOps, never()).set(anyString(), anyString(), anyLong(), any()); } @Test void expireRejectsNonPositiveTtl() { assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", 0)); assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", -5)); verify(redis, never()).expire(anyString(), anyLong(), any()); } @Test void getOrLoadRejectsNonPositiveTtl() { props.getBreakdown().setEnabled(false); // 避免跑红时走入等待自旋 assertThrows(IllegalArgumentException.class, () -> template.getOrLoad("user:1", User.class, 0, () -> new User(1L, "a"))); } // ---- #3+#4: 击穿等待的中断响应与 interval 兜底 ---- @Test void getOrLoadWaitIntervalZeroDoesNotSpinForever() { props.getBreakdown().setWaitIntervalMs(0); // 误配 0:旧实现 waited 永不前进 → 无限循环 props.getBreakdown().setWaitTimeoutMs(50); when(valueOps.get("cache:user:1")).thenReturn(null); when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); User got = assertTimeoutPreemptively(Duration.ofSeconds(2), () -> template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb"))); assertEquals("fb", got.getName()); } @Test void getOrLoadInterruptedDuringWaitStopsSpinning() { props.getBreakdown().setWaitIntervalMs(20); props.getBreakdown().setWaitTimeoutMs(10000); // 大超时:旧实现中断后空转数百次到超时 when(valueOps.get("cache:user:1")).thenReturn(null); when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); Thread.currentThread().interrupt(); // 当前线程预置中断,getOrLoad 同步执行可感知 User got = template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb")); boolean wasInterrupted = Thread.interrupted(); // 读取并清除,避免污染后续测试 assertEquals("fb", got.getName()); assertTrue(wasInterrupted, "中断标志应被保留(协作式取消)"); // 中断后立即停止等待:仅首次 tryGet 读一次,而非空转数百次 verify(valueOps, atMost(2)).get("cache:user:1"); } // ---- #5: 构造期配置 fail-fast 校验 ---- private RedisCacheEnhanceTemplate newTemplateWith(Consumer mut) { RedisCacheProperties p = new RedisCacheProperties(); mut.accept(p); return new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, p); } @Test void constructorRejectsInvalidProps() { assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setDefaultTtlSeconds(0))); assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setNullTtlSeconds(-1))); assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(1.0))); assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(-0.1))); assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setLockTtlMs(0))); assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setWaitIntervalMs(0))); } @Test void constructorAcceptsDefaults() { new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, new RedisCacheProperties()); } // ---- #7: exists 排除空值标记(与 get 口径一致) ---- @Test void existsTrueForRealValue() { when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a"))); assertTrue(template.exists("user:1")); } @Test void existsFalseForNullSentinel() { when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL); assertFalse(template.exists("user:1")); } @Test void existsFalseForMissing() { when(valueOps.get("cache:user:1")).thenReturn(null); assertFalse(template.exists("user:1")); } }