diff --git a/src/main/java/com/almang/inventory/global/api/SuccessMessage.java b/src/main/java/com/almang/inventory/global/api/SuccessMessage.java index 1ff899c4..d1bd4225 100644 --- a/src/main/java/com/almang/inventory/global/api/SuccessMessage.java +++ b/src/main/java/com/almang/inventory/global/api/SuccessMessage.java @@ -10,6 +10,7 @@ public enum SuccessMessage { LOGIN_SUCCESS("로그인 성공"), ACCESS_TOKEN_REISSUE_SUCCESS("액세스 토큰 재발급 성공"), CHANGE_PASSWORD_SUCCESS("비밀번호 변경 성공"), + LOGOUT_SUCCESS("로그아웃 성공"), ; private final String message; diff --git a/src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java b/src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java index cff4f023..851520bc 100644 --- a/src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java +++ b/src/main/java/com/almang/inventory/global/config/security/SecurityConfig.java @@ -27,6 +27,7 @@ @RequiredArgsConstructor public class SecurityConfig { + private final RedisService redisService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private final JwtAuthEntryPoint jwtAuthEntryPoint; @@ -39,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers(h -> h.frameOptions(FrameOptionsConfig::sameOrigin)) // H2 콘솔 접근 시 iframe 사용 허용 (동일 출처만 허용하여 보안 유지) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/logout").authenticated() .anyRequest().permitAll() // 추후 변경 예정 ) .exceptionHandling(e -> e @@ -68,6 +70,6 @@ public CorsConfigurationSource corsConfigurationSource() { @Bean public TokenAuthenticationFilter tokenAuthenticationFilter() { - return new TokenAuthenticationFilter(jwtTokenProvider, userRepository); + return new TokenAuthenticationFilter(redisService, jwtTokenProvider, userRepository); } } diff --git a/src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java b/src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java index d7e214e5..1e02773d 100644 --- a/src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java +++ b/src/main/java/com/almang/inventory/global/security/jwt/TokenAuthenticationFilter.java @@ -3,6 +3,7 @@ import com.almang.inventory.global.exception.BaseException; import com.almang.inventory.global.exception.ErrorCode; import com.almang.inventory.global.security.principal.CustomUserPrincipal; +import com.almang.inventory.user.auth.service.RedisService; import com.almang.inventory.user.domain.User; import com.almang.inventory.user.repository.UserRepository; import jakarta.servlet.FilterChain; @@ -25,6 +26,7 @@ @RequiredArgsConstructor public class TokenAuthenticationFilter extends OncePerRequestFilter { + private final RedisService redisService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; private static final String TOKEN_PREFIX = "Bearer "; @@ -68,18 +70,24 @@ protected void doFilterInternal( log.debug("[AUTH] path={}, authHeader={}", request.getServletPath(), request.getHeader(HttpHeaders.AUTHORIZATION)); log.debug("[AUTH] tokenStatus={}", status); if (status == TokenStatus.VALID) { - Long userId = jwtTokenProvider.getUserIdFromToken(token); - User user = userRepository.findById(userId) - .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); + if (redisService.isAccessTokenBlacklisted(token)) { + log.warn("블랙리스트에 등록된 액세스 토큰입니다."); + SecurityContextHolder.clearContext(); + request.setAttribute("authErrorCode", ErrorCode.ACCESS_TOKEN_INVALID); + } else { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); - GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + user.getRole().name()); - List authorities = List.of(authority); - CustomUserPrincipal principal = - new CustomUserPrincipal(user.getId(), user.getUsername(), authorities); + GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + user.getRole().name()); + List authorities = List.of(authority); + CustomUserPrincipal principal = + new CustomUserPrincipal(user.getId(), user.getUsername(), authorities); - UsernamePasswordAuthenticationToken authenticationToken = - new UsernamePasswordAuthenticationToken(principal, null, authorities); - SecurityContextHolder.getContext().setAuthentication(authenticationToken); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } } else { SecurityContextHolder.clearContext(); ErrorCode errorCode = (status == TokenStatus.EXPIRED) diff --git a/src/main/java/com/almang/inventory/user/auth/controller/AuthController.java b/src/main/java/com/almang/inventory/user/auth/controller/AuthController.java index 0148d650..ed1031d1 100644 --- a/src/main/java/com/almang/inventory/user/auth/controller/AuthController.java +++ b/src/main/java/com/almang/inventory/user/auth/controller/AuthController.java @@ -9,6 +9,7 @@ import com.almang.inventory.user.auth.dto.response.AccessTokenResponse; import com.almang.inventory.user.auth.dto.response.ChangePasswordResponse; import com.almang.inventory.user.auth.dto.response.LoginResponse; +import com.almang.inventory.user.auth.dto.response.LogoutResponse; import com.almang.inventory.user.auth.service.AuthService; import com.almang.inventory.user.auth.service.TokenService; import io.swagger.v3.oas.annotations.Operation; @@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -76,4 +78,19 @@ public ResponseEntity> changePassword( ApiResponse.success(SuccessMessage.CHANGE_PASSWORD_SUCCESS.getMessage(), response) ); } + + @DeleteMapping("/logout") + @Operation(summary = "로그아웃", description = "로그아웃을 진행합니다.") + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserPrincipal userPrincipal, + HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse + ) { + Long userId = userPrincipal.getId(); + log.info("[AuthController] 로그아웃 요청 - userId={}", userId); + LogoutResponse response = authService.logout(userId, httpServletRequest, httpServletResponse); + + return ResponseEntity.ok( + ApiResponse.success(SuccessMessage.LOGOUT_SUCCESS.getMessage(), response) + ); + } } diff --git a/src/main/java/com/almang/inventory/user/auth/dto/response/LogoutResponse.java b/src/main/java/com/almang/inventory/user/auth/dto/response/LogoutResponse.java new file mode 100644 index 00000000..788ff1c2 --- /dev/null +++ b/src/main/java/com/almang/inventory/user/auth/dto/response/LogoutResponse.java @@ -0,0 +1,5 @@ +package com.almang.inventory.user.auth.dto.response; + +public record LogoutResponse( + boolean success +) {} diff --git a/src/main/java/com/almang/inventory/user/auth/service/AuthService.java b/src/main/java/com/almang/inventory/user/auth/service/AuthService.java index 8fff7e76..e9784efa 100644 --- a/src/main/java/com/almang/inventory/user/auth/service/AuthService.java +++ b/src/main/java/com/almang/inventory/user/auth/service/AuthService.java @@ -6,8 +6,10 @@ import com.almang.inventory.user.auth.dto.request.LoginRequest; import com.almang.inventory.user.auth.dto.response.ChangePasswordResponse; import com.almang.inventory.user.auth.dto.response.LoginResponse; +import com.almang.inventory.user.auth.dto.response.LogoutResponse; import com.almang.inventory.user.domain.User; import com.almang.inventory.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,6 +50,15 @@ public ChangePasswordResponse changePassword(ChangePasswordRequest request, Long return new ChangePasswordResponse(true); } + @Transactional + public LogoutResponse logout(Long userId, HttpServletRequest request, HttpServletResponse response) { + log.info("[AuthService] 로그아웃 요청 - userId: {}", userId); + tokenService.revokeTokens(request, response, userId); + + log.info("[AuthService] 로그아웃 성공 - userId: {}", userId); + return new LogoutResponse(true); + } + private User findUserByUsername(String username) { return userRepository.findByUsername(username) .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/almang/inventory/user/auth/service/RedisService.java b/src/main/java/com/almang/inventory/user/auth/service/RedisService.java index 8f04b6f5..69f9007c 100644 --- a/src/main/java/com/almang/inventory/user/auth/service/RedisService.java +++ b/src/main/java/com/almang/inventory/user/auth/service/RedisService.java @@ -12,6 +12,7 @@ public class RedisService { private static final String REFRESH_USER_PREFIX = "refresh:user:"; private static final String REFRESH_TOKEN_PREFIX = "refresh:token:"; + private static final String ACCESS_BLACKLIST_PREFIX = "blacklist:access:"; private final RedisTemplate redisTemplate; @@ -26,6 +27,10 @@ private String tokenKey(String token) { return REFRESH_TOKEN_PREFIX + token; } + private String blacklistAccessTokenKey(String token) { + return ACCESS_BLACKLIST_PREFIX + token; + } + public void saveRefreshToken(String userId, String refreshToken) { Duration ttl = Duration.ofDays(refreshTokenExpiration); @@ -69,4 +74,14 @@ public void rotateRefreshToken(String userId, String oldToken, String newToken) redisTemplate.delete(userKey(userId)); saveRefreshToken(userId, newToken); } + + public void addAccessTokenToBlacklist(String accessToken, long remainingMillis) { + if (remainingMillis <= 0) return; + redisTemplate.opsForValue().set( + blacklistAccessTokenKey(accessToken), "true", Duration.ofMillis(remainingMillis)); + } + + public boolean isAccessTokenBlacklisted(String accessToken) { + return redisTemplate.hasKey(blacklistAccessTokenKey(accessToken)); + } } diff --git a/src/main/java/com/almang/inventory/user/auth/service/TokenService.java b/src/main/java/com/almang/inventory/user/auth/service/TokenService.java index 8bc78ab4..0eaaba1d 100644 --- a/src/main/java/com/almang/inventory/user/auth/service/TokenService.java +++ b/src/main/java/com/almang/inventory/user/auth/service/TokenService.java @@ -82,6 +82,36 @@ private String extractRefreshToken(HttpServletRequest request) { .orElseThrow(() -> new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); } + public void revokeTokens(HttpServletRequest request, HttpServletResponse response, Long userId) { + String accessToken = resolveToken(request); + + // 액세스 토큰 무효화 + if (accessToken != null && !accessToken.isBlank()) { + long remainMillis = jwtTokenProvider.getRemainingMillis(accessToken); + if (remainMillis > 0) { + redisService.addAccessTokenToBlacklist(accessToken, remainMillis); + } + } + + // 리프레시 토큰 삭제 + redisService.deleteByUserId(userId.toString()); + + // 리프레시 쿠키 삭제 + clearRefreshTokenCookie(response); + } + + private void clearRefreshTokenCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_PREFIX, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(0) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader(HttpHeaders.AUTHORIZATION); diff --git a/src/test/java/com/almang/inventory/store/global/config/TestSecurityConfig.java b/src/test/java/com/almang/inventory/store/global/config/TestSecurityConfig.java index a358733e..b6fff55f 100644 --- a/src/test/java/com/almang/inventory/store/global/config/TestSecurityConfig.java +++ b/src/test/java/com/almang/inventory/store/global/config/TestSecurityConfig.java @@ -14,6 +14,7 @@ public SecurityFilterChain testSecurityFilterChain(HttpSecurity httpSecurity) th httpSecurity .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/logout").authenticated() .anyRequest().permitAll()); return httpSecurity.build(); } diff --git a/src/test/java/com/almang/inventory/user/auth/controller/AuthControllerTest.java b/src/test/java/com/almang/inventory/user/auth/controller/AuthControllerTest.java index 7445a798..8cf19c02 100644 --- a/src/test/java/com/almang/inventory/user/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/almang/inventory/user/auth/controller/AuthControllerTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -16,6 +17,7 @@ import com.almang.inventory.user.auth.dto.request.LoginRequest; import com.almang.inventory.user.auth.dto.response.ChangePasswordResponse; import com.almang.inventory.user.auth.dto.response.LoginResponse; +import com.almang.inventory.user.auth.dto.response.LogoutResponse; import com.almang.inventory.user.auth.service.AuthService; import com.almang.inventory.user.auth.service.TokenService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -214,4 +216,46 @@ public class AuthControllerTest { .andExpect(jsonPath("$.message").value(ErrorCode.INVALID_INPUT_VALUE.getMessage())) .andExpect(jsonPath("$.data").doesNotExist()); } + + @Test + void 로그아웃에_성공한다() throws Exception { + // given + CustomUserPrincipal principal = + new CustomUserPrincipal(1L, "store_admin", List.of()); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + + LogoutResponse response = new LogoutResponse(true); + + when(authService.logout(anyLong(), any(HttpServletRequest.class), any(HttpServletResponse.class))) + .thenReturn(response); + + // when & then + mockMvc.perform(delete("/api/v1/auth/logout") + .with(authentication(auth))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.message").value(SuccessMessage.LOGOUT_SUCCESS.getMessage())) + .andExpect(jsonPath("$.data.success").value(true)); + } + + @Test + void 로그아웃_중_예외가_발생하면_에러_응답을_반환한다() throws Exception { + // given + CustomUserPrincipal principal = + new CustomUserPrincipal(1L, "store_admin", List.of()); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + + when(authService.logout(anyLong(), any(HttpServletRequest.class), any(HttpServletResponse.class))) + .thenThrow(new BaseException(ErrorCode.ACCESS_TOKEN_INVALID)); + + // when & then + mockMvc.perform(delete("/api/v1/auth/logout") + .with(authentication(auth))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(ErrorCode.ACCESS_TOKEN_INVALID.getHttpStatus().value())) + .andExpect(jsonPath("$.message").value(ErrorCode.ACCESS_TOKEN_INVALID.getMessage())) + .andExpect(jsonPath("$.data").doesNotExist()); + } } diff --git a/src/test/java/com/almang/inventory/user/auth/service/AuthServiceTest.java b/src/test/java/com/almang/inventory/user/auth/service/AuthServiceTest.java index 38793c26..fd2de4a8 100644 --- a/src/test/java/com/almang/inventory/user/auth/service/AuthServiceTest.java +++ b/src/test/java/com/almang/inventory/user/auth/service/AuthServiceTest.java @@ -11,8 +11,10 @@ import com.almang.inventory.user.auth.dto.request.LoginRequest; import com.almang.inventory.user.auth.dto.response.ChangePasswordResponse; import com.almang.inventory.user.auth.dto.response.LoginResponse; +import com.almang.inventory.user.auth.dto.response.LogoutResponse; import com.almang.inventory.user.domain.User; import com.almang.inventory.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -28,6 +30,7 @@ public class AuthServiceTest { @Mock private TokenService tokenService; @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; + @Mock private HttpServletRequest httpServletRequest; @Mock private HttpServletResponse httpServletResponse; @InjectMocks private AuthService authService; @@ -143,4 +146,17 @@ public class AuthServiceTest { .isInstanceOf(BaseException.class) .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); } + + @Test + void 로그아웃에_성공하면_true를_반환하고_토큰을_폐기한다() { + // given + Long userId = 1L; + + // when + LogoutResponse response = authService.logout(userId, httpServletRequest, httpServletResponse); + + // then + assertThat(response.success()).isTrue(); + verify(tokenService).revokeTokens(httpServletRequest, httpServletResponse, userId); + } } diff --git a/src/test/java/com/almang/inventory/user/auth/service/RedisServiceTest.java b/src/test/java/com/almang/inventory/user/auth/service/RedisServiceTest.java index 39e6e46a..66a55121 100644 --- a/src/test/java/com/almang/inventory/user/auth/service/RedisServiceTest.java +++ b/src/test/java/com/almang/inventory/user/auth/service/RedisServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; @@ -117,4 +118,70 @@ void init() { verify(valueOperations).set("refresh:user:1", newToken, Duration.ofDays(7)); verify(valueOperations).set("refresh:token:new-token", userId, Duration.ofDays(7)); } + + @Test + void userId와_refreshToken으로_리프레시_토큰을_삭제한다() { + // given + String userId = "1"; + String refreshToken = "token-value"; + + // when + redisService.deleteRefreshToken(userId, refreshToken); + + // then + verify(redisTemplate).delete("refresh:user:1"); + verify(redisTemplate).delete("refresh:token:token-value"); + } + + @Test + void 액세스_토큰을_블랙리스트에_추가한다() { + // given + String accessToken = "access-token"; + long remainingMillis = 1000L; + + // when + redisService.addAccessTokenToBlacklist(accessToken, remainingMillis); + + // then + verify(valueOperations).set("blacklist:access:access-token", "true", Duration.ofMillis(remainingMillis)); + } + + @Test + void 만료시간이_0이하면_블랙리스트에_추가하지_않는다() { + // given + String accessToken = "access-token"; + long remainingMillis = 0L; + + // when + redisService.addAccessTokenToBlacklist(accessToken, remainingMillis); + + // then + verifyNoInteractions(valueOperations); + } + + @Test + void 액세스_토큰이_블랙리스트에_포함되어_있으면_true를_반환한다() { + // given + String accessToken = "access-token"; + given(redisTemplate.hasKey("blacklist:access:access-token")).willReturn(true); + + // when + boolean result = redisService.isAccessTokenBlacklisted(accessToken); + + // then + assertThat(result).isTrue(); + } + + @Test + void 액세스_토큰이_블랙리스트에_포함되어_있지_않으면_false를_반환한다() { + // given + String accessToken = "access-token"; + given(redisTemplate.hasKey("blacklist:access:access-token")).willReturn(false); + + // when + boolean result = redisService.isAccessTokenBlacklisted(accessToken); + + // then + assertThat(result).isFalse(); + } } diff --git a/src/test/java/com/almang/inventory/user/auth/service/TokenServiceTest.java b/src/test/java/com/almang/inventory/user/auth/service/TokenServiceTest.java index d9f619d5..4672975d 100644 --- a/src/test/java/com/almang/inventory/user/auth/service/TokenServiceTest.java +++ b/src/test/java/com/almang/inventory/user/auth/service/TokenServiceTest.java @@ -145,4 +145,81 @@ void init() { .isInstanceOf(BaseException.class) .hasMessageContaining(ErrorCode.REFRESH_TOKEN_NOT_FOUND.getMessage()); } + + @Test + void 액세스_토큰과_리프레시_토큰을_모두_폐기한다() { + // given + Long userId = 1L; + String accessToken = "access-token"; + + when(httpServletRequest.getHeader("Authorization")) + .thenReturn("Bearer " + accessToken); + when(jwtTokenProvider.getRemainingMillis(accessToken)) + .thenReturn(1000L); + + // when + tokenService.revokeTokens(httpServletRequest, httpServletResponse, userId); + + // then + // 액세스 토큰 블랙리스트 등록 + verify(jwtTokenProvider).getRemainingMillis(accessToken); + verify(redisService).addAccessTokenToBlacklist(accessToken, 1000L); + + // 리프레시 토큰 삭제 + verify(redisService).deleteByUserId("1"); + + // 리프레시 토큰 쿠키 삭제(Set-Cookie 헤더 확인) + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(httpServletResponse).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + String cookieValue = headerCaptor.getValue(); + assertThat(cookieValue).contains("refreshToken="); + assertThat(cookieValue).contains("Max-Age=0"); + assertThat(cookieValue).contains("Path=/"); + assertThat(cookieValue).contains("HttpOnly"); + assertThat(cookieValue).contains("Secure"); + assertThat(cookieValue).contains("SameSite=None"); + } + + @Test + void Authorization_헤더가_없어도_리프레시_토큰은_삭제된다() { + // given + Long userId = 1L; + when(httpServletRequest.getHeader("Authorization")) + .thenReturn(null); + + // when + tokenService.revokeTokens(httpServletRequest, httpServletResponse, userId); + + // then + verify(redisService).deleteByUserId("1"); + + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(httpServletResponse).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + String cookieValue = headerCaptor.getValue(); + assertThat(cookieValue).contains("refreshToken="); + assertThat(cookieValue).contains("Max-Age=0"); + } + + @Test + void 액세스_토큰_남은_시간이_없으면_블랙리스트에_추가하지_않는다() { + // given + Long userId = 1L; + String accessToken = "access-token"; + + when(httpServletRequest.getHeader("Authorization")) + .thenReturn("Bearer " + accessToken); + when(jwtTokenProvider.getRemainingMillis(accessToken)) + .thenReturn(0L); + + // when + tokenService.revokeTokens(httpServletRequest, httpServletResponse, userId); + + // then + verify(jwtTokenProvider).getRemainingMillis(accessToken); + verify(redisService, org.mockito.Mockito.never()) + .addAccessTokenToBlacklist(anyString(), org.mockito.Mockito.anyLong()); + verify(redisService).deleteByUserId("1"); + } }