diff --git a/src/main/java/org/sopt/app/application/soptamp/SoptampUserService.java b/src/main/java/org/sopt/app/application/soptamp/SoptampUserService.java index 9de482c5..2600bef8 100755 --- a/src/main/java/org/sopt/app/application/soptamp/SoptampUserService.java +++ b/src/main/java/org/sopt/app/application/soptamp/SoptampUserService.java @@ -4,7 +4,6 @@ import static org.sopt.app.domain.enums.SoptPart.findSoptPartByPartName; import java.util.*; -import lombok.*; import org.sopt.app.application.platform.dto.PlatformUserInfoResponse; import org.sopt.app.application.rank.CachedUserInfo; @@ -14,18 +13,28 @@ import org.sopt.app.common.response.ErrorCode; import org.sopt.app.domain.entity.soptamp.SoptampUser; import org.sopt.app.domain.enums.SoptPart; +import org.sopt.app.interfaces.postgres.AppjamUserRepository; import org.sopt.app.interfaces.postgres.SoptampUserRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; + @Service @RequiredArgsConstructor public class SoptampUserService { private final SoptampUserRepository soptampUserRepository; + private final AppjamUserRepository appjamUserRepository; private final RankCacheService rankCacheService; + @Value("${app.soptamp.appjam-mode:false}") + private boolean appjamMode; + + /* ==================== 조회/프로필 ==================== */ + @Transactional(readOnly = true) public SoptampUserInfo getSoptampUserInfo(Long userId) { SoptampUser user = soptampUserRepository.findByUserId(userId) @@ -42,27 +51,45 @@ public SoptampUserInfo editProfileMessage(Long userId, String profileMessage) { return SoptampUserInfo.of(soptampUser); } + /* ==================== upsert 진입점 ==================== */ + + // 앱잼 시즌 여부에 따라 upsert 로직 분기 @Transactional public void upsertSoptampUser(PlatformUserInfoResponse profile, Long userId) { - if (profile == null) return; + if (profile == null) + return; var latest = profile.getLatestActivity(); - if (latest == null) return; + if (latest == null) + return; + + if (appjamMode) { + upsertSoptampUserForAppjam(profile, userId, latest); + } else { + upsertSoptampUserNormal(profile, userId, latest); + } + } + /* ==================== NORMAL 시즌용 upsert ==================== */ + + // 기본 시즌용 upsert (파트 + 이름 기반 닉네임) + private void upsertSoptampUserNormal(PlatformUserInfoResponse profile, Long userId, + PlatformUserInfoResponse.SoptActivities latest) { Optional user = soptampUserRepository.findByUserId(userId); if (user.isEmpty()) { - this.createSoptampUser(profile, userId, latest); + this.createSoptampUserNormal(profile, userId, latest); return; } SoptampUser registeredUser = user.get(); if(this.isGenerationChanged(registeredUser, (long)profile.lastGeneration())) { - updateSoptampUser(registeredUser, profile, latest); + updateSoptampUserNormal(registeredUser, profile, latest); } } - private void updateSoptampUser(SoptampUser registeredUser, PlatformUserInfoResponse profile, PlatformUserInfoResponse.SoptActivities latest){ + private void updateSoptampUserNormal(SoptampUser registeredUser, PlatformUserInfoResponse profile, PlatformUserInfoResponse.SoptActivities latest){ Long userId = registeredUser.getUserId(); String part = latest.part() == null ? "미상" : latest.part(); - String newNickname = generateUniqueNickname(profile.name(), part); + String newNickname = generatePartBasedUniqueNickname(profile.name(), part, userId); + registeredUser.initTotalPoints(); registeredUser.updateChangedGenerationInfo( (long)profile.lastGeneration(), @@ -73,9 +100,9 @@ private void updateSoptampUser(SoptampUser registeredUser, PlatformUserInfoRespo rankCacheService.createNewRank(userId); } - private void createSoptampUser(PlatformUserInfoResponse profile, Long userId, PlatformUserInfoResponse.SoptActivities latest) { + private void createSoptampUserNormal(PlatformUserInfoResponse profile, Long userId, PlatformUserInfoResponse.SoptActivities latest) { String part = latest.part() == null ? "미상" : latest.part(); - String uniqueNickname = generateUniqueNickname(profile.name(), part); + String uniqueNickname = generatePartBasedUniqueNickname(profile.name(), part, null); SoptampUser newSoptampUser = createNewSoptampUser(userId, uniqueNickname, (long)profile.lastGeneration(), findSoptPartByPartName(part)); soptampUserRepository.save(newSoptampUser); rankCacheService.createNewRank(userId); @@ -85,28 +112,146 @@ private boolean isGenerationChanged(SoptampUser registeredUser, Long profileGene return !registeredUser.getGeneration().equals(profileGeneration); } - private String generateUniqueNickname(String nickname, String part) { - String prefixPartName = SoptPart.findSoptPartByPartName(part).getShortedPartName(); - StringBuilder uniqueNickname = new StringBuilder().append(prefixPartName).append(nickname); - if (soptampUserRepository.existsByNickname(uniqueNickname.toString())) { - return addSuffixToNickname(uniqueNickname); + // ==================== 앱잼 시즌용 upsert ==================== + + private void upsertSoptampUserForAppjam(PlatformUserInfoResponse profile, + Long userId, + PlatformUserInfoResponse.SoptActivities latest) { + Optional userOpt = soptampUserRepository.findByUserId(userId); + + if (userOpt.isEmpty()) { + createSoptampUserAppjam(profile, userId, latest); + return; + } + + SoptampUser registeredUser = userOpt.get(); + + // 이미 앱잼 규칙이 적용된 닉네임이면 그대로 둠 (비트OOO, 37기OOO 등) + if (!needsAppjamNicknameMigration(registeredUser)) { + return; } - return uniqueNickname.toString(); + + // 여기까지 오면: 기존 닉네임이 "서버OOO" 같은 파트 기반 → 앱잼 닉네임으로 변환 + String baseNickname = buildAppjamBaseNickname(profile, userId); + + String uniqueNickname = generateUniqueNicknameInternal(baseNickname, userId); + + String part = latest.part() == null ? "미상" : latest.part(); + + registeredUser.updateChangedGenerationInfo( + (long) profile.lastGeneration(), + findSoptPartByPartName(part), + uniqueNickname + ); + + // 앱잼 변환 시점에 한 번 포인트 초기화 + registeredUser.initTotalPoints(); + + // 랭킹 캐시 동기화 + rankCacheService.updateCachedUserInfo( + registeredUser.getUserId(), + CachedUserInfo.of(SoptampUserInfo.of(registeredUser)) + ); } - private String addSuffixToNickname(StringBuilder uniqueNickname) { + private void createSoptampUserAppjam(PlatformUserInfoResponse profile, + Long userId, + PlatformUserInfoResponse.SoptActivities latest) { + + String baseNickname = buildAppjamBaseNickname(profile, userId); + + // 새 유저: 전체에서 중복 검사 + String uniqueNickname = generateUniqueNicknameInternal( + baseNickname, + null + ); + + String part = latest.part() == null ? "미상" : latest.part(); + + SoptampUser newSoptampUser = createNewSoptampUser( + userId, + uniqueNickname, + (long) profile.lastGeneration(), + findSoptPartByPartName(part) + ); + newSoptampUser.initTotalPoints(); // 새 시즌이니 0점부터 + + soptampUserRepository.save(newSoptampUser); + rankCacheService.createNewRank(userId); + } + + private boolean needsAppjamNicknameMigration(SoptampUser user) { + String nickname = user.getNickname(); + if (nickname == null || nickname.isBlank()) { + // 닉네임이 비어 있으면 앱잼 규칙으로 한 번 세팅해 주는 게 자연스러움 + return true; + } + + // SoptPart 기준으로 "서버", "기획" 같은 축약/프리픽스를 모두 검사 + for (SoptPart part : SoptPart.values()) { + String prefix = part.getShortedPartName(); + if (nickname.startsWith(prefix)) { + // 서버김솝트, 디자인김솝트 등 → 기존 시즌(파트 기반) 닉네임이므로 앱잼 변환 필요 + return true; + } + } + + // 그 외 (비트김솝트, 37기김솝트 등) → 이미 앱잼 스타일로 적용된 걸로 간주 + return false; + } + + /** + * 앱잼용 base nickname 생성 + * 1. AppjamUser에 있으면: teamName + 이름 (ex. 비트김솝트) + * 2. 없으면: lastGeneration + "기" + 이름 (ex. 37기김솝트) + */ + private String buildAppjamBaseNickname(PlatformUserInfoResponse profile, Long userId) { + return appjamUserRepository.findByUserId(userId) + .map(appjamUser -> appjamUser.getTeamName() + profile.name()) + .orElseGet(() -> profile.lastGeneration() + "기" + profile.name()); + } + + // ==================== 닉네임 유니크 로직 공통부 ==================== + + /** + * 파트 기반 닉네임 (NORMAL 시즌용) + * ex. "서버" + "김솝트" → "서버김솝트" + */ + private String generatePartBasedUniqueNickname(String name, String part, Long currentUserIdOrNull) { + String prefixPartName = SoptPart.findSoptPartByPartName(part).getShortedPartName(); + String baseNickname = prefixPartName + name; + return generateUniqueNicknameInternal(baseNickname, currentUserIdOrNull); + } + + /** + * baseNickname을 기준으로, 전역 유니크 닉네임 생성 + * - currentUserIdOrNull == null : 새 유저 생성 (그냥 existsByNickname) + * - currentUserIdOrNull != null : 내 row는 제외하고 중복 체크 + */ + private String generateUniqueNicknameInternal(String baseNickname, Long currentUserIdOrNull) { + if (!existsNickname(baseNickname, currentUserIdOrNull)) { + return baseNickname; + } + char suffix = 'A'; - uniqueNickname.append(suffix); - for(int i = 0; i < 52; i++) { - if (!soptampUserRepository.existsByNickname(uniqueNickname.toString())) { - return uniqueNickname.toString(); + for (int i = 0; i < 52; i++, suffix++) { + String candidate = baseNickname + suffix; + if (!existsNickname(candidate, currentUserIdOrNull)) { + return candidate; } - uniqueNickname.deleteCharAt(uniqueNickname.length() - 1); - uniqueNickname.append(++suffix); } throw new BadRequestException(ErrorCode.NICKNAME_IS_FULL); } + private boolean existsNickname(String nickname, Long currentUserIdOrNull) { + if (currentUserIdOrNull == null) { + return soptampUserRepository.existsByNickname(nickname); + } + return soptampUserRepository.existsByNicknameAndUserIdNot(nickname, currentUserIdOrNull); + } + + // ==================== 포인트/회원 탈퇴 로직 ==================== + @Transactional public void addPointByLevel(Long userId, Integer level) { SoptampUser soptampUser = soptampUserRepository.findByUserId(userId) @@ -134,13 +279,20 @@ public void initPoint(Long userId) { @Transactional public void initAllSoptampUserPoints() { - val soptampUserList = soptampUserRepository.findAll(); + List soptampUserList = soptampUserRepository.findAll(); soptampUserList.forEach(SoptampUser::initTotalPoints); soptampUserRepository.saveAll(soptampUserList); rankCacheService.deleteAll(); rankCacheService.addAll(soptampUserList.stream().map(SoptampUserInfo::of).toList()); } + @Transactional + public void initSoptampRankCache() { + List soptampUserList = soptampUserRepository.findAll(); + rankCacheService.deleteAll(); + rankCacheService.addAll(soptampUserList.stream().map(SoptampUserInfo::of).toList()); + } + @EventListener(UserWithdrawEvent.class) public void handleUserWithdrawEvent(final UserWithdrawEvent event) { soptampUserRepository.deleteByUserId(event.getUserId()); diff --git a/src/main/java/org/sopt/app/facade/AdminSoptampFacade.java b/src/main/java/org/sopt/app/facade/AdminSoptampFacade.java index 704df28b..dc95c636 100755 --- a/src/main/java/org/sopt/app/facade/AdminSoptampFacade.java +++ b/src/main/java/org/sopt/app/facade/AdminSoptampFacade.java @@ -18,4 +18,14 @@ public void initAllMissionAndStampAndPoints() { stampService.deleteAll(); soptampUserService.initAllSoptampUserPoints(); } + + @Transactional + public void initPoints() { + soptampUserService.initAllSoptampUserPoints(); + } + + @Transactional + public void initRankCache() { + soptampUserService.initSoptampRankCache(); + } } diff --git a/src/main/java/org/sopt/app/interfaces/postgres/SoptampUserRepository.java b/src/main/java/org/sopt/app/interfaces/postgres/SoptampUserRepository.java index 170cd982..824faaf4 100755 --- a/src/main/java/org/sopt/app/interfaces/postgres/SoptampUserRepository.java +++ b/src/main/java/org/sopt/app/interfaces/postgres/SoptampUserRepository.java @@ -18,6 +18,8 @@ public interface SoptampUserRepository extends JpaRepository boolean existsByNickname(String nickname); + boolean existsByNicknameAndUserIdNot(String nickname, Long userId); + void deleteByUserId(Long userId); List findAllByUserIdIn(Collection userIds); diff --git a/src/main/java/org/sopt/app/presentation/admin/AdminSoptampController.java b/src/main/java/org/sopt/app/presentation/admin/AdminSoptampController.java index a3ee640a..c09f2486 100755 --- a/src/main/java/org/sopt/app/presentation/admin/AdminSoptampController.java +++ b/src/main/java/org/sopt/app/presentation/admin/AdminSoptampController.java @@ -28,7 +28,7 @@ public class AdminSoptampController { @ApiResponse(responseCode = "401", description = "token error", content = @Content), @ApiResponse(responseCode = "500", description = "server error", content = @Content) }) - @DeleteMapping(value = "/point") + @DeleteMapping(value = "/stamp") public ResponseEntity initAllMissionAndStampAndPoints( @RequestParam(name = "password") String password ) { @@ -37,6 +37,36 @@ public ResponseEntity initAllMissionAndStampAndPoints( return ResponseEntity.ok().build(); } + @Operation(summary = "포인트 전체 초기화") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "success"), + @ApiResponse(responseCode = "401", description = "token error", content = @Content), + @ApiResponse(responseCode = "500", description = "server error", content = @Content) + }) + @DeleteMapping(value = "/point") + public ResponseEntity initPoints( + @RequestParam(name = "password") String password + ) { + validateAdmin(password); + adminSoptampFacade.initPoints(); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "랭킹 캐시 초기화") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "success"), + @ApiResponse(responseCode = "401", description = "token error", content = @Content), + @ApiResponse(responseCode = "500", description = "server error", content = @Content) + }) + @DeleteMapping(value = "/cache") + public ResponseEntity initRankingCache( + @RequestParam(name = "password") String password + ) { + validateAdmin(password); + adminSoptampFacade.initRankCache(); + return ResponseEntity.ok().build(); + } + private void validateAdmin(String password) { if (!password.equals(adminPassword)) { throw new BadRequestException(ErrorCode.INVALID_APP_ADMIN_PASSWORD); diff --git a/src/test/java/org/sopt/app/application/SoptampUserServiceTest.java b/src/test/java/org/sopt/app/application/SoptampUserServiceTest.java index 11dbf4e7..12c925cd 100755 --- a/src/test/java/org/sopt/app/application/SoptampUserServiceTest.java +++ b/src/test/java/org/sopt/app/application/SoptampUserServiceTest.java @@ -1,331 +1,429 @@ -// package org.sopt.app.application; -// -// import static org.assertj.core.api.Assertions.assertThat; -// import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -// import static org.junit.jupiter.api.Assertions.assertThrows; -// import static org.mockito.ArgumentMatchers.*; -// import static org.mockito.BDDMockito.given; -// 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; -// import static org.sopt.app.common.fixtures.SoptampUserFixture.SOPTAMP_USER_1; -// -// import java.util.List; -// import java.util.Optional; -// import org.junit.jupiter.api.Assertions; -// import org.junit.jupiter.api.DisplayName; -// import org.junit.jupiter.api.Test; -// import org.junit.jupiter.api.extension.ExtendWith; -// import org.mockito.ArgumentCaptor; -// import org.mockito.InjectMocks; -// import org.mockito.Mock; -// import org.mockito.junit.jupiter.MockitoExtension; -// import org.sopt.app.application.playground.dto.PlaygroundProfileInfo.ActivityCardinalInfo; -// import org.sopt.app.application.playground.dto.PlaygroundProfileInfo.PlaygroundProfile; -// import org.sopt.app.application.rank.RankCacheService; -// import org.sopt.app.application.soptamp.SoptampUserInfo; -// import org.sopt.app.application.soptamp.SoptampUserService; -// import org.sopt.app.common.exception.BadRequestException; -// import org.sopt.app.domain.enums.PlaygroundPart; -// import org.sopt.app.interfaces.postgres.SoptampUserRepository; -// import org.sopt.app.domain.entity.soptamp.SoptampUser; -// -// @ExtendWith(MockitoExtension.class) -// class SoptampUserServiceTest { -// -// @Mock -// private SoptampUserRepository soptampUserRepository; -// -// @Mock -// private RankCacheService rankCacheService; -// -// @InjectMocks -// private SoptampUserService soptampUserService; -// -// @Test -// @DisplayName("SUCCESS_솝탬프 유저 정보 조회") -// void SUCCESS_getSoptampUserInfo() { -// //given -// final Long id = 1L; -// final Long anyUserId = anyLong(); -// final String profileMessage = "profileMessage"; -// final Long totalPoints = 100L; -// final String nickname = "nickname"; -// -// Optional soptampUser = Optional.of(SoptampUser.builder() -// .id(id) -// .userId(anyUserId) -// .profileMessage(profileMessage) -// .totalPoints(totalPoints) -// .nickname(nickname) -// .build()); -// -// //when -// SoptampUserInfo expected = SoptampUserInfo.builder() -// .id(id) -// .userId(anyUserId) -// .profileMessage(profileMessage) -// .totalPoints(totalPoints) -// .nickname(nickname) -// .build(); -// -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(soptampUser); -// SoptampUserInfo result = soptampUserService.getSoptampUserInfo(anyUserId); -// //then -// -// assertThat(result).usingRecursiveComparison().isEqualTo(expected); -// } -// -// @Test -// @DisplayName("FAIL_솝탬프 유저 정보 조회") -// void FAIL_getSoptampUserInfo() { -// //given -// final Long anyUserId = anyLong(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.empty()); -// -// //then -// assertThrows(BadRequestException.class, () -> { -// soptampUserService.getSoptampUserInfo(anyUserId); -// }); -// } -// -// @Test -// @DisplayName("SUCCESS_프로필 메시지 변경") -// void SUCCESS_editProfileMessage() { -// //given -// final String newProfileMessage = "newProfileMessage"; -// final SoptampUser editedSoptampUser = SoptampUser.builder() -// .id(SOPTAMP_USER_1.getId()) -// .userId(SOPTAMP_USER_1.getUserId()) -// .nickname(SOPTAMP_USER_1.getNickname()) -// .totalPoints(SOPTAMP_USER_1.getTotalPoints()) -// .part(PlaygroundPart.SERVER) -// .profileMessage(newProfileMessage) -// .build(); -// -// given(soptampUserRepository.findByUserId(anyLong())).willReturn(Optional.of(editedSoptampUser)); -// -// // when -// String result = soptampUserService.editProfileMessage(SOPTAMP_USER_1.getUserId(), newProfileMessage) -// .getProfileMessage(); -// -// //then -// Assertions.assertEquals(newProfileMessage, result); -// } -// -// @Test -// @DisplayName("SUCCESS_기존 솝탬프 유저가 없다면 새로 생성") -// void SUCCESS_upsertSoptampUserIfEmpty() { -// //given -// given(soptampUserRepository.findByUserId(anyLong())).willReturn(Optional.empty()); -// PlaygroundProfile profile = PlaygroundProfile.builder() -// .name("name") -// .activities(List.of(new ActivityCardinalInfo("35,서버"))) -// .build(); -// Long userId = 1L; -// //when -// soptampUserService.upsertSoptampUser(profile, userId); -// String expectedNickname = profile.getLatestActivity().getPlaygroundPart().getShortedPartName()+ profile.getName(); -// -// //then -// ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); -// verify(soptampUserRepository, times(1)).existsByNickname(anyString()); -// verify(soptampUserRepository, times(1)).save(captor.capture()); -// assertThat(captor.getValue().getUserId()).isEqualTo(userId); -// assertThat(captor.getValue().getNickname()).isEqualTo(expectedNickname); -// assertThat(captor.getValue().getPart().getPartName()) -// .isEqualTo(profile.getLatestActivity().getPlaygroundPart().getPartName()); -// assertThat(captor.getValue().getGeneration()).isEqualTo(profile.getLatestActivity().getGeneration()); -// } -// -// @Test -// void 기존_솝탬프_유저가_없다면_새로_생성_닉네임_중복시_suffix_추가() { -// //given -// Long userId = 1L; -// PlaygroundProfile profile = PlaygroundProfile.builder() -// .name("name") -// .activities(List.of(new ActivityCardinalInfo("35,서버"))) -// .build(); -// given(soptampUserRepository.findByUserId(anyLong())).willReturn(Optional.empty()); -// given(soptampUserRepository.existsByNickname( -// profile.getLatestActivity().getPlaygroundPart().getShortedPartName() + profile.getName())) -// .willReturn(true); -// given(soptampUserRepository.existsByNickname( -// profile.getLatestActivity().getPlaygroundPart().getShortedPartName() + profile.getName() + 'A')) -// .willReturn(true); -// -// //when -// soptampUserService.upsertSoptampUser(profile, userId); -// String expectedNickname = -// profile.getLatestActivity().getPlaygroundPart().getShortedPartName() + profile.getName() + 'B'; -// -// //then -// ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); -// verify(soptampUserRepository, times(3)).existsByNickname(anyString()); -// verify(soptampUserRepository, times(1)).save(captor.capture()); -// assertThat(captor.getValue().getUserId()).isEqualTo(userId); -// assertThat(captor.getValue().getNickname()).isEqualTo(expectedNickname); -// assertThat(captor.getValue().getPart().getPartName()) -// .isEqualTo(profile.getLatestActivity().getPlaygroundPart().getPartName()); -// assertThat(captor.getValue().getGeneration()).isEqualTo(profile.getLatestActivity().getGeneration()); -// } -// -// @Test -// void 기존_솝탬프_유저가_없다면_새로_생성_닉네임_중복시_suffix_추가_모든_suffix_사용시_에러() { -// //given -// Long userId = 1L; -// PlaygroundProfile profile = PlaygroundProfile.builder() -// .name("name") -// .activities(List.of(new ActivityCardinalInfo("35,서버"))) -// .build(); -// given(soptampUserRepository.findByUserId(userId)).willReturn(Optional.empty()); -// given(soptampUserRepository.existsByNickname(anyString())).willReturn(true); -// -// //when & then -// assertThrows(BadRequestException.class, () -> soptampUserService.upsertSoptampUser(profile, userId)); -// } -// -// @Test -// void 기존_솝탬프_유저가_있고_기수가_변경되었다면_업데이트() { -// //given -// Long userId = 1L; -// PlaygroundProfile profile = PlaygroundProfile.builder() -// .name("name") -// .activities(List.of(new ActivityCardinalInfo("36,아요"))) // 기수와 파트가 변경됨 -// .build(); -// SoptampUser existingUser = mock(SoptampUser.class); -// given(soptampUserRepository.findByUserId(anyLong())).willReturn(Optional.of(existingUser)); -// -// //when -// soptampUserService.upsertSoptampUser(profile, userId); -// -// //then -// verify(existingUser, times(1)).updateChangedGenerationInfo(anyLong(), any(), anyString()); -// } -// -// @Test -// void 기존_솝탬프_유저가_있고_기수가_변경되지_않았다면_변경하지_않음() { -// //given -// SoptampUser existingUser = mock(SoptampUser.class); -// Long userId = 1L; -// PlaygroundProfile profile = PlaygroundProfile.builder() -// .name("name") -// .activities(List.of(new ActivityCardinalInfo("36,아요"))) -// .build(); -// given(soptampUserRepository.findByUserId(userId)).willReturn(Optional.of(existingUser)); -// given(existingUser.getGeneration()).willReturn(36L); -// -// //when -// soptampUserService.upsertSoptampUser(profile, userId); -// -// //then -// verify(soptampUserRepository, times(1)).findByUserId(userId); -// verify(existingUser, never()).updateChangedGenerationInfo(anyLong(), any(), anyString()); -// verify(soptampUserRepository, never()).save(any(SoptampUser.class)); -// } -// -// @Test -// @DisplayName("SUCCESS_미션 레벨별로 유저의 포인트 추가") -// void SUCCESS_addPointByLevel() { -// //given -// final Long anyUserId = anyLong(); -// final Integer level = 1; -// final Long soptampUserTotalPoints = 100L; -// final SoptampUser oldSoptampUser = SoptampUser.builder() -// .userId(anyUserId) -// .totalPoints(soptampUserTotalPoints) -// .build(); -// //when -// -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.of(oldSoptampUser)); -// -// //then -// assertDoesNotThrow(() -> soptampUserService.addPointByLevel(anyUserId, level)); -// } -// -// @Test -// @DisplayName("FAIL_유저를 찾지 못하면 BadRequestException 발생") -// void FAIL_addPointByLevel() { -// //given -// final Long anyUserId = anyLong(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.empty()); -// -// //then -// assertThrows(BadRequestException.class, () -> { -// soptampUserService.addPointByLevel(anyUserId, 1); -// }); -// } -// -// @Test -// @DisplayName("SUCCESS_미션 레벨별로 유저의 포인트 감소") -// void SUCCESS_subtractPointByLevel() { -// //given -// final Long anyUserId = anyLong(); -// final Integer level = 1; -// final Long soptampUserTotalPoints = 100L; -// final SoptampUser oldSoptampUser = SoptampUser.builder() -// .userId(anyUserId) -// .totalPoints(soptampUserTotalPoints) -// .build(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.of(oldSoptampUser)); -// -// //then -// assertDoesNotThrow(()-> soptampUserService.subtractPointByLevel(anyUserId, level)); -// } -// -// @Test -// @DisplayName("FAIL_유저를 찾지 못하면 BadRequestException 발생") -// void FAIL_subtractPointByLevel() { -// //given -// final Long anyUserId = anyLong(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.empty()); -// -// //then -// assertThrows(BadRequestException.class, () -> { -// soptampUserService.subtractPointByLevel(anyUserId, 1); -// }); -// } -// -// @Test -// @DisplayName("SUCCESS_포인트 초기화") -// void SUCCESS_initPoint() { -// //given -// final Long anyUserId = anyLong(); -// final SoptampUser soptampUser = SoptampUser.builder() -// .userId(anyUserId) -// .build(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.of(soptampUser)); -// when(soptampUserRepository.save(any(SoptampUser.class))).thenReturn(soptampUser); -// -// //then -// assertDoesNotThrow(() -> { -// soptampUserService.initPoint(anyUserId); -// }); -// } -// -// @Test -// @DisplayName("FAIL_포인트 초기화") -// void FAIL_initPoint() { -// //given -// final Long anyUserId = anyLong(); -// -// //when -// when(soptampUserRepository.findByUserId(anyUserId)).thenReturn(Optional.empty()); -// -// //then -// assertThrows(BadRequestException.class, () -> soptampUserService.initPoint(anyUserId)); -// } -// -// } \ No newline at end of file +package org.sopt.app.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.app.application.platform.dto.PlatformUserInfoResponse; +import org.sopt.app.application.rank.RankCacheService; +import org.sopt.app.application.soptamp.SoptampUserService; +import org.sopt.app.domain.entity.AppjamUser; +import org.sopt.app.domain.entity.soptamp.SoptampUser; +import org.sopt.app.domain.enums.SoptPart; +import org.sopt.app.domain.enums.TeamNumber; +import org.sopt.app.interfaces.postgres.AppjamUserRepository; +import org.sopt.app.interfaces.postgres.SoptampUserRepository; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SoptampUserServiceTest { + + @Mock + SoptampUserRepository soptampUserRepository; + + @Mock + AppjamUserRepository appjamUserRepository; + + @Mock + RankCacheService rankCacheService; + + @InjectMocks + SoptampUserService soptampUserService; + + private PlatformUserInfoResponse buildProfile(String name, int lastGeneration, String part) { + PlatformUserInfoResponse.SoptActivities latest = + new PlatformUserInfoResponse.SoptActivities( + 1, // activityId + lastGeneration, // generation + part, // part + "아무팀" // team (여기선 안 씀) + ); + + return new PlatformUserInfoResponse( + 1, // userId + name, + null, null, null, null, + lastGeneration, + List.of(latest) + ); + } + + @BeforeEach + void setUp() { + // 기본은 NORMAL 모드로 두고, 테스트에서 필요할 때 변경 + ReflectionTestUtils.setField(soptampUserService, "appjamMode", false); + } + + /* ==================== NORMAL 모드 테스트 ==================== */ + + @Test + @DisplayName("NORMAL 모드 - 프로필이 null이면 아무 동작도 하지 않는다") + void 일반모드_프로필널이면_동작없음() { + // given + long userId = 1L; + + // when + soptampUserService.upsertSoptampUser(null, userId); + + // then + verifyNoInteractions(soptampUserRepository, appjamUserRepository, rankCacheService); + } + + @Test + @DisplayName("NORMAL 모드 - 활동 내역이 없으면 아무 동작도 하지 않는다") + void 일반모드_활동내역없으면_동작없음() { + // given + long userId = 1L; + + PlatformUserInfoResponse profile = new PlatformUserInfoResponse( + 1, + "김솝트", + null, null, null, null, + 37, + Collections.emptyList() // soptActivities 비어있음 → getLatestActivity() = null + ); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + verifyNoInteractions(soptampUserRepository, appjamUserRepository, rankCacheService); + } + + @Test + @DisplayName("NORMAL 모드 - SoptampUser가 없으면 파트+이름 기반 닉네임으로 새 유저를 생성한다") + void 일반모드_신규유저면_파트기반닉네임으로_생성() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", false); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(soptampUserRepository.existsByNickname(anyString())).thenReturn(false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + verify(soptampUserRepository).save(captor.capture()); + SoptampUser saved = captor.getValue(); + + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getGeneration()).isEqualTo(37L); + assertThat(saved.getNickname()).contains("김솝트"); + assertThat(saved.getNickname()) + .startsWith(SoptPart.findSoptPartByPartName("서버").getShortedPartName()); + assertThat(saved.getTotalPoints()).isZero(); + + verify(rankCacheService).createNewRank(userId); + } + + @Test + @DisplayName("NORMAL 모드 - 동일 기수라면 닉네임과 포인트는 변경되지 않는다") + void 일반모드_기수변경없으면_업데이트안함() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", false); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + SoptampUser existing = SoptampUser.builder() + .id(10L) + .userId(userId) + .nickname("서버김솝트") + .generation(37L) + .part(SoptPart.findSoptPartByPartName("서버")) + .totalPoints(100L) + .profileMessage("") + .build(); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.of(existing)); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + assertThat(existing.getNickname()).isEqualTo("서버김솝트"); + assertThat(existing.getGeneration()).isEqualTo(37L); + assertThat(existing.getTotalPoints()).isEqualTo(100L); + + verify(rankCacheService, never()).removeRank(anyLong()); + verify(rankCacheService, never()).createNewRank(anyLong()); + } + + @Test + @DisplayName("NORMAL 모드 - 기수가 변경되면 닉네임을 재생성하고 포인트를 초기화한다") + void 일반모드_기수변경되면_닉네임재생성과_포인트리셋() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", false); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 38, "서버"); + + SoptampUser existing = SoptampUser.builder() + .id(10L) + .userId(userId) + .nickname("서버김솝트") + .generation(37L) + .part(SoptPart.findSoptPartByPartName("서버")) + .totalPoints(120L) + .profileMessage("") + .build(); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.of(existing)); + when(soptampUserRepository.existsByNicknameAndUserIdNot(anyString(), anyLong())) + .thenReturn(false); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + assertThat(existing.getGeneration()).isEqualTo(38L); + assertThat(existing.getNickname()).contains("김솝트"); + assertThat(existing.getNickname()) + .startsWith(SoptPart.findSoptPartByPartName("서버").getShortedPartName()); + assertThat(existing.getTotalPoints()).isZero(); + + verify(rankCacheService).removeRank(userId); + verify(rankCacheService).createNewRank(userId); + } + + /* ==================== APPJAM 모드 테스트 ==================== */ + + @Test + @DisplayName("APPJAM 모드 - SoptampUser가 없고 AppjamUser가 있으면 팀명+이름으로 앱잼 유저 생성") + void 앱잼모드_신규유저_AppjamUser있으면_팀명닉네임으로생성() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", true); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + AppjamUser appjamUser = new AppjamUser( + 100L, + userId, + "비트", + TeamNumber.FIRST + ); + when(appjamUserRepository.findByUserId(userId)).thenReturn(Optional.of(appjamUser)); + + when(soptampUserRepository.existsByNickname(anyString())).thenReturn(false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + verify(soptampUserRepository).save(captor.capture()); + SoptampUser saved = captor.getValue(); + + assertThat(saved.getNickname()).startsWith("비트"); + assertThat(saved.getNickname()).contains("김솝트"); + assertThat(saved.getTotalPoints()).isZero(); + assertThat(saved.getGeneration()).isEqualTo(37L); + + verify(rankCacheService).createNewRank(userId); + } + + @Test + @DisplayName("APPJAM 모드 - SoptampUser와 AppjamUser가 모두 없으면 기수+기+이름으로 앱잼 유저 생성") + void 앱잼모드_신규유저_AppjamUser없으면_기수닉네임으로생성() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", true); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(appjamUserRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(soptampUserRepository.existsByNickname(anyString())).thenReturn(false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + verify(soptampUserRepository).save(captor.capture()); + SoptampUser saved = captor.getValue(); + + assertThat(saved.getNickname()).startsWith("37기"); + assertThat(saved.getNickname()).contains("김솝트"); + assertThat(saved.getTotalPoints()).isZero(); + + verify(rankCacheService).createNewRank(userId); + } + + @Test + @DisplayName("APPJAM 모드 - 기존 닉네임이 파트 기반이면 앱잼 닉네임으로 1회 마이그레이션 후 포인트 초기화") + void 앱잼모드_파트닉네임이면_앱잼닉네임으로변환_포인트초기화() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", true); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + String partPrefix = SoptPart.findSoptPartByPartName("서버").getShortedPartName(); + + SoptampUser existing = SoptampUser.builder() + .id(10L) + .userId(userId) + .nickname(partPrefix + "김솝트") // "서버김솝트" + .generation(37L) + .part(SoptPart.findSoptPartByPartName("서버")) + .totalPoints(50L) + .profileMessage("") + .build(); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.of(existing)); + + AppjamUser appjamUser = new AppjamUser( + 100L, + userId, + "비트", + TeamNumber.FIRST + ); + when(appjamUserRepository.findByUserId(userId)).thenReturn(Optional.of(appjamUser)); + + when(soptampUserRepository.existsByNicknameAndUserIdNot(anyString(), anyLong())) + .thenReturn(false); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + assertThat(existing.getNickname()).startsWith("비트"); + assertThat(existing.getNickname()).contains("김솝트"); + assertThat(existing.getTotalPoints()).isZero(); + assertThat(existing.getGeneration()).isEqualTo(37L); + + verify(rankCacheService).updateCachedUserInfo(eq(userId), any()); + } + + @Test + @DisplayName("APPJAM 모드 - 기존 닉네임이 이미 앱잼 스타일이면 아무 업데이트도 하지 않는다") + void 앱잼모드_이미앱잼닉이면_업데이트안함() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", true); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + SoptampUser existing = SoptampUser.builder() + .id(10L) + .userId(userId) + .nickname("비트김솝트") // 이미 앱잼 규칙 + .generation(37L) + .part(SoptPart.findSoptPartByPartName("서버")) + .totalPoints(30L) + .profileMessage("") + .build(); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.of(existing)); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + assertThat(existing.getNickname()).isEqualTo("비트김솝트"); + assertThat(existing.getTotalPoints()).isEqualTo(30L); + + verify(rankCacheService, never()).updateCachedUserInfo(anyLong(), any()); + } + + @Test + @DisplayName("APPJAM 모드 - 다른 유저가 같은 앱잼 닉네임을 쓰고 있으면 접미사 A를 붙여 유니크하게 만든다") + void 앱잼모드_닉네임충돌시_접미사A추가() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", true); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + String partPrefix = SoptPart.findSoptPartByPartName("서버").getShortedPartName(); + + SoptampUser existing = SoptampUser.builder() + .id(10L) + .userId(userId) + .nickname(partPrefix + "김솝트") // "서버김솝트" + .generation(37L) + .part(SoptPart.findSoptPartByPartName("서버")) + .totalPoints(20L) + .profileMessage("") + .build(); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.of(existing)); + + AppjamUser appjamUser = new AppjamUser( + 100L, + userId, + "비트", + TeamNumber.FIRST + ); + when(appjamUserRepository.findByUserId(userId)).thenReturn(Optional.of(appjamUser)); + + // baseNickname = "비트김솝트" 라고 가정 + // 다른 유저가 이미 baseNickname을 쓰고 있다 → true + when(soptampUserRepository.existsByNicknameAndUserIdNot(eq("비트김솝트"), eq(userId))) + .thenReturn(true); + // "비트김솝트A"는 아직 아무도 안 씀 → false (stub 없으면 기본 false) + when(soptampUserRepository.existsByNicknameAndUserIdNot(eq("비트김솝트A"), eq(userId))) + .thenReturn(false); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + assertThat(existing.getNickname()).isEqualTo("비트김솝트A"); + assertThat(existing.getTotalPoints()).isZero(); + } + + @Test + @DisplayName("NORMAL 모드 - 다른 유저가 같은 파트 기반 닉네임을 쓰고 있으면 접미사 A를 붙인다") + void 일반모드_닉네임충돌시_접미사A추가() { + // given + ReflectionTestUtils.setField(soptampUserService, "appjamMode", false); + + long userId = 1L; + PlatformUserInfoResponse profile = buildProfile("김솝트", 37, "서버"); + + when(soptampUserRepository.findByUserId(userId)).thenReturn(Optional.empty()); + + String partPrefix = SoptPart.findSoptPartByPartName("서버").getShortedPartName(); + String baseNickname = partPrefix + "김솝트"; + + // 다른 유저가 baseNickname 사용 중 + when(soptampUserRepository.existsByNickname(baseNickname)).thenReturn(true); + // baseNicknameA는 사용 안 함 + when(soptampUserRepository.existsByNickname(baseNickname + "A")).thenReturn(false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoptampUser.class); + + // when + soptampUserService.upsertSoptampUser(profile, userId); + + // then + verify(soptampUserRepository).save(captor.capture()); + SoptampUser saved = captor.getValue(); + + assertThat(saved.getNickname()).isEqualTo(baseNickname + "A"); + } +}