|
|
@@ -0,0 +1,99 @@
|
|
|
+package com.zsjz.ai.common.auth;
|
|
|
+
|
|
|
+import cn.dev33.satoken.stp.StpUtil;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.MockedStatic;
|
|
|
+import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
+import org.springframework.data.redis.core.ValueOperations;
|
|
|
+
|
|
|
+import java.time.Duration;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.Mockito.doThrow;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.mockStatic;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 登录活跃度判定的契约。
|
|
|
+ *
|
|
|
+ * <p>「正在使用」= 有有效 token <b>且</b> 窗口内活跃过。两个条件缺一不可:
|
|
|
+ * 只看 token 会把「登录后一直没人动」的死会话当成在用;只看活跃时间则可能在会话已被顶掉后误报。
|
|
|
+ */
|
|
|
+class LoginActivityServiceTest {
|
|
|
+
|
|
|
+ private static final Long USER_ID = 1L;
|
|
|
+
|
|
|
+ private StringRedisTemplate redis;
|
|
|
+ private ValueOperations<String, String> valueOps;
|
|
|
+ private LoginActivityService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ void setUp() {
|
|
|
+ redis = mock(StringRedisTemplate.class);
|
|
|
+ valueOps = mock(ValueOperations.class);
|
|
|
+ when(redis.opsForValue()).thenReturn(valueOps);
|
|
|
+ service = new LoginActivityService(redis, 30);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("没有任何有效 token:不算在使用(即使活跃标记还在)")
|
|
|
+ void notInUseWithoutLiveToken() {
|
|
|
+ try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
|
|
+ stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of());
|
|
|
+
|
|
|
+ assertFalse(service.isInUse(USER_ID));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("有有效 token 但活跃标记已过期:不算在使用,登录直接放行")
|
|
|
+ void notInUseWhenActivityExpired() {
|
|
|
+ try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
|
|
+ stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of("token-a"));
|
|
|
+ when(valueOps.get(anyString())).thenReturn(null);
|
|
|
+
|
|
|
+ assertFalse(service.isInUse(USER_ID));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("有有效 token 且窗口内活跃过:判定为正在使用,登录需要确认")
|
|
|
+ void inUseWhenTokenAliveAndRecentlyActive() {
|
|
|
+ try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
|
|
+ stp.when(() -> StpUtil.getTokenValueListByLoginId(USER_ID)).thenReturn(List.of("token-a"));
|
|
|
+ when(valueOps.get(anyString())).thenReturn(String.valueOf(System.currentTimeMillis()));
|
|
|
+
|
|
|
+ assertTrue(service.isInUse(USER_ID));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("活跃标记按窗口设置 TTL,同一用户一分钟内只写一次 Redis")
|
|
|
+ void touchWritesAtMostOncePerMinute() {
|
|
|
+ service.touch(USER_ID);
|
|
|
+ service.touch(USER_ID);
|
|
|
+ service.touch(USER_ID);
|
|
|
+
|
|
|
+ verify(valueOps, times(1)).set(anyString(), anyString(), any(Duration.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("Redis 异常不能阻断请求:touch 吞掉异常")
|
|
|
+ void touchSwallowsRedisError() {
|
|
|
+ doThrow(new RuntimeException("redis down"))
|
|
|
+ .when(valueOps).set(anyString(), anyString(), any(Duration.class));
|
|
|
+
|
|
|
+ assertDoesNotThrow(() -> service.touch(USER_ID));
|
|
|
+ }
|
|
|
+}
|