diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 6f10133..54edf77 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -47,11 +47,21 @@ jobs: java-version: '17' cache: gradle + # clean build 는 test 태스크를 포함 — 단위테스트 실패 시 빌드/배포 차단(게이트) - name: Build & Test run: | chmod +x ./gradlew ./gradlew --no-daemon clean build + # 테스트 결과 리포트 — 성공/실패 무관하게 항상 업로드(실패 원인 확인용) + - name: Upload test report + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-report + path: build/reports/tests/test + retention-days: 7 + - name: Upload jar uses: actions/upload-artifact@v3 with: diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index f4ba045..206448a 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -74,4 +74,22 @@ public class AuthController { authService.changePassword(current.getId(), req); return ResponseEntity.noContent().build(); } + + /** 가입정보 변경 진입 전 비밀번호 재인증 */ + @PostMapping("/verify-password") + public ResponseEntity verifyPassword( + @Valid @RequestBody com.sb.web.auth.dto.PasswordVerifyRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + authService.verifyPassword(current.getId(), req.getPassword()); + return ResponseEntity.noContent().build(); + } + + /** 가입정보(이름/이메일) 변경 */ + @PutMapping("/profile") + public MemberResponse updateProfile( + @Valid @RequestBody com.sb.web.auth.dto.ProfileUpdateRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current, + HttpServletRequest request) { + return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req); + } } diff --git a/src/main/java/com/sb/web/auth/dto/PasswordVerifyRequest.java b/src/main/java/com/sb/web/auth/dto/PasswordVerifyRequest.java new file mode 100644 index 0000000..3c8d159 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/PasswordVerifyRequest.java @@ -0,0 +1,14 @@ +package com.sb.web.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 비밀번호 재인증 요청. 가입정보 변경 화면 진입 전 본인 확인용. + */ +@Data +public class PasswordVerifyRequest { + + @NotBlank(message = "비밀번호를 입력하세요.") + private String password; +} diff --git a/src/main/java/com/sb/web/auth/dto/ProfileUpdateRequest.java b/src/main/java/com/sb/web/auth/dto/ProfileUpdateRequest.java new file mode 100644 index 0000000..563e1b0 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/ProfileUpdateRequest.java @@ -0,0 +1,22 @@ +package com.sb.web.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 가입정보(프로필) 변경 요청. 비밀번호 인증을 통과한 뒤 호출된다. + * 변경 대상: 이름, 이메일 (아이디/가입유형은 변경 불가). + */ +@Data +public class ProfileUpdateRequest { + + @NotBlank(message = "이름을 입력하세요.") + @Size(max = 100, message = "이름은 100자 이하여야 합니다.") + private String name; + + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 255, message = "이메일은 255자 이하여야 합니다.") + private String email; +} diff --git a/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java index 5449696..7e5a4e2 100644 --- a/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java @@ -18,6 +18,9 @@ public interface AuthSessionMapper { int updateExpiry(@Param("token") String token, @Param("expiresAt") LocalDateTime expiresAt); + /** 프로필 변경 시 백업 세션의 표시 이름 동기화 */ + int updateName(@Param("token") String token, @Param("name") String name); + int delete(@Param("token") String token); int deleteExpired(@Param("now") LocalDateTime now); diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java index b0f0daa..df2550c 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -28,6 +28,9 @@ public interface MemberMapper { int updatePassword(@Param("id") Long id, @Param("password") String password); + /** 프로필(이름/이메일) 변경 */ + int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email); + int updateRole(@Param("id") Long id, @Param("role") String role); int updateStatus(@Param("id") Long id, @Param("status") String status); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 1e789c6..406c00c 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -186,6 +186,71 @@ public class AuthService { } } + /** + * 비밀번호 재인증 (가입정보 변경 진입 전 본인 확인). 일치하지 않으면 401. + */ + public void verifyPassword(Long memberId, String password) { + Member member = memberMapper.findById(memberId); + if (member == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + } + if (!"LOCAL".equals(member.getProvider()) || member.getPassword() == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다."); + } + if (password == null || !passwordEncoder.matches(password, member.getPassword())) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다."); + } + } + + /** + * 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다. + * 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다. + */ + @Transactional + public MemberResponse updateProfile(Long memberId, String token, com.sb.web.auth.dto.ProfileUpdateRequest req) { + Member member = memberMapper.findById(memberId); + if (member == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + } + String name = req.getName() == null ? null : req.getName().trim(); + if (name == null || name.isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이름을 입력하세요."); + } + String email = (req.getEmail() == null || req.getEmail().isBlank()) ? null : req.getEmail().trim(); + memberMapper.updateProfile(memberId, name, email); + member.setName(name); + member.setEmail(email); + syncSessionName(token, name); + log.info("[profile] updated for {} (name={})", member.getLoginId(), name); + return MemberResponse.from(member); + } + + /** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */ + private void syncSessionName(String token, String name) { + if (token == null || token.isBlank()) { + return; + } + String key = SESSION_PREFIX + token; + try { + Object cached = redisTemplate.opsForValue().get(key); + if (cached instanceof SessionUser u) { + u.setName(name); + Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS); + Duration remain = (ttl != null && ttl > 0) + ? Duration.ofSeconds(ttl) + : (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL); + redisTemplate.opsForValue().set(key, u, remain); + } + } catch (Exception e) { + log.warn("[profile] Redis 세션 이름 동기화 실패(무시): {}", e.toString()); + } + try { + authSessionMapper.updateName(token, name); + } catch (Exception ignore) { + // DB 백업 동기화 실패해도 다음 로그인 시 갱신됨 + } + } + /** * 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체. */ diff --git a/src/main/resources/mapper/AuthSessionMapper.xml b/src/main/resources/mapper/AuthSessionMapper.xml index 449e7e8..d0bdb69 100644 --- a/src/main/resources/mapper/AuthSessionMapper.xml +++ b/src/main/resources/mapper/AuthSessionMapper.xml @@ -23,6 +23,10 @@ UPDATE auth_session SET expires_at = #{expiresAt} WHERE token = #{token} + + UPDATE auth_session SET name = #{name} WHERE token = #{token} + + DELETE FROM auth_session WHERE token = #{token} diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 5dbb870..7ed95a1 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -54,6 +54,10 @@ UPDATE member SET password = #{password}, updated_at = NOW() WHERE id = #{id} + + UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id} + + UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id} diff --git a/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java b/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java new file mode 100644 index 0000000..87ec7de --- /dev/null +++ b/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java @@ -0,0 +1,90 @@ +package com.sb.web.account.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 카드 결제 알림 파서 단위테스트. + * 핵심: 정상 결제 인식 / 취소 인식 / 광고성 푸시 차단 / 강신호 우선. + */ +class CardNotificationParserTest { + + private final CardNotificationParser parser = new CardNotificationParser(); + private final LocalDate today = LocalDate.of(2026, 6, 6); + + @Test + @DisplayName("정상 승인 결제는 카드로 인식하고 카드사·금액을 추출한다") + void parsesApprovedPayment() { + var p = parser.parse("[Web발신]", "삼성카드 승인 50,000원 일시불\n스타벅스 06/06 14:30", today); + + assertThat(p.isCard()).isTrue(); + assertThat(p.isCanceled()).isFalse(); + assertThat(p.getIssuer()).isEqualTo("삼성"); + assertThat(p.getAmount()).isEqualTo(50000L); + assertThat(p.getNotifKey()).isNotBlank(); + } + + @Test + @DisplayName("승인취소는 취소 결제로 인식한다") + void parsesCanceledPayment() { + var p = parser.parse("현대카드", "승인취소 12,000원", today); + + assertThat(p.isCard()).isTrue(); + assertThat(p.isCanceled()).isTrue(); + assertThat(p.getIssuer()).isEqualTo("현대"); + assertThat(p.getAmount()).isEqualTo(12000L); + } + + @Test + @DisplayName("강한 승인신호 없이 광고성 표현만 있으면 결제로 보지 않는다") + void blocksAdvertisementPush() { + var p = parser.parse("카카오", "결제하고 혜택 받기! 10,000원 쿠폰 적립 이벤트", today); + + assertThat(p.isCard()).isFalse(); + } + + @Test + @DisplayName("승인 신호가 있으면 광고 표현이 섞여도 결제로 인식한다") + void strongSignalOverridesAdWords() { + var p = parser.parse("삼성카드", "승인 30,000원 (이벤트 적립 대상)", today); + + assertThat(p.isCard()).isTrue(); + assertThat(p.getAmount()).isEqualTo(30000L); + } + + @Test + @DisplayName("금액이 없으면 결제 알림이 아니다") + void requiresAmount() { + var p = parser.parse("삼성카드", "승인 완료되었습니다", today); + + assertThat(p.isCard()).isFalse(); + } + + @Test + @DisplayName("승인/취소 키워드가 없으면 결제 알림이 아니다") + void requiresApprovalKeyword() { + var p = parser.parse("삼성카드", "50,000원", today); + + assertThat(p.isCard()).isFalse(); + } + + @Test + @DisplayName("빈 알림은 결제가 아니다") + void blankIsNotCard() { + assertThat(parser.parse(null, null, today).isCard()).isFalse(); + assertThat(parser.parse("", "", today).isCard()).isFalse(); + } + + @Test + @DisplayName("MM/DD 날짜를 올해 거래일로 추정한다") + void guessesDate() { + var p = parser.parse("신한카드", "승인 5,000원 6/6", today); + + assertThat(p.isCard()).isTrue(); + assertThat(p.getDate()).isEqualTo(LocalDate.of(2026, 6, 6)); + } +} diff --git a/src/test/java/com/sb/web/auth/service/AuthServiceTest.java b/src/test/java/com/sb/web/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..c68d570 --- /dev/null +++ b/src/test/java/com/sb/web/auth/service/AuthServiceTest.java @@ -0,0 +1,294 @@ +package com.sb.web.auth.service; + +import com.sb.web.admin.service.AppSettingService; +import com.sb.web.auth.domain.AuthSession; +import com.sb.web.auth.domain.Member; +import com.sb.web.auth.dto.LoginRequest; +import com.sb.web.auth.dto.LoginResponse; +import com.sb.web.auth.dto.MemberResponse; +import com.sb.web.auth.dto.ProfileUpdateRequest; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.dto.SignupRequest; +import com.sb.web.auth.mapper.AuthSessionMapper; +import com.sb.web.auth.mapper.MemberMapper; +import com.sb.web.common.exception.ApiException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * AuthService 단위테스트 (Mockito). + * 인증 핵심: 비밀번호 재인증 / 가입정보 변경 / 회원가입 차단 / 로그인 / 세션 DB 백업 복원. + */ +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock MemberMapper memberMapper; + @Mock PasswordEncoder passwordEncoder; + @Mock RedisTemplate redisTemplate; + @Mock ValueOperations valueOps; + @Mock AppSettingService appSettingService; + @Mock AuthSessionMapper authSessionMapper; + + AuthService service; + + @BeforeEach + void setUp() { + service = new AuthService(memberMapper, passwordEncoder, redisTemplate, appSettingService, authSessionMapper); + } + + private Member localMember() { + return Member.builder() + .id(1L).loginId("u1").password("hash").name("원래이름") + .email("old@b.com").provider("LOCAL").role("USER").status("ACTIVE") + .build(); + } + + // ===== verifyPassword ===== + + @Test + @DisplayName("verifyPassword: 일치하면 예외 없음") + void verifyPassword_ok() { + when(memberMapper.findById(1L)).thenReturn(localMember()); + when(passwordEncoder.matches("pw", "hash")).thenReturn(true); + + service.verifyPassword(1L, "pw"); + } + + @Test + @DisplayName("verifyPassword: 불일치면 401") + void verifyPassword_wrong() { + when(memberMapper.findById(1L)).thenReturn(localMember()); + when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + + assertThatThrownBy(() -> service.verifyPassword(1L, "bad")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("verifyPassword: 소셜 계정은 400") + void verifyPassword_social() { + Member social = Member.builder().id(1L).provider("NAVER").password(null).build(); + when(memberMapper.findById(1L)).thenReturn(social); + + assertThatThrownBy(() -> service.verifyPassword(1L, "pw")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("verifyPassword: 회원 없으면 404") + void verifyPassword_notFound() { + when(memberMapper.findById(1L)).thenReturn(null); + + assertThatThrownBy(() -> service.verifyPassword(1L, "pw")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.NOT_FOUND); + } + + // ===== updateProfile ===== + + @Test + @DisplayName("updateProfile: 이름/이메일 변경 + 세션 이름 동기화") + void updateProfile_ok() { + when(memberMapper.findById(1L)).thenReturn(localMember()); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get("session:tok")).thenReturn(null); // Redis 세션 없음 → DB만 동기화 + + ProfileUpdateRequest req = new ProfileUpdateRequest(); + req.setName(" 새이름 "); + req.setEmail("new@b.com"); + + MemberResponse res = service.updateProfile(1L, "tok", req); + + assertThat(res.getName()).isEqualTo("새이름"); // trim + assertThat(res.getEmail()).isEqualTo("new@b.com"); + verify(memberMapper).updateProfile(1L, "새이름", "new@b.com"); + verify(authSessionMapper).updateName("tok", "새이름"); + } + + @Test + @DisplayName("updateProfile: 이름이 비면 400, 변경 안 함") + void updateProfile_blankName() { + when(memberMapper.findById(1L)).thenReturn(localMember()); + + ProfileUpdateRequest req = new ProfileUpdateRequest(); + req.setName(" "); + + assertThatThrownBy(() -> service.updateProfile(1L, "tok", req)) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.BAD_REQUEST); + verify(memberMapper, never()).updateProfile(any(), any(), any()); + } + + // ===== signup ===== + + @Test + @DisplayName("signup: 가입 제한 시 403") + void signup_disabled() { + when(appSettingService.isSignupEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("signup: 허니팟(website) 채워지면 400") + void signup_honeypot() { + when(appSettingService.isSignupEnabled()).thenReturn(true); + SignupRequest req = new SignupRequest(); + req.setWebsite("http://bot"); + + assertThatThrownBy(() -> service.signup(req, "1.2.3.4")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("signup: IP 레이트리밋 초과 시 429") + void signup_rateLimited() { + when(appSettingService.isSignupEnabled()).thenReturn(true); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.increment(anyString())).thenReturn(6L); // SIGNUP_LIMIT(5) 초과 + + assertThatThrownBy(() -> service.signup(new SignupRequest(), "1.2.3.4")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + } + + @Test + @DisplayName("signup: 정상 가입 시 회원 저장") + void signup_ok() { + when(appSettingService.isSignupEnabled()).thenReturn(true); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.increment(anyString())).thenReturn(1L); + when(memberMapper.countByLoginId("newbie")).thenReturn(0); + when(passwordEncoder.encode("password1")).thenReturn("hashed"); + + SignupRequest req = new SignupRequest(); + req.setLoginId("newbie"); + req.setPassword("password1"); + req.setName("새회원"); + req.setEmail("n@b.com"); + + MemberResponse res = service.signup(req, "1.2.3.4"); + + assertThat(res.getLoginId()).isEqualTo("newbie"); + verify(memberMapper).insert(any(Member.class)); + } + + // ===== login ===== + + @Test + @DisplayName("login: 비밀번호 불일치 시 401") + void login_wrongPassword() { + when(memberMapper.findByLoginId("u1")).thenReturn(localMember()); + when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + + LoginRequest req = new LoginRequest(); + req.setLoginId("u1"); + req.setPassword("bad"); + + assertThatThrownBy(() -> service.login(req)) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("login: 성공 시 토큰 발급 + Redis/DB 이중 저장") + void login_ok() { + when(memberMapper.findByLoginId("u1")).thenReturn(localMember()); + when(passwordEncoder.matches("pw", "hash")).thenReturn(true); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + + LoginRequest req = new LoginRequest(); + req.setLoginId("u1"); + req.setPassword("pw"); + req.setRememberMe(true); + + LoginResponse res = service.login(req); + + assertThat(res.getToken()).isNotBlank(); + verify(valueOps).set(anyString(), any(), any()); // Redis 저장 + verify(authSessionMapper).insert(any(AuthSession.class)); // DB 백업 + } + + // ===== getSession (DB 백업 복원) ===== + + @Test + @DisplayName("getSession: Redis 적중 시 그대로 반환") + void getSession_redisHit() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + SessionUser cached = SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").rememberMe(false).build(); + when(valueOps.get("session:tok")).thenReturn(cached); + + SessionUser out = service.getSession("tok"); + + assertThat(out).isNotNull(); + assertThat(out.getLoginId()).isEqualTo("u1"); + } + + @Test + @DisplayName("getSession: Redis 미스 + DB 유효 → 복원 및 재수화") + void getSession_dbFallback() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get("session:tok")).thenReturn(null); + AuthSession db = AuthSession.builder() + .token("tok").memberId(1L).loginId("u1").name("N").role("USER") + .provider("LOCAL").rememberMe(true) + .expiresAt(LocalDateTime.now().plusDays(1)) + .build(); + when(authSessionMapper.findByToken("tok")).thenReturn(db); + + SessionUser out = service.getSession("tok"); + + assertThat(out).isNotNull(); + assertThat(out.getLoginId()).isEqualTo("u1"); + verify(valueOps).set(eq("session:tok"), any(), any()); // Redis 재수화 + verify(authSessionMapper).updateExpiry(eq("tok"), any()); // 슬라이딩 갱신 + } + + @Test + @DisplayName("getSession: Redis 미스 + DB 없음 → null") + void getSession_miss() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get("session:tok")).thenReturn(null); + when(authSessionMapper.findByToken("tok")).thenReturn(null); + + assertThat(service.getSession("tok")).isNull(); + } + + @Test + @DisplayName("getSession: DB 백업이 만료면 정리하고 null") + void getSession_dbExpired() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.get("session:tok")).thenReturn(null); + AuthSession expired = AuthSession.builder() + .token("tok").memberId(1L).expiresAt(LocalDateTime.now().minusMinutes(1)) + .build(); + when(authSessionMapper.findByToken("tok")).thenReturn(expired); + + assertThat(service.getSession("tok")).isNull(); + verify(authSessionMapper).delete("tok"); + } +}