From c98c833685970d8b3cd5d196e7558a678ed22ee6 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 14:12:37 +0900 Subject: [PATCH 1/7] =?UTF-8?q?test(backend):=20MemberService=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20@OnDelete=20=EB=88=84=EB=9D=BD=202?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=9C=EA=B2=AC=C2=B7?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberServiceTest 신규 — 회원가입/로그인/로그아웃/탈퇴. happy path뿐 아니라 실패 경로 (중복 이메일, 존재하지 않는 회원, 비밀번호 불일치)와 탈퇴 시 pose_data 비동기 정리 트리거 조건(afterCommit 이전엔 미호출, 세션 없으면 동기화 자체를 등록 안 함)까지 검증 - MemberDeletionCascadeIntegrationTest 신규 — 실제 DB(H2)로 회원 탈퇴 CASCADE 체인 검증 (세션·리포트·피드백로그). 작성 중 Session.member/DailyLog.member에 Hibernate @OnDelete가 없어 테스트 스키마에서 탈퇴해도 세션이 안 지워지는 걸 발견 - refresh_token(2일 전), Report/SessionFeedbackLog(오늘 초반)에 이은 같은 버그 클래스. 실 mysql/schema.sql은 이미 ON DELETE CASCADE라 프로덕션 동작엔 영향 없고, JPA 엔티티 매핑과 테스트 스키마만 뒤늦게 맞춘 것 - 라이브 재배포 불필요 - refresh_token/body_records는 엔티티 매핑 구조상 이 테스트로 검증 불가함을 주석으로 명시 (전자는 Member로 가는 @ManyToOne 자체가 없음, 후자는 JPA 엔티티 자체가 없는 미구현 테이블) Co-Authored-By: Claude Sonnet 5 --- .../com/shadowfit/model/exercise/Session.java | 3 + .../com/shadowfit/model/report/DailyLog.java | 3 + .../MemberDeletionCascadeIntegrationTest.java | 124 ++++++++ .../service/Member/MemberServiceTest.java | 265 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Member/MemberServiceTest.java diff --git a/backend/src/main/java/com/shadowfit/model/exercise/Session.java b/backend/src/main/java/com/shadowfit/model/exercise/Session.java index 07ecac8..2a5fb51 100644 --- a/backend/src/main/java/com/shadowfit/model/exercise/Session.java +++ b/backend/src/main/java/com/shadowfit/model/exercise/Session.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import jakarta.persistence.Version; import lombok.AccessLevel; @@ -26,6 +28,7 @@ public class Session { // 연관관계 설정 (N:1) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) // 실 schema.sql의 ON DELETE CASCADE와 일치 — 회원 탈퇴 시 함께 정리 private Member member; @ManyToOne(fetch = FetchType.LAZY) diff --git a/backend/src/main/java/com/shadowfit/model/report/DailyLog.java b/backend/src/main/java/com/shadowfit/model/report/DailyLog.java index 85adb01..0e6e1ed 100644 --- a/backend/src/main/java/com/shadowfit/model/report/DailyLog.java +++ b/backend/src/main/java/com/shadowfit/model/report/DailyLog.java @@ -4,6 +4,8 @@ import com.shadowfit.model.member.Member; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import java.math.BigDecimal; import java.time.LocalDate; @@ -25,6 +27,7 @@ public class DailyLog extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) // 실 schema.sql의 ON DELETE CASCADE와 일치 — 회원 탈퇴 시 함께 정리 private Member member; @Column(nullable = false) diff --git a/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java new file mode 100644 index 0000000..8b8a45b --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java @@ -0,0 +1,124 @@ +package com.shadowfit.integration; + +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.FeedbackType; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.SessionFeedbackLog; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.model.report.Report; +import com.shadowfit.model.report.ReportType; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionFeedbackLogRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.repository.report.ReportRepository; +import com.shadowfit.service.Member.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 회원 탈퇴 시 실제 CASCADE 체인이 (H2 테스트 스키마 기준으로도) 끝까지 도는지 검증하는 + * 통합테스트. MemberServiceTest(모킹 기반)는 로직 분기는 검증하지만 실제 JPA cascade 동작은 + * 못 잡는다 — 오늘 Report/SessionFeedbackLog에서 @OnDelete 누락을 발견한 게 바로 이 경로라 + * 회원 삭제도 실제 DB로 한 번 더 확인한다 (pose-data-partition-fk-tradeoff.md §5). + * + * pose_data 정리(afterCommit + @Async)는 테스트 트랜잭션이 실제로 커밋되지 않아 여기서 + * 검증 불가 — 그 부분은 MemberServiceTest의 동기화 등록/발동 단위테스트가 커버한다. + */ +@SpringBootTest +@Transactional +@DisplayName("회원 탈퇴 CASCADE 통합테스트") +class MemberDeletionCascadeIntegrationTest { + + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private ReportRepository reportRepository; + @Autowired private SessionFeedbackLogRepository feedbackLogRepository; + @Autowired private EntityManager entityManager; + + private Member member; + private Session session; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("cascade@test.com") + .username("cascade유저") + .password("dummy") + .selectedPersona(SelectedPersona.BEGINNER) + .role(UserRole.USER) + .build()); + + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER) + .expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")) + .syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + + session = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise) + .startTime(LocalDateTime.now().minusHours(1)) + .endTime(LocalDateTime.now()) + .status(Status.COMPLETED) + .totalReps(10) + .avgSyncRate(new BigDecimal("77.5")) + .caloriesBurned(new BigDecimal("50.0")) + .build()); + + Report report = new Report(); + report.setMember(member); + report.setSession(session); + report.setReportType(ReportType.SESSION); + reportRepository.saveAndFlush(report); + + feedbackLogRepository.saveAndFlush(SessionFeedbackLog.builder() + .session(session).feedbackType(FeedbackType.KNEE_OUT) + .occurredAt(LocalDateTime.now()) + .build()); + + // refresh_token/body_records는 이 통합테스트로 검증 불가: + // - refresh_token: RefreshToken 엔티티가 Member로 가는 @ManyToOne 매핑 자체가 없어서 + // (memberId가 단순 Long 컬럼) H2 테스트 스키마엔 FK조차 안 생김 — 별도 구조 개선 필요. + // - body_records: JPA 엔티티 자체가 없는 미구현 테이블(production-signal-checklist.md)이라 + // Hibernate ddl-auto로 생성되는 H2 테스트 스키마엔 테이블 자체가 없음. + // 둘 다 오늘 추가/확인한 ON DELETE CASCADE는 라이브 MySQL 컨테이너에서 직접 확인 완료. + } + + @Test + @DisplayName("탈퇴 시 세션·리포트·피드백로그 전부 정리됨 (실제 JPA cascade로 검증 가능한 범위)") + void deleteAccount_cascadesToAllOwnedData() { + Long memberId = member.getId(); + Long sessionId = session.getId(); + + memberService.deleteAccount(member.getEmail()); + + // deleteAccount 내부의 memberRepository.delete()는 즉시 flush되지 않고 트랜잭션 커밋 + // 시점까지 지연될 수 있음 — 테스트는 커밋 없이 검증해야 하므로 명시적으로 flush해서 + // 실제 DELETE(및 DB 레벨 CASCADE)가 지금 실행되게 한 뒤, 1차 캐시의 stale 엔티티를 + // clear()로 비워서 이후 조회가 DB를 다시 타게 함(그래야 CASCADE 결과가 보임). + entityManager.flush(); + entityManager.clear(); + + assertThat(memberRepository.findById(memberId)).isEmpty(); + assertThat(sessionRepository.findById(sessionId)).isEmpty(); + assertThat(reportRepository.findBySessionId(sessionId)).isEmpty(); + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(sessionId)).isEmpty(); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Member/MemberServiceTest.java b/backend/src/test/java/com/shadowfit/service/Member/MemberServiceTest.java new file mode 100644 index 0000000..c3edccc --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Member/MemberServiceTest.java @@ -0,0 +1,265 @@ +package com.shadowfit.service.Member; + +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.dto.login.LoginRequestDto; +import com.shadowfit.dto.login.LoginResponseDto; +import com.shadowfit.dto.login.LogOutRequestDto; +import com.shadowfit.dto.login.MemberRequestDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.global.security.jwt.JwtBlacklist; +import com.shadowfit.global.security.jwt.JwtUtil; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.RefreshToken; +import com.shadowfit.model.member.Sex; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.repository.member.RefreshTokenRepository; +import com.shadowfit.service.Exercise.PoseDataCleanupService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Optional; + +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * MemberService 단위 테스트 — 회원가입/로그인/로그아웃/탈퇴. happy path뿐 아니라 실패 경로 + * (중복 이메일, 비밀번호 불일치, 존재하지 않는 회원)와 탈퇴 시 pose_data 비동기 정리 트리거 + * 조건(afterCommit 이전엔 호출 안 됨, 세션 없으면 등록 자체를 안 함)까지 검증한다. + * + * pose-data-partition-fk-tradeoff.md §5(B5) — 탈퇴는 회원이 쌓은 세션 규모가 커질 수 있어 + * pose_data 정리를 커밋 직후 비동기로 트리거하는 구조. 오늘 refresh_token·body_records에서 + * CASCADE 누락 버그를 두 번 발견한 그 경로라 이 서비스 자체의 자동 테스트가 중요. + */ +@DisplayName("MemberService 테스트") +class MemberServiceTest { + + @Mock private JwtUtil jwtUtil; + @Mock private MemberRepository memberRepository; + @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock private SessionRepository sessionRepository; + @Mock private PoseDataCleanupService poseDataCleanupService; + @Mock private PasswordEncoder passwordEncoder; + + private JwtBlacklist jwtBlacklist; // 순수 인메모리 맵이라 모킹 없이 실사용 + private MemberService memberService; + + private static final String EMAIL = "test@test.com"; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + jwtBlacklist = new JwtBlacklist(); + memberService = new MemberService(jwtUtil, memberRepository, refreshTokenRepository, + sessionRepository, poseDataCleanupService, passwordEncoder, jwtBlacklist); + } + + @Nested + @DisplayName("회원가입") + class Signup { + + @Test + @DisplayName("정상 가입 — 비밀번호 인코딩 후 저장, username 반환") + void signup_success() { + MemberRequestDto dto = new MemberRequestDto("user1", EMAIL, "raw-pw", Sex.MALE, UserRole.USER); + when(memberRepository.existsByEmail(EMAIL)).thenReturn(false); + when(passwordEncoder.encode("raw-pw")).thenReturn("encoded-pw"); + + String result = memberService.signup(dto); + + assertThat(result).isEqualTo("user1"); + ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository).save(captor.capture()); + assertThat(captor.getValue().getPassword()).isEqualTo("encoded-pw"); + assertThat(captor.getValue().getEmail()).isEqualTo(EMAIL); + } + + @Test + @DisplayName("이미 가입된 이메일이면 USERID_DUPLICATION, 저장 안 함") + void signup_duplicateEmail_throws() { + MemberRequestDto dto = new MemberRequestDto("user1", EMAIL, "raw-pw", Sex.MALE, UserRole.USER); + when(memberRepository.existsByEmail(EMAIL)).thenReturn(true); + + assertThatThrownBy(() -> memberService.signup(dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USERID_DUPLICATION); + + verify(memberRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("로그인") + class Login { + + private Member existingMember() { + return Member.builder().id(1L).email(EMAIL).username("user1") + .password("encoded-pw").role(UserRole.USER).build(); + } + + @Test + @DisplayName("정상 로그인 — 토큰 발급 + refresh_token 저장") + void login_success() { + Member member = existingMember(); + LoginRequestDto dto = new LoginRequestDto(EMAIL, "raw-pw"); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true); + when(jwtUtil.createAccessToken(any(CustomUserInfoDto.class))).thenReturn("access-token"); + when(jwtUtil.createRefreshToken(any(CustomUserInfoDto.class))).thenReturn("refresh-token"); + + LoginResponseDto result = memberService.login(dto); + + assertThat(result.getAccessToken()).isEqualTo("access-token"); + assertThat(result.getRefreshToken()).isEqualTo("refresh-token"); + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenRepository).save(captor.capture()); + assertThat(captor.getValue().getMemberId()).isEqualTo(1L); + assertThat(captor.getValue().getToken()).isEqualTo("refresh-token"); + } + + @Test + @DisplayName("존재하지 않는 이메일이면 USER_NOT_FOUND") + void login_userNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> memberService.login(new LoginRequestDto(EMAIL, "raw-pw"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + + verify(jwtUtil, never()).createAccessToken(any()); + } + + @Test + @DisplayName("비밀번호 불일치면 LOGIN_INPUT_INVALID, 토큰 발급 안 함") + void login_wrongPassword_throws() { + Member member = existingMember(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + when(passwordEncoder.matches("wrong-pw", "encoded-pw")).thenReturn(false); + + assertThatThrownBy(() -> memberService.login(new LoginRequestDto(EMAIL, "wrong-pw"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.LOGIN_INPUT_INVALID); + + verify(jwtUtil, never()).createAccessToken(any()); + verify(refreshTokenRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + @Test + @DisplayName("refresh_token 삭제 + access token 블랙리스트 등록 (Bearer 접두어 제거)") + void logout_bearerPrefix_stripped() { + when(jwtUtil.getExpiration("raw-access-token")).thenReturn(123456L); + LogOutRequestDto dto = LogOutRequestDto.builder() + .accessToken("Bearer raw-access-token") + .refreshToken("some-refresh-token") + .build(); + + memberService.logout(dto); + + verify(refreshTokenRepository).deleteByToken("some-refresh-token"); + assertThat(jwtBlacklist.isBlacklisted("raw-access-token")).isTrue(); + } + + @Test + @DisplayName("Bearer 접두어 없는 토큰도 그대로 블랙리스트 등록됨") + void logout_withoutBearerPrefix_stillWorks() { + when(jwtUtil.getExpiration("plain-token")).thenReturn(123456L); + LogOutRequestDto dto = LogOutRequestDto.builder() + .accessToken("plain-token") + .refreshToken("some-refresh-token") + .build(); + + memberService.logout(dto); + + assertThat(jwtBlacklist.isBlacklisted("plain-token")).isTrue(); + } + } + + @Nested + @DisplayName("회원 탈퇴 (pose-data-partition-fk-tradeoff.md §5, B5)") + class DeleteAccount { + + @Test + @DisplayName("존재하지 않는 이메일이면 USER_NOT_FOUND, delete 호출 안 함") + void deleteAccount_memberNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> memberService.deleteAccount(EMAIL)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + + verify(memberRepository, never()).delete(any()); + } + + @Test + @DisplayName("세션이 있으면 삭제 후 커밋 시점에만 pose_data 비동기 정리가 트리거됨") + void deleteAccount_withSessions_triggersCleanupOnlyAfterCommit() { + Member member = Member.builder().id(1L).email(EMAIL).username("u").password("p").build(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + when(sessionRepository.findIdsByMemberId(1L)).thenReturn(List.of(101L, 102L)); + + // 실제 트랜잭션 없이도 동기화 등록/발동만 검증하기 위해 동기화 컨텍스트를 직접 활성화 + TransactionSynchronizationManager.initSynchronization(); + try { + memberService.deleteAccount(EMAIL); + + verify(memberRepository).delete(member); + // 커밋 전이라 아직 호출되면 안 됨 + verify(poseDataCleanupService, never()).cleanupBySessionIds(any()); + + List syncs = TransactionSynchronizationManager.getSynchronizations(); + assertThat(syncs).hasSize(1); + syncs.forEach(TransactionSynchronization::afterCommit); // 커밋 시뮬레이션 + + verify(poseDataCleanupService, times(1)).cleanupBySessionIds(List.of(101L, 102L)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("세션이 없으면 커밋 동기화 자체를 등록하지 않음 (불필요한 비동기 트리거 방지)") + void deleteAccount_withoutSessions_registersNoSynchronization() { + Member member = Member.builder().id(1L).email(EMAIL).username("u").password("p").build(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + when(sessionRepository.findIdsByMemberId(1L)).thenReturn(List.of()); + + TransactionSynchronizationManager.initSynchronization(); + try { + memberService.deleteAccount(EMAIL); + + verify(memberRepository).delete(member); + assertThat(TransactionSynchronizationManager.getSynchronizations()).isEmpty(); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + } +} From 5fac10efe6b079a14050d27d9b34d811c1916b3f Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 14:18:36 +0900 Subject: [PATCH 2/7] =?UTF-8?q?test(backend):=20FeedbackLogService.saveBat?= =?UTF-8?q?ch=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI가 BT-SET으로 세트 경계마다 재전송할 수 있는 피드백 batch가 uniqueKey(session_id, occurred_at, feedback_type) + INSERT IGNORE로 실제로 안전한지 검증(db-deep-dive.md §C). 실제 JdbcTemplate.batchUpdate 경로를 타야 하는 로직이라 모킹 대신 real H2로 확인. - 전체 신규 삽입, 완전 동일 재전송(전부 흡수), 일부만 겹치는 재전송(신규분만 삽입) - 존재하지 않는 세션(SESSION_NOT_FOUND, 삽입 없음), 빈 이벤트(0 반환) - 배치 중간에 잘못된 feedback_type이 있으면 INVALID_INPUT_VALUE + 같은 배치의 앞선 유효 행도 삽입 안 됨(setValues() 단계에서 예외 발생 시 executeBatch() 자체가 호출 안 됨을 실측 확인) Co-Authored-By: Claude Sonnet 5 --- .../Exercise/FeedbackLogServiceTest.java | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/FeedbackLogServiceTest.java diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackLogServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackLogServiceTest.java new file mode 100644 index 0000000..8be6c71 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackLogServiceTest.java @@ -0,0 +1,173 @@ +package com.shadowfit.service.Exercise; + +import com.google.protobuf.Timestamp; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.grpc.FeedbackBatchRequest; +import com.shadowfit.grpc.FeedbackEvent; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionFeedbackLogRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * FeedbackLogService.saveBatch 통합테스트 — AI가 BT-SET으로 세트 경계마다 batch 송신하는 + * 피드백 이벤트 로그의 멱등성(uniqueKey (session_id, occurred_at, feedback_type) + INSERT + * IGNORE)이 실제로 재전송에 안전한지 검증한다(db-deep-dive.md §C). 실제 JdbcTemplate + * batchUpdate 경로를 타야 하는 로직(잘못된 feedback_type이 배치 내부에서 던지는 예외 등)이라 + * 모킹이 아니라 real H2로 검증. + */ +@SpringBootTest +@Transactional +@DisplayName("FeedbackLogService 테스트") +class FeedbackLogServiceTest { + + @Autowired private FeedbackLogService feedbackLogService; + @Autowired private SessionRepository sessionRepository; + @Autowired private SessionFeedbackLogRepository feedbackLogRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + + private Session session; + + @BeforeEach + void setUp() { + Member member = memberRepository.saveAndFlush(Member.builder() + .email("feedback@test.com").username("피드백유저").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + session = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise) + .startTime(LocalDateTime.now()).status(Status.IN_PROGRESS) + .totalReps(0).difficultyLevel(1).build()); + } + + private Timestamp ts(long epochSecond) { + return Timestamp.newBuilder().setSeconds(epochSecond).build(); + } + + private FeedbackEvent event(String feedbackType, long epochSecond, double syncRate) { + return FeedbackEvent.newBuilder() + .setFeedbackType(feedbackType) + .setSyncRateAtTrigger(syncRate) + .setOccurredAt(ts(epochSecond)) + .build(); + } + + private FeedbackBatchRequest batch(FeedbackEvent... events) { + FeedbackBatchRequest.Builder b = FeedbackBatchRequest.newBuilder() + .setSessionId(session.getId()).setSetNo(1).setIsFinal(false); + for (FeedbackEvent e : events) b.addEvents(e); + return b.build(); + } + + @Test + @DisplayName("정상 batch — 전부 신규 삽입, 삽입 개수 그대로 반환") + void saveBatch_allNew_insertsAll() { + int inserted = feedbackLogService.saveBatch(batch( + event("KNEE_OUT", 1000, 50.0), + event("HIP_HIGH", 1001, 60.0) + )); + + assertThat(inserted).isEqualTo(2); + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(session.getId())).hasSize(2); + } + + @Test + @DisplayName("완전히 같은 batch를 재전송하면(at-least-once 재시도) 전부 흡수 — 중복 없음") + void saveBatch_exactRetry_isIdempotent() { + FeedbackBatchRequest request = batch( + event("KNEE_OUT", 1000, 50.0), + event("HIP_HIGH", 1001, 60.0) + ); + + int firstInserted = feedbackLogService.saveBatch(request); + int secondInserted = feedbackLogService.saveBatch(request); // 완전히 동일한 재전송 + + assertThat(firstInserted).isEqualTo(2); + assertThat(secondInserted).isZero(); // 전부 중복으로 흡수됨 + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(session.getId())).hasSize(2); // 그대로 2건 + } + + @Test + @DisplayName("일부만 겹치는 batch는 겹치는 것만 흡수하고 새 것만 삽입") + void saveBatch_partialOverlap_insertsOnlyNewOnes() { + feedbackLogService.saveBatch(batch( + event("KNEE_OUT", 1000, 50.0), + event("HIP_HIGH", 1001, 60.0) + )); + + // 두 번째 batch: 1000(KNEE_OUT)은 중복, 1002(BACK_BENT)는 신규 + int secondInserted = feedbackLogService.saveBatch(batch( + event("KNEE_OUT", 1000, 50.0), + event("BACK_BENT", 1002, 70.0) + )); + + assertThat(secondInserted).isEqualTo(1); + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(session.getId())).hasSize(3); + } + + @Test + @DisplayName("존재하지 않는 세션이면 SESSION_NOT_FOUND, 아무 것도 삽입 안 함") + void saveBatch_unknownSession_throwsAndInsertsNothing() { + FeedbackBatchRequest request = FeedbackBatchRequest.newBuilder() + .setSessionId(999999L).setSetNo(1) + .addEvents(event("KNEE_OUT", 1000, 50.0)) + .build(); + + assertThatThrownBy(() -> feedbackLogService.saveBatch(request)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(999999L)).isEmpty(); + } + + @Test + @DisplayName("이벤트가 비어있으면 0 반환, 아무 것도 삽입 안 함") + void saveBatch_emptyEvents_returnsZero() { + int inserted = feedbackLogService.saveBatch(batch()); + + assertThat(inserted).isZero(); + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(session.getId())).isEmpty(); + } + + @Test + @DisplayName("배치 안에 잘못된 feedback_type 문자열이 있으면 INVALID_INPUT_VALUE, 같은 배치의 다른 행도 삽입 안 됨") + void saveBatch_invalidFeedbackType_throwsAndInsertsNothing() { + FeedbackBatchRequest request = batch( + event("KNEE_OUT", 1000, 50.0), // 유효 — 먼저 옴 + event("NOT_A_REAL_TYPE", 1001, 60.0) // 무효 — 배치 파라미터 바인딩 중 예외 + ); + + assertThatThrownBy(() -> feedbackLogService.saveBatch(request)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + // setValues() 단계에서 던져지면 executeBatch() 자체가 호출 안 되므로 앞선 유효 행도 삽입 안 됨 + assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(session.getId())).isEmpty(); + } +} From 43d5b0d068e94610bf30f543ba7107df3e1dd8d9 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 14:49:41 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test(backend):=20=EB=B3=B4=EC=95=88=C2=B7?= =?UTF-8?q?=EC=86=8C=EA=B7=9C=EB=AA=A8=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B7?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=A7=84=EC=A7=9C=20=EB=B2=84=EA=B7=B8=202=EA=B1=B4=20?= =?UTF-8?q?=EB=B0=9C=EA=B2=AC=C2=B7=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 커버리지 전면 확장 — 남아있던 무테스트 영역(보안, CRUD성 서비스, 컨트롤러/HTTP 레벨) 전부 커버: - JwtUtilTest, CustomUserDetailsServiceTest, JwtAuthFilterTest — 인증 메커니즘 자체가 지금까지 무테스트였음 - OnboardingServiceTest, PreferenceServiceTest, FeedbackTemplateServiceTest, SessionFeedbackQueryServiceTest - MemberControllerIntegrationTest, SessionControllerIntegrationTest, AdminAuthorizationIntegrationTest, ProtectedEndpointSmokeTest — 실제 보안 필터체인까지 태우는 MockMvc 통합테스트(컨트롤러 계층이 지금까지 전부 무테스트였음) 발견·수정한 진짜 버그 2건: 1. JwtUtil.isValidToken — io.jsonwebtoken.security.SecurityException을 명시 import 안 해서 catch절의 SecurityException이 java.lang.SecurityException으로 잘못 resolve됨. 서명 변조 토큰이 이 catch에 안 걸리고 그대로 예외를 던지던 버그. 2. @PreAuthorize("hasRole('ADMIN')") 권한 없음이 403이 아니라 500으로 나가던 버그 — GlobalExceptionHandler의 catch-all(@ExceptionHandler(Exception.class))이 MVC 디스패치 도중 발생하는 AccessDeniedException을 SecurityConfig의 CustomAccessDeniedHandler(필터체인 레벨 전용)보다 먼저 가로채서 생김. AdminExerciseController·ExercisesController. extractReference 등 @PreAuthorize 쓰는 모든 엔드포인트에 영향 — 명시적 @ExceptionHandler(AccessDeniedException.class) 추가로 수정. 전체 테스트 스위트 0 실패. 2번 버그는 실제 동작에 영향을 주는 수정이라 Docker 이미지 재빌드 + 재기동으로 라이브 반영 완료. Co-Authored-By: Claude Sonnet 5 --- .../global/error/GlobalExceptionHandler.java | 21 +++ .../global/security/jwt/JwtUtil.java | 5 + .../security/jwt/JwtAuthFilterTest.java | 154 ++++++++++++++++++ .../global/security/jwt/JwtUtilTest.java | 85 ++++++++++ .../AdminAuthorizationIntegrationTest.java | 116 +++++++++++++ .../MemberControllerIntegrationTest.java | 135 +++++++++++++++ .../ProtectedEndpointSmokeTest.java | 108 ++++++++++++ .../SessionControllerIntegrationTest.java | 111 +++++++++++++ .../Exercise/FeedbackTemplateServiceTest.java | 100 ++++++++++++ .../SessionFeedbackQueryServiceTest.java | 124 ++++++++++++++ .../Member/CustomUserDetailsServiceTest.java | 59 +++++++ .../service/Member/OnboardingServiceTest.java | 108 ++++++++++++ .../service/Member/PreferenceServiceTest.java | 97 +++++++++++ 13 files changed, 1223 insertions(+) create mode 100644 backend/src/test/java/com/shadowfit/global/security/jwt/JwtAuthFilterTest.java create mode 100644 backend/src/test/java/com/shadowfit/global/security/jwt/JwtUtilTest.java create mode 100644 backend/src/test/java/com/shadowfit/integration/AdminAuthorizationIntegrationTest.java create mode 100644 backend/src/test/java/com/shadowfit/integration/MemberControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/shadowfit/integration/ProtectedEndpointSmokeTest.java create mode 100644 backend/src/test/java/com/shadowfit/integration/SessionControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/FeedbackTemplateServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/SessionFeedbackQueryServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Member/CustomUserDetailsServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Member/OnboardingServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Member/PreferenceServiceTest.java diff --git a/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java b/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java index c64ab49..4384d94 100644 --- a/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -30,6 +31,26 @@ public ResponseEntity handleBusinessException(BusinessExceptio .build()); } + /** + * ⚠️ 2026-07-24 추가: @PreAuthorize("hasRole(...)")가 던지는 AccessDeniedException은 MVC + * 핸들러 호출(디스패처 서블릿) 도중 발생해서, SecurityConfig의 CustomAccessDeniedHandler + * (필터체인 레벨 전용)까지 못 가고 여기 도착함. 이 핸들러가 없으면 아래 + * handleUnexpectedException(Exception.class)이 그냥 삼켜서 403 대신 500이 나가던 버그가 + * 있었음 — AdminAuthorizationIntegrationTest로 발견. + */ + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException e) { + ErrorCode code = ErrorCode.ACCESS_DENIED; + log.warn("AccessDeniedException: {}", e.getMessage()); + return ResponseEntity + .status(code.getStatus()) + .body(ErrorResponseDto.builder() + .status(code.getStatus()) + .message(code.getMessage()) + .timestamp(LocalDateTime.now()) + .build()); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { ErrorCode code = ErrorCode.INVALID_INPUT_VALUE; diff --git a/backend/src/main/java/com/shadowfit/global/security/jwt/JwtUtil.java b/backend/src/main/java/com/shadowfit/global/security/jwt/JwtUtil.java index d5ffb73..5261377 100644 --- a/backend/src/main/java/com/shadowfit/global/security/jwt/JwtUtil.java +++ b/backend/src/main/java/com/shadowfit/global/security/jwt/JwtUtil.java @@ -4,6 +4,7 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -63,6 +64,10 @@ public String getUserEmail(String token){ } // JWT 검증 + // ⚠️ 2026-07-24 수정: 위에서 io.jsonwebtoken.security.SecurityException을 명시 import하지 + // 않았을 땐 여기 SecurityException이 java.lang.SecurityException으로 잘못 resolve돼(io.jsonwebtoken + // 패키지엔 이 이름의 클래스가 base package에 없음), 서명 변조 토큰(io.jsonwebtoken.security. + // SignatureException)이 이 catch에 안 걸리고 그대로 던져지는 버그가 있었음 — JwtUtilTest로 발견. public boolean isValidToken(String token){ try{ Jwts.parser().verifyWith(key).build().parseSignedClaims(token); diff --git a/backend/src/test/java/com/shadowfit/global/security/jwt/JwtAuthFilterTest.java b/backend/src/test/java/com/shadowfit/global/security/jwt/JwtAuthFilterTest.java new file mode 100644 index 0000000..4a76ed7 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/global/security/jwt/JwtAuthFilterTest.java @@ -0,0 +1,154 @@ +package com.shadowfit.global.security.jwt; + +import com.shadowfit.global.security.auth.CustomUserDetails; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.service.Member.CustomUserDetailsService; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * JwtAuthFilter 단위테스트 — 인증 필터 자체가 지금까지 무테스트였던 영역. 정상 인증뿐 아니라 + * 무효 토큰·블랙리스트·회원 없음·헤더 없음·/ws 경로 예외 케이스까지 검증. + */ +@DisplayName("JwtAuthFilter 테스트") +class JwtAuthFilterTest { + + @Mock private CustomUserDetailsService customUserDetailsService; + @Mock private JwtUtil jwtUtil; + private JwtBlacklist jwtBlacklist; // 순수 인메모리 맵이라 실사용 + private JwtAuthFilter filter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + jwtBlacklist = new JwtBlacklist(); + filter = new JwtAuthFilter(customUserDetailsService, jwtUtil, jwtBlacklist); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private CustomUserDetails userDetails() { + Member member = Member.builder().id(1L).email("test@test.com") + .username("u").password("pw").role(UserRole.USER).build(); + return new CustomUserDetails(member); + } + + @Test + @DisplayName("유효한 Bearer 토큰이면 SecurityContext에 인증 정보가 설정됨") + void validToken_setsAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + when(jwtUtil.isValidToken("valid-token")).thenReturn(true); + when(jwtUtil.getUserEmail("valid-token")).thenReturn("test@test.com"); + when(customUserDetailsService.loadUserByUsername("test@test.com")).thenReturn(userDetails()); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("test@test.com"); + verify(chain, times(1)).doFilter(request, response); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 인증 없이 체인만 진행") + void noAuthHeader_skipsAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain, times(1)).doFilter(request, response); + verify(jwtUtil, never()).isValidToken(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("무효 토큰이면 인증 안 됨, 체인은 그대로 진행(에러 응답 아님)") + void invalidToken_noAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1"); + request.addHeader("Authorization", "Bearer bad-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + when(jwtUtil.isValidToken("bad-token")).thenReturn(false); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain, times(1)).doFilter(request, response); + } + + @Test + @DisplayName("블랙리스트에 등록된 토큰이면(로그아웃됨) 검증에 유효해도 인증 안 됨") + void blacklistedToken_noAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1"); + request.addHeader("Authorization", "Bearer logged-out-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + when(jwtUtil.isValidToken("logged-out-token")).thenReturn(true); + jwtBlacklist.add("logged-out-token", System.currentTimeMillis() + 100000); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain, times(1)).doFilter(request, response); + } + + @Test + @DisplayName("토큰은 유효하지만 회원을 못 찾으면 예외를 삼키고 체인은 계속 진행") + void validTokenButUserNotFound_swallowsExceptionAndContinues() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + when(jwtUtil.isValidToken("valid-token")).thenReturn(true); + when(jwtUtil.getUserEmail("valid-token")).thenReturn("ghost@test.com"); + when(customUserDetailsService.loadUserByUsername("ghost@test.com")) + .thenThrow(new UsernameNotFoundException("no such user")); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain, times(1)).doFilter(request, response); // 예외가 필터 체인을 끊지 않음 + } + + @Test + @DisplayName("/ws 로 시작하는 경로는 인증 로직 자체를 건너뜀") + void wsPath_skipsAuthEntirely() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/ws/somewhere"); + request.addHeader("Authorization", "Bearer whatever"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(jwtUtil, never()).isValidToken(org.mockito.ArgumentMatchers.anyString()); + verify(chain, times(1)).doFilter(request, response); + } +} diff --git a/backend/src/test/java/com/shadowfit/global/security/jwt/JwtUtilTest.java b/backend/src/test/java/com/shadowfit/global/security/jwt/JwtUtilTest.java new file mode 100644 index 0000000..0179ef0 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/global/security/jwt/JwtUtilTest.java @@ -0,0 +1,85 @@ +package com.shadowfit.global.security.jwt; + +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.model.member.UserRole; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * JwtUtil 단위테스트 — 토큰 생성/검증/만료/파싱. 보안 메커니즘 자체가 지금까지 무테스트였던 + * 영역이라 happy path뿐 아니라 변조·만료·형식오류 케이스까지 검증. + */ +@DisplayName("JwtUtil 테스트") +class JwtUtilTest { + + private static final String SECRET = "test-secret-key-must-be-long-enough-for-hmac-sha256-1234567890"; + + private JwtUtil jwtUtil; + private CustomUserInfoDto userInfo; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(SECRET, 3600L, 604800L); // access 1시간, refresh 7일 + userInfo = CustomUserInfoDto.builder().email("test@test.com").role(UserRole.USER).build(); + } + + @Test + @DisplayName("access token 생성 후 이메일 추출·검증 가능") + void createAccessToken_andExtractEmail() { + String token = jwtUtil.createAccessToken(userInfo); + + assertThat(token).isNotBlank(); + assertThat(jwtUtil.getUserEmail(token)).isEqualTo("test@test.com"); + assertThat(jwtUtil.isValidToken(token)).isTrue(); + } + + @Test + @DisplayName("refresh token도 정상 생성·검증됨") + void createRefreshToken_isValid() { + String token = jwtUtil.createRefreshToken(userInfo); + + assertThat(jwtUtil.isValidToken(token)).isTrue(); + assertThat(jwtUtil.getUserEmail(token)).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("만료된 토큰은 isValidToken false — 단 getExpiration/parseClaims는 여전히 값 반환 (로그아웃 블랙리스트 계산용)") + void expiredToken_isInvalid_butClaimsStillReadable() { + JwtUtil expiredIssuer = new JwtUtil(SECRET, -10L, -10L); // 발급 즉시 만료 + String expiredToken = expiredIssuer.createAccessToken(userInfo); + + assertThat(jwtUtil.isValidToken(expiredToken)).isFalse(); + Claims claims = jwtUtil.parseClaims(expiredToken); + assertThat(claims.getSubject()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("서명이 변조된 토큰은 isValidToken false") + void tamperedToken_isInvalid() { + String token = jwtUtil.createAccessToken(userInfo); + String tampered = token.substring(0, token.length() - 5) + "AAAAA"; + + assertThat(jwtUtil.isValidToken(tampered)).isFalse(); + } + + @Test + @DisplayName("형식이 아예 잘못된 문자열은 예외 전파 없이 isValidToken false") + void malformedString_isInvalid() { + assertThat(jwtUtil.isValidToken("not-a-jwt-at-all")).isFalse(); + } + + @Test + @DisplayName("getExpiration은 발급 시점 + 설정된 만료시간(초)의 epoch millis를 반환") + void getExpiration_reflectsConfiguredExpiry() { + String token = jwtUtil.createAccessToken(userInfo); + long expiration = jwtUtil.getExpiration(token); + + long expectedMin = System.currentTimeMillis() + 3600L * 1000 - 5000; + long expectedMax = System.currentTimeMillis() + 3600L * 1000 + 5000; + assertThat(expiration).isBetween(expectedMin, expectedMax); + } +} diff --git a/backend/src/test/java/com/shadowfit/integration/AdminAuthorizationIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/AdminAuthorizationIntegrationTest.java new file mode 100644 index 0000000..b46fc4b --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/AdminAuthorizationIntegrationTest.java @@ -0,0 +1,116 @@ +package com.shadowfit.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shadowfit.dto.admin.ThresholdUpdateDto; +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.global.security.jwt.JwtUtil; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.service.Exercise.ExerciseAnalysisService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @PreAuthorize("hasRole('ADMIN')") 경계 통합테스트 — AdminExerciseController, + * ExercisesController.extractReference 둘 다 지금까지 이 권한 경계가 실제로 작동하는지 + * 검증한 적이 한 번도 없었음. USER 역할로 호출 시 진짜로 막히는지가 핵심. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("관리자 권한(@PreAuthorize) 통합테스트") +class AdminAuthorizationIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtUtil jwtUtil; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + @MockitoBean private ExerciseAnalysisService analysisService; // 실제 gRPC/추출 로직 우회, 권한만 검증 + + private Exercise exercise; + private String userToken; + private String adminToken; + + @BeforeEach + void setUp() { + Member user = memberRepository.saveAndFlush(Member.builder() + .email("user@test.com").username("u").password("dummy").role(UserRole.USER).build()); + Member admin = memberRepository.saveAndFlush(Member.builder() + .email("admin@test.com").username("a").password("dummy").role(UserRole.ADMIN).build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + + userToken = jwtUtil.createAccessToken(CustomUserInfoDto.builder().email(user.getEmail()).role(user.getRole()).build()); + adminToken = jwtUtil.createAccessToken(CustomUserInfoDto.builder().email(admin.getEmail()).role(admin.getRole()).build()); + } + + @Test + @DisplayName("임계값 변경 — USER 역할이면 403") + void updateThresholds_userRole_returns403() throws Exception { + ThresholdUpdateDto dto = new ThresholdUpdateDto( + new BigDecimal("60"), new BigDecimal("85"), new BigDecimal("70"), new BigDecimal("50")); + + mockMvc.perform(patch("/admin/exercises/" + exercise.getId() + "/thresholds") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("임계값 변경 — ADMIN 역할이면 200") + void updateThresholds_adminRole_returns200() throws Exception { + ThresholdUpdateDto dto = new ThresholdUpdateDto( + new BigDecimal("60"), new BigDecimal("85"), new BigDecimal("70"), new BigDecimal("50")); + + mockMvc.perform(patch("/admin/exercises/" + exercise.getId() + "/thresholds") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("기준 좌표 추출 — USER 역할이면 403") + void extractReference_userRole_returns403() throws Exception { + mockMvc.perform(post("/exercises/" + exercise.getId() + "/reference") + .header("Authorization", "Bearer " + userToken) + .param("youtubeUrl", "https://youtu.be/dummy")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("기준 좌표 추출 — ADMIN 역할이면 통과(202)") + void extractReference_adminRole_returns202() throws Exception { + doNothing().when(analysisService).extractReferencePoses(anyLong(), anyString()); + + mockMvc.perform(post("/exercises/" + exercise.getId() + "/reference") + .header("Authorization", "Bearer " + adminToken) + .param("youtubeUrl", "https://youtu.be/dummy")) + .andExpect(status().isAccepted()); + } +} diff --git a/backend/src/test/java/com/shadowfit/integration/MemberControllerIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/MemberControllerIntegrationTest.java new file mode 100644 index 0000000..d83f0db --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/MemberControllerIntegrationTest.java @@ -0,0 +1,135 @@ +package com.shadowfit.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.dto.login.LoginRequestDto; +import com.shadowfit.dto.login.MemberRequestDto; +import com.shadowfit.global.security.jwt.JwtUtil; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.Sex; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * MemberController 통합테스트 — 실제 보안 필터체인(JwtAuthFilter)까지 태워서 HTTP 레벨로 검증. + * 지금까지 컨트롤러 계층이 전부 무테스트였고, 그중에서도 인증 게이트웨이라 가장 중요. + * requireSelf(IDOR 방지) 는 지금까지 어떤 테스트도 실제 HTTP 요청으로 확인한 적 없음. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("MemberController 통합테스트") +class MemberControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtUtil jwtUtil; + @Autowired private MemberRepository memberRepository; + @Autowired private PasswordEncoder passwordEncoder; + + private Member member; + private String accessToken; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("http@test.com").username("httpuser") + .password(passwordEncoder.encode("password123")) + .role(UserRole.USER).build()); + + CustomUserInfoDto info = CustomUserInfoDto.builder().email(member.getEmail()).role(member.getRole()).build(); + accessToken = jwtUtil.createAccessToken(info); + } + + @Test + @DisplayName("회원가입 — 인증 없이 호출 가능, 200 + username 반환") + void signup_noAuthRequired_returns200() throws Exception { + MemberRequestDto dto = new MemberRequestDto("newuser", "new@test.com", "pw1234", Sex.MALE, UserRole.USER); + + mockMvc.perform(post("/member/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(content().string("newuser")); + } + + @Test + @DisplayName("로그인 성공 — 토큰 반환") + void login_success_returnsTokens() throws Exception { + LoginRequestDto dto = new LoginRequestDto(member.getEmail(), "password123"); + + mockMvc.perform(post("/member/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").exists()); + } + + @Test + @DisplayName("로그인 실패 — 비밀번호 틀리면 401") + void login_wrongPassword_returns401() throws Exception { + LoginRequestDto dto = new LoginRequestDto(member.getEmail(), "wrong-password"); + + mockMvc.perform(post("/member/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("보호된 엔드포인트를 토큰 없이 호출하면 401") + void protectedEndpoint_noToken_returns401() throws Exception { + mockMvc.perform(get("/member/onboarding/" + member.getEmail())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("본인 온보딩 조회 — 200") + void getOnboarding_self_returns200() throws Exception { + mockMvc.perform(get("/member/onboarding/" + member.getEmail()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("남의 온보딩 조회 시도 — 403 (requireSelf IDOR 방지)") + void getOnboarding_otherEmail_returns403() throws Exception { + mockMvc.perform(get("/member/onboarding/someone-else@test.com") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("본인 탈퇴 — 204") + void deleteMember_self_returns204() throws Exception { + mockMvc.perform(delete("/member/" + member.getEmail()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("남의 계정 탈퇴 시도 — 403 (requireSelf IDOR 방지)") + void deleteMember_otherEmail_returns403() throws Exception { + mockMvc.perform(delete("/member/someone-else@test.com") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isForbidden()); + } +} diff --git a/backend/src/test/java/com/shadowfit/integration/ProtectedEndpointSmokeTest.java b/backend/src/test/java/com/shadowfit/integration/ProtectedEndpointSmokeTest.java new file mode 100644 index 0000000..c674033 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/ProtectedEndpointSmokeTest.java @@ -0,0 +1,108 @@ +package com.shadowfit.integration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 나머지 컨트롤러들(ExerciseRecordController·ExerciseReportController·ExercisesController· + * FeedbackTemplateController·PreferenceController·SessionFeedbackController)의 보호된 + * 엔드포인트가 실제로 인증 없이는 401을 반환하는지 확인하는 저비용 스모크 테스트. + * 비즈니스 로직 자체는 이미 서비스 단위테스트로 커버돼 있어, 여기서는 라우팅·보안설정 + * 자체가 살아있는지만 넓게 확인한다(라우팅 오타·화이트리스트 실수 등을 잡는 안전망). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("보호된 엔드포인트 인증 스모크 테스트") +class ProtectedEndpointSmokeTest { + + @Autowired private MockMvc mockMvc; + + @Test + @DisplayName("GET /reports/weekly-summary — 토큰 없으면 401") + void weeklySummary_noToken_401() throws Exception { + mockMvc.perform(get("/reports/weekly-summary")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /reports/calendar — 토큰 없으면 401") + void calendar_noToken_401() throws Exception { + mockMvc.perform(get("/reports/calendar").param("year", "2026").param("month", "7")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /reports/daily — 토큰 없으면 401") + void daily_noToken_401() throws Exception { + mockMvc.perform(get("/reports/daily").param("date", "2026-07-24")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("POST /reports/daily-logs — 토큰 없으면 401") + void saveDailyLog_noToken_401() throws Exception { + mockMvc.perform(post("/reports/daily-logs") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /reports/session/{id} — 토큰 없으면 401") + void sessionReport_noToken_401() throws Exception { + mockMvc.perform(get("/reports/session/1")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("POST /exercises/sessions — 토큰 없으면 401") + void startAnalysis_noToken_401() throws Exception { + mockMvc.perform(post("/exercises/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /exercises/{id}/feedback-templates — 토큰 없으면 401") + void feedbackTemplates_noToken_401() throws Exception { + mockMvc.perform(get("/exercises/1/feedback-templates")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /preferences/tts — 토큰 없으면 401") + void getPreferences_noToken_401() throws Exception { + mockMvc.perform(get("/preferences/tts")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("PATCH /preferences/tts — 토큰 없으면 401") + void updatePreferences_noToken_401() throws Exception { + mockMvc.perform(patch("/preferences/tts") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /sessions/{id}/feedbacks — 토큰 없으면 401") + void sessionFeedbacks_noToken_401() throws Exception { + mockMvc.perform(get("/sessions/1/feedbacks")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /sessions/{id}/feedback-summary — 토큰 없으면 401") + void sessionFeedbackSummary_noToken_401() throws Exception { + mockMvc.perform(get("/sessions/1/feedback-summary")).andExpect(status().isUnauthorized()); + } +} diff --git a/backend/src/test/java/com/shadowfit/integration/SessionControllerIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/SessionControllerIntegrationTest.java new file mode 100644 index 0000000..41a4dfd --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/SessionControllerIntegrationTest.java @@ -0,0 +1,111 @@ +package com.shadowfit.integration; + +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.global.security.jwt.JwtUtil; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * SessionController 통합테스트 — 오늘 추가한 DELETE /sessions/{id}를 실제 HTTP 레벨(보안 + * 필터체인 포함)로 검증. 서비스 단위테스트(ExerciseSessionFlowIntegrationTest)는 있었지만 + * 컨트롤러/HTTP 매핑·인증은 무테스트였음. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("SessionController 통합테스트") +class SessionControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtUtil jwtUtil; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private SessionRepository sessionRepository; + + private Member member; + private Exercise exercise; + private String accessToken; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("session-http@test.com").username("u").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + + CustomUserInfoDto info = CustomUserInfoDto.builder().email(member.getEmail()).role(member.getRole()).build(); + accessToken = jwtUtil.createAccessToken(info); + } + + private Session session(Status status) { + return sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise) + .startTime(LocalDateTime.now().minusHours(1)) + .endTime(status == Status.IN_PROGRESS ? null : LocalDateTime.now()) + .status(status).totalReps(5).difficultyLevel(1) + .avgSyncRate(new BigDecimal("70.0")).caloriesBurned(new BigDecimal("30.0")) + .build()); + } + + @Test + @DisplayName("완료된 세션 삭제 — 204") + void deleteSession_completed_returns204() throws Exception { + Session s = session(Status.COMPLETED); + + mockMvc.perform(delete("/sessions/" + s.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("IN_PROGRESS 세션 삭제 시도 — 409") + void deleteSession_inProgress_returns409() throws Exception { + Session s = session(Status.IN_PROGRESS); + + mockMvc.perform(delete("/sessions/" + s.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("존재하지 않는 세션 삭제 시도 — 404") + void deleteSession_unknown_returns404() throws Exception { + mockMvc.perform(delete("/sessions/999999") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("토큰 없이 세션 삭제 시도 — 401") + void deleteSession_noToken_returns401() throws Exception { + Session s = session(Status.COMPLETED); + + mockMvc.perform(delete("/sessions/" + s.getId())) + .andExpect(status().isUnauthorized()); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackTemplateServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackTemplateServiceTest.java new file mode 100644 index 0000000..7321d5f --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/FeedbackTemplateServiceTest.java @@ -0,0 +1,100 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.dto.exercises.feedback.FeedbackTemplateDto; +import com.shadowfit.model.exercise.ExerciseFeedbackTemplate; +import com.shadowfit.model.exercise.FeedbackType; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.repository.exercise.ExerciseFeedbackTemplateRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * FeedbackTemplateService 테스트 — persona row가 있으면 그것, 없으면 persona IS NULL + * fallback을 쓰는 merge 로직(분기 4-A) 검증. + */ +@DisplayName("FeedbackTemplateService 테스트") +class FeedbackTemplateServiceTest { + + @Mock private ExerciseFeedbackTemplateRepository templateRepository; + private FeedbackTemplateService service; + + private static final Long EXERCISE_ID = 1L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new FeedbackTemplateService(templateRepository); + } + + private ExerciseFeedbackTemplate template(FeedbackType type, SelectedPersona persona, String message, int priority) { + return ExerciseFeedbackTemplate.builder() + .feedbackType(type).persona(persona).message(message).priority(priority).build(); + } + + @Test + @DisplayName("persona가 null이면 fallback(공통) 템플릿만 반환") + void nullPersona_returnsOnlyFallback() { + when(templateRepository.findByExerciseIdAndPersonaIsNullOrderByPriorityAsc(EXERCISE_ID)) + .thenReturn(List.of(template(FeedbackType.KNEE_OUT, null, "무릎 조심", 1))); + + List result = service.getTemplatesByExercise(EXERCISE_ID, null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).message()).isEqualTo("무릎 조심"); + } + + @Test + @DisplayName("페르소나 전용 row가 있으면 그게 같은 feedback_type의 fallback을 덮어씀") + void personaTemplate_overridesFallbackForSameType() { + when(templateRepository.findByExerciseIdAndPersonaIsNullOrderByPriorityAsc(EXERCISE_ID)) + .thenReturn(List.of(template(FeedbackType.KNEE_OUT, null, "공통 메시지", 1))); + when(templateRepository.findByExerciseIdAndPersonaOrderByPriorityAsc(EXERCISE_ID, SelectedPersona.REHAB)) + .thenReturn(List.of(template(FeedbackType.KNEE_OUT, SelectedPersona.REHAB, "재활 전용 메시지", 1))); + + List result = service.getTemplatesByExercise(EXERCISE_ID, SelectedPersona.REHAB); + + assertThat(result).hasSize(1); + assertThat(result.get(0).message()).isEqualTo("재활 전용 메시지"); // fallback 아니라 페르소나 전용이 이김 + } + + @Test + @DisplayName("페르소나 row가 없는 feedback_type은 fallback으로 채워짐 — 페르소나·공통 합쳐진 결과") + void mixedTypes_fallbackFillsGapsNotCoveredByPersona() { + when(templateRepository.findByExerciseIdAndPersonaIsNullOrderByPriorityAsc(EXERCISE_ID)) + .thenReturn(List.of( + template(FeedbackType.KNEE_OUT, null, "공통-무릎", 1), + template(FeedbackType.HIP_HIGH, null, "공통-엉덩이", 2) + )); + when(templateRepository.findByExerciseIdAndPersonaOrderByPriorityAsc(EXERCISE_ID, SelectedPersona.REHAB)) + .thenReturn(List.of(template(FeedbackType.KNEE_OUT, SelectedPersona.REHAB, "재활-무릎", 1))); + + List result = service.getTemplatesByExercise(EXERCISE_ID, SelectedPersona.REHAB); + + assertThat(result).hasSize(2); + assertThat(result).extracting(FeedbackTemplateDto::message) + .containsExactlyInAnyOrder("재활-무릎", "공통-엉덩이"); + } + + @Test + @DisplayName("결과는 priority 오름차순 정렬") + void result_sortedByPriorityAscending() { + when(templateRepository.findByExerciseIdAndPersonaIsNullOrderByPriorityAsc(EXERCISE_ID)) + .thenReturn(List.of( + template(FeedbackType.HIP_HIGH, null, "낮은 우선순위", 100), + template(FeedbackType.KNEE_OUT, null, "높은 우선순위", 1) + )); + + List result = service.getTemplatesByExercise(EXERCISE_ID, null); + + assertThat(result).extracting(FeedbackTemplateDto::message) + .containsExactly("높은 우선순위", "낮은 우선순위"); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/SessionFeedbackQueryServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/SessionFeedbackQueryServiceTest.java new file mode 100644 index 0000000..88b9c83 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/SessionFeedbackQueryServiceTest.java @@ -0,0 +1,124 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.dto.exercises.feedback.SessionFeedbackEventDto; +import com.shadowfit.dto.exercises.feedback.SessionFeedbackSummaryDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.FeedbackType; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.SessionFeedbackLog; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.SessionFeedbackLogRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("SessionFeedbackQueryService 테스트") +class SessionFeedbackQueryServiceTest { + + @Mock private SessionFeedbackLogRepository feedbackLogRepository; + @Mock private SessionRepository sessionRepository; + private SessionFeedbackQueryService service; + + private static final Long OWNER_ID = 1L; + private static final Long SESSION_ID = 10L; + private Session session; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new SessionFeedbackQueryService(feedbackLogRepository, sessionRepository); + + Member owner = Member.builder().id(OWNER_ID).email("t@t.com").username("u").password("p").role(UserRole.USER).build(); + Exercise exercise = Exercise.builder().id(1L).name("스쿼트").category(ExerciseCategory.LOWER) + .expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build(); + session = Session.builder().id(SESSION_ID).member(owner).exercise(exercise) + .startTime(LocalDateTime.now()).status(Status.COMPLETED).build(); + } + + @Test + @DisplayName("getEvents — 본인 세션이면 발생시각 순 이벤트 목록 반환") + void getEvents_success() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + SessionFeedbackLog log = SessionFeedbackLog.builder() + .id(1L).session(session).feedbackType(FeedbackType.KNEE_OUT) + .occurredAt(LocalDateTime.now()).build(); + when(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(SESSION_ID)).thenReturn(List.of(log)); + + List result = service.getEvents(SESSION_ID, OWNER_ID); + + assertThat(result).hasSize(1); + assertThat(result.get(0).feedbackType()).isEqualTo(FeedbackType.KNEE_OUT); + } + + @Test + @DisplayName("getEvents — 세션이 없으면 SESSION_NOT_FOUND") + void getEvents_sessionNotFound_throws() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEvents(SESSION_ID, OWNER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + } + + @Test + @DisplayName("getEvents — 본인 세션이 아니면 ACCESS_DENIED") + void getEvents_notOwner_throws() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.getEvents(SESSION_ID, 999L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("getSummary — 타입별 카운트·통계를 총합과 함께 반환") + void getSummary_success() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + SessionFeedbackLogRepository.TypeStats stat = mock(SessionFeedbackLogRepository.TypeStats.class); + when(stat.getFeedbackType()).thenReturn(FeedbackType.KNEE_OUT); + when(stat.getCount()).thenReturn(3L); + when(stat.getAvgSyncRate()).thenReturn(new BigDecimal("55.5")); + when(stat.getMinSyncRate()).thenReturn(new BigDecimal("40.0")); + when(stat.getMaxSyncRate()).thenReturn(new BigDecimal("70.0")); + when(feedbackLogRepository.aggregateBySession(SESSION_ID)).thenReturn(List.of(stat)); + + SessionFeedbackSummaryDto result = service.getSummary(SESSION_ID, OWNER_ID); + + assertThat(result.totalCount()).isEqualTo(3L); + assertThat(result.byType()).hasSize(1); + assertThat(result.byType().get(0).count()).isEqualTo(3L); + } + + @Test + @DisplayName("getSummary — 본인 세션이 아니면 ACCESS_DENIED (getEvents와 동일 소유권 검증 공유)") + void getSummary_notOwner_throws() { + when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.getSummary(SESSION_ID, 999L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCESS_DENIED); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Member/CustomUserDetailsServiceTest.java b/backend/src/test/java/com/shadowfit/service/Member/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..a36623e --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Member/CustomUserDetailsServiceTest.java @@ -0,0 +1,59 @@ +package com.shadowfit.service.Member; + +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.global.security.auth.CustomUserDetails; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@DisplayName("CustomUserDetailsService 테스트") +class CustomUserDetailsServiceTest { + + @Mock private MemberRepository memberRepository; + private CustomUserDetailsService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new CustomUserDetailsService(memberRepository); + } + + @Test + @DisplayName("존재하는 이메일이면 CustomUserDetails로 감싸 반환, 권한은 ROLE_") + void loadUserByUsername_success() { + Member member = Member.builder().id(1L).email("test@test.com") + .username("u").password("encoded-pw").role(UserRole.USER).build(); + when(memberRepository.findByEmail("test@test.com")).thenReturn(Optional.of(member)); + + UserDetails result = service.loadUserByUsername("test@test.com"); + + assertThat(result).isInstanceOf(CustomUserDetails.class); + assertThat(result.getUsername()).isEqualTo("test@test.com"); + assertThat(result.getPassword()).isEqualTo("encoded-pw"); + assertThat(result.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_USER"); + } + + @Test + @DisplayName("존재하지 않는 이메일이면 USER_NOT_FOUND") + void loadUserByUsername_notFound_throws() { + when(memberRepository.findByEmail("nobody@test.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.loadUserByUsername("nobody@test.com")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Member/OnboardingServiceTest.java b/backend/src/test/java/com/shadowfit/service/Member/OnboardingServiceTest.java new file mode 100644 index 0000000..2ad149c --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Member/OnboardingServiceTest.java @@ -0,0 +1,108 @@ +package com.shadowfit.service.Member; + +import com.shadowfit.dto.onboarding.OnboardingDto; +import com.shadowfit.dto.onboarding.OnboardingRequestDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.model.member.WorkoutLevel; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@DisplayName("OnboardingService 테스트") +class OnboardingServiceTest { + + @Mock private MemberRepository memberRepository; + private OnboardingService service; + + private static final String EMAIL = "test@test.com"; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new OnboardingService(memberRepository); + } + + private Member freshMember() { + return Member.builder().id(1L).email(EMAIL).username("u").password("p").role(UserRole.USER).build(); + } + + @Test + @DisplayName("필수 5개 필드가 모두 채워지면 onboardingCompleted가 true로 바뀜") + void updateOnboarding_allFieldsPresent_completesOnboarding() { + Member member = freshMember(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + OnboardingRequestDto dto = OnboardingRequestDto.builder() + .selectedPersona(SelectedPersona.ADVANCED) + .workoutLevel(WorkoutLevel.BEGINNER) + .height(180.0).weight(75.0) + .preferredUrl("https://youtu.be/dummy") + .build(); + + OnboardingDto result = service.updateOnboarding(EMAIL, dto); + + assertThat(member.isOnboardingCompleted()).isTrue(); + assertThat(result.getHeight()).isEqualTo(180.0); + } + + @Test + @DisplayName("일부 필드만 채워지면 onboardingCompleted는 그대로 false (부분 저장)") + void updateOnboarding_partialFields_doesNotComplete() { + Member member = freshMember(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + OnboardingRequestDto dto = OnboardingRequestDto.builder() + .selectedPersona(SelectedPersona.ADVANCED) + .build(); // 나머지 4개는 null + + service.updateOnboarding(EMAIL, dto); + + assertThat(member.isOnboardingCompleted()).isFalse(); + assertThat(member.getSelectedPersona()).isEqualTo(SelectedPersona.ADVANCED); + } + + @Test + @DisplayName("존재하지 않는 이메일이면 USER_NOT_FOUND") + void updateOnboarding_userNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateOnboarding(EMAIL, new OnboardingRequestDto())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("readOnboarding — 존재하면 현재 값 그대로 반환") + void readOnboarding_success() { + Member member = freshMember(); + member.updateOnboarding(OnboardingRequestDto.builder().height(170.0).build()); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + + OnboardingDto result = service.readOnboarding(EMAIL); + + assertThat(result.getHeight()).isEqualTo(170.0); + } + + @Test + @DisplayName("readOnboarding — 존재하지 않으면 USER_NOT_FOUND") + void readOnboarding_userNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.readOnboarding(EMAIL)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Member/PreferenceServiceTest.java b/backend/src/test/java/com/shadowfit/service/Member/PreferenceServiceTest.java new file mode 100644 index 0000000..9afb582 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Member/PreferenceServiceTest.java @@ -0,0 +1,97 @@ +package com.shadowfit.service.Member; + +import com.shadowfit.dto.preference.TtsPreferenceDto; +import com.shadowfit.dto.preference.TtsPreferenceUpdateDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@DisplayName("PreferenceService 테스트") +class PreferenceServiceTest { + + @Mock private MemberRepository memberRepository; + private PreferenceService service; + + private static final String EMAIL = "test@test.com"; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new PreferenceService(memberRepository); + } + + private Member freshMember() { + return Member.builder().id(1L).email(EMAIL).username("u").password("p").role(UserRole.USER).build(); + } + + @Test + @DisplayName("조회 — 기본값(ttsEnabled=true, ttsSpeed=1.0) 반환") + void getTtsPreferences_returnsDefaults() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(freshMember())); + + TtsPreferenceDto result = service.getTtsPreferences(EMAIL); + + assertThat(result.ttsEnabled()).isTrue(); + assertThat(result.ttsSpeed()).isEqualByComparingTo(new BigDecimal("1.0")); + } + + @Test + @DisplayName("조회 — 존재하지 않으면 USER_NOT_FOUND") + void getTtsPreferences_userNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getTtsPreferences(EMAIL)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("수정 — ttsEnabled만 바꾸면 ttsSpeed는 그대로") + void updateTtsPreferences_onlyEnabled_leavesSpeedUnchanged() { + Member member = freshMember(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + + TtsPreferenceDto result = service.updateTtsPreferences(EMAIL, new TtsPreferenceUpdateDto(false, null)); + + assertThat(result.ttsEnabled()).isFalse(); + assertThat(result.ttsSpeed()).isEqualByComparingTo(new BigDecimal("1.0")); // 그대로 + } + + @Test + @DisplayName("수정 — ttsSpeed만 바꾸면 ttsEnabled는 그대로") + void updateTtsPreferences_onlySpeed_leavesEnabledUnchanged() { + Member member = freshMember(); + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.of(member)); + + TtsPreferenceDto result = service.updateTtsPreferences(EMAIL, new TtsPreferenceUpdateDto(null, new BigDecimal("1.5"))); + + assertThat(result.ttsEnabled()).isTrue(); // 그대로 + assertThat(result.ttsSpeed()).isEqualByComparingTo(new BigDecimal("1.5")); + } + + @Test + @DisplayName("수정 — 존재하지 않으면 USER_NOT_FOUND") + void updateTtsPreferences_userNotFound_throws() { + when(memberRepository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateTtsPreferences(EMAIL, new TtsPreferenceUpdateDto(true, null))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } +} From f19b7401b0bcb4340c31aeaf3909162a4b0cb467 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 15:17:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?test(backend):=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=84=EB=B6=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=20+=20@Async=20self-inv?= =?UTF-8?q?ocation=20=EB=B2=84=EA=B7=B8=20=EB=B0=9C=EA=B2=AC=C2=B7?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무테스트로 남아있던 서비스 전부 커버 완료: - AdminExerciseServiceTest, PoseDataCleanupServiceTest, PoseDataServiceTest, ExerciseGrpcServiceTest, ExerciseAnalysisServiceTest, SessionServiceTest (createSession/getWeeklyActivity/getCalendarMain/getDailyActivity/endSession) 발견·수정한 진짜 버그(8번째) — ExerciseAnalysisService.startAnalysis가 this.sendAnalysisRequestToFastApi(...)로 자기호출(self-invocation)해서 @Async가 Spring 프록시를 못 타고 조용히 동기 실행되던 문제. 같은 클래스의 completeSession→ applyCompleteFromApp는 이미 self 필드로 이 문제를 피해갔는데(주석에도 명시), 이 호출만 빠져있었음. 즉 POST /exercises/sessions가 "즉시 세션ID 반환"하는 설계 의도와 달리 FastAPI gRPC 왕복(최대 5초 데드라인)을 전부 기다린 뒤에야 응답했을 가능성 - self.로 수정해 실제 비동기 실행되도록 함(같은 클래스의 self 패턴과 통일). 전체 테스트 스위트 0 실패. 라이브 컨테이너 재빌드+재기동 완료. Co-Authored-By: Claude Sonnet 5 --- .../Exercise/ExerciseAnalysisService.java | 7 +- .../Exercise/AdminExerciseServiceTest.java | 87 ++++++ .../Exercise/ExerciseAnalysisServiceTest.java | 204 +++++++++++++++ .../Exercise/ExerciseGrpcServiceTest.java | 180 +++++++++++++ .../Exercise/PoseDataCleanupServiceTest.java | 90 +++++++ .../service/Exercise/PoseDataServiceTest.java | 139 ++++++++++ .../service/Exercise/SessionServiceTest.java | 247 ++++++++++++++++++ 7 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/AdminExerciseServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/ExerciseGrpcServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/PoseDataServiceTest.java create mode 100644 backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java diff --git a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java index 3731eef..dc184e9 100644 --- a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java +++ b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java @@ -145,8 +145,11 @@ public Long startAnalysis(VideoRequestDto appDto, Long currentMemberId) { Session savedSession = sessionService.createSession(appDto, currentMemberId, finalUrl); Long sessionId = savedSession.getId(); - // 비동기로 FastAPI에 분석 요청 - this.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, member.getSelectedPersona().name()); + // 비동기로 FastAPI에 분석 요청 — self를 거쳐야 @Async가 Spring 프록시를 타고 실제로 + // 비동기 실행됨. this.로 호출하면 자기호출(self-invocation)이라 AOP 프록시를 우회해서 + // @Async가 조용히 무시되고 동기 실행되는 문제가 있었음(2026-07-24, 테스트로 발견) — + // completeSession→applyCompleteFromApp에 이미 쓰던 self 패턴을 여기에도 동일 적용. + self.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, member.getSelectedPersona().name()); return sessionId; } diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/AdminExerciseServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/AdminExerciseServiceTest.java new file mode 100644 index 0000000..6c051bf --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/AdminExerciseServiceTest.java @@ -0,0 +1,87 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.dto.admin.ExerciseThresholdResponseDto; +import com.shadowfit.dto.admin.ThresholdUpdateDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.repository.exercise.ExercisesRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@DisplayName("AdminExerciseService 테스트") +class AdminExerciseServiceTest { + + @Mock private ExercisesRepository exercisesRepository; + private AdminExerciseService service; + + private static final Long EXERCISE_ID = 1L; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new AdminExerciseService(exercisesRepository); + } + + private Exercise exercise() { + return Exercise.builder().id(EXERCISE_ID).name("스쿼트").category(ExerciseCategory.LOWER) + .expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .syncThresholdDiet(new BigDecimal("70.00")).syncThresholdRehab(new BigDecimal("50.00")) + .build(); + } + + @Test + @DisplayName("정상 변경 — 4개 임계값 전부 갱신") + void updateThresholds_success() { + Exercise exercise = exercise(); + when(exercisesRepository.findById(EXERCISE_ID)).thenReturn(Optional.of(exercise)); + ThresholdUpdateDto dto = new ThresholdUpdateDto( + new BigDecimal("55"), new BigDecimal("90"), new BigDecimal("65"), new BigDecimal("45")); + + ExerciseThresholdResponseDto result = service.updateThresholds(EXERCISE_ID, dto); + + assertThat(result.syncThresholdBeginner()).isEqualByComparingTo(new BigDecimal("55")); + assertThat(result.syncThresholdAdvanced()).isEqualByComparingTo(new BigDecimal("90")); + assertThat(exercise.getSyncThresholdDiet()).isEqualByComparingTo(new BigDecimal("65")); + } + + @Test + @DisplayName("beginner >= advanced 이면 INVALID_INPUT_VALUE, 저장 시도 자체를 안 함") + void updateThresholds_beginnerNotLessThanAdvanced_throws() { + ThresholdUpdateDto dto = new ThresholdUpdateDto( + new BigDecimal("90"), new BigDecimal("90"), new BigDecimal("65"), new BigDecimal("45")); + + assertThatThrownBy(() -> service.updateThresholds(EXERCISE_ID, dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + + // beginner==advanced 케이스라 findById까지 안 가고 검증에서 바로 걸려야 함 + org.mockito.Mockito.verifyNoInteractions(exercisesRepository); + } + + @Test + @DisplayName("존재하지 않는 운동이면 EXERCISE_NOT_FOUND") + void updateThresholds_exerciseNotFound_throws() { + when(exercisesRepository.findById(EXERCISE_ID)).thenReturn(Optional.empty()); + ThresholdUpdateDto dto = new ThresholdUpdateDto( + new BigDecimal("55"), new BigDecimal("90"), new BigDecimal("65"), new BigDecimal("45")); + + assertThatThrownBy(() -> service.updateThresholds(EXERCISE_ID, dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXERCISE_NOT_FOUND); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java new file mode 100644 index 0000000..a029d68 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java @@ -0,0 +1,204 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.dto.exercises.VideoRequestDto; +import com.shadowfit.dto.exercises.session.SessionUpdateRequestDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * ExerciseAnalysisService 통합테스트 — 실제 Spring 컨텍스트(자기주입 self, @GrpcClient 스텁 모두 + * 정상 구성)로 검증. AI 서버로 나가는 실제 gRPC 호출이 필요한 성공 경로는 서킷브레이커를 강제로 + * OPEN시켜 우회하고, 그 앞단의 검증 로직·완료 콜백 처리 로직만 검증한다. + */ +@SpringBootTest +@Transactional +@DisplayName("ExerciseAnalysisService 테스트") +class ExerciseAnalysisServiceTest { + + @Autowired private ExerciseAnalysisService analysisService; + @Autowired private CircuitBreakerRegistry circuitBreakerRegistry; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private SessionRepository sessionRepository; + + private Member member; + private Exercise exercise; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("analysis@test.com").username("u").password("dummy") + .preferredUrl("https://youtu.be/dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + } + + @AfterEach + void resetCircuitBreaker() { + // 다른 테스트에 영향 안 주도록 매번 CLOSED로 복구 + circuitBreakerRegistry.circuitBreaker("aiServer").transitionToClosedState(); + } + + // ---- extractReferencePoses ---- + + @Test + @DisplayName("기준 좌표 추출 — 존재하지 않는 운동이면 EXERCISE_NOT_FOUND") + void extractReferencePoses_unknownExercise_throws() { + assertThatThrownBy(() -> analysisService.extractReferencePoses(999999L, "https://youtu.be/dummy")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("기준 좌표 추출 — youtubeUrl이 비어있으면 INVALID_INPUT_VALUE") + void extractReferencePoses_blankUrl_throws() { + assertThatThrownBy(() -> analysisService.extractReferencePoses(exercise.getId(), "")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("기준 좌표 추출 — 서킷브레이커 OPEN이면 예외 없이 조용히 스킵") + void extractReferencePoses_circuitOpen_skipsSilently() { + circuitBreakerRegistry.circuitBreaker("aiServer").transitionToOpenState(); + + // AI 서버 호출을 아예 시도하지 않아야 하므로(스텁 실제 연결 없이도) 예외 없이 반환돼야 함 + analysisService.extractReferencePoses(exercise.getId(), "https://youtu.be/dummy"); + } + + // ---- startAnalysis ---- + + @Test + @DisplayName("세션 시작 — 존재하지 않는 회원이면 USER_NOT_FOUND") + void startAnalysis_unknownMember_throws() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + + assertThatThrownBy(() -> analysisService.startAnalysis(dto, 999999L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("세션 시작 — preferredUrl이 없으면 INVALID_INPUT_VALUE") + void startAnalysis_noPreferredUrl_throws() { + Member noUrlMember = memberRepository.saveAndFlush(Member.builder() + .email("nourl@test.com").username("u2").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + + assertThatThrownBy(() -> analysisService.startAnalysis(dto, noUrlMember.getId())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("세션 시작 — 정상 케이스면 세션이 즉시 IN_PROGRESS로 동기 생성·반환됨 " + + "(self.sendAnalysisRequestToFastApi가 진짜 비동기로 나가 이 트랜잭션 안에서 영향 없어야 함)") + void startAnalysis_success_createsSessionSynchronously() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + + Long sessionId = analysisService.startAnalysis(dto, member.getId()); + + // self.를 거쳐 실제로 @Async 프록시를 타면, 비동기 스레드는 이 테스트 트랜잭션이 커밋되기 + // 전이라 세션을 아예 못 봐서(findById 실패) markAsFailedIfStillInProgress가 조용히 + // no-op됨 — 그래서 동기 반환 직후 이 트랜잭션 안에서는 항상 IN_PROGRESS로 보여야 함. + // (self. 대신 this.로 self-invocation하면 @Async가 무시돼 동기 실행되면서 이 값이 + // 깨질 수 있음 — 2026-07-24 발견·수정한 버그의 회귀 방지 성격도 겸함) + Session created = sessionRepository.findById(sessionId).orElseThrow(); + assertThat(created.getStatus()).isEqualTo(Status.IN_PROGRESS); + assertThat(created.getMember().getId()).isEqualTo(member.getId()); + } + + // ---- stopAnalysis ---- + + @Test + @DisplayName("분석 중단 — 서킷브레이커 OPEN이면 예외 없이 조용히 스킵") + void stopAnalysis_circuitOpen_skipsSilently() { + circuitBreakerRegistry.circuitBreaker("aiServer").transitionToOpenState(); + + analysisService.stopAnalysis(1L); + } + + // ---- completeSession / applyCompleteFromApp (AI 콜백, 자기주입 self 필요 — 실컨텍스트라 정상 동작) ---- + + private Session inProgressSession() { + return sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise) + .startTime(LocalDateTime.now().minusMinutes(10)) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + } + + private SessionUpdateRequestDto completeDto() { + return new SessionUpdateRequestDto(10, 82.5, 95.0, 40.0, 120.5, 3); + } + + @Test + @DisplayName("완료 콜백 — 존재하지 않는 세션이면 SESSION_NOT_FOUND") + void completeSession_unknownSession_throws() { + assertThatThrownBy(() -> analysisService.completeSession(999999L, completeDto())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + } + + @Test + @DisplayName("완료 콜백 — 정상 처리 시 COMPLETED로 갱신, 결과값 반영") + void completeSession_success_updatesSession() { + Session session = inProgressSession(); + + analysisService.completeSession(session.getId(), completeDto()); + + Session result = sessionRepository.findById(session.getId()).orElseThrow(); + assertThat(result.getStatus()).isEqualTo(Status.COMPLETED); + assertThat(result.getTotalReps()).isEqualTo(10); + assertThat(result.getAvgSyncRate()).isEqualByComparingTo(new BigDecimal("82.5")); + assertThat(result.getEndTime()).isNotNull(); + } + + @Test + @DisplayName("완료 콜백 — 이미 COMPLETED면 멱등적으로 재적용 안 함") + void completeSession_alreadyCompleted_isIdempotent() { + Session session = inProgressSession(); + analysisService.completeSession(session.getId(), completeDto()); + LocalDateTime firstEndTime = sessionRepository.findById(session.getId()).orElseThrow().getEndTime(); + + // 다른 값으로 재호출해도 첫 결과가 보존돼야 함 + analysisService.completeSession(session.getId(), new SessionUpdateRequestDto(99, 10.0, 10.0, 10.0, 10.0, 1)); + + Session result = sessionRepository.findById(session.getId()).orElseThrow(); + assertThat(result.getTotalReps()).isEqualTo(10); // 99로 덮이지 않음 + assertThat(result.getEndTime()).isEqualTo(firstEndTime); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseGrpcServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseGrpcServiceTest.java new file mode 100644 index 0000000..24109e2 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseGrpcServiceTest.java @@ -0,0 +1,180 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.grpc.ExtractRequest; +import com.shadowfit.grpc.ExtractResponse; +import com.shadowfit.grpc.FeedbackBatchRequest; +import com.shadowfit.grpc.FeedbackBatchResponse; +import com.shadowfit.grpc.PoseDataBatchRequest; +import com.shadowfit.grpc.PoseDataResponse; +import com.shadowfit.grpc.SessionCompleteRequest; +import com.shadowfit.grpc.SessionCompleteResponse; +import com.shadowfit.grpc.SessionStatus; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * ExerciseGrpcService 단위테스트 — gRPC 서비스 구현체가 서비스 계층 예외를 올바른 gRPC + * Status로 매핑하는지 검증(BusinessException → INVALID_ARGUMENT, 그 외 → INTERNAL). + */ +@DisplayName("ExerciseGrpcService 테스트") +class ExerciseGrpcServiceTest { + + @Mock private PoseDataService poseDataService; + @Mock private SessionService sessionService; + @Mock private FeedbackLogService feedbackLogService; + private ExerciseGrpcService grpcService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + grpcService = new ExerciseGrpcService(poseDataService, sessionService, feedbackLogService); + } + + @Test + @DisplayName("savePoseDataBatch 성공 — onNext + onCompleted") + void savePoseDataBatch_success() { + PoseDataBatchRequest request = PoseDataBatchRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + + grpcService.savePoseDataBatch(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PoseDataResponse.class); + verify(obs).onNext(captor.capture()); + verify(obs).onCompleted(); + verify(obs, never()).onError(any()); + assertThat(captor.getValue().getSuccess()).isTrue(); + } + + @Test + @DisplayName("savePoseDataBatch 실패 — 서비스 예외는 INTERNAL로 매핑") + void savePoseDataBatch_serviceThrows_mapsToInternal() { + PoseDataBatchRequest request = PoseDataBatchRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + doThrow(new BusinessException(ErrorCode.SESSION_NOT_FOUND)) + .when(poseDataService).savePoseDataBatch(anyLong(), anyList()); + + grpcService.savePoseDataBatch(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); + verify(obs).onError(captor.capture()); + assertThat(((StatusRuntimeException) captor.getValue()).getStatus().getCode()) + .isEqualTo(Status.Code.INTERNAL); + verify(obs, never()).onNext(any()); + } + + @Test + @DisplayName("extractReferenceData 성공") + void extractReferenceData_success() { + ExtractRequest request = ExtractRequest.newBuilder().setExerciseId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + + grpcService.extractReferenceData(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExtractResponse.class); + verify(obs).onNext(captor.capture()); + verify(obs).onCompleted(); + assertThat(captor.getValue().getSuccess()).isTrue(); + } + + @Test + @DisplayName("completeAnalysis 성공 — COMPLETED 상태로 응답") + void completeAnalysis_success() { + SessionCompleteRequest request = SessionCompleteRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + + grpcService.completeAnalysis(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SessionCompleteResponse.class); + verify(obs).onNext(captor.capture()); + verify(obs).onCompleted(); + assertThat(captor.getValue().getStatus()).isEqualTo(SessionStatus.COMPLETED); + } + + @Test + @DisplayName("completeAnalysis 실패 — 서비스 예외는 INTERNAL로 매핑") + void completeAnalysis_serviceThrows_mapsToInternal() { + SessionCompleteRequest request = SessionCompleteRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + doThrow(new BusinessException(ErrorCode.SESSION_NOT_FOUND)).when(sessionService).completeSession(any()); + + grpcService.completeAnalysis(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); + verify(obs).onError(captor.capture()); + assertThat(((StatusRuntimeException) captor.getValue()).getStatus().getCode()) + .isEqualTo(Status.Code.INTERNAL); + } + + @Test + @DisplayName("reportFeedbackBatch 성공") + void reportFeedbackBatch_success() { + FeedbackBatchRequest request = FeedbackBatchRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + when(feedbackLogService.saveBatch(request)).thenReturn(3); + + grpcService.reportFeedbackBatch(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FeedbackBatchResponse.class); + verify(obs).onNext(captor.capture()); + verify(obs).onCompleted(); + assertThat(captor.getValue().getSavedCount()).isEqualTo(3); + } + + @Test + @DisplayName("reportFeedbackBatch — BusinessException은 INVALID_ARGUMENT로 매핑(그 외 예외와 구분)") + void reportFeedbackBatch_businessException_mapsToInvalidArgument() { + FeedbackBatchRequest request = FeedbackBatchRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + when(feedbackLogService.saveBatch(request)).thenThrow(new BusinessException(ErrorCode.INVALID_INPUT_VALUE)); + + grpcService.reportFeedbackBatch(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); + verify(obs).onError(captor.capture()); + assertThat(((StatusRuntimeException) captor.getValue()).getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + } + + @Test + @DisplayName("reportFeedbackBatch — 예상 못한 예외는 INTERNAL로 매핑") + void reportFeedbackBatch_unexpectedException_mapsToInternal() { + FeedbackBatchRequest request = FeedbackBatchRequest.newBuilder().setSessionId(1L).build(); + @SuppressWarnings("unchecked") + StreamObserver obs = mock(StreamObserver.class); + when(feedbackLogService.saveBatch(request)).thenThrow(new RuntimeException("boom")); + + grpcService.reportFeedbackBatch(request, obs); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); + verify(obs).onError(captor.capture()); + assertThat(((StatusRuntimeException) captor.getValue()).getStatus().getCode()) + .isEqualTo(Status.Code.INTERNAL); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java new file mode 100644 index 0000000..ae061b4 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java @@ -0,0 +1,90 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.PoseData; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.PoseDataRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * PoseDataCleanupService 통합테스트 — 회원 탈퇴 시 pose_data 참조무결성 대체(B5, + * pose-data-partition-fk-tradeoff.md). @Async라 실제 스레드풀에서 실행되므로 폴링으로 확인. + */ +@SpringBootTest +@Transactional +@DisplayName("PoseDataCleanupService 테스트") +class PoseDataCleanupServiceTest { + + @Autowired private PoseDataCleanupService cleanupService; + @Autowired private PoseDataRepository poseDataRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + + private Session targetSession; + private Session otherSession; + + @BeforeEach + void setUp() { + Member member = memberRepository.saveAndFlush(Member.builder() + .email("cleanup@test.com").username("u").password("dummy").role(UserRole.USER).build()); + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + + targetSession = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(LocalDateTime.now()) + .status(Status.COMPLETED).totalReps(5).build()); + otherSession = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(LocalDateTime.now()) + .status(Status.COMPLETED).totalReps(5).build()); + + poseDataRepository.saveAndFlush(PoseData.builder() + .session(targetSession).timestampSec(0.0).jointCoordinates("{}") + .syncRate(70.0).isCorrect(true).build()); + poseDataRepository.saveAndFlush(PoseData.builder() + .session(otherSession).timestampSec(0.0).jointCoordinates("{}") + .syncRate(70.0).isCorrect(true).build()); + } + + @Test + @DisplayName("대상 세션의 pose_data만 지워지고 다른 세션 것은 보존됨") + void cleanupBySessionIds_removesOnlyTargetSessions() { + cleanupService.cleanupBySessionIds(List.of(targetSession.getId())); + + await().atMost(java.time.Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(poseDataRepository.findFramesBySessionId(targetSession.getId())).isEmpty() + ); + assertThat(poseDataRepository.findFramesBySessionId(otherSession.getId())).hasSize(1); + } + + @Test + @DisplayName("빈 리스트/null이면 아무 것도 지우지 않고 조용히 반환") + void cleanupBySessionIds_emptyOrNull_noop() { + cleanupService.cleanupBySessionIds(List.of()); + cleanupService.cleanupBySessionIds(null); + + assertThat(poseDataRepository.findFramesBySessionId(targetSession.getId())).hasSize(1); + assertThat(poseDataRepository.findFramesBySessionId(otherSession.getId())).hasSize(1); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataServiceTest.java new file mode 100644 index 0000000..dd32f81 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataServiceTest.java @@ -0,0 +1,139 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.grpc.PoseDataRequest; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.ExerciseReference; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExerciseReferenceRepository; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * PoseDataService 통합테스트 — savePoseDataBatch(실시간 저장, JdbcTemplate batchUpdate)와 + * saveReferencePoses(관리자용 기준 좌표 저장) 둘 다 실제 DB로 검증. e2e에 곁다리로만 검증되던 + * savePoseDataBatch의 is_correct 임계값(40.0) 로직도 여기서 직접 확인한다. + */ +@SpringBootTest +@Transactional +@DisplayName("PoseDataService 테스트") +class PoseDataServiceTest { + + @Autowired private PoseDataService poseDataService; + @Autowired private SessionRepository sessionRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private ExerciseReferenceRepository referenceRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private Session session; + private Exercise exercise; + + @BeforeEach + void setUp() { + Member member = memberRepository.saveAndFlush(Member.builder() + .email("posedata@test.com").username("u").password("dummy").role(UserRole.USER).build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + session = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(LocalDateTime.now()) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + } + + private PoseDataRequest frame(double timestampSec, double syncRate) { + return PoseDataRequest.newBuilder() + .setTimestampSec(timestampSec) + .setJointCoordinates("{}") + .setSyncRate(syncRate) + .setFeedbackMessage("ok") + .build(); + } + + @Test + @DisplayName("정상 batch — 전부 삽입, is_correct는 syncRate>=40.0 기준으로 계산됨") + void savePoseDataBatch_success_computesIsCorrect() { + poseDataService.savePoseDataBatch(session.getId(), List.of( + frame(0.0, 50.0), // is_correct = true + frame(0.1, 30.0) // is_correct = false + )); + + List> rows = jdbcTemplate.queryForList( + "SELECT sync_rate, is_correct FROM pose_data WHERE session_id = ? ORDER BY timestamp_sec", session.getId()); + + assertThat(rows).hasSize(2); + assertThat((Boolean) rows.get(0).get("IS_CORRECT")).isTrue(); + assertThat((Boolean) rows.get(1).get("IS_CORRECT")).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 세션이면 SESSION_NOT_FOUND, 아무 것도 삽입 안 함") + void savePoseDataBatch_unknownSession_throwsAndInsertsNothing() { + assertThatThrownBy(() -> poseDataService.savePoseDataBatch(999999L, List.of(frame(0.0, 50.0)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pose_data WHERE session_id = 999999", Integer.class); + assertThat(count).isZero(); + } + + @Test + @DisplayName("빈 리스트/null이면 조용히 반환, 삽입 없음") + void savePoseDataBatch_emptyOrNull_noop() { + poseDataService.savePoseDataBatch(session.getId(), List.of()); + poseDataService.savePoseDataBatch(session.getId(), null); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pose_data WHERE session_id = ?", Integer.class, session.getId()); + assertThat(count).isZero(); + } + + @Test + @DisplayName("기준 좌표 저장 — 정상 등록") + void saveReferencePoses_success() { + poseDataService.saveReferencePoses(exercise.getId(), List.of(frame(0.0, 100.0), frame(0.1, 100.0))); + + List refs = referenceRepository.findByExerciseId(exercise.getId()); + assertThat(refs).hasSize(2); + } + + @Test + @DisplayName("기준 좌표 저장 — 존재하지 않는 운동이면 EXERCISE_NOT_FOUND") + void saveReferencePoses_unknownExercise_throws() { + assertThatThrownBy(() -> poseDataService.saveReferencePoses(999999L, List.of(frame(0.0, 100.0)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("기준 좌표 저장 — 빈 리스트면 조용히 반환") + void saveReferencePoses_empty_noop() { + poseDataService.saveReferencePoses(exercise.getId(), List.of()); + + assertThat(referenceRepository.findByExerciseId(exercise.getId())).isEmpty(); + } +} diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java new file mode 100644 index 0000000..6a223e2 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java @@ -0,0 +1,247 @@ +package com.shadowfit.service.Exercise; + +import com.shadowfit.dto.exercises.VideoRequestDto; +import com.shadowfit.dto.report.record.CalendarMainResponseDto; +import com.shadowfit.dto.report.record.DailyActivityResponseDto; +import com.shadowfit.dto.report.record.WeeklyActivityResponseDto; +import com.shadowfit.global.error.BusinessException; +import com.shadowfit.global.error.ErrorCode; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.ExerciseCategory; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * SessionService 통합테스트 — 오늘 다룬 deleteSession/applyComplete(precompute) 외에 지금까지 + * 무테스트였던 나머지 메서드들: createSession(활성세션 락), getWeeklyActivity/getCalendarMain/ + * getDailyActivity(집계 조회), endSession(자체 단위 검증 + afterCommit AI 통보 트리거). + */ +@SpringBootTest +@Transactional +@DisplayName("SessionService 테스트") +class SessionServiceTest { + + @Autowired private SessionService sessionService; + @Autowired private SessionRepository sessionRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + @MockitoBean private ExerciseAnalysisService analysisService; // endSession의 afterCommit 트리거 격리 검증용 + + private Member member; + private Exercise exercise; + + @BeforeEach + void setUp() { + member = memberRepository.saveAndFlush(Member.builder() + .email("sessionsvc@test.com").username("u").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + } + + @Nested + @DisplayName("createSession") + class CreateSession { + + @Test + @DisplayName("정상 생성 — IN_PROGRESS 세션 저장됨") + void createSession_success() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + + Session result = sessionService.createSession(dto, member.getId(), "https://youtu.be/dummy"); + + assertThat(result.getId()).isNotNull(); + assertThat(result.getStatus()).isEqualTo(Status.IN_PROGRESS); + assertThat(result.getMember().getId()).isEqualTo(member.getId()); + } + + @Test + @DisplayName("이미 진행 중인 세션이 있으면 SESSION_ALREADY_IN_PROGRESS") + void createSession_activeSessionExists_throws() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + sessionService.createSession(dto, member.getId(), "https://youtu.be/dummy"); + + assertThatThrownBy(() -> sessionService.createSession(dto, member.getId(), "https://youtu.be/dummy")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_ALREADY_IN_PROGRESS); + } + + @Test + @DisplayName("존재하지 않는 회원이면 USER_NOT_FOUND") + void createSession_unknownMember_throws() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(exercise.getId()).build(); + + assertThatThrownBy(() -> sessionService.createSession(dto, 999999L, "https://youtu.be/dummy")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않는 운동이면 EXERCISE_NOT_FOUND") + void createSession_unknownExercise_throws() { + VideoRequestDto dto = VideoRequestDto.builder().exerciseId(999999L).build(); + + assertThatThrownBy(() -> sessionService.createSession(dto, member.getId(), "https://youtu.be/dummy")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXERCISE_NOT_FOUND); + } + } + + @Nested + @DisplayName("endSession") + class EndSession { + + private Session inProgressSession() { + return sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(LocalDateTime.now().minusMinutes(10)) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + } + + @Test + @DisplayName("본인 세션 종료 — endTime 기록됨") + void endSession_self_setsEndTime() { + Session session = inProgressSession(); + + sessionService.endSession(session.getId(), member.getId()); + + assertThat(sessionRepository.findById(session.getId()).orElseThrow().getEndTime()).isNotNull(); + } + + @Test + @DisplayName("본인 세션이 아니면 ACCESS_DENIED") + void endSession_notOwner_throws() { + Session session = inProgressSession(); + + assertThatThrownBy(() -> sessionService.endSession(session.getId(), 999999L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("존재하지 않는 세션이면 SESSION_NOT_FOUND") + void endSession_unknownSession_throws() { + assertThatThrownBy(() -> sessionService.endSession(999999L, member.getId())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + } + + @Test + @DisplayName("이미 종료된 세션 재호출은 멱등 — endTime 안 바뀜, AI 재통보 없음") + void endSession_alreadyEnded_isIdempotent() { + Session session = inProgressSession(); + sessionService.endSession(session.getId(), member.getId()); + LocalDateTime firstEndTime = sessionRepository.findById(session.getId()).orElseThrow().getEndTime(); + + sessionService.endSession(session.getId(), member.getId()); + + assertThat(sessionRepository.findById(session.getId()).orElseThrow().getEndTime()).isEqualTo(firstEndTime); + } + + @Test + @DisplayName("커밋 시점에만 AI 분석 중단(stopAnalysis)이 트리거됨") + void endSession_triggersStopAnalysisOnlyAfterCommit() { + Session session = inProgressSession(); + + sessionService.endSession(session.getId(), member.getId()); + + verify(analysisService, never()).stopAnalysis(session.getId()); // 커밋 전이라 아직 + + List syncs = TransactionSynchronizationManager.getSynchronizations(); + syncs.forEach(TransactionSynchronization::afterCommit); // 커밋 시뮬레이션 + + verify(analysisService, times(1)).stopAnalysis(session.getId()); + } + } + + @Nested + @DisplayName("조회 집계 (getWeeklyActivity / getCalendarMain / getDailyActivity)") + class Aggregation { + + private Session completedSessionOn(LocalDate date, double avgSyncRate, double calories, int minutes) { + LocalDateTime start = date.atTime(9, 0); + return sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise) + .startTime(start).endTime(start.plusMinutes(minutes)) + .status(Status.COMPLETED).totalReps(10) + .avgSyncRate(BigDecimal.valueOf(avgSyncRate)) + .caloriesBurned(BigDecimal.valueOf(calories)) + .build()); + } + + @Test + @DisplayName("getWeeklyActivity — 이번 주 세션 합산") + void getWeeklyActivity_aggregatesThisWeek() { + LocalDate today = LocalDate.now(); + completedSessionOn(today, 80.0, 100.0, 20); + + WeeklyActivityResponseDto result = sessionService.getWeeklyActivity(member.getId()); + + assertThat(result.getTotalWorkouts()).isEqualTo(1); + assertThat(result.getTotalMinutes()).isEqualTo(20); + assertThat(result.getTotalCalories()).isEqualTo(100); + assertThat(result.getTodayDetails()).hasSize(1); + } + + @Test + @DisplayName("getCalendarMain — 이번 달 운동일수·평균 싱크로율 계산") + void getCalendarMain_aggregatesThisMonth() { + LocalDate today = LocalDate.now(); + completedSessionOn(today, 80.0, 100.0, 20); + + CalendarMainResponseDto result = sessionService.getCalendarMain(member.getId(), today.getYear(), today.getMonthValue()); + + assertThat(result.getMonthlyExerciseDays()).isEqualTo(1); + assertThat(result.getTotalAvgSyncRate()).isEqualTo(80); + assertThat(result.getRecords()).hasSize(1); + assertThat(result.getRecords().get(0).isHasRecord()).isTrue(); + } + + @Test + @DisplayName("getDailyActivity — 특정 날짜의 세션만 반환, 빈 날은 빈 리스트") + void getDailyActivity_returnsOnlyThatDate() { + LocalDate today = LocalDate.now(); + completedSessionOn(today, 80.0, 100.0, 20); + + DailyActivityResponseDto todayResult = sessionService.getDailyActivity(member.getId(), today); + DailyActivityResponseDto yesterdayResult = sessionService.getDailyActivity(member.getId(), today.minusDays(1)); + + assertThat(todayResult.getTotalWorkouts()).isEqualTo(1); + assertThat(yesterdayResult.getTotalWorkouts()).isZero(); + assertThat(yesterdayResult.getSessions()).isEmpty(); + } + } +} From 742ba10cb76c25c71033bf0dab6da6aa42f5e30d Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 15:58:52 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(backend):=20CodeRabbit=202=EC=B0=A8=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20afterComm?= =?UTF-8?q?it=20=EB=A0=88=EC=9D=B4=EC=8A=A4,=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EA=B3=B5=EB=B0=B1,=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExerciseAnalysisService: self.sendAnalysisRequestToFastApi 호출을 세션 INSERT 커밋 이후로 지연시켜, 비동기 스레드가 아직 커밋 안 된 세션을 못 찾는 레이스 제거 - SessionServiceTest: endSession 멱등 테스트가 실제로 stopAnalysis 호출 여부를 검증하지 않던 공백 수정 (afterCommit 시뮬레이션 후 호출 횟수 단언 추가) - MemberDeletionCascadeIntegrationTest: DailyLog cascade 커버리지 누락 보완 - GlobalExceptionHandler: 4개 핸들러의 응답 생성 중복을 buildResponse 헬퍼로 정리 - PoseDataCleanupServiceTest: tearDown()의 @Transactional이 조용히 무시되던 문제 수정 — Spring의 TransactionalTestExecutionListener는 @Test 메서드만 트랜잭션으로 감싸고 JUnit이 직접 호출하는 @AfterEach 콜백은 감싸지 않아 @Modifying 벌크 삭제가 TransactionRequiredException을 던졌음. TransactionTemplate으로 명시적 트랜잭션 사용 Co-Authored-By: Claude Sonnet 5 --- .../global/error/GlobalExceptionHandler.java | 38 ++++++---------- .../Exercise/ExerciseAnalysisService.java | 18 +++++++- .../MemberDeletionCascadeIntegrationTest.java | 15 ++++++- .../Exercise/PoseDataCleanupServiceTest.java | 43 +++++++++++++++++-- .../service/Exercise/SessionServiceTest.java | 12 +++++- 5 files changed, 94 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java b/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java index 4384d94..f401597 100644 --- a/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/shadowfit/global/error/GlobalExceptionHandler.java @@ -22,13 +22,7 @@ public class GlobalExceptionHandler { public ResponseEntity handleBusinessException(BusinessException e) { ErrorCode code = e.getErrorCode(); log.warn("BusinessException: {} ({})", code.getCode(), code.getMessage()); - return ResponseEntity - .status(code.getStatus()) - .body(ErrorResponseDto.builder() - .status(code.getStatus()) - .message(code.getMessage()) - .timestamp(LocalDateTime.now()) - .build()); + return buildResponse(code); } /** @@ -40,15 +34,8 @@ public ResponseEntity handleBusinessException(BusinessExceptio */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDeniedException(AccessDeniedException e) { - ErrorCode code = ErrorCode.ACCESS_DENIED; log.warn("AccessDeniedException: {}", e.getMessage()); - return ResponseEntity - .status(code.getStatus()) - .body(ErrorResponseDto.builder() - .status(code.getStatus()) - .message(code.getMessage()) - .timestamp(LocalDateTime.now()) - .build()); + return buildResponse(ErrorCode.ACCESS_DENIED); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -58,24 +45,27 @@ public ResponseEntity handleValidationException(MethodArgument .map(err -> err.getField() + ": " + err.getDefaultMessage()) .collect(Collectors.joining(", ")); log.warn("Validation failed: {}", message); - return ResponseEntity - .status(code.getStatus()) - .body(ErrorResponseDto.builder() - .status(code.getStatus()) - .message(message.isEmpty() ? code.getMessage() : message) - .timestamp(LocalDateTime.now()) - .build()); + return buildResponse(code, message.isEmpty() ? code.getMessage() : message); } @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpectedException(Exception e) { - ErrorCode code = ErrorCode.INTERNAL_SERVER_ERROR; log.error("Unhandled exception", e); + return buildResponse(ErrorCode.INTERNAL_SERVER_ERROR); + } + + // CodeRabbit 지적 반영(2026-07-24): 4개 핸들러가 거의 동일한 ErrorResponseDto 생성 로직을 + // 반복하고 있어 공통 헬퍼로 추출. + private ResponseEntity buildResponse(ErrorCode code) { + return buildResponse(code, code.getMessage()); + } + + private ResponseEntity buildResponse(ErrorCode code, String message) { return ResponseEntity .status(code.getStatus()) .body(ErrorResponseDto.builder() .status(code.getStatus()) - .message(code.getMessage()) + .message(message) .timestamp(LocalDateTime.now()) .build()); } diff --git a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java index dc184e9..9b25473 100644 --- a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java +++ b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java @@ -28,6 +28,8 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.reactive.function.client.WebClient; import java.time.LocalDateTime; import java.util.List; @@ -144,12 +146,26 @@ public Long startAnalysis(VideoRequestDto appDto, Long currentMemberId) { Session savedSession = sessionService.createSession(appDto, currentMemberId, finalUrl); Long sessionId = savedSession.getId(); + String persona = member.getSelectedPersona().name(); // 비동기로 FastAPI에 분석 요청 — self를 거쳐야 @Async가 Spring 프록시를 타고 실제로 // 비동기 실행됨. this.로 호출하면 자기호출(self-invocation)이라 AOP 프록시를 우회해서 // @Async가 조용히 무시되고 동기 실행되는 문제가 있었음(2026-07-24, 테스트로 발견) — // completeSession→applyCompleteFromApp에 이미 쓰던 self 패턴을 여기에도 동일 적용. - self.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, member.getSelectedPersona().name()); + // + // ⚠️ CodeRabbit 지적으로 추가 수정(2026-07-24): self.로 진짜 비동기가 되면서 세션 INSERT가 + // 커밋되기 전에 이 비동기 작업이 먼저 실행될 수 있는 레이스가 새로 생김 — 서킷 OPEN/gRPC + // 에러 시 sendAnalysisRequestToFastApi가 markAsFailedIfStillInProgress로 세션을 찾는데, + // 아직 커밋 전이라 못 찾으면 조용히 no-op(스케줄러 30분+ 타임아웃까지 방치). endSession→ + // stopAnalysis와 동일하게 afterCommit 이후로 미뤄서 방지. + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + self.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, persona); + } + } + ); return sessionId; } diff --git a/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java index 8b8a45b..f1b6781 100644 --- a/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java +++ b/backend/src/test/java/com/shadowfit/integration/MemberDeletionCascadeIntegrationTest.java @@ -9,12 +9,14 @@ import com.shadowfit.model.member.Member; import com.shadowfit.model.member.SelectedPersona; import com.shadowfit.model.member.UserRole; +import com.shadowfit.model.report.DailyLog; import com.shadowfit.model.report.Report; import com.shadowfit.model.report.ReportType; import com.shadowfit.repository.exercise.ExercisesRepository; import com.shadowfit.repository.exercise.SessionFeedbackLogRepository; import com.shadowfit.repository.exercise.SessionRepository; import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.repository.report.DailyLogRepository; import com.shadowfit.repository.report.ReportRepository; import com.shadowfit.service.Member.MemberService; import org.junit.jupiter.api.BeforeEach; @@ -26,6 +28,7 @@ import jakarta.persistence.EntityManager; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; @@ -50,6 +53,7 @@ class MemberDeletionCascadeIntegrationTest { @Autowired private ExercisesRepository exercisesRepository; @Autowired private ReportRepository reportRepository; @Autowired private SessionFeedbackLogRepository feedbackLogRepository; + @Autowired private DailyLogRepository dailyLogRepository; @Autowired private EntityManager entityManager; private Member member; @@ -93,6 +97,11 @@ void setUp() { .occurredAt(LocalDateTime.now()) .build()); + dailyLogRepository.saveAndFlush(DailyLog.builder() + .member(member).logDate(LocalDate.now()) + .totalExerciseTime(20).totalCalories(new BigDecimal("100.0")) + .build()); + // refresh_token/body_records는 이 통합테스트로 검증 불가: // - refresh_token: RefreshToken 엔티티가 Member로 가는 @ManyToOne 매핑 자체가 없어서 // (memberId가 단순 Long 컬럼) H2 테스트 스키마엔 FK조차 안 생김 — 별도 구조 개선 필요. @@ -102,10 +111,11 @@ void setUp() { } @Test - @DisplayName("탈퇴 시 세션·리포트·피드백로그 전부 정리됨 (실제 JPA cascade로 검증 가능한 범위)") + @DisplayName("탈퇴 시 세션·리포트·피드백로그·일일로그 전부 정리됨 (실제 JPA cascade로 검증 가능한 범위)") void deleteAccount_cascadesToAllOwnedData() { Long memberId = member.getId(); Long sessionId = session.getId(); + LocalDate logDate = LocalDate.now(); memberService.deleteAccount(member.getEmail()); @@ -120,5 +130,8 @@ void deleteAccount_cascadesToAllOwnedData() { assertThat(sessionRepository.findById(sessionId)).isEmpty(); assertThat(reportRepository.findBySessionId(sessionId)).isEmpty(); assertThat(feedbackLogRepository.findBySessionIdOrderByOccurredAtAsc(sessionId)).isEmpty(); + // CodeRabbit 지적 반영(2026-07-24): DailyLog.member도 오늘 @OnDelete를 추가한 대상인데 + // 이 테스트가 빠뜨리고 있었음 — 실제 cascade까지 확인. + assertThat(dailyLogRepository.findByMemberIdAndLogDate(memberId, logDate)).isEmpty(); } } diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java index ae061b4..c0bfb5a 100644 --- a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java @@ -11,12 +11,14 @@ import com.shadowfit.repository.exercise.PoseDataRepository; import com.shadowfit.repository.exercise.SessionRepository; import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -28,9 +30,16 @@ /** * PoseDataCleanupService 통합테스트 — 회원 탈퇴 시 pose_data 참조무결성 대체(B5, * pose-data-partition-fk-tradeoff.md). @Async라 실제 스레드풀에서 실행되므로 폴링으로 확인. + * + * ⚠️ 의도적으로 @Transactional 미사용(CodeRabbit 지적, 2026-07-24): 이 테스트가 @Transactional + * 이었을 때는 setUp()의 fixture가 테스트 트랜잭션 안에서만 존재(커밋 안 됨)해서, cleanupService의 + * @Async 스레드나 Awaitility의 폴링 스레드가 애초에 그 row 자체를 볼 수 없었다 — 그래서 + * "isEmpty()" 단언이 실제 삭제 성공 때문이 아니라 "그 스레드에서 원래 안 보여서" 통과하는 + * 거짓 양성이었다(실제로 cleanupBySessionIds가 완전히 고장나도 이 테스트는 통과했을 것). + * fixture를 진짜로 커밋시켜야 다른 스레드에서도 보이고, 삭제를 제대로 검증할 수 있어서 + * @Transactional을 빼고 @AfterEach에서 수동 정리한다. */ @SpringBootTest -@Transactional @DisplayName("PoseDataCleanupService 테스트") class PoseDataCleanupServiceTest { @@ -39,18 +48,25 @@ class PoseDataCleanupServiceTest { @Autowired private SessionRepository sessionRepository; @Autowired private MemberRepository memberRepository; @Autowired private ExercisesRepository exercisesRepository; + @Autowired private PlatformTransactionManager transactionManager; + private Long memberId; + private Long exerciseId; private Session targetSession; private Session otherSession; @BeforeEach void setUp() { + String unique = String.valueOf(System.nanoTime()); Member member = memberRepository.saveAndFlush(Member.builder() - .email("cleanup@test.com").username("u").password("dummy").role(UserRole.USER).build()); + .email("cleanup-" + unique + "@test.com").username("u-" + unique).password("dummy") + .role(UserRole.USER).build()); + memberId = member.getId(); Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() .name("스쿼트").category(ExerciseCategory.LOWER).expectedDurationMinutes(15) .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) .build()); + exerciseId = exercise.getId(); targetSession = sessionRepository.saveAndFlush(Session.builder() .member(member).exercise(exercise).startTime(LocalDateTime.now()) @@ -67,6 +83,24 @@ void setUp() { .syncRate(70.0).isCorrect(true).build()); } + @AfterEach + void tearDown() { + // @Transactional이 없어 자동 롤백이 안 되므로 직접 정리(생성 역순). + // + // ⚠️ 2026-07-24: 처음엔 이 메서드에 @Transactional을 직접 붙였는데도 + // deleteBySessionIdIn(@Modifying 벌크 쿼리라 트랜잭션 컨텍스트 필수)이 + // TransactionRequiredException을 던졌다 — Spring의 TransactionalTestExecutionListener는 + // @Test 메서드 실행만 트랜잭션으로 감싸고, JUnit이 직접 호출하는 @BeforeEach/@AfterEach + // 콜백 자체는 TestContextManager를 안 거쳐서 @Transactional이 조용히 무시됐던 것. + // TransactionTemplate으로 직접 트랜잭션을 열어서 우회한다. + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + poseDataRepository.deleteBySessionIdIn(List.of(targetSession.getId(), otherSession.getId())); + sessionRepository.deleteAllById(List.of(targetSession.getId(), otherSession.getId())); + exercisesRepository.deleteById(exerciseId); + memberRepository.deleteById(memberId); + }); + } + @Test @DisplayName("대상 세션의 pose_data만 지워지고 다른 세션 것은 보존됨") void cleanupBySessionIds_removesOnlyTargetSessions() { @@ -80,9 +114,10 @@ void cleanupBySessionIds_removesOnlyTargetSessions() { @Test @DisplayName("빈 리스트/null이면 아무 것도 지우지 않고 조용히 반환") - void cleanupBySessionIds_emptyOrNull_noop() { + void cleanupBySessionIds_emptyOrNull_noop() throws InterruptedException { cleanupService.cleanupBySessionIds(List.of()); cleanupService.cleanupBySessionIds(null); + Thread.sleep(200); // no-op이라 폴링할 상태 변화가 없음 — 비동기 디스패치가 끝날 정도만 대기 assertThat(poseDataRepository.findFramesBySessionId(targetSession.getId())).hasSize(1); assertThat(poseDataRepository.findFramesBySessionId(otherSession.getId())).hasSize(1); diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java index 6a223e2..edb1dd8 100644 --- a/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/Exercise/SessionServiceTest.java @@ -160,15 +160,23 @@ void endSession_unknownSession_throws() { } @Test - @DisplayName("이미 종료된 세션 재호출은 멱등 — endTime 안 바뀜, AI 재통보 없음") + @DisplayName("이미 종료된 세션 재호출은 멱등 — endTime 안 바뀜, AI 재통보도 정확히 1회만(중복 등록 안 됨)") void endSession_alreadyEnded_isIdempotent() { Session session = inProgressSession(); sessionService.endSession(session.getId(), member.getId()); LocalDateTime firstEndTime = sessionRepository.findById(session.getId()).orElseThrow().getEndTime(); - sessionService.endSession(session.getId(), member.getId()); + sessionService.endSession(session.getId(), member.getId()); // 멱등 경로 — 동기화 재등록 안 해야 함 assertThat(sessionRepository.findById(session.getId()).orElseThrow().getEndTime()).isEqualTo(firstEndTime); + + // CodeRabbit 지적 반영(2026-07-24): 이전엔 afterCommit을 시뮬레이션 안 해서 + // stopAnalysis가 애초에 호출될 일이 없어 "재통보 없음"을 증명하지 못했음 — 커밋 + // 시뮬레이션 후 정확히 1회(첫 endSession분)만 호출됐는지 직접 검증. + List syncs = TransactionSynchronizationManager.getSynchronizations(); + syncs.forEach(TransactionSynchronization::afterCommit); + + verify(analysisService, times(1)).stopAnalysis(session.getId()); } @Test From 038600bf810c5aef5a66458b50830d93639ee0b7 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Fri, 24 Jul 2026 16:23:24 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(backend):=20CodeRabbit=203=EC=B0=A8=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=20=EB=B0=98=EC=98=81=20=E2=80=94=20tearDown?= =?UTF-8?q?=20null=20=EC=95=88=EC=A0=84=EC=84=B1,=20=EA=B3=A0=EC=A0=95=20s?= =?UTF-8?q?leep=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tearDown(): setUp() 중간 실패 시 targetSession/otherSession/exerciseId/memberId가 null일 수 있는데 그대로 삭제 호출하면 NPE로 원래 setup 실패 원인이 가려짐 — null 아닌 것만 골라 정리하도록 방어 - cleanupBySessionIds_emptyOrNull_noop: null/빈 리스트는 repository 호출 자체가 없는 no-op 경로라 Thread.sleep(200) 불필요 — 제거 Co-Authored-By: Claude Sonnet 5 --- .../Exercise/PoseDataCleanupServiceTest.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java index c0bfb5a..e9eab66 100644 --- a/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/Exercise/PoseDataCleanupServiceTest.java @@ -93,11 +93,27 @@ void tearDown() { // @Test 메서드 실행만 트랜잭션으로 감싸고, JUnit이 직접 호출하는 @BeforeEach/@AfterEach // 콜백 자체는 TestContextManager를 안 거쳐서 @Transactional이 조용히 무시됐던 것. // TransactionTemplate으로 직접 트랜잭션을 열어서 우회한다. + // + // ⚠️ 2026-07-24 (CodeRabbit): setUp()이 중간에 실패하면 targetSession/otherSession/ + // exerciseId/memberId가 null인 채로 여기 진입할 수 있어서, null 체크 없이 그대로 + // deleteById 등을 부르면 NPE가 나면서 원래 setUp 실패 원인을 가려버린다 — null이 아닌 + // 것만 골라서 정리. + List sessionIds = List.of(targetSession, otherSession).stream() + .filter(java.util.Objects::nonNull) + .map(Session::getId) + .toList(); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { - poseDataRepository.deleteBySessionIdIn(List.of(targetSession.getId(), otherSession.getId())); - sessionRepository.deleteAllById(List.of(targetSession.getId(), otherSession.getId())); - exercisesRepository.deleteById(exerciseId); - memberRepository.deleteById(memberId); + if (!sessionIds.isEmpty()) { + poseDataRepository.deleteBySessionIdIn(sessionIds); + sessionRepository.deleteAllById(sessionIds); + } + if (exerciseId != null) { + exercisesRepository.deleteById(exerciseId); + } + if (memberId != null) { + memberRepository.deleteById(memberId); + } }); } @@ -114,10 +130,11 @@ void cleanupBySessionIds_removesOnlyTargetSessions() { @Test @DisplayName("빈 리스트/null이면 아무 것도 지우지 않고 조용히 반환") - void cleanupBySessionIds_emptyOrNull_noop() throws InterruptedException { + void cleanupBySessionIds_emptyOrNull_noop() { + // CodeRabbit 지적 반영(2026-07-24): null/빈 리스트는 repository를 아예 안 부르는 + // no-op 경로라 비동기 디스패치 완료를 기다릴 필요 자체가 없음 — 고정 sleep 제거. cleanupService.cleanupBySessionIds(List.of()); cleanupService.cleanupBySessionIds(null); - Thread.sleep(200); // no-op이라 폴링할 상태 변화가 없음 — 비동기 디스패치가 끝날 정도만 대기 assertThat(poseDataRepository.findFramesBySessionId(targetSession.getId())).hasSize(1); assertThat(poseDataRepository.findFramesBySessionId(otherSession.getId())).hasSize(1); From 810f0730f35c828544225cf4bb8e7f99d918c5ea Mon Sep 17 00:00:00 2001 From: Khyojae Date: Sat, 25 Jul 2026 08:30:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(backend):=20CodeRabbit=20gRPC=20?= =?UTF-8?q?=EB=AA=A9=20=EC=B2=98=EB=A6=AC=20=EC=A7=80=EC=A0=81=20=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EA=B7=BC=EA=B1=B0=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startAnalysis_success_createsSessionSynchronously에서 self.sendAnalysisRequestToFastApi 목 처리를 시도했으나 @Async 메서드에 Mockito spy를 씌우는 조합이 깨져 불가능했음. 대신 이 테스트가 @Transactional rollback 정책상 실제 커밋이 없어 afterCommit 콜백(비동기 gRPC 호출의 트리거)이 애초에 발동하지 않는다는 사실을 로그로 실측 확인 — 목 처리 대상 자체가 없어 주석으로 근거만 남김. Co-Authored-By: Claude Sonnet 5 --- .../service/Exercise/ExerciseAnalysisServiceTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java index a029d68..7f6b090 100644 --- a/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/Exercise/ExerciseAnalysisServiceTest.java @@ -136,6 +136,13 @@ void startAnalysis_success_createsSessionSynchronously() { // no-op됨 — 그래서 동기 반환 직후 이 트랜잭션 안에서는 항상 IN_PROGRESS로 보여야 함. // (self. 대신 this.로 self-invocation하면 @Async가 무시돼 동기 실행되면서 이 값이 // 깨질 수 있음 — 2026-07-24 발견·수정한 버그의 회귀 방지 성격도 겸함) + // + // CodeRabbit 지적(2026-07-24, sendAnalysisRequestToFastApi를 목 처리해서 백그라운드 gRPC + // 호출 부수효과를 끊어내라) 검토 결과 skip: self.sendAnalysisRequestToFastApi()는 + // registerSynchronization(...).afterCommit()으로만 실행되는데, 이 테스트는 클래스 + // @Transactional 기본 rollback 정책상 실제 커밋이 한 번도 일어나지 않아 afterCommit + // 콜백 자체가 트리거되지 않음(실측: 로그에 "비동기 분석 요청 시작"이 전혀 안 찍힘) — + // 즉 이 테스트에서는 gRPC 호출도, 그로 인한 부수효과도 실제로 발생하지 않아 목 처리 대상이 없음. Session created = sessionRepository.findById(sessionId).orElseThrow(); assertThat(created.getStatus()).isEqualTo(Status.IN_PROGRESS); assertThat(created.getMember().getId()).isEqualTo(member.getId());