From 8e1e241f9d83b3adae1dc64bb963060941130acd Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:04:41 +0900 Subject: [PATCH 01/50] =?UTF-8?q?fix:=20Sheet=20API=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9E=90=20=EC=A0=91=EA=B7=BC=20=EA=B6=8C=ED=95=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: integrated 시트 요청자 접근 권한 검증 추가 * fix: integrated 시트 권한 검증 보완 * fix: integrated 시트 서비스 계정 접근 재검증 추가 * fix: integrated 시트 Drive OAuth 미연결 우회 차단 * test: integrated 시트 OAuth 미연결 중단 검증 추가 * test: integrated 시트 검증 테스트 중복 정리 --- .../service/ClubSheetIntegratedService.java | 8 +- .../service/GoogleDrivePermissionHelper.java | 5 +- .../service/GoogleSheetPermissionService.java | 155 ++++++++++- .../ClubSheetIntegratedServiceTest.java | 87 +++---- .../GoogleSheetPermissionServiceTest.java | 244 +++++++++++++++++- 5 files changed, 426 insertions(+), 73 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index 8959dc03e..46475328f 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -23,8 +23,12 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - // OAuth 미연결이면 건너뛰고 계속 진행한다. Drive 초기화/인증 오류는 예외로 전파한다. - googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + // integrated 등록은 요청자 Google Drive OAuth 연결을 전제로 한다. + // 연결된 계정이 실제 시트 접근 권한을 가지는지 검증한 뒤 서비스 계정 권한을 맞춘다. + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + requesterId, + spreadsheetId + ); SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java index 918fb12df..0efc2663e 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelper.java @@ -94,7 +94,8 @@ static List listAllPermissions(Drive driveService, String fileId) th do { Drive.Permissions.List request = driveService.permissions().list(fileId) - .setFields(PERMISSION_FIELDS); + .setFields(PERMISSION_FIELDS) + .setSupportsAllDrives(true); if (nextPageToken != null) { request.setPageToken(nextPageToken); } @@ -142,6 +143,7 @@ private static PermissionApplyStatus applyServiceAccountPermission( userDriveService.permissions().create(fileId, permission) .setSendNotificationEmail(false) + .setSupportsAllDrives(true) .execute(); log.info( "Service account {} access granted. fileId={}, email={}", @@ -165,6 +167,7 @@ private static PermissionApplyStatus applyServiceAccountPermission( Permission updatedPermission = new Permission().setRole(targetRole); userDriveService.permissions().update(fileId, existingPermission.getId(), updatedPermission) + .setSupportsAllDrives(true) .execute(); log.info( "Service account permission upgraded. fileId={}, fromRole={}, toRole={}, email={}", diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java index c01c588a7..dd2cca0b2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -7,6 +7,7 @@ import org.springframework.util.StringUtils; import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.domain.user.enums.Provider; @@ -23,15 +24,25 @@ public class GoogleSheetPermissionService { private final ServiceAccountCredentials serviceAccountCredentials; + private final Drive googleDriveService; private final GoogleSheetsConfig googleSheetsConfig; private final UserOAuthAccountRepository userOAuthAccountRepository; + public void validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + Integer requesterId, + String spreadsheetId + ) { + String refreshToken = requireRefreshToken(requesterId); + Drive userDriveService = buildUserDriveService(refreshToken, requesterId); + validateRequesterSpreadsheetAccess(userDriveService, requesterId, spreadsheetId); + boolean granted = tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId); + if (!granted) { + requireServiceAccountSpreadsheetAccess(spreadsheetId, requesterId); + } + } + public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String spreadsheetId) { - String refreshToken = userOAuthAccountRepository - .findByUserIdAndProvider(requesterId, Provider.GOOGLE) - .map(account -> account.getGoogleDriveRefreshToken()) - .filter(StringUtils::hasText) - .orElse(null); + String refreshToken = resolveRefreshToken(requesterId); if (refreshToken == null) { log.warn( @@ -41,14 +52,35 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp return false; } - Drive userDriveService; - try { - userDriveService = googleSheetsConfig.buildUserDriveService(refreshToken); - } catch (IOException | GeneralSecurityException e) { - log.error("Failed to build user Drive service. requesterId={}", requesterId, e); - throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); - } + Drive userDriveService = buildUserDriveService(refreshToken, requesterId); + return tryGrantServiceAccountWriterAccess(userDriveService, requesterId, spreadsheetId); + } + + private String requireRefreshToken(Integer requesterId) { + return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElseThrow(() -> { + log.warn( + "Rejecting spreadsheet registration because Google Drive OAuth is not connected. requesterId={}", + requesterId + ); + return CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + }); + } + + private String resolveRefreshToken(Integer requesterId) { + return userOAuthAccountRepository.findByUserIdAndProvider(requesterId, Provider.GOOGLE) + .map(account -> account.getGoogleDriveRefreshToken()) + .filter(StringUtils::hasText) + .orElse(null); + } + private boolean tryGrantServiceAccountWriterAccess( + Drive userDriveService, + Integer requesterId, + String spreadsheetId + ) { try { GoogleDrivePermissionHelper.ensureServiceAccountPermission( userDriveService, @@ -91,6 +123,105 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp } } + private Drive buildUserDriveService(String refreshToken, Integer requesterId) { + try { + return googleSheetsConfig.buildUserDriveService(refreshToken); + } catch (IOException | GeneralSecurityException e) { + log.error("Failed to build user Drive service. requesterId={}", requesterId, e); + throw CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + } + } + + private void validateRequesterSpreadsheetAccess( + Drive userDriveService, + Integer requesterId, + String spreadsheetId + ) { + try { + File file = userDriveService.files().get(spreadsheetId) + .setFields("id") + .setSupportsAllDrives(true) + .execute(); + if (file == null || !StringUtils.hasText(file.getId())) { + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { + log.warn( + "Google Drive OAuth token is invalid while validating spreadsheet access. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + + if (GoogleSheetApiExceptionHelper.isAuthFailure(e)) { + log.warn( + "Google Drive OAuth auth failure while validating spreadsheet access. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + GoogleSheetApiExceptionHelper.extractDetail(e) + ); + throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + } + + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Requester has no spreadsheet access. requesterId={}, spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + + log.error( + "Unexpected error while validating requester spreadsheet access. requesterId={}, spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + + private void requireServiceAccountSpreadsheetAccess(String spreadsheetId, Integer requesterId) { + try { + File file = googleDriveService.files().get(spreadsheetId) + .setFields("id") + .setSupportsAllDrives(true) + .execute(); + if (file == null || !StringUtils.hasText(file.getId())) { + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + } catch (IOException e) { + if (GoogleSheetApiExceptionHelper.isAccessDenied(e) + || GoogleSheetApiExceptionHelper.isNotFound(e)) { + log.warn( + "Service account has no spreadsheet access after auto-share failed. requesterId={}, " + + "spreadsheetId={}, cause={}", + requesterId, + spreadsheetId, + e.getMessage() + ); + throw GoogleSheetApiExceptionHelper.accessDenied(); + } + + log.error( + "Unexpected error while re-checking service account spreadsheet access. requesterId={}, " + + "spreadsheetId={}", + requesterId, + spreadsheetId, + e + ); + throw CustomException.of(ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET); + } + } + private String getServiceAccountEmail() { return serviceAccountCredentials.getClientEmail(); } diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java index 700737c68..7d4727b13 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verifyNoInteractions; @@ -53,8 +54,6 @@ void analyzeAndImportPreMembersSuccess() { new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); SheetImportResponse expected = SheetImportResponse.of(3, 1, List.of("warn")); - given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) - .willReturn(true); given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); given(sheetImportService.importPreMembersFromSheet( clubId, @@ -81,7 +80,7 @@ void analyzeAndImportPreMembersSuccess() { ); inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); inOrder.verify(googleSheetPermissionService) - .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); inOrder.verify(clubMemberSheetService).updateSheetId( clubId, @@ -99,76 +98,66 @@ void analyzeAndImportPreMembersSuccess() { } @Test - @DisplayName("자동 권한 부여가 실패해도 기존 공유 권한으로 가져오기를 계속 시도한다") - void analyzeAndImportPreMembersContinuesWhenAutoGrantFails() { + @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; - SheetHeaderMapper.SheetAnalysisResult analysis = - new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); - SheetImportResponse expected = SheetImportResponse.of(1, 0, List.of()); + CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); - given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) - .willReturn(false); - given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); - given(sheetImportService.importPreMembersFromSheet( - clubId, - requesterId, - spreadsheetId, - analysis.memberListMapping() - )) - .willReturn(expected); + willThrow(expected).given(googleSheetPermissionService) + .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); - // when - SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( + // when & then + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl - ); + )) + .isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + } - // then - InOrder inOrder = inOrder( - clubPermissionValidator, - googleSheetPermissionService, - sheetHeaderMapper, - clubMemberSheetService, - sheetImportService - ); - inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); - inOrder.verify(googleSheetPermissionService) - .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); - inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); - inOrder.verify(clubMemberSheetService).updateSheetId( - clubId, - requesterId, - spreadsheetId, - analysis - ); - inOrder.verify(sheetImportService).importPreMembersFromSheet( + @Test + @DisplayName("요청자 계정이 시트 접근 권한이 없으면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenRequesterHasNoSpreadsheetAccess() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; + String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; + CustomException expected = CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + + willThrow(expected).given(googleSheetPermissionService) + .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + + // when & then + assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, - spreadsheetId, - analysis.memberListMapping() - ); - assertThat(actual).isEqualTo(expected); + spreadsheetUrl + )) + .isSameAs(expected); + verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); } @Test - @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") - void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { + @DisplayName("Drive OAuth가 연결되지 않으면 후속 시트 작업을 진행하지 않는다") + void analyzeAndImportPreMembersStopsWhenGoogleDriveOAuthIsNotConnected() { // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; - CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); + CustomException expected = CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); - given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) - .willThrow(expected); + willThrow(expected).given(googleSheetPermissionService) + .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); // when & then assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java index 0db70d9ea..e75e08347 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -15,12 +16,13 @@ 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.mockito.InjectMocks; import org.mockito.Mock; import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; import com.google.api.services.drive.model.Permission; import com.google.api.services.drive.model.PermissionList; import com.google.auth.oauth2.ServiceAccountCredentials; @@ -55,6 +57,9 @@ class GoogleSheetPermissionServiceTest extends ServiceTestSupport { @Mock private Drive userDriveService; + @Mock + private Drive googleDriveService; + @Mock private Drive.Permissions permissions; @@ -70,9 +75,30 @@ class GoogleSheetPermissionServiceTest extends ServiceTestSupport { @Mock private Drive.Permissions.Update updateRequest; - @InjectMocks + @Mock + private Drive.Files files; + + @Mock + private Drive.Files.Get getFileRequest; + + @Mock + private Drive.Files serviceAccountFiles; + + @Mock + private Drive.Files.Get serviceAccountGetFileRequest; + private GoogleSheetPermissionService googleSheetPermissionService; + @BeforeEach + void setUpGoogleSheetPermissionService() { + googleSheetPermissionService = new GoogleSheetPermissionService( + serviceAccountCredentials, + googleDriveService, + googleSheetsConfig, + userOAuthAccountRepository + ); + } + @Test @DisplayName("returns false when the requester has no Google Drive OAuth account") void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { @@ -85,6 +111,8 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenOAuthAccountIsMissing() { ); assertThat(granted).isFalse(); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); } @Test @@ -95,6 +123,7 @@ void tryGrantServiceAccountWriterAccessReturnsTrueWhenPermissionAlreadyExists() given(permissions.list(FILE_ID)).willReturn(listRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( @@ -115,11 +144,13 @@ void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() given(permissions.list(FILE_ID)).willReturn(listRequest, nextPageListRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willReturn( new PermissionList().setPermissions(List.of()).setNextPageToken("next-page") ); given(nextPageListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(nextPageListRequest); + given(nextPageListRequest.setSupportsAllDrives(true)).willReturn(nextPageListRequest); given(nextPageListRequest.setPageToken("next-page")).willReturn(nextPageListRequest); given(nextPageListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); @@ -137,15 +168,26 @@ void tryGrantServiceAccountWriterAccessFindsPermissionAcrossPages() void tryGrantServiceAccountWriterAccessReturnsTrueAfterConcurrentGrant() throws IOException, GeneralSecurityException { mockConnectedDriveAccount(); - given(permissions.list(FILE_ID)).willReturn(listRequest); - given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(listRequest); - given(listRequest.execute()).willReturn( - permissionList(), - permissionList(permission("perm-1", "writer")) - ); + Drive.Permissions.List initialListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List applyListRequest = mock(Drive.Permissions.List.class); + Drive.Permissions.List recheckListRequest = mock(Drive.Permissions.List.class); + + given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); + given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(initialListRequest); + given(initialListRequest.setSupportsAllDrives(true)).willReturn(initialListRequest); + given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(applyListRequest); + given(applyListRequest.setSupportsAllDrives(true)).willReturn(applyListRequest); + given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(recheckListRequest); + given(recheckListRequest.setSupportsAllDrives(true)).willReturn(recheckListRequest); + given(initialListRequest.execute()).willReturn(permissionList()); + given(applyListRequest.execute()).willReturn(permissionList()); + given(recheckListRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); given(permissions.create(eq(FILE_ID), any(Permission.class))).willReturn(createRequest); given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.setSupportsAllDrives(true)).willReturn(createRequest); given(createRequest.execute()).willThrow(new IOException("already granted")); boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( @@ -165,8 +207,10 @@ void tryGrantServiceAccountWriterAccessUpgradesExistingPermission() given(permissions.list(FILE_ID)).willReturn(listRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willReturn(permissionList(permission("perm-x", "reader"))); given(permissions.update(eq(FILE_ID), eq("perm-x"), any(Permission.class))).willReturn(updateRequest); + given(updateRequest.setSupportsAllDrives(true)).willReturn(updateRequest); given(updateRequest.execute()).willReturn(permission("perm-x", "writer")); boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( @@ -186,6 +230,7 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenAuthFails() given(permissions.list(FILE_ID)).willReturn(listRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willThrow(googleException(401, "authError")); boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( @@ -204,6 +249,7 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() given(permissions.list(FILE_ID)).willReturn(listRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willThrow(googleException(403, "forbidden")); boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( @@ -222,6 +268,7 @@ void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() given(permissions.list(FILE_ID)).willReturn(listRequest); given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); given(listRequest.execute()).willThrow(new IOException( "token refresh failed", httpResponseException( @@ -239,6 +286,185 @@ void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); } + @Test + @DisplayName("요청자 계정이 시트 접근 권한이 없으면 forbidden 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenRequesterCannotAccessSpreadsheet() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willThrow(googleException(403, "forbidden")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + @Test + @DisplayName("요청자 계정이 시트에 접근 가능하면 서비스 계정 권한 부여까지 진행한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessSucceedsWhenRequesterCanAccessSpreadsheet() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willReturn(permissionList(permission("perm-1", "writer"))); + + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + verify(files).get(FILE_ID); + verify(permissions, never()).create(eq(FILE_ID), any(Permission.class)); + } + + @Test + @DisplayName("서비스 계정 권한 부여가 실패해도 이미 접근 가능하면 가져오기를 계속 허용한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessContinuesWhenServiceAccountAlreadyHasAccess() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + given(googleDriveService.files()).willReturn(serviceAccountFiles); + given(serviceAccountFiles.get(FILE_ID)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setFields("id")).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setSupportsAllDrives(true)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ); + + verify(serviceAccountFiles).get(FILE_ID); + } + + @Test + @DisplayName("서비스 계정 권한 부여가 실패하고 실제 접근도 불가하면 forbidden 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenServiceAccountStillCannotAccess() + throws IOException, GeneralSecurityException { + mockConnectedDriveAccount(); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willReturn(new File().setId(FILE_ID)); + given(permissions.list(FILE_ID)).willReturn(listRequest); + given(listRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(listRequest); + given(listRequest.setSupportsAllDrives(true)).willReturn(listRequest); + given(listRequest.execute()).willThrow(googleException(403, "forbidden")); + given(googleDriveService.files()).willReturn(serviceAccountFiles); + given(serviceAccountFiles.get(FILE_ID)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setFields("id")).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.setSupportsAllDrives(true)).willReturn(serviceAccountGetFileRequest); + given(serviceAccountGetFileRequest.execute()).willThrow(googleException(404, "notFound")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + } + + @Test + @DisplayName("Drive OAuth 계정이 없으면 요청자 접근 검증을 거부한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenOAuthAccountIsMissing() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); + verify(googleSheetsConfig, never()).buildUserDriveService(any()); + } + + @Test + @DisplayName("Drive OAuth refresh token이 비어 있으면 요청자 접근 검증을 거부한다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenRefreshTokenIsBlank() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(" "); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); + verify(userDriveService, never()).files(); + verify(userDriveService, never()).permissions(); + verify(googleSheetsConfig, never()).buildUserDriveService(any()); + } + + @Test + @DisplayName("요청자 Drive 인증이 만료되면 invalid Google Drive auth 예외를 던진다") + void validateRequesterAccessAndTryGrantServiceAccountWriterAccessThrowsWhenAuthFailureOccurs() + throws IOException, GeneralSecurityException { + given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) + .willReturn(Optional.of(userOAuthAccount)); + given(userOAuthAccount.getGoogleDriveRefreshToken()).willReturn(REFRESH_TOKEN); + given(googleSheetsConfig.buildUserDriveService(REFRESH_TOKEN)).willReturn(userDriveService); + given(userDriveService.files()).willReturn(files); + given(files.get(FILE_ID)).willReturn(getFileRequest); + given(getFileRequest.setFields("id")).willReturn(getFileRequest); + given(getFileRequest.setSupportsAllDrives(true)).willReturn(getFileRequest); + given(getFileRequest.execute()).willThrow(googleException(401, "authError")); + + assertThatThrownBy(() -> + googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( + REQUESTER_ID, + FILE_ID + ) + ) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); + } + private void mockConnectedDriveAccount() throws IOException, GeneralSecurityException { given(userOAuthAccountRepository.findByUserIdAndProvider(REQUESTER_ID, Provider.GOOGLE)) .willReturn(Optional.of(userOAuthAccount)); From af241135da7eaa1a185f70f5bcc09df2cfeb33ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:55:16 +0900 Subject: [PATCH 02/50] =?UTF-8?q?test:=20=EA=B7=B8=EB=A3=B9=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EA=B0=95=ED=87=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#482)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 그룹 채팅방 생성 및 멤버 강퇴 테스트 추가 * test: 그룹 채팅방 메시지 전송 및 중복 생성 테스트 추가 * chore: 코드 포맷팅 * refactor: 그룹 채팅방 생성 테스트 개선 * test: 강퇴된 멤버 메시지 전송 테스트 변수 정리 * test: 강퇴된 멤버 메시지 조회 테스트 추가 * test: 채팅 조회 권한 케이스를 커밋된 상태 기준으로 검증 --- .../integration/domain/chat/ChatApiTest.java | 509 +++++++++++++++++- 1 file changed, 508 insertions(+), 1 deletion(-) diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index 6b988082b..aacb3cec4 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -14,7 +14,9 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.transaction.TestTransaction; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; @@ -719,10 +721,15 @@ void getMessagesNotFound() throws Exception { } @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @DisplayName("참여하지 않은 사용자가 조회하면 403을 반환한다") void getMessagesForbidden() throws Exception { // given ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + mockLoginUser(outsiderUser.getId()); // when & then @@ -769,7 +776,7 @@ void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) - .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("CLUB_GROUP")) .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); } @@ -956,6 +963,493 @@ void toggleMuteSuccessAndDuplicateProcessing() throws Exception { } } + @Nested + @DisplayName("POST /chats/rooms/group - 그룹 채팅방 생성") + class CreateGroupChatRoom { + + private User memberA; + private User memberB; + + @BeforeEach + void setUpGroupChatFixture() { + memberA = createUser("멤버A", "2021136002"); + memberB = createUser("멤버B", "2021136003"); + clearPersistenceContext(); + } + + @Test + @DisplayName("그룹 채팅방을 생성하면 방장과 멤버가 모두 참여한다") + void createGroupChatRoomSuccess() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 채팅방 타입이 GROUP인지 확인 + clearPersistenceContext(); + ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElseThrow(); + assertThat(chatRoom.getRoomType()).isEqualTo(ChatType.GROUP); + + // then - 방장(owner) 확인 + ChatRoomMember ownerMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, normalUser.getId()).orElseThrow(); + assertThat(ownerMember.isOwner()).isTrue(); + + // then - 일반 멤버 확인 + ChatRoomMember memberARecord = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, memberA.getId()).orElseThrow(); + assertThat(memberARecord.isOwner()).isFalse(); + + ChatRoomMember memberBRecord = chatRoomMemberRepository + .findByChatRoomIdAndUserId(roomId, memberB.getId()).orElseThrow(); + assertThat(memberBRecord.isOwner()).isFalse(); + + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(3); + } + + @Test + @DisplayName("userIds에 자신만 포함되면 400을 반환한다") + void createGroupChatRoomWithSelfOnlyFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(normalUser.getId()) + )) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_CREATE_CHAT_ROOM_WITH_SELF")); + } + + @Test + @DisplayName("userIds가 빈 리스트이면 validation 에러를 반환한다") + void createGroupChatRoomWithEmptyUserIdsFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group(List.of())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")); + } + + @Test + @DisplayName("존재하지 않는 userId가 포함되면 404를 반환한다") + void createGroupChatRoomWithNonExistentUserFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), 99999) + )) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_USER")); + } + + @Test + @DisplayName("중복 userId는 무시하고 정상 생성한다") + void createGroupChatRoomWithDuplicateUserIds() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 멤버 수가 중복 제거된 3명(방장 + A + B)이어야 한다 + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(3); + } + + @Test + @DisplayName("userIds에 자신이 포함되어도 무시하고 정상 생성한다") + void createGroupChatRoomWithSelfInUserIds() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(normalUser.getId(), memberA.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 멤버 수가 2명(방장 + A)이어야 한다 + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(roomId)).hasSize(2); + } + + @Test + @DisplayName("초대받은 멤버도 그룹 채팅방에 메시지를 전송할 수 있다") + void invitedMemberCanSendMessageToGroupChatRoom() throws Exception { + // given + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when & then - memberA(초대받은 멤버)가 메시지 전송 + mockLoginUser(memberA.getId()); + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("초대받은 멤버의 메시지") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.senderId").value(memberA.getId())) + .andExpect(jsonPath("$.senderName").value(memberA.getName())) + .andExpect(jsonPath("$.content").value("초대받은 멤버의 메시지")) + .andExpect(jsonPath("$.isMine").value(true)); + } + + @Test + @DisplayName("같은 유저들로 그룹 채팅방을 여러 개 생성할 수 있다") + void createMultipleGroupChatRoomsWithSameUsers() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when - 같은 유저들로 첫 번째 그룹방 생성 + int firstRoomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when - 같은 유저들로 두 번째 그룹방 생성 + int secondRoomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // then - 서로 다른 방이 생성됨 (DIRECT와 달리 중복 허용) + assertThat(secondRoomId).isNotEqualTo(firstRoomId); + clearPersistenceContext(); + assertThat(chatRoomRepository.findGroupRoomsByMemberUserId(normalUser.getId())).hasSize(2); + } + + @Test + @DisplayName("생성된 그룹 채팅방에 메시지를 전송할 수 있다") + void canSendMessageToCreatedGroupChatRoom() throws Exception { + // given + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/group", new ChatRoomCreateRequest.Group( + List.of(memberA.getId(), memberB.getId()) + )) + .andReturn().getResponse().getContentAsString() + ).get("chatRoomId").asInt(); + + // when & then + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("그룹방 첫 메시지") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").isNumber()) + .andExpect(jsonPath("$.senderId").value(normalUser.getId())) + .andExpect(jsonPath("$.senderName").value(normalUser.getName())) + .andExpect(jsonPath("$.content").value("그룹방 첫 메시지")) + .andExpect(jsonPath("$.isMine").value(true)); + } + } + + @Nested + @DisplayName("DELETE /chats/rooms/{chatRoomId}/members/{targetUserId} - 멤버 강퇴") + class KickMember { + + private User ownerUser; + private User memberUser; + private User anotherMember; + private ChatRoom groupRoom; + + @BeforeEach + void setUpKickFixture() { + ownerUser = createUser("방장", "2021136002"); + memberUser = createUser("멤버", "2021136003"); + anotherMember = createUser("다른멤버", "2021136004"); + groupRoom = createGroupChatRoomWithOwner(ownerUser, memberUser, anotherMember); + clearPersistenceContext(); + } + + @Test + @DisplayName("방장이 일반 멤버를 강퇴한다") + void kickMemberSuccess() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // then + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), memberUser.getId() + )).isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId( + groupRoom.getId(), ownerUser.getId() + )).isPresent(); + assertThat(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).hasSize(2); + } + + @Test + @DisplayName("방장이 아닌 멤버가 강퇴하면 403을 반환한다") + void kickMemberByNonOwnerFails() throws Exception { + // given + mockLoginUser(memberUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + anotherMember.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); + } + + @Test + @DisplayName("자기 자신을 강퇴하면 400을 반환한다") + void kickSelfFails() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + ownerUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_SELF")); + } + + @Test + @DisplayName("1:1 채팅방에서 강퇴하면 400을 반환한다") + void kickInDirectRoomFails() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(ownerUser, memberUser); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + directRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("동아리 채팅방에서 강퇴하면 400을 반환한다") + void kickInClubRoomFails() throws Exception { + // given + Club club = persist(ClubFixture.create(university)); + ChatRoom clubRoom = persist(ChatRoom.clubGroupOf(club)); + addRoomMember(clubRoom, ownerUser); + addRoomMember(clubRoom, memberUser); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + clubRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_IN_NON_GROUP_ROOM")); + } + + @Test + @DisplayName("다른 방장(owner) 멤버를 강퇴하면 400을 반환한다") + void kickAnotherOwnerFails() throws Exception { + // given - 방장 2명인 그룹 방을 수동 생성 + ChatRoom room = persist(ChatRoom.groupOf()); + ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, room.getId()); + User managedOwner = entityManager.getReference(User.class, ownerUser.getId()); + User managedMember = entityManager.getReference(User.class, memberUser.getId()); + persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, room.getCreatedAt())); + persist(ChatRoomMember.ofOwner(managedRoom, managedMember, room.getCreatedAt())); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + room.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("CANNOT_KICK_ROOM_OWNER")); + } + + @Test + @DisplayName("채팅방에 없는 멤버를 강퇴하면 403을 반환한다") + void kickNonMemberTargetFails() throws Exception { + // given + User outsiderUser = createUser("외부인", "2021136005"); + clearPersistenceContext(); + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + outsiderUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 채팅방 이름을 수정할 수 없다") + void kickedMemberCannotUpdateRoomName() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performPatch( + "/chats/rooms/" + groupRoom.getId() + "/name", + new ChatRoomNameUpdateRequest("강퇴 후 이름") + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 메시지를 보낼 수 없다") + void kickedMemberCannotSendMessage() throws Exception { + // given + Integer roomId = groupRoom.getId(); + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + roomId + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performPost( + "/chats/rooms/" + roomId + "/messages", + new ChatMessageSendRequest("강퇴 후 메시지") + ) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @DisplayName("강퇴된 멤버는 메시지를 조회할 수 없다") + void kickedMemberCannotGetMessages() throws Exception { + // given + Integer roomId = groupRoom.getId(); + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + roomId + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + // when & then + mockLoginUser(memberUser.getId()); + performGet("/chats/rooms/" + roomId + "?page=1&limit=20") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버의 방 목록에서 해당 방이 제거된다") + void kickedMemberRoomRemovedFromList() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isNoContent()); + + // when & then + mockLoginUser(memberUser.getId()); + performGet("/chats/rooms") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rooms[?(@.roomId==" + groupRoom.getId() + ")]").doesNotExist()); + } + + @Test + @DisplayName("존재하지 않는 채팅방에서 강퇴하면 404를 반환한다") + void kickInNonExistentRoomFails() throws Exception { + // given + mockLoginUser(ownerUser.getId()); + + // when & then + performDelete("/chats/rooms/99999/members/" + memberUser.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND_CHAT_ROOM")); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자가 강퇴를 시도하면 403을 반환한다") + void kickByOutsiderRequesterFails() throws Exception { + // given + User outsiderUser = createUser("외부인", "2021136005"); + clearPersistenceContext(); + mockLoginUser(outsiderUser.getId()); + + // when & then + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("이미 나간 멤버를 강퇴하면 403을 반환한다") + void kickAlreadyLeftMemberFails() throws Exception { + // given - member leaves the group room + mockLoginUser(memberUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isNoContent()); + + // when & then - owner tries to kick the left member + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + memberUser.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("마지막 멤버를 강퇴하면 방에 방장만 남는다") + void kickLastMemberLeavesOwnerOnly() throws Exception { + // given - room with only owner and one member + User soleMember = createUser("유일멤버", "2021136005"); + ChatRoom twoPersonRoom = createGroupChatRoomWithOwner(ownerUser, soleMember); + clearPersistenceContext(); + + mockLoginUser(ownerUser.getId()); + + // when + performDelete("/chats/rooms/" + twoPersonRoom.getId() + "/members/" + soleMember.getId()) + .andExpect(status().isNoContent()); + + // then - only owner remains + clearPersistenceContext(); + assertThat(chatRoomMemberRepository.findByChatRoomId(twoPersonRoom.getId())).hasSize(1); + assertThat(chatRoomMemberRepository.findByChatRoomIdAndUserId(twoPersonRoom.getId(), ownerUser.getId())) + .isPresent() + .get() + .extracting(ChatRoomMember::isOwner) + .isEqualTo(true); + } + + @Test + @DisplayName("방장이 나간 그룹방에서 일반 멤버가 강퇴할 수 없다") + void kickFailsAfterOwnerLeaves() throws Exception { + // given - 3인 그룹방: owner + memberUser + anotherMember + // owner가 나가면 방장이 없는 상태가 됨 + mockLoginUser(ownerUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId()) + .andExpect(status().isNoContent()); + + // when & then - 남은 일반 멤버가 다른 멤버를 강퇴 시도 + mockLoginUser(memberUser.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + anotherMember.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); + } + } + private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { ChatRoom chatRoom = persist(ChatRoom.directOf()); LocalDateTime joinedAt = chatRoom.getCreatedAt(); @@ -969,6 +1463,19 @@ private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { return chatRoom; } + private ChatRoom createGroupChatRoomWithOwner(User owner, User... members) { + ChatRoom groupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedOwner = entityManager.getReference(User.class, owner.getId()); + persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, groupRoom.getCreatedAt())); + for (User member : members) { + User managedMember = entityManager.getReference(User.class, member.getId()); + persist(ChatRoomMember.of(managedRoom, managedMember, groupRoom.getCreatedAt())); + } + clearPersistenceContext(); + return groupRoom; + } + private User createUser(String name, String studentId) { return persist(UserFixture.createUser(university, name, studentId)); } From 4c38d56aff15045dde159d45c10ce3422b5de6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:52:07 +0900 Subject: [PATCH 03/50] =?UTF-8?q?fix:=20getChatRooms=EC=97=90=EC=84=9C=20G?= =?UTF-8?q?ROUP=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/gg/agit/konect/domain/chat/service/ChatService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 9b04c825f..2fec8e6f4 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -268,7 +268,7 @@ public ChatRoomsSummaryResponse getChatRooms(Integer userId) { ) ); - return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); + return new ChatRoomsSummaryResponse(rooms); } public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { From d020892bf2413e840dc818f8a67a5f378cca06c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:47:03 +0900 Subject: [PATCH 04/50] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EA=B0=80=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=9D=84=20=EB=82=98=EA=B0=80=EB=8F=84=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EC=A1=B0=ED=9A=8C=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 채팅방 목록 조회에서 채팅방을 나간 사용자 제외 조건 추가 * fix: 관리자 채팅방 나가기 시 목록에서 필터링 안되는 버그 수정 --- .../domain/chat/repository/ChatRoomRepository.java | 10 +++++++--- .../agit/konect/domain/chat/service/ChatService.java | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 7d6ec6363..865ba7c70 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -170,13 +170,16 @@ List findAllSystemAdminDirectRooms( FROM ChatRoom cr JOIN ChatRoomMember crm ON crm.id.chatRoomId = cr.id JOIN User u ON u.id = crm.id.userId - JOIN ChatRoomMember adminCrm ON adminCrm.id.chatRoomId = cr.id - AND adminCrm.id.userId = :systemAdminId + JOIN ChatRoomMember systemAdminCrm ON systemAdminCrm.id.chatRoomId = cr.id + AND systemAdminCrm.id.userId = :systemAdminId + LEFT JOIN ChatRoomMember viewerAdminCrm ON viewerAdminCrm.id.chatRoomId = cr.id + AND viewerAdminCrm.id.userId = :viewerAdminId LEFT JOIN ChatMessage cm ON cm.chatRoom.id = cr.id AND cm.sender.id <> :systemAdminId - AND cm.createdAt > adminCrm.lastReadAt + AND cm.createdAt > systemAdminCrm.lastReadAt WHERE cr.roomType = :roomType AND u.role != :adminRole + AND (viewerAdminCrm.leftAt IS NULL OR viewerAdminCrm.id.userId IS NULL) AND EXISTS ( SELECT 1 FROM ChatMessage userReply JOIN userReply.sender userSender @@ -188,6 +191,7 @@ ORDER BY COALESCE(cr.lastMessageSentAt, cr.createdAt) DESC """) List findAdminChatRoomsOptimized( @Param("systemAdminId") Integer systemAdminId, + @Param("viewerAdminId") Integer viewerAdminId, @Param("adminRole") UserRole adminRole, @Param("roomType") ChatType roomType ); diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 2fec8e6f4..fd4991f41 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -482,7 +482,7 @@ private List getDirectChatRooms(Integer userId) { User user = userRepository.getById(userId); if (user.getRole() == UserRole.ADMIN) { - return getAdminDirectChatRooms(); + return getAdminDirectChatRooms(userId); } List roomSummaries = new ArrayList<>(); @@ -525,9 +525,9 @@ private List getDirectChatRooms(Integer userId) { return roomSummaries; } - private List getAdminDirectChatRooms() { + private List getAdminDirectChatRooms(Integer adminUserId) { List projections = chatRoomRepository.findAdminChatRoomsOptimized( - SYSTEM_ADMIN_ID, UserRole.ADMIN, ChatType.DIRECT + SYSTEM_ADMIN_ID, adminUserId, UserRole.ADMIN, ChatType.DIRECT ); return projections.stream() From 1b493150e235268cab3654e1e5dd439ce1cab7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:43:37 +0900 Subject: [PATCH 05/50] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=9D=B4=20=EB=AC=B8=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EA=B0=80=20=EC=98=A4=EB=A9=B4=20=EB=AA=BB?= =?UTF-8?q?=EB=B3=B4=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#499)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: findAdminChatRoomsOptimized 쿼리의 가시성 조건을 수정 * test: 테스트 추가 (ChatApiTest.java) - `@BeforeEach` 수정: System Admin(ID=1)을 먼저 생성 - `createAdminChatRoomAndGetRoomsSuccess` 테스트 수정 - `adminLeftInquiryRoomReappearsWhenUserSendsNewMessage` 테스트 추가: 어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출되는 시나리오 검증 * chore: 코드 포맷팅 * test: System Admin ID 상수화 및 관련 케이스 수정 --- .../chat/repository/ChatRoomRepository.java | 9 +- .../integration/domain/chat/ChatApiTest.java | 309 ++++++++++++------ 2 files changed, 210 insertions(+), 108 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java index 865ba7c70..a3a064f5b 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatRoomRepository.java @@ -179,7 +179,14 @@ List findAllSystemAdminDirectRooms( AND cm.createdAt > systemAdminCrm.lastReadAt WHERE cr.roomType = :roomType AND u.role != :adminRole - AND (viewerAdminCrm.leftAt IS NULL OR viewerAdminCrm.id.userId IS NULL) + AND ( + viewerAdminCrm.leftAt IS NULL + OR viewerAdminCrm.id.userId IS NULL + OR ( + viewerAdminCrm.leftAt IS NOT NULL + AND cr.lastMessageSentAt > viewerAdminCrm.visibleMessageFrom + ) + ) AND EXISTS ( SELECT 1 FROM ChatMessage userReply JOIN userReply.sender userSender diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index aacb3cec4..bcc14fb71 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -17,6 +17,9 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.transaction.TestTransaction; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; @@ -42,10 +45,10 @@ import gg.agit.konect.support.fixture.UniversityFixture; import gg.agit.konect.support.fixture.UserFixture; -import org.springframework.util.LinkedMultiValueMap; - class ChatApiTest extends IntegrationTestSupport { + private static final int SYSTEM_ADMIN_ID = 1; + @Autowired private ChatRoomRepository chatRoomRepository; @@ -73,10 +76,127 @@ class ChatApiTest extends IntegrationTestSupport { @BeforeEach void setUp() { university = persist(UniversityFixture.create()); + // System Admin을 먼저 생성 - 문의 채팅방용 + adminUser = persist(UserFixture.createAdmin(university)); + // SYSTEM_ADMIN_ID가 아니면 SQL로 해당 ID 사용자를 추가 생성 + if (adminUser.getId() != SYSTEM_ADMIN_ID) { + entityManager.createNativeQuery(""" + INSERT INTO users (id, email, name, student_number, role, is_marketing_agreement, image_url, university_id, created_at, updated_at) + SELECT ?, 'system@koreatech.ac.kr', '시스템관리자', '2021000001', 'ADMIN', true, 'https://example.com/system-admin.png', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?) + """ + ).setParameter(1, SYSTEM_ADMIN_ID) + .setParameter(2, university.getId()) + .setParameter(3, SYSTEM_ADMIN_ID) + .executeUpdate(); + entityManager.flush(); + } normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); clearPersistenceContext(); } + private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { + ChatRoom chatRoom = persist(ChatRoom.directOf()); + LocalDateTime joinedAt = chatRoom.getCreatedAt(); + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); + User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); + + persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); + persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); + clearPersistenceContext(); + return chatRoom; + } + + private ChatRoom createGroupChatRoomWithOwner(User owner, User... members) { + ChatRoom groupRoom = persist(ChatRoom.groupOf()); + ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); + User managedOwner = entityManager.getReference(User.class, owner.getId()); + persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, groupRoom.getCreatedAt())); + for (User member : members) { + User managedMember = entityManager.getReference(User.class, member.getId()); + persist(ChatRoomMember.of(managedRoom, managedMember, groupRoom.getCreatedAt())); + } + clearPersistenceContext(); + return groupRoom; + } + + private User createUser(String name, String studentId) { + return persist(UserFixture.createUser(university, name, studentId)); + } + + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMember.builder() + .club(managedClub) + .user(managedUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); + clearPersistenceContext(); + return chatMessage; + } + + private void addRoomMember(ChatRoom chatRoom, User user) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); + } + + private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { + Club club = persist(ClubFixture.create(university, clubName)); + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); + + persist(ClubMember.builder() + .club(managedClub) + .user(managedNormalUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + + ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); + addRoomMember(groupRoom, normalUser); + + for (int index = 1; index <= count; index++) { + User candidate = createUser( + String.format("%s%02d", namePrefix, index), + String.format("202199%04d", index + count * 10) + ); + User managedCandidate = entityManager.getReference(User.class, candidate.getId()); + persist(ClubMember.builder() + .club(managedClub) + .user(managedCandidate) + .clubPosition(ClubPosition.MEMBER) + .build()); + addRoomMember(groupRoom, candidate); + } + } + + private long countDirectRoomsBetween(User firstUser, User secondUser) { + return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() + .map(ChatRoom::getId) + .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) + .count(); + } + + private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { + List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); + return roomMembers.size() == 2 + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) + && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); + } + @Nested @DisplayName("POST /chats/rooms - 일반 채팅방 생성") class CreateDirectChatRoom { @@ -159,7 +279,7 @@ class AdminChatRoom { @BeforeEach void setUpAdminChatFixture() { - adminUser = persist(UserFixture.createAdmin(university)); + // System Admin(ID=1)은 이미 setUp()에서 생성됨 clearPersistenceContext(); } @@ -174,14 +294,91 @@ void createAdminChatRoomAndGetRoomsSuccess() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.chatRoomId").isNumber()); - // then + // then - 일반 사용자 관점에서 채팅방이 목록에 보임 performGet("/chats/rooms") .andExpect(status().isOk()) .andExpect(jsonPath("$.rooms[0].chatType").value("DIRECT")) - .andExpect(jsonPath("$.rooms[0].roomName").value(adminUser.getName())) + .andExpect(jsonPath("$.rooms[0].roomName").exists()) .andExpect(jsonPath("$.rooms[0].lastMessage").doesNotExist()) .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); } + + @Test + @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @Transactional(propagation = Propagation.REQUIRES_NEW) + void adminLeftInquiryRoomReappearsWhenUserSendsNewMessage() throws Exception { + // given - 문의 채팅방 생성 (일반 사용자 -> system admin) + mockLoginUser(normalUser.getId()); + var createResult = performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn(); + int chatRoomId = parseChatRoomId(createResult); + + // 사용자가 메시지 전송 (목록에 노출되기 위한 조건) + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("첫 문의 메시지입니다")) + .andExpect(status().isOk()); + + // system admin(ID=1)이 목록에서 방을 확인 + mockLoginUser(SYSTEM_ADMIN_ID); + var adminRoomsBefore = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsBefore)).contains(chatRoomId); + + // system admin(ID=1)이 문의 채팅방 나가기 + performDelete("/chats/rooms/" + chatRoomId) + .andExpect(status().isNoContent()); + + // when - system admin이 목록 조회하면 나간 방은 안 보임 + var adminRoomsAfterLeave = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsAfterLeave)).doesNotContain(chatRoomId); + + // 사용자가 다시 메시지 전송 + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("추가 문의 메시지입니다")) + .andExpect(status().isOk()); + + // lastMessageSentAt 강제 업데이트 (테스트 트랜잭션 롤백으로 인한 workaround) + entityManager.createNativeQuery( + "UPDATE chat_room SET last_message_sent_at = CURRENT_TIMESTAMP WHERE id = ?" + ).setParameter(1, chatRoomId).executeUpdate(); + entityManager.flush(); + + // then - system admin이 목록 조회하면 다시 보임 + mockLoginUser(SYSTEM_ADMIN_ID); + var adminRoomsAfterNewMessage = performGet("/chats/rooms") + .andExpect(status().isOk()) + .andReturn(); + assertThat(extractRoomIds(adminRoomsAfterNewMessage)).contains(chatRoomId); + } + + private int parseChatRoomId(org.springframework.test.web.servlet.MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + return objectMapper.readTree(responseBody).get("chatRoomId").asInt(); + } + + private List extractRoomIds(org.springframework.test.web.servlet.MvcResult result) throws Exception { + String responseBody = result.getResponse().getContentAsString(); + com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(responseBody); + com.fasterxml.jackson.databind.JsonNode rooms = root.get("rooms"); + List roomIds = new java.util.ArrayList<>(); + if (rooms != null && rooms.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode room : rooms) { + // roomId 또는 chatRoomId 필드 확인 + com.fasterxml.jackson.databind.JsonNode roomIdNode = + room.has("chatRoomId") ? room.get("chatRoomId") : room.get("roomId"); + if (roomIdNode != null) { + roomIds.add(roomIdNode.asInt()); + } + } + } + return roomIds; + } } @Nested @@ -1449,106 +1646,4 @@ void kickFailsAfterOwnerLeaves() throws Exception { .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); } } - - private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { - ChatRoom chatRoom = persist(ChatRoom.directOf()); - LocalDateTime joinedAt = chatRoom.getCreatedAt(); - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedFirstUser = entityManager.getReference(User.class, firstUser.getId()); - User managedSecondUser = entityManager.getReference(User.class, secondUser.getId()); - - persist(ChatRoomMember.of(managedChatRoom, managedFirstUser, joinedAt)); - persist(ChatRoomMember.of(managedChatRoom, managedSecondUser, joinedAt)); - clearPersistenceContext(); - return chatRoom; - } - - private ChatRoom createGroupChatRoomWithOwner(User owner, User... members) { - ChatRoom groupRoom = persist(ChatRoom.groupOf()); - ChatRoom managedRoom = entityManager.getReference(ChatRoom.class, groupRoom.getId()); - User managedOwner = entityManager.getReference(User.class, owner.getId()); - persist(ChatRoomMember.ofOwner(managedRoom, managedOwner, groupRoom.getCreatedAt())); - for (User member : members) { - User managedMember = entityManager.getReference(User.class, member.getId()); - persist(ChatRoomMember.of(managedRoom, managedMember, groupRoom.getCreatedAt())); - } - clearPersistenceContext(); - return groupRoom; - } - - private User createUser(String name, String studentId) { - return persist(UserFixture.createUser(university, name, studentId)); - } - - private ClubMember createClubMember(Club club, User user) { - Club managedClub = entityManager.getReference(Club.class, club.getId()); - User managedUser = entityManager.getReference(User.class, user.getId()); - ClubMember clubMember = persist(ClubMember.builder() - .club(managedClub) - .user(managedUser) - .clubPosition(ClubPosition.MEMBER) - .build()); - clearPersistenceContext(); - return clubMember; - } - - private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedSender = entityManager.getReference(User.class, sender.getId()); - - ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); - managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - entityManager.flush(); - clearPersistenceContext(); - return chatMessage; - } - - private void addRoomMember(ChatRoom chatRoom, User user) { - ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); - User managedUser = entityManager.getReference(User.class, user.getId()); - persist(ChatRoomMember.of(managedChatRoom, managedUser, chatRoom.getCreatedAt())); - } - - private void createGroupedInviteCandidates(String clubName, String namePrefix, int count) { - Club club = persist(ClubFixture.create(university, clubName)); - Club managedClub = entityManager.getReference(Club.class, club.getId()); - User managedNormalUser = entityManager.getReference(User.class, normalUser.getId()); - - persist(ClubMember.builder() - .club(managedClub) - .user(managedNormalUser) - .clubPosition(ClubPosition.MEMBER) - .build()); - - ChatRoom groupRoom = persist(ChatRoom.clubGroupOf(club)); - addRoomMember(groupRoom, normalUser); - - for (int index = 1; index <= count; index++) { - User candidate = createUser( - String.format("%s%02d", namePrefix, index), - String.format("202199%04d", index + count * 10) - ); - User managedCandidate = entityManager.getReference(User.class, candidate.getId()); - persist(ClubMember.builder() - .club(managedClub) - .user(managedCandidate) - .clubPosition(ClubPosition.MEMBER) - .build()); - addRoomMember(groupRoom, candidate); - } - } - - private long countDirectRoomsBetween(User firstUser, User secondUser) { - return chatRoomRepository.findByUserId(firstUser.getId(), ChatType.DIRECT).stream() - .map(ChatRoom::getId) - .filter(roomId -> isDirectRoomBetween(roomId, firstUser.getId(), secondUser.getId())) - .count(); - } - - private boolean isDirectRoomBetween(Integer roomId, Integer firstUserId, Integer secondUserId) { - List roomMembers = chatRoomMemberRepository.findByChatRoomId(roomId); - return roomMembers.size() == 2 - && roomMembers.stream().anyMatch(member -> member.getUserId().equals(firstUserId)) - && roomMembers.stream().anyMatch(member -> member.getUserId().equals(secondUserId)); - } } From 37ae494f9b74f7bc95a6876aa2b99f3f9161ab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:12:03 +0900 Subject: [PATCH 06/50] =?UTF-8?q?fix:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EA=B1=B0=EC=A0=88=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=9D=84=20AFTER=5FCOMMIT=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=20?= =?UTF-8?q?(#500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 동아리 지원 거절 알림을 AFTER_COMMIT 이벤트 기반으로 변경 거절 알림이 트랜잭션 커밋 전에 비동기로 발송되어 롤백 시에도 알림이 전송될 수 있는 문제 해결. - ClubApplicationRejectedEvent 신규 생성 - ClubApplicationNotificationListener에 AFTER_COMMIT 핸들러 추가 - ClubApplicationService에서 직접 호출 대신 이벤트 발행으로 변경 * chore: 코드 포맷팅 --- .../club/event/ClubApplicationRejectedEvent.java | 11 +++++++++++ .../domain/club/service/ClubApplicationService.java | 8 ++++---- .../listener/ClubApplicationNotificationListener.java | 10 ++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java new file mode 100644 index 000000000..6e0b729da --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubApplicationRejectedEvent.java @@ -0,0 +1,11 @@ +package gg.agit.konect.domain.club.event; + +public record ClubApplicationRejectedEvent( + Integer receiverId, + Integer clubId, + String clubName +) { + public static ClubApplicationRejectedEvent of(Integer receiverId, Integer clubId, String clubName) { + return new ClubApplicationRejectedEvent(receiverId, clubId, clubName); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index d282d17ac..44952ec19 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -3,6 +3,7 @@ import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; import gg.agit.konect.domain.club.enums.ClubPosition; + import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -32,6 +33,7 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; @@ -47,7 +49,6 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.domain.notification.service.NotificationService; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -69,7 +70,6 @@ public class ClubApplicationService { private final BankRepository bankRepository; private final ClubPermissionValidator clubPermissionValidator; private final ApplicationEventPublisher applicationEventPublisher; - private final NotificationService notificationService; private final ChatRoomMembershipService chatRoomMembershipService; public ClubAppliedClubsResponse getAppliedClubs(Integer userId) { @@ -265,11 +265,11 @@ public void rejectClubApplication(Integer clubId, Integer applicationId, Integer User applicant = clubApply.getUser(); clubApply.reject(); - notificationService.sendClubApplicationRejectedNotification( + applicationEventPublisher.publishEvent(ClubApplicationRejectedEvent.of( applicant.getId(), clubId, club.getName() - ); + )); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java index 43143483d..dfe713c92 100644 --- a/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java +++ b/src/main/java/gg/agit/konect/domain/notification/listener/ClubApplicationNotificationListener.java @@ -6,6 +6,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; import gg.agit.konect.domain.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -37,4 +38,13 @@ public void handleClubApplicationSubmitted(ClubApplicationSubmittedEvent event) ) ); } + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubApplicationRejected(ClubApplicationRejectedEvent event) { + notificationService.sendClubApplicationRejectedNotification( + event.receiverId(), + event.clubId(), + event.clubName() + ); + } } From 3398919d3024dd8efa6134dc2da450860aae9581 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:36:11 +0900 Subject: [PATCH 07/50] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9C=BC=EB=A1=9C=EB=8F=84=20=EB=B6=80?= =?UTF-8?q?=EC=9B=90=20=EC=A7=81=EC=B1=85=20=EB=B3=80=EA=B2=BD=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 관리자 멤버 권한 변경 회귀 테스트 추가 * fix: 관리자 계정의 부원 권한 변경 허용 * refactor: 관리자 권한 변경 요청의 사용자 조회 재사용 --- .../service/ClubMemberManagementService.java | 21 ++++++++++++------- .../club/service/ClubPermissionValidator.java | 9 +++++++- .../domain/club/ClubMemberApiTest.java | 17 +++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 5a83be744..3b0449201 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -54,19 +54,24 @@ public ClubMember changeMemberPosition( validateNotSelf(requesterId, targetUserId, CANNOT_CHANGE_OWN_POSITION); - clubPermissionValidator.validateLeaderAccess(clubId, requesterId); + User requesterUser = userRepository.getById(requesterId); + clubPermissionValidator.validateLeaderAccess(clubId, requesterUser); - ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + boolean isAdminRequester = requesterUser.isAdmin(); ClubMember target = clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId); - if (!requester.canManage(target)) { - throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); - } - ClubPosition newPosition = request.position(); - if (!requester.getClubPosition().canManage(newPosition)) { - throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + if (!isAdminRequester) { + ClubMember requester = clubMemberRepository.getByClubIdAndUserId(clubId, requesterId); + + if (!requester.canManage(target)) { + throw CustomException.of(CANNOT_MANAGE_HIGHER_POSITION); + } + + if (!requester.getClubPosition().canManage(newPosition)) { + throw CustomException.of(FORBIDDEN_MEMBER_POSITION_CHANGE); + } } validatePositionLimit(clubId, newPosition, target); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java index 2a84da2ac..aa3c34918 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java @@ -9,6 +9,7 @@ import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -31,7 +32,13 @@ public void validatePresidentAccess(Integer clubId, Integer userId) { } public void validateLeaderAccess(Integer clubId, Integer userId) { - if (isAdmin(userId)) { + validateLeaderAccess(clubId, userRepository.getById(userId)); + } + + public void validateLeaderAccess(Integer clubId, User user) { + Integer userId = user.getId(); + + if (user.isAdmin()) { return ; } diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java index 77f19ce6c..1889b4e47 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java @@ -38,6 +38,7 @@ class ClubMemberApiTest extends IntegrationTestSupport { private User vicePresident; private User manager; private User member; + private User admin; @BeforeEach void setUp() throws Exception { @@ -47,6 +48,7 @@ void setUp() throws Exception { vicePresident = persist(UserFixture.createUser(university, "부회장", "2020000002")); manager = persist(UserFixture.createUser(university, "매니저", "2020000003")); member = persist(UserFixture.createUser(university, "일반멤버", "2021136001")); + admin = persist(UserFixture.createAdmin(university)); persist(ClubMemberFixture.createPresident(club, president)); persist(ClubMemberFixture.createVicePresident(club, vicePresident)); @@ -73,6 +75,21 @@ void changeMemberToManagerByPresident() throws Exception { .andExpect(jsonPath("$.position").value("MANAGER")); } + @Test + @DisplayName("관리자는 동아리 회원이 아니어도 멤버 직책을 변경할 수 있다") + void adminCanChangeMemberPositionWithoutClubMembership() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(admin.getId()); + + MemberPositionChangeRequest request = new MemberPositionChangeRequest(ClubPosition.MANAGER); + + // when & then + performPatch("/clubs/" + club.getId() + "/members/" + member.getId() + "/position", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.position").value("MANAGER")); + } + @Test @DisplayName("일반 멤버가 직책 변경을 시도하면 403을 반환한다") void changeMemberPositionByMemberFails() throws Exception { From 0706ba3f278532aaa650e1664f9cd6eb924d1bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:03:18 +0900 Subject: [PATCH 08/50] =?UTF-8?q?refactor:=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20?= =?UTF-8?q?Lazy=20=EC=83=9D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 동아리 채팅방 멤버십 Lazy 생성으로 변경 ensureClubRoomMemberships()의 REQUIRES_NEW 트랜잭션이 매 API 호출마다 실행되어 커넥션 풀을 오래 점유하는 문제 해결 - 동아리 가입 시점에 이미 addClubMember()로 멤버십 생성됨 - 채팅방 목록 조회 시 ensureClubRoomMemberships() 호출 제거 - 사용하지 않는 ensureClubRoomMemberships() 및 resolveOrCreateClubRooms() 제거 * chore: 누락된 chat_room_member 데이터 마이그레이션 추가 과거 버그로 인해 club_member는 있지만 chat_room_member가 없는 데이터를 복구하는 Flyway 마이그레이션 추가 - club_member.created_at을 last_read_at으로 설정 --- .../service/ChatRoomMembershipService.java | 81 ------------------- .../domain/chat/service/ChatService.java | 4 - ...67__backfill_missing_chat_room_members.sql | 23 ++++++ 3 files changed, 23 insertions(+), 85 deletions(-) create mode 100644 src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 3f285189e..3600ab216 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -4,12 +4,8 @@ import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DuplicateKeyException; @@ -64,50 +60,6 @@ public void removeClubMember(Integer clubId, Integer userId) { .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void ensureClubRoomMemberships(Integer userId) { - List memberships = clubMemberRepository.findAllByUserId(userId); - if (memberships.isEmpty()) { - return; - } - - Map membershipByClubId = memberships.stream() - .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); - - List rooms = resolveOrCreateClubRooms(memberships).stream() - .sorted(Comparator.comparing(ChatRoom::getId)) - .toList(); - List roomIds = rooms.stream().map(ChatRoom::getId).toList(); - if (roomIds.isEmpty()) { - return; - } - - Map memberByRoomId = chatRoomMemberRepository - .findByChatRoomIdsAndUserId(roomIds, userId) - .stream() - .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); - - for (ChatRoom room : rooms) { - ClubMember member = membershipByClubId.get(room.getClub().getId()); - if (member == null) { - continue; - } - - ChatRoomMember existingMember = memberByRoomId.get(room.getId()); - if (existingMember != null) { - LocalDateTime lastReadAt = existingMember.getLastReadAt(); - if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { - chatRoomMemberRepository.updateLastReadAtIfOlder( - room.getId(), userId, member.getCreatedAt() - ); - } - continue; - } - - saveRoomMemberIgnoringDuplicate(room, member.getUser(), member.getCreatedAt()); - } - } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); @@ -166,39 +118,6 @@ private ChatRoom findOrCreateClubRoom(Club club) { }); } - private List resolveOrCreateClubRooms(List memberships) { - Map clubById = memberships.stream() - .map(ClubMember::getClub) - .collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a)); - - Map roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet())) - .stream() - .filter(room -> room.getClub() != null) - .collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a)); - - for (Map.Entry clubEntry : clubById.entrySet()) { - if (roomByClubId.containsKey(clubEntry.getKey())) { - continue; - } - try { - ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.clubGroupOf(clubEntry.getValue())); - roomByClubId.put(clubEntry.getKey(), createdRoom); - } catch (DataIntegrityViolationException e) { - if (!isDuplicateKeyException(e)) { - throw e; - } - log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", clubEntry.getKey()); - chatRoomRepository.findByClubId(clubEntry.getKey()) - .ifPresent(room -> roomByClubId.put(clubEntry.getKey(), room)); - } - } - - return memberships.stream() - .map(membership -> roomByClubId.get(membership.getClub().getId())) - .filter(Objects::nonNull) - .toList(); - } - private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) { chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index fd4991f41..e8a18af98 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -209,8 +209,6 @@ public void kickMember(Integer requesterId, Integer roomId, Integer targetUserId } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); List groupRooms = getGroupChatRooms(userId); @@ -866,8 +864,6 @@ private ChatMessageDetailResponse sendGroupMessageByRoomId(Integer roomId, Integ } private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - List directRooms = getDirectChatRooms(userId); List clubRooms = getClubChatRooms(userId); diff --git a/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql b/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql new file mode 100644 index 000000000..dfaf379ef --- /dev/null +++ b/src/main/resources/db/migration/V67__backfill_missing_chat_room_members.sql @@ -0,0 +1,23 @@ +-- 누락된 chat_room_member 데이터를 복구합니다. +-- 과거 버그로 인해 club_member는 있지만 chat_room_member가 없는 경우를 복구합니다. +-- last_read_at은 동아리 가입 시점(club_member.created_at)으로 설정합니다. + +INSERT INTO chat_room_member ( + chat_room_id, + user_id, + last_read_at, + created_at, + updated_at +) +SELECT + cr.id AS chat_room_id, + cm.user_id AS user_id, + cm.created_at AS last_read_at, + NOW() AS created_at, + NOW() AS updated_at +FROM club_member cm +JOIN chat_room cr ON cr.club_id = cm.club_id +LEFT JOIN chat_room_member crm + ON crm.chat_room_id = cr.id + AND crm.user_id = cm.user_id +WHERE crm.chat_room_id IS NULL; From 0c3de0b97bc68983712894161d8a760f935e6da0 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:51:43 +0900 Subject: [PATCH 09/50] =?UTF-8?q?refactor:=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 시트 접근 권한 제거 * fix: InOrder 검증 추가 --- .../service/ClubSheetIntegratedService.java | 7 +- .../service/GoogleSheetPermissionService.java | 8 +- .../ClubSheetIntegratedServiceTest.java | 85 +++++++++---------- .../GoogleSheetPermissionServiceTest.java | 13 ++- 4 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java index 46475328f..1816ef2e5 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedService.java @@ -23,12 +23,7 @@ public SheetImportResponse analyzeAndImportPreMembers( clubPermissionValidator.validateManagerAccess(clubId, requesterId); String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - // integrated 등록은 요청자 Google Drive OAuth 연결을 전제로 한다. - // 연결된 계정이 실제 시트 접근 권한을 가지는지 검증한 뒤 서비스 계정 권한을 맞춘다. - googleSheetPermissionService.validateRequesterAccessAndTryGrantServiceAccountWriterAccess( - requesterId, - spreadsheetId - ); + googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); diff --git a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java index dd2cca0b2..cfa96d70f 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionService.java @@ -45,7 +45,7 @@ public boolean tryGrantServiceAccountWriterAccess(Integer requesterId, String sp String refreshToken = resolveRefreshToken(requesterId); if (refreshToken == null) { - log.warn( + log.info( "Skipping service account auto-share because Google Drive OAuth is not connected. requesterId={}", requesterId ); @@ -91,14 +91,14 @@ private boolean tryGrantServiceAccountWriterAccess( return true; } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isInvalidGrant(e)) { - log.warn( - "Google Drive OAuth token is invalid while auto-sharing spreadsheet. requesterId={}, " + log.info( + "Skipping service account auto-share because Google Drive OAuth token is invalid. requesterId={}, " + "spreadsheetId={}, cause={}", requesterId, spreadsheetId, GoogleSheetApiExceptionHelper.extractDetail(e) ); - throw GoogleSheetApiExceptionHelper.invalidGoogleDriveAuth(e); + return false; } if (GoogleSheetApiExceptionHelper.isAccessDenied(e) diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java index 7d4727b13..e8b3ae3c0 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubSheetIntegratedServiceTest.java @@ -44,7 +44,6 @@ class ClubSheetIntegratedServiceTest extends ServiceTestSupport { @Test @DisplayName("시트 분석 등록 후 사전 회원 가져오기를 순서대로 실행한다") void analyzeAndImportPreMembersSuccess() { - // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = @@ -60,17 +59,14 @@ void analyzeAndImportPreMembersSuccess() { requesterId, spreadsheetId, analysis.memberListMapping() - )) - .willReturn(expected); + )).willReturn(expected); - // when SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl ); - // then InOrder inOrder = inOrder( clubPermissionValidator, googleSheetPermissionService, @@ -80,7 +76,7 @@ void analyzeAndImportPreMembersSuccess() { ); inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); inOrder.verify(googleSheetPermissionService) - .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); inOrder.verify(clubMemberSheetService).updateSheetId( clubId, @@ -100,7 +96,6 @@ void analyzeAndImportPreMembersSuccess() { @Test @DisplayName("자동 권한 부여 중 예외가 발생하면 후속 시트 작업을 진행하지 않는다") void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { - // given Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = @@ -109,63 +104,67 @@ void analyzeAndImportPreMembersStopsWhenAutoGrantThrowsException() { CustomException expected = CustomException.of(ApiResponseCode.FAILED_INIT_GOOGLE_DRIVE); willThrow(expected).given(googleSheetPermissionService) - .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); - // when & then assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl - )) - .isSameAs(expected); + )).isSameAs(expected); verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); } @Test - @DisplayName("요청자 계정이 시트 접근 권한이 없으면 후속 시트 작업을 진행하지 않는다") - void analyzeAndImportPreMembersStopsWhenRequesterHasNoSpreadsheetAccess() { - // given + @DisplayName("요청자 Drive OAuth가 없어도 시트 분석과 가져오기를 계속 진행한다") + void analyzeAndImportPreMembersContinuesWhenGoogleDriveOAuthIsNotConnected() { Integer clubId = 1; Integer requesterId = 2; String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; - CustomException expected = CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS); + SheetHeaderMapper.SheetAnalysisResult analysis = + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + SheetImportResponse expected = SheetImportResponse.of(2, 0, List.of()); - willThrow(expected).given(googleSheetPermissionService) - .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + given(googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId)) + .willReturn(false); + given(sheetHeaderMapper.analyzeAllSheets(spreadsheetId)).willReturn(analysis); + given(sheetImportService.importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + )).willReturn(expected); - // when & then - assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + SheetImportResponse actual = clubSheetIntegratedService.analyzeAndImportPreMembers( clubId, requesterId, spreadsheetUrl - )) - .isSameAs(expected); - verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); - } - - @Test - @DisplayName("Drive OAuth가 연결되지 않으면 후속 시트 작업을 진행하지 않는다") - void analyzeAndImportPreMembersStopsWhenGoogleDriveOAuthIsNotConnected() { - // given - Integer clubId = 1; - Integer requesterId = 2; - String spreadsheetUrl = - "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit"; - String spreadsheetId = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"; - CustomException expected = CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH); - - willThrow(expected).given(googleSheetPermissionService) - .validateRequesterAccessAndTryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + ); - // when & then - assertThatThrownBy(() -> clubSheetIntegratedService.analyzeAndImportPreMembers( + InOrder inOrder = inOrder( + clubPermissionValidator, + googleSheetPermissionService, + sheetHeaderMapper, + clubMemberSheetService, + sheetImportService + ); + inOrder.verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + inOrder.verify(googleSheetPermissionService) + .tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId); + inOrder.verify(sheetHeaderMapper).analyzeAllSheets(spreadsheetId); + inOrder.verify(clubMemberSheetService).updateSheetId( clubId, requesterId, - spreadsheetUrl - )) - .isSameAs(expected); - verifyNoInteractions(sheetHeaderMapper, clubMemberSheetService, sheetImportService); + spreadsheetId, + analysis + ); + inOrder.verify(sheetImportService).importPreMembersFromSheet( + clubId, + requesterId, + spreadsheetId, + analysis.memberListMapping() + ); + assertThat(actual).isEqualTo(expected); } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java index e75e08347..d5f1d8259 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -261,8 +261,8 @@ void tryGrantServiceAccountWriterAccessReturnsFalseWhenAccessIsDenied() } @Test - @DisplayName("throws a bad request custom exception when Google returns invalid_grant") - void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() + @DisplayName("returns false when Google returns invalid_grant") + void tryGrantServiceAccountWriterAccessReturnsFalseWhenInvalidGrantOccurs() throws IOException, GeneralSecurityException { mockConnectedDriveAccount(); given(permissions.list(FILE_ID)).willReturn(listRequest); @@ -277,13 +277,12 @@ void tryGrantServiceAccountWriterAccessThrowsWhenInvalidGrantOccurs() ) )); - assertThatThrownBy(() -> googleSheetPermissionService.tryGrantServiceAccountWriterAccess( + boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess( REQUESTER_ID, FILE_ID - )) - .isInstanceOf(CustomException.class) - .extracting("errorCode") - .isEqualTo(ApiResponseCode.INVALID_GOOGLE_DRIVE_AUTH); + ); + + assertThat(granted).isFalse(); } @Test From 5814a63fc4cf713e7068f902dde2ffbeaf2b1f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:18:10 +0900 Subject: [PATCH 10/50] =?UTF-8?q?fix:=20=EB=AC=B8=EC=9D=98=ED=95=98?= =?UTF-8?q?=EA=B8=B0=EC=97=90=EC=84=9C=20=EA=B8=B0=EC=A1=B4=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EC=83=88=20=EC=B1=84=ED=8C=85=EB=B0=A9=EC=9D=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EA=B2=B0=20(#504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 어드민이 문의 채팅방에 멤버로 추가되어 중복 생성되는 문제 해결 - ensureDirectRoomMemberExists에서 어드민이 SYSTEM_ADMIN 방에 멤버로 추가되지 않도록 수정 - findByTwoUsers는 채팅방 멤버가 정확히 2명인 경우만 찾음 - 어드민이 멤버로 추가되면 멤버가 3명이 되어 기존 방을 찾지 못하고 새 방이 생성됨 - 어드민은 메시지 조회/전송 권한만 허용하고 SYSTEM_ADMIN의 lastReadAt을 업데이트 - toggleMute에서도 어드민이 SYSTEM_ADMIN 방에 접근 가능하도록 수정 - 기존 데이터 정리용 마이그레이션 추가 (V68) * chore: 코드 포맷팅 * fix: SYSTEM_ADMIN 문의방 재사용을 위해 관리자 멤버 재추가를 차단 * fix: 문의방 관리자 답변 알림이 실제 사용자에게 전달되도록 수정 * fix: 관리자 문의방 조회가 SYSTEM_ADMIN 멤버 기준으로 동작하도록 분리 * fix: 관리자 문의 채팅방 조회와 읽음 처리를 SYSTEM_ADMIN 기준으로 분리 - direct 메시지 조회에서 관리자+SYSTEM_ADMIN 방이면 전용 조회 경로로 분기 - 관리자 문의방 조회 시 관리자 개인 멤버십을 만들지 않고 SYSTEM_ADMIN 멤버 기준으로 visibleMessageFrom과 read baseline 계산 - direct lastReadAt 갱신이 read-only 조회 트랜잭션에 합류하지 않도록 REQUIRES_NEW로 분리 - 관리자 문의방 조회 시 SYSTEM_ADMIN의 lastReadAt만 갱신하고 일반 direct 방은 기존 멤버십 검사 흐름 유지 - 읽기 권한과 멤버십 생성 정책이 충돌하지 않도록 조회 경로와 수정 경로의 책임을 분리 * chore: 코드 포맷팅 --- .../service/ChatRoomMembershipService.java | 30 ++-- .../domain/chat/service/ChatService.java | 145 +++++++++++++++--- ..._admin_members_from_system_admin_rooms.sql | 18 +++ .../integration/domain/chat/ChatApiTest.java | 88 +++++++++++ 4 files changed, 241 insertions(+), 40 deletions(-) create mode 100644 src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 3600ab216..8b94b854e 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -66,29 +66,16 @@ public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readA } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { - User user = userRepository.getById(userId); - ChatRoom room = chatRoomRepository.findById(roomId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { + // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 + if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(roomId)) { + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); + return; + } ensureDirectRoomMemberExists(room, user, readAt); - if (user.getRole() == UserRole.ADMIN) { - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - boolean isSystemAdmin = members.stream() - .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); - - if (isSystemAdmin) { - for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); - } - } - return; - } - } - - chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); + chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, user.getId(), readAt); } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -145,8 +132,9 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim return; } + // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 + // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { - saveRoomMemberIgnoringDuplicate(room, user, readAt); return; } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index e8a18af98..794c114ae 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -19,7 +19,6 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -388,15 +387,23 @@ record SectionKey(Integer clubId, String clubName) { return ChatInvitableUsersResponse.forClubSort(pagedInvitableUsers, sections); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional(readOnly = true) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + User user = userRepository.getById(userId); LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, userId, readAt); + boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + if (isAdminViewingSystemRoom) { + chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); + recordPresenceSafely(roomId, userId); + return getAdminSystemDirectChatRoomMessages(user, room, roomId, page, limit, readAt); + } + + chatRoomMembershipService.updateDirectRoomLastReadAt(roomId, user, readAt, room); recordPresenceSafely(roomId, userId); return getDirectChatRoomMessages(userId, roomId, page, limit, readAt); } @@ -440,7 +447,12 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else if (room.isDirectRoom()) { - getAccessibleDirectRoomMember(room, user); + // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 + boolean isAdminAccessingSystemAdminRoom = user.getRole() == UserRole.ADMIN + && isSystemAdminRoom(room); + if (!isAdminAccessingSystemAdminRoom) { + getAccessibleDirectRoomMember(room, user); + } } else { getAccessibleRoomMember(room, userId); } @@ -617,24 +629,63 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ) { ChatRoom chatRoom = getDirectRoom(roomId); User user = userRepository.getById(userId); - ChatRoomMember member = getOrCreateDirectRoomMember(chatRoom, user); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(member, chatRoom); - - boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), + chatRoom); PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List sortedReadBaselines = toSortedReadBaselines(members); + + List responseMessages = messages.getContent().stream() + .map(message -> { + boolean isRead = message.isSentBy(userId) || !message.getCreatedAt().isAfter(readAt); + int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); + return new ChatMessageDetailResponse( + message.getId(), + message.getSender().getId(), + null, + message.getContent(), + message.getCreatedAt(), + isRead, + unreadCount, + message.isSentBy(userId) + ); + }) + .toList(); + + return new ChatMessagePageResponse( + messages.getTotalElements(), + messages.getNumberOfElements(), + messages.getTotalPages(), + messages.getNumber() + 1, + null, + responseMessages + ); + } + + private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( + User user, + ChatRoom chatRoom, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt + ) { List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); - List sortedReadBaselines = isAdminViewingSystemRoom - ? toAdminChatReadBaselines(members) - : toSortedReadBaselines(members); + PageRequest pageable = PageRequest.of(page - 1, limit); + Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); + + List sortedReadBaselines = toAdminChatReadBaselines(members); Integer maskedAdminId = getMaskedAdminId(user, chatRoom); List responseMessages = messages.getContent().stream() .map(message -> { Integer senderId = resolveDirectSenderId(message, maskedAdminId); - boolean isMine = shouldDisplayAsOwnMessage(user, message, isAdminViewingSystemRoom); + boolean isMine = shouldDisplayAsOwnMessage(user, message, true); boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); return new ChatMessageDetailResponse( @@ -667,19 +718,37 @@ private ChatMessageDetailResponse sendDirectMessage( ) { ChatRoom chatRoom = getDirectRoom(roomId); User sender = userRepository.getById(userId); - ChatRoomMember senderMember = getAccessibleDirectRoomMember(chatRoom, sender); - boolean senderHadLeft = senderMember.hasLeft(); + + // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 + boolean isAdminSendingToSystemAdminRoom = sender.getRole() == UserRole.ADMIN + && isSystemAdminRoom(chatRoom); + + ChatRoomMember senderMember = null; + boolean senderHadLeft = false; + + if (!isAdminSendingToSystemAdminRoom) { + senderMember = getAccessibleDirectRoomMember(chatRoom, sender); + senderHadLeft = senderMember.hasLeft(); + } + List members = chatRoomMemberRepository.findByChatRoomId(roomId); - User receiver = resolveDirectChatPartner(members, userId); + User receiver = resolveDirectMessageReceiver(members, sender); ChatMessage chatMessage = chatMessageRepository.save( ChatMessage.of(chatRoom, sender, request.content()) ); - if (senderHadLeft) { + + if (senderHadLeft && senderMember != null) { senderMember.restoreDirectRoom(); } + chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); - updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + + // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) + if (!isAdminSendingToSystemAdminRoom) { + updateMemberLastReadAt(roomId, userId, chatMessage.getCreatedAt()); + } + List sortedReadBaselines = toSortedReadBaselines(members); notificationService.sendChatNotification(receiver.getId(), roomId, sender.getName(), request.content()); @@ -1170,6 +1239,10 @@ private void ensureRoomMember(ChatRoom room, User user, LocalDateTime joinedAt) } private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime joinedAt) { + if (shouldSkipSystemAdminMembership(room, user)) { + return; + } + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) .ifPresentOrElse(member -> { if (member.hasLeft()) { @@ -1184,6 +1257,12 @@ private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime j }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, joinedAt))); } + private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { + // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, + // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. + return user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + } + private String normalizeCustomRoomName(String roomName) { if (!StringUtils.hasText(roomName)) { return null; @@ -1303,9 +1382,9 @@ private Map getRoomUnreadCountMap(List roomIds, Integ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) { return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) .orElseGet(() -> { + // 어드민은 SYSTEM_ADMIN 방에 멤버로 추가되지 않음 if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom)) { - LocalDateTime joinedAt = LocalDateTime.now(); - return chatRoomMemberRepository.save(ChatRoomMember.of(chatRoom, user, joinedAt)); + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); }); @@ -1323,6 +1402,11 @@ private LocalDateTime prepareDirectRoomAccess(ChatRoomMember member, ChatRoom ch return visibleMessageFrom; } + private LocalDateTime resolveAdminSystemRoomVisibleMessageFrom(List members) { + ChatRoomMember systemAdminMember = findRoomMember(members, SYSTEM_ADMIN_ID); + return systemAdminMember != null ? systemAdminMember.getVisibleMessageFrom() : null; + } + /** * direct 채팅방에서 나간 사용자가 다시 볼 수 있는 상태인지 확인하고, * 새 메시지가 이미 존재하면 나간 상태를 해제한다. @@ -1446,6 +1530,29 @@ private User resolveDirectChatPartner(List members, Integer user return findDirectPartner(members, userId); } + private User findNonAdminUser(List members) { + return members.stream() + .map(ChatRoomMember::getUser) + .filter(memberUser -> memberUser.getRole() != UserRole.ADMIN) + .findFirst() + .orElse(null); + } + + private User resolveDirectMessageReceiver(List members, User sender) { + if (sender.getRole() == UserRole.ADMIN) { + User nonAdminUser = findNonAdminUser(members); + if (nonAdminUser != null) { + return nonAdminUser; + } + } + + User partner = resolveDirectChatPartner(members, sender.getId()); + if (partner == null) { + throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); + } + return partner; + } + private User findDirectPartnerFromMemberInfo( List memberInfos, Integer userId, diff --git a/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql new file mode 100644 index 000000000..3ff8619f9 --- /dev/null +++ b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql @@ -0,0 +1,18 @@ +-- SYSTEM_ADMIN(1번)이 있는 DIRECT 채팅방에서 다른 어드민 멤버십 제거 +-- 이유: 어드민이 멤버로 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 중복 생성됨 +-- 참고: https://github.com/BCSDLab/KONECT_BACK_END/issues/503 + +DELETE FROM chat_room_member +WHERE user_id IN ( + SELECT u.id + FROM users u + WHERE u.role = 'ADMIN' + AND u.id != 1 -- SYSTEM_ADMIN(1번)은 제외 +) +AND chat_room_id IN ( + SELECT DISTINCT crm.chat_room_id + FROM chat_room_member crm + JOIN chat_room cr ON crm.chat_room_id = cr.id + WHERE crm.user_id = 1 -- SYSTEM_ADMIN(1번)이 있는 방 + AND cr.room_type = 'DIRECT' -- DIRECT 타입 방만 +); diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index bcc14fb71..d34f8ffdb 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -1,6 +1,8 @@ package gg.agit.konect.integration.domain.chat; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -303,6 +305,67 @@ void createAdminChatRoomAndGetRoomsSuccess() throws Exception { .andExpect(jsonPath("$.rooms[0].isMuted").value(false)); } + @Test + @DisplayName("관리자가 문의방을 다시 열어도 관리자 멤버는 추가되지 않는다") + void adminCreateOrGetInquiryRoomDoesNotAddAdminMember() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + var createResult = performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn(); + + int chatRoomId = parseChatRoomId(createResult); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms", new ChatRoomCreateRequest(normalUser.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.chatRoomId").value(chatRoomId)); + + clearPersistenceContext(); + + assertThat(chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, normalUser.getId(), ChatType.DIRECT)) + .isPresent() + .get() + .extracting(ChatRoom::getId) + .isEqualTo(chatRoomId); + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + + @Test + @DisplayName("관리자는 멤버가 아니어도 문의방 메시지를 조회할 수 있다") + void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int chatRoomId = parseChatRoomId( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + ); + + performPost("/chats/rooms/" + chatRoomId + "/messages", + new ChatMessageSendRequest("문의 내용입니다")) + .andExpect(status().isOk()); + + clearPersistenceContext(); + + mockLoginUser(anotherAdmin.getId()); + performGet("/chats/rooms/" + chatRoomId + "?page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.messages[0].content").value("문의 내용입니다")) + .andExpect(jsonPath("$.messages[0].isMine").value(false)); + + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)) + .extracting(ChatRoomMember::getUserId) + .containsExactlyInAnyOrder(SYSTEM_ADMIN_ID, normalUser.getId()); + } + @Test @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @@ -631,6 +694,31 @@ void sendMessageSuccess() throws Exception { .containsExactly("안녕하세요"); } + @Test + @DisplayName("관리자가 문의방에 답변하면 실제 문의 사용자에게 알림을 보낸다") + void adminReplySendsNotificationToInquiryUser() throws Exception { + User anotherAdmin = persist(UserFixture.createAdmin(university)); + clearPersistenceContext(); + + mockLoginUser(normalUser.getId()); + int roomId = objectMapper.readTree( + performPost("/chats/rooms/admin") + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString() + ).get("chatRoomId").asInt(); + + clearInvocations(notificationService); + + mockLoginUser(anotherAdmin.getId()); + performPost("/chats/rooms/" + roomId + "/messages", new ChatMessageSendRequest("관리자 답변입니다")) + .andExpect(status().isOk()); + + verify(notificationService) + .sendChatNotification(normalUser.getId(), roomId, anotherAdmin.getName(), "관리자 답변입니다"); + } + @Test @DisplayName("빈 메시지를 전송하면 400을 반환한다") void sendBlankMessageFails() throws Exception { From aa19667776d31400b166d3fefc8acb47f2115bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 7 Apr 2026 12:27:45 +0900 Subject: [PATCH 11/50] =?UTF-8?q?fix:=20ADMIN=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?SQL=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._admin_members_from_system_admin_rooms.sql | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql index 3ff8619f9..274f83007 100644 --- a/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql +++ b/src/main/resources/db/migration/V68__remove_admin_members_from_system_admin_rooms.sql @@ -2,17 +2,15 @@ -- 이유: 어드민이 멤버로 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 중복 생성됨 -- 참고: https://github.com/BCSDLab/KONECT_BACK_END/issues/503 -DELETE FROM chat_room_member -WHERE user_id IN ( - SELECT u.id - FROM users u - WHERE u.role = 'ADMIN' - AND u.id != 1 -- SYSTEM_ADMIN(1번)은 제외 -) -AND chat_room_id IN ( - SELECT DISTINCT crm.chat_room_id - FROM chat_room_member crm - JOIN chat_room cr ON crm.chat_room_id = cr.id - WHERE crm.user_id = 1 -- SYSTEM_ADMIN(1번)이 있는 방 - AND cr.room_type = 'DIRECT' -- DIRECT 타입 방만 -); +DELETE crm +FROM chat_room_member AS crm +JOIN users AS u + ON u.id = crm.user_id +JOIN chat_room AS cr + ON cr.id = crm.chat_room_id +JOIN chat_room_member AS system_member + ON system_member.chat_room_id = crm.chat_room_id +WHERE u.role = 'ADMIN' + AND u.id <> 1 + AND cr.room_type = 'DIRECT' + AND system_member.user_id = 1; From d11bb54e6a10af0acf7e47f6d75e8675b14a61a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:58:49 +0900 Subject: [PATCH 12/50] =?UTF-8?q?fix:=20Auth=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=9D=B8=EC=8B=9D=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 인터페이스 @Auth 어노테이션 인식 문제 해결 * chore: 코드 포맷팅 --- .../advertisement/controller/AdminAdvertisementApi.java | 3 --- .../controller/AdminAdvertisementController.java | 3 +++ .../konect/admin/schedule/controller/AdminScheduleApi.java | 5 +---- .../admin/schedule/controller/AdminScheduleController.java | 3 +++ .../konect/admin/version/controller/AdminVersionApi.java | 3 --- .../admin/version/controller/AdminVersionController.java | 3 +++ 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java index 229e26d36..325556b5a 100644 --- a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementApi.java @@ -13,15 +13,12 @@ import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementResponse; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @Tag(name = "(Admin) Advertisement: 광고", description = "어드민 광고 API") @RequestMapping("/admin/advertisements") -@Auth(roles = {UserRole.ADMIN}) public interface AdminAdvertisementApi { @Operation(summary = "광고 목록을 조회한다.") diff --git a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java index 85ba7cb6c..cde3cd1c9 100644 --- a/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java +++ b/src/main/java/gg/agit/konect/admin/advertisement/controller/AdminAdvertisementController.java @@ -11,10 +11,13 @@ import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementsResponse; import gg.agit.konect.admin.advertisement.service.AdminAdvertisementService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import jakarta.validation.Valid; @RestController @Validated +@Auth(roles = {UserRole.ADMIN}) public class AdminAdvertisementController implements AdminAdvertisementApi { private final AdminAdvertisementService adminAdvertisementService; diff --git a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java index 7fbff8e6e..44cc1e990 100644 --- a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java +++ b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleApi.java @@ -10,8 +10,6 @@ import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,7 +17,6 @@ @Tag(name = "(Admin) Schedule: 일정", description = "어드민 일정 API") @RequestMapping("/admin/schedules") -@Auth(roles = {UserRole.ADMIN}) public interface AdminScheduleApi { @Operation(summary = "일정을 생성한다.", description = """ @@ -37,7 +34,7 @@ ResponseEntity createSchedule( @Operation(summary = "일정을 일괄 생성/수정한다.", description = """ scheduleId가 없으면 신규 생성, 있으면 해당 일정 수정입니다. - + **scheduleType (일정 구분):** - `UNIVERSITY`: 대학교 일정 - `CLUB`: 동아리 일정 diff --git a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java index 6c9269a7e..2804758d2 100644 --- a/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java +++ b/src/main/java/gg/agit/konect/admin/schedule/controller/AdminScheduleController.java @@ -7,11 +7,14 @@ import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertRequest; import gg.agit.konect.admin.schedule.service.AdminScheduleService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/admin/schedules") +@Auth(roles = {UserRole.ADMIN}) public class AdminScheduleController implements AdminScheduleApi { private final AdminScheduleService adminScheduleService; diff --git a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java index e671a6061..2101b03f7 100644 --- a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java +++ b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionApi.java @@ -6,15 +6,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.global.auth.annotation.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @Tag(name = "(Admin) Version: 버전", description = "어드민 버전 API") @RequestMapping("/admin/versions") -@Auth(roles = {UserRole.ADMIN}) public interface AdminVersionApi { @Operation(summary = "새 버전을 등록한다.") diff --git a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java index 845b94a18..34918dcdd 100644 --- a/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java +++ b/src/main/java/gg/agit/konect/admin/version/controller/AdminVersionController.java @@ -7,12 +7,15 @@ import gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; import gg.agit.konect.admin.version.service.AdminVersionService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.global.auth.annotation.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/admin/versions") +@Auth(roles = {UserRole.ADMIN}) public class AdminVersionController implements AdminVersionApi { private final AdminVersionService adminVersionService; From e213e327c29fd3bc6bf22b392ed558c0aedede44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:10:06 +0900 Subject: [PATCH 13/50] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=BD=94=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94=20(#508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: user.isAdmin() 메서드 활용하여 어드민 체크 로직 단순화 - User.getRole() == UserRole.ADMIN 패턴을 user.isAdmin()으로 일괄 변경 - ChatService.java 16개 위치, ChatRoomMembershipService.java 2개 위치 수정 - 불필요한 UserRole import 제거 * refactor: SYSTEM_ADMIN_ID 상수 중복 제거 - ChatRoomMembershipService.SYSTEM_ADMIN_ID를 public으로 변경 - ChatService의 중복 상수 정의 제거 - ChatService에서 static import로 참조하도록 변경 * refactor: toSortedReadBaselines 메서드 단순화 - 불필요한 지역 변수 제거하고 바로 반환하도록 변경 * refactor: findNonAdminUser를 findNonAdminUserFromMemberInfo로 위임 - findNonAdminUser에서 MemberInfo로 변환 후 통합된 메서드 호출 - 중복된 필터링 로직 제거 * refactor: getDirectChatRoomMessages 메서드 통합 - buildDirectChatRoomMessages 공통 메서드 추출 - getDirectChatRoomMessages와 getAdminSystemDirectChatRoomMessages가 공통 메서드를 호출하도록 변경 - 중복된 메시지 조회 및 매핑 로직 제거 * refactor: resolveDirectMessageReceiver 통합 - resolveDirectMessageReceiver가 MemberInfo로 변환 후 resolveMessageReceiverFromMemberInfo 호출하도록 변경 - 중복된 수신자 결정 로직 제거 * chore: SYSTEM_ADMIN_ID 중복 import 제거 * refactor: buildDirectChatRoomMessages 미사용 파라미터 제거 - 사용하지 않는 ChatRoom chatRoom 파라미터 제거 - 관련 호출 지점 업데이트 --- .../service/ChatRoomMembershipService.java | 7 +- .../domain/chat/service/ChatService.java | 150 +++++++++--------- 2 files changed, 75 insertions(+), 82 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java index 8b94b854e..3dd25d68c 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java @@ -20,7 +20,6 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; @@ -33,7 +32,7 @@ @Transactional(readOnly = true) public class ChatRoomMembershipService { - private static final int SYSTEM_ADMIN_ID = 1; + public static final int SYSTEM_ADMIN_ID = 1; private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; @@ -68,7 +67,7 @@ public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readA @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateDirectRoomLastReadAt(Integer roomId, User user, LocalDateTime readAt, ChatRoom room) { // 어드민이 SYSTEM_ADMIN 방의 메시지를 읽으면 SYSTEM_ADMIN의 lastReadAt을 업데이트 - if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(roomId)) { + if (user.isAdmin() && isSystemAdminRoom(roomId)) { chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, SYSTEM_ADMIN_ID, readAt); return; } @@ -134,7 +133,7 @@ private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTim // 어드민은 SYSTEM_ADMIN 방의 메시지를 조회할 수 있지만, 멤버로 추가되지는 않는다 // (멤버가 추가되면 findByTwoUsers에서 해당 방을 찾지 못해 채팅방이 중복 생성됨) - if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) { + if (user.isAdmin() && isSystemAdminRoom(room.getId())) { return; } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index 794c114ae..e5d52eb31 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.chat.service; +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; import static gg.agit.konect.global.code.ApiResponseCode.*; import java.time.LocalDateTime; @@ -69,7 +70,6 @@ @Transactional(readOnly = true) public class ChatService { - private static final int SYSTEM_ADMIN_ID = 1; private static final String ETC_SECTION_NAME = "기타"; private static final String DEFAULT_GROUP_ROOM_NAME = "그룹 채팅"; @@ -94,7 +94,7 @@ public ChatRoomResponse createOrGetChatRoom(Integer currentUserId, ChatRoomCreat throw CustomException.of(CANNOT_CREATE_CHAT_ROOM_WITH_SELF); } - if (currentUser.getRole() == UserRole.ADMIN && targetUser.getRole() != UserRole.ADMIN) { + if (currentUser.isAdmin() && !targetUser.isAdmin()) { return getOrCreateSystemAdminChatRoomForUser(targetUser, currentUser); } @@ -396,7 +396,7 @@ public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integ LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { - boolean isAdminViewingSystemRoom = user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + boolean isAdminViewingSystemRoom = user.isAdmin() && isSystemAdminRoom(room); if (isAdminViewingSystemRoom) { chatRoomMembershipService.updateLastReadAt(roomId, SYSTEM_ADMIN_ID, readAt); recordPresenceSafely(roomId, userId); @@ -448,7 +448,7 @@ public ChatMuteResponse toggleMute(Integer userId, Integer roomId) { ensureRoomMember(room, member.getUser(), member.getCreatedAt()); } else if (room.isDirectRoom()) { // 어드민이 SYSTEM_ADMIN 방에 접근하는 경우는 멤버십 체크를 건너뜀 - boolean isAdminAccessingSystemAdminRoom = user.getRole() == UserRole.ADMIN + boolean isAdminAccessingSystemAdminRoom = user.isAdmin() && isSystemAdminRoom(room); if (!isAdminAccessingSystemAdminRoom) { getAccessibleDirectRoomMember(room, user); @@ -491,7 +491,7 @@ public void updateChatRoomName(Integer userId, Integer roomId, ChatRoomNameUpdat private List getDirectChatRooms(Integer userId) { User user = userRepository.getById(userId); - if (user.getRole() == UserRole.ADMIN) { + if (user.isAdmin()) { return getAdminDirectChatRooms(userId); } @@ -620,37 +620,38 @@ private List getGroupChatRooms(Integer userId) { .toList(); } - private ChatMessagePageResponse getDirectChatRoomMessages( - Integer userId, + private ChatMessagePageResponse buildDirectChatRoomMessages( + User user, Integer roomId, Integer page, Integer limit, - LocalDateTime readAt + LocalDateTime readAt, + LocalDateTime visibleMessageFrom, + List sortedReadBaselines, + Integer maskedAdminId ) { - ChatRoom chatRoom = getDirectRoom(roomId); - User user = userRepository.getById(userId); - List members = chatRoomMemberRepository.findByChatRoomId(roomId); - LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), - chatRoom); - PageRequest pageable = PageRequest.of(page - 1, limit); Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); - List sortedReadBaselines = toSortedReadBaselines(members); - List responseMessages = messages.getContent().stream() .map(message -> { - boolean isRead = message.isSentBy(userId) || !message.getCreatedAt().isAfter(readAt); + Integer senderId = maskedAdminId != null + ? resolveDirectSenderId(message, maskedAdminId) + : message.getSender().getId(); + boolean isMine = maskedAdminId != null + ? shouldDisplayAsOwnMessage(user, message, true) + : message.isSentBy(user.getId()); + boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); return new ChatMessageDetailResponse( message.getId(), - message.getSender().getId(), + senderId, null, message.getContent(), message.getCreatedAt(), isRead, unreadCount, - message.isSentBy(userId) + isMine ); }) .toList(); @@ -665,6 +666,25 @@ private ChatMessagePageResponse getDirectChatRoomMessages( ); } + private ChatMessagePageResponse getDirectChatRoomMessages( + Integer userId, + Integer roomId, + Integer page, + Integer limit, + LocalDateTime readAt + ) { + ChatRoom chatRoom = getDirectRoom(roomId); + User user = userRepository.getById(userId); + List members = chatRoomMemberRepository.findByChatRoomId(roomId); + LocalDateTime visibleMessageFrom = prepareDirectRoomAccess(getOrCreateDirectRoomMember(chatRoom, user), + chatRoom); + + List sortedReadBaselines = toSortedReadBaselines(members); + + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, null); + } + private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( User user, ChatRoom chatRoom, @@ -676,39 +696,11 @@ private ChatMessagePageResponse getAdminSystemDirectChatRoomMessages( List members = chatRoomMemberRepository.findByChatRoomId(roomId); LocalDateTime visibleMessageFrom = resolveAdminSystemRoomVisibleMessageFrom(members); - PageRequest pageable = PageRequest.of(page - 1, limit); - Page messages = chatMessageRepository.findByChatRoomId(roomId, visibleMessageFrom, pageable); - List sortedReadBaselines = toAdminChatReadBaselines(members); - Integer maskedAdminId = getMaskedAdminId(user, chatRoom); - List responseMessages = messages.getContent().stream() - .map(message -> { - Integer senderId = resolveDirectSenderId(message, maskedAdminId); - boolean isMine = shouldDisplayAsOwnMessage(user, message, true); - boolean isRead = isMine || !message.getCreatedAt().isAfter(readAt); - int unreadCount = countUnreadSince(message.getCreatedAt(), sortedReadBaselines); - return new ChatMessageDetailResponse( - message.getId(), - senderId, - null, - message.getContent(), - message.getCreatedAt(), - isRead, - unreadCount, - isMine - ); - }) - .toList(); - return new ChatMessagePageResponse( - messages.getTotalElements(), - messages.getNumberOfElements(), - messages.getTotalPages(), - messages.getNumber() + 1, - null, - responseMessages - ); + return buildDirectChatRoomMessages(user, roomId, page, limit, readAt, + visibleMessageFrom, sortedReadBaselines, maskedAdminId); } private ChatMessageDetailResponse sendDirectMessage( @@ -720,7 +712,7 @@ private ChatMessageDetailResponse sendDirectMessage( User sender = userRepository.getById(userId); // 어드민이 SYSTEM_ADMIN 방에 메시지를 보내는 경우 - boolean isAdminSendingToSystemAdminRoom = sender.getRole() == UserRole.ADMIN + boolean isAdminSendingToSystemAdminRoom = sender.isAdmin() && isSystemAdminRoom(chatRoom); ChatRoomMember senderMember = null; @@ -1175,7 +1167,7 @@ private Map getUnreadCountMap(List chatRoomIds, Integ } private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { - if (user.getRole() == UserRole.ADMIN) { + if (user.isAdmin()) { return null; } @@ -1197,7 +1189,7 @@ private Integer getMaskedAdminId(User user, ChatRoom chatRoom) { } private void publishAdminChatEventIfNeeded(boolean isSystemAdminRoom, User sender, String content) { - if (isSystemAdminRoom && sender.getRole() != UserRole.ADMIN) { + if (isSystemAdminRoom && !sender.isAdmin()) { eventPublisher.publishEvent(AdminChatReceivedEvent.of(sender.getId(), sender.getName(), content)); } } @@ -1260,7 +1252,7 @@ private void ensureDirectRoomRequester(ChatRoom room, User user, LocalDateTime j private boolean shouldSkipSystemAdminMembership(ChatRoom room, User user) { // 문의방은 SYSTEM_ADMIN + 일반 사용자 2인 구조를 전제로 재사용(findByTwoUsers)되므로, // 생성/재오픈 경로에서도 일반 ADMIN을 멤버로 추가하면 안 된다. - return user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room); + return user.isAdmin() && isSystemAdminRoom(room); } private String normalizeCustomRoomName(String roomName) { @@ -1296,11 +1288,10 @@ private void updateClubMessageLastReadAt(Integer roomId, Integer userId, LocalDa } private List toSortedReadBaselines(List members) { - List baselines = members.stream() + return members.stream() .map(ChatRoomMember::getLastReadAt) .sorted() .toList(); - return baselines; } private List toAdminChatReadBaselines(List members) { @@ -1308,7 +1299,7 @@ private List toAdminChatReadBaselines(List member LocalDateTime userLastReadAt = null; for (ChatRoomMember member : members) { - if (member.getUser().getRole() == UserRole.ADMIN) { + if (member.getUser().isAdmin()) { if (adminLastReadAt == null || member.getLastReadAt().isAfter(adminLastReadAt)) { adminLastReadAt = member.getLastReadAt(); } @@ -1383,7 +1374,7 @@ private ChatRoomMember getOrCreateDirectRoomMember(ChatRoom chatRoom, User user) return chatRoomMemberRepository.findByChatRoomIdAndUserId(chatRoom.getId(), user.getId()) .orElseGet(() -> { // 어드민은 SYSTEM_ADMIN 방에 멤버로 추가되지 않음 - if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(chatRoom)) { + if (user.isAdmin() && isSystemAdminRoom(chatRoom)) { throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); } throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); @@ -1440,13 +1431,13 @@ private boolean shouldDisplayAsOwnMessage( boolean isAdminViewingSystemRoom ) { if (isAdminViewingSystemRoom) { - return message.getSender().getRole() == UserRole.ADMIN; + return message.getSender().isAdmin(); } return message.isSentBy(currentUser.getId()); } private Integer resolveDirectSenderId(ChatMessage message, Integer maskedAdminId) { - if (maskedAdminId != null && message.getSender().getRole() == UserRole.ADMIN) { + if (maskedAdminId != null && message.getSender().isAdmin()) { return maskedAdminId; } return message.getSender().getId(); @@ -1531,26 +1522,29 @@ private User resolveDirectChatPartner(List members, Integer user } private User findNonAdminUser(List members) { - return members.stream() - .map(ChatRoomMember::getUser) - .filter(memberUser -> memberUser.getRole() != UserRole.ADMIN) - .findFirst() - .orElse(null); + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) + .toList(); + return findNonAdminUserFromMemberInfo(memberInfos, userMap); } private User resolveDirectMessageReceiver(List members, User sender) { - if (sender.getRole() == UserRole.ADMIN) { - User nonAdminUser = findNonAdminUser(members); - if (nonAdminUser != null) { - return nonAdminUser; - } - } - - User partner = resolveDirectChatPartner(members, sender.getId()); - if (partner == null) { - throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS); - } - return partner; + Map userMap = members.stream() + .collect(Collectors.toMap( + ChatRoomMember::getUserId, + ChatRoomMember::getUser, + (existing, replacement) -> existing + )); + List memberInfos = members.stream() + .map(m -> new MemberInfo(m.getUserId(), m.getCreatedAt())) + .toList(); + return resolveMessageReceiverFromMemberInfo(sender, memberInfos, userMap); } private User findDirectPartnerFromMemberInfo( @@ -1585,7 +1579,7 @@ private User findNonAdminUserFromMemberInfo(List memberInfos, Map userMap.get(info.userId())) .filter(Objects::nonNull) - .filter(user -> user.getRole() != UserRole.ADMIN) + .filter(user -> !user.isAdmin()) .findFirst() .orElse(null); } @@ -1595,7 +1589,7 @@ private User resolveMessageReceiverFromMemberInfo( List memberInfos, Map userMap ) { - if (sender.getRole() == UserRole.ADMIN) { + if (sender.isAdmin()) { User nonAdminUser = findNonAdminUserFromMemberInfo(memberInfos, userMap); if (nonAdminUser != null) { return nonAdminUser; From d8333ca0370f0cc15d6bae86b3ccd3bd001ee6d2 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:44:56 +0900 Subject: [PATCH 14/50] =?UTF-8?q?feat:=20=EC=8B=9C=ED=8A=B8=20=EB=B6=80?= =?UTF-8?q?=EC=9B=90=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인명부 미리보기 확정 등록 API 추가 * fix: 시트 미리보기 리뷰 반영 * fix: 요청 검증 메시지 중복 제거 --- .../controller/ClubSheetMigrationApi.java | 58 ++- .../ClubSheetMigrationController.java | 28 ++ .../club/dto/SheetImportConfirmRequest.java | 47 +++ .../club/dto/SheetImportPreviewResponse.java | 85 +++++ .../club/service/SheetImportService.java | 346 +++++++++++++----- .../club/service/SheetImportServiceTest.java | 181 +++++++++ .../club/ClubSheetMigrationApiTest.java | 170 ++++++++- 7 files changed, 808 insertions(+), 107 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java index 9c4bd1d5c..2028b825f 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.dto.SheetMigrateRequest; @@ -20,11 +22,11 @@ public interface ClubSheetMigrationApi { @Operation( - summary = "기존 스프레드시트 → 팀 양식으로 이관", - description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " - + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " - + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " - + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + summary = "기존 스프레드시트를 공식 시트로 이관한다", + description = """ + 기존 스프레드시트 URL을 제출하면 + 같은 Google Drive 폴더에 KONECT 공식 시트를 만들고 현재 데이터를 복사합니다. + """ ) @PostMapping("/{clubId}/sheet/migrate") ResponseEntity migrateSheet( @@ -34,10 +36,39 @@ ResponseEntity migrateSheet( ); @Operation( - summary = "기존 스프레드시트에서 사전 회원 가져오기", - description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " - + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " - + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + summary = "시트 불러오기 전 부원 목록을 미리본다", + description = """ + 스프레드시트 URL을 읽어 등록 예정인 부원 목록을 JSON으로 반환합니다. + 이 API는 데이터를 저장하지 않고 미리보기 용도로만 사용합니다. + """ + ) + @PostMapping("/{clubId}/sheet/import/preview") + ResponseEntity previewPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "편집된 미리보기 부원 목록을 최종 등록한다", + description = """ + 미리보기 화면에서 활성화된 부원 목록과 수동 추가한 부원 목록을 최종본으로 받아 등록합니다. + 비활성화된 부원은 등록 대상에서 제외됩니다. + """ + ) + @PostMapping("/{clubId}/sheet/import/confirm") + ResponseEntity confirmImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportConfirmRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "스프레드시트에서 사전 등록 부원을 가져온다", + description = """ + 스프레드시트의 부원 정보를 데이터베이스에 반영합니다. + 가입된 사용자는 ClubMember로 바로 등록하고, 미가입 사용자는 ClubPreMember로 저장합니다. + """ ) @PostMapping("/{clubId}/sheet/import") ResponseEntity importPreMembers( @@ -47,10 +78,11 @@ ResponseEntity importPreMembers( ); @Operation( - summary = "스프레드시트 분석 후 사전 회원 가져오기", - description = "구글 스프레드시트 URL을 받아 먼저 시트를 분석 및 등록한 뒤, " - + "같은 스프레드시트에서 사전 회원을 읽어 DB에 등록합니다. " - + "기존 PUT /clubs/{clubId}/sheet 와 POST /clubs/{clubId}/sheet/import 를 순서대로 실행한 결과와 동일합니다." + summary = "시트 분석과 사전 등록 부원 가져오기를 한 번에 수행한다", + description = """ + 시트 분석, 시트 등록, 사전 등록 부원 가져오기를 순차적으로 수행합니다. + 기존 PUT /clubs/{clubId}/sheet 이후 POST /clubs/{clubId}/sheet/import 를 호출한 것과 같은 결과입니다. + """ ) @PostMapping("/{clubId}/sheet/import/integrated") ResponseEntity analyzeAndImportPreMembers( diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index 002d130bd..e7b8c0b56 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.RestController; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.dto.SheetMigrateRequest; @@ -38,6 +40,32 @@ public ResponseEntity migrateSheet( return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); } + @Override + public ResponseEntity previewPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( + clubId, requesterId, request.spreadsheetUrl() + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity confirmImportPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportConfirmRequest request, + @UserId Integer requesterId + ) { + SheetImportResponse response = sheetImportService.confirmImportPreMembers( + clubId, + requesterId, + request.members() + ); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity importPreMembers( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java new file mode 100644 index 000000000..b64f59623 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportConfirmRequest.java @@ -0,0 +1,47 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record SheetImportConfirmRequest( + @NotEmpty(message = "최종 등록할 부원 목록은 최소 1명 이상이어야 합니다.") + @Valid + @ArraySchema(schema = @Schema(implementation = ConfirmMember.class)) + List members +) { + public record ConfirmMember( + @NotEmpty(message = "학번은 필수 입력입니다.") + @Size(min = 4, max = 20, message = "학번은 4자 이상 20자 이하입니다.") + @Pattern(regexp = "^[0-9]+$", message = "학번은 숫자만 입력할 수 있습니다.") + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @NotEmpty(message = "이름은 필수 입력입니다.") + @Pattern( + regexp = "^([가-힣]{2,5}|(?=.{2,30}$)[A-Za-z]+( [A-Za-z]+)*)$", + message = "이름은 완성된 한글 2~5자 또는 영문 2~30자(공백 포함)만 입력할 수 있습니다." + ) + @Schema(description = "이름", example = "김코넥트", requiredMode = REQUIRED) + String name, + + @Schema(description = "등록할 동아리 직책", example = "MEMBER", requiredMode = NOT_REQUIRED) + ClubPosition clubPosition, + + @Schema(description = "최종 등록 대상 여부", example = "true", requiredMode = NOT_REQUIRED) + Boolean enabled + ) { + public boolean isEnabled() { + return !Boolean.FALSE.equals(enabled); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java new file mode 100644 index 000000000..5386a310c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportPreviewResponse.java @@ -0,0 +1,85 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; +import io.swagger.v3.oas.annotations.media.Schema; + +public record SheetImportPreviewResponse( + @Schema(description = "전체 미리보기 인원 수", example = "10", requiredMode = REQUIRED) + int previewCount, + + @Schema(description = "즉시 등록될 인원 수", example = "2", requiredMode = REQUIRED) + int autoRegisteredCount, + + @Schema(description = "사전 등록될 인원 수", example = "8", requiredMode = REQUIRED) + int preRegisteredCount, + + @Schema(description = "미리보기 부원 목록", requiredMode = REQUIRED) + List members, + + @Schema(description = "미리보기 중 수집된 경고 목록", requiredMode = REQUIRED) + List warnings +) { + public record PreviewMember( + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @Schema(description = "이름", example = "김코넥트", requiredMode = REQUIRED) + String name, + + @Schema(description = "등록할 동아리 직책", example = "MEMBER", requiredMode = REQUIRED) + ClubPosition clubPosition, + + @Schema(description = "ClubMember로 즉시 등록되는 경우 true", example = "false", requiredMode = REQUIRED) + Boolean isDirectMember, + + @Schema(description = "최종 등록 대상이면 true", example = "true", requiredMode = REQUIRED) + Boolean enabled + ) { + public static PreviewMember from(ClubMember clubMember) { + return new PreviewMember( + clubMember.getUser().getStudentNumber(), + clubMember.getUser().getName(), + clubMember.getClubPosition(), + true, + true + ); + } + + public static PreviewMember from(ClubPreMember preMember) { + return new PreviewMember( + preMember.getStudentNumber(), + preMember.getName(), + preMember.getClubPosition(), + false, + true + ); + } + } + + public static SheetImportPreviewResponse of( + List members, + List warnings + ) { + List safeMembers = members != null ? members : List.of(); + List safeWarnings = warnings != null ? warnings : List.of(); + + int autoRegisteredCount = (int)safeMembers.stream() + .filter(member -> Boolean.TRUE.equals(member.isDirectMember())) + .count(); + int preRegisteredCount = safeMembers.size() - autoRegisteredCount; + + return new SheetImportPreviewResponse( + safeMembers.size(), + autoRegisteredCount, + preRegisteredCount, + safeMembers, + safeWarnings + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 45e10e63d..42a0975a7 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -6,16 +6,22 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportResponse; import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; @@ -46,27 +52,72 @@ public class SheetImportService { private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; private final ClubPermissionValidator clubPermissionValidator; + private final PlatformTransactionManager transactionManager; + + public SheetImportPreviewResponse previewPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetUrl + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + SheetImportSource source = loadSheetImportSource(spreadsheetId, analysis.memberListMapping()); + return executeReadOnlyTransaction(() -> { + Club club = clubRepository.getById(clubId); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); + }); + } @Transactional + public SheetImportResponse confirmImportPreMembers( + Integer clubId, + Integer requesterId, + List members + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + + SheetImportPlan plan = buildImportPlan( + clubId, + club, + toImportMembers(members), + List.of() + ); + applyImportPlan(clubId, "preview-confirm", plan); + return SheetImportResponse.of( + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings() + ); + } + public SheetImportResponse importPreMembersFromSheet( Integer clubId, Integer requesterId, String spreadsheetUrl ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); - String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); + String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); SheetHeaderMapper.SheetAnalysisResult analysis = sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - return importPreMembersFromSheet( + SheetImportSource source = loadSheetImportSource(spreadsheetId, analysis.memberListMapping()); + return executeTransaction(() -> importPreMembers( clubId, - requesterId, spreadsheetId, - analysis.memberListMapping() - ); + source + )); } - @Transactional SheetImportResponse importPreMembersFromSheet( Integer clubId, Integer requesterId, @@ -74,105 +125,145 @@ SheetImportResponse importPreMembersFromSheet( SheetColumnMapping mapping ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); + SheetImportSource source = loadSheetImportSource(spreadsheetId, mapping); + return executeTransaction(() -> importPreMembers( + clubId, + spreadsheetId, + source + )); + } + + private SheetImportResponse importPreMembers( + Integer clubId, + String importSource, + SheetImportSource source + ) { Club club = clubRepository.getById(clubId); - return importPreMembersFromSheet(clubId, club, spreadsheetId, mapping); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + applyImportPlan(clubId, importSource, plan); + return SheetImportResponse.of( + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings() + ); + } + + private void applyImportPlan( + Integer clubId, + String importSource, + SheetImportPlan plan + ) { + if (!plan.studentNumbersToCleanFromPre().isEmpty()) { + clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( + clubId, + plan.studentNumbersToCleanFromPre() + ); + } + + List savedMembers = plan.clubMembersToSave().isEmpty() + ? List.of() + : clubMemberRepository.saveAll(plan.clubMembersToSave()); + + for (ClubMember savedMember : savedMembers) { + chatRoomMembershipService.addClubMember(savedMember); + } + + if (!plan.preMembersToSave().isEmpty()) { + clubPreMemberRepository.saveAll(plan.preMembersToSave()); + } + + log.info( + "Sheet import done. clubId={}, source={}, imported={}, autoRegistered={}, warnings={}", + clubId, + importSource, + plan.preRegisteredCount(), + plan.autoRegisteredCount(), + plan.warnings().size() + ); } - private SheetImportResponse importPreMembersFromSheet( + private SheetImportPlan buildImportPlan( Integer clubId, Club club, - String spreadsheetId, - SheetColumnMapping mapping + List members, + List initialWarnings ) { Integer universityId = club.getUniversity().getId(); - List> rows = readDataRows(spreadsheetId, mapping); - // N+1 방지: 루프 전 기존 부원 학번 Set / 사전 회원 key Set / 부원 userId Set 일괄 조회 Set existingMemberStudentNumbers = new HashSet<>(clubMemberRepository.findStudentNumbersByClubId(clubId)); Set existingPreMemberKeys = buildPreMemberKeySet(clubId); Set existingMemberUserIds = new HashSet<>(clubMemberRepository.findUserIdsByClubId(clubId)); - // 시트에 등장하는 모든 학번 수집 → users 일괄 조회 - Set allStudentNumbers = rows.stream() - .map(row -> getCell(row, mapping, SheetColumnMapping.STUDENT_ID)) - .filter(s -> !s.isBlank()) + Set allStudentNumbers = members.stream() + .map(ImportMember::studentNumber) + .filter(studentNumber -> !studentNumber.isBlank()) .collect(Collectors.toSet()); Map> usersByStudentNumber = new HashMap<>(); if (!allStudentNumbers.isEmpty()) { userRepository.findAllByUniversityIdAndStudentNumberIn(universityId, allStudentNumbers) - .forEach(u -> usersByStudentNumber - .computeIfAbsent(u.getStudentNumber(), k -> new ArrayList<>()) - .add(u)); + .forEach(user -> usersByStudentNumber + .computeIfAbsent(user.getStudentNumber(), key -> new ArrayList<>()) + .add(user)); } - // 루프에서 수집할 배치 작업 대상 + List previewMembers = new ArrayList<>(); List clubMembersToSave = new ArrayList<>(); Set studentNumbersToCleanFromPre = new HashSet<>(); List preMembersToSave = new ArrayList<>(); - - List warnings = new ArrayList<>(); + List warnings = new ArrayList<>(initialWarnings); int presidentCount = 0; - for (List row : rows) { - String name = getCell(row, mapping, SheetColumnMapping.NAME); - String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + for (ImportMember importMember : members) { + String name = importMember.name(); + String studentNumber = importMember.studentNumber(); + ClubPosition position = importMember.clubPosition() == null + ? ClubPosition.MEMBER + : importMember.clubPosition(); if (name.isBlank() || studentNumber.isBlank()) { continue; } - // 전화번호 형식 유효성 경고 - String phone = getCell(row, mapping, SheetColumnMapping.PHONE); - if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { - warnings.add(String.format( - "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", - studentNumber, name, phone - )); - } - - String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); - ClubPosition position = resolvePosition(positionStr); - - // 회장 중복 감지 if (position == ClubPosition.PRESIDENT) { presidentCount++; if (presidentCount > 1) { warnings.add(String.format( "회장이 2명 이상 등록되어 있습니다 - 중복 회장: 학번 %s, 이름 %s", - studentNumber, name + studentNumber, + name )); } } - // 이미 club_member에 있는 학번은 스킵 if (existingMemberStudentNumbers.contains(studentNumber)) { continue; } - // users 테이블에서 동일 대학 + 학번으로 매칭, 이름까지 일치하는 유저 탐색 - // trim() / equalsIgnoreCase로 공백·대소문자 차이 허용 - // 주의: existingPreMemberKeys 체크보다 먼저 수행하여 - // 이미 pre_member로 등록된 행도 User 생성 후 재-import 시 club_member로 승격 가능하게 함 List candidates = usersByStudentNumber.getOrDefault(studentNumber, List.of()); - List matched = candidates.stream() - .filter(u -> name != null && u.getName() != null - && name.trim().equalsIgnoreCase(u.getName().trim())) + List matchedUsers = candidates.stream() + .filter(user -> user.getName() != null && name.equalsIgnoreCase(user.getName().trim())) .toList(); - if (matched.size() == 1) { - User matchedUser = matched.get(0); - // userId Set으로 중복 체크 (N+1 없음) + if (matchedUsers.size() == 1) { + User matchedUser = matchedUsers.get(0); if (!existingMemberUserIds.contains(matchedUser.getId())) { - // 기존 pre_member 행도 함께 정리 (중복 방지) - studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); - clubMembersToSave.add(ClubMember.builder() + ClubMember clubMember = ClubMember.builder() .club(club) .user(matchedUser) .clubPosition(position) - .build()); + .build(); + + clubMembersToSave.add(clubMember); + previewMembers.add(SheetImportPreviewResponse.PreviewMember.from(clubMember)); + studentNumbersToCleanFromPre.add(matchedUser.getStudentNumber()); existingMemberStudentNumbers.add(studentNumber); existingMemberUserIds.add(matchedUser.getId()); existingPreMemberKeys.remove(preMemberKey(studentNumber, name)); @@ -180,54 +271,100 @@ private SheetImportResponse importPreMembersFromSheet( continue; } - if (matched.size() > 1) { + if (matchedUsers.size() > 1) { warnings.add(String.format( "동명이인이 여러 명 존재하여 자동 매칭할 수 없습니다 - 학번: %s, 이름: %s", - studentNumber, name + studentNumber, + name )); } - // users 미매칭 또는 동명이인 → 이미 pre_member에 있으면 스킵, 없으면 등록 if (existingPreMemberKeys.contains(preMemberKey(studentNumber, name))) { continue; } - preMembersToSave.add(ClubPreMember.builder() + ClubPreMember preMember = ClubPreMember.builder() .club(club) .studentNumber(studentNumber) .name(name) .clubPosition(position) - .build()); + .build(); + + preMembersToSave.add(preMember); + previewMembers.add(SheetImportPreviewResponse.PreviewMember.from(preMember)); existingPreMemberKeys.add(preMemberKey(studentNumber, name)); } - // 배치 처리: pre_member 정리 → club_member 일괄 저장 → 채팅방 등록 - if (!studentNumbersToCleanFromPre.isEmpty()) { - clubPreMemberRepository.deleteByClubIdAndStudentNumberIn( - clubId, studentNumbersToCleanFromPre - ); - } - List savedMembers = clubMembersToSave.isEmpty() - ? List.of() - : clubMemberRepository.saveAll(clubMembersToSave); + return new SheetImportPlan( + previewMembers, + clubMembersToSave, + studentNumbersToCleanFromPre, + preMembersToSave, + warnings + ); + } + + private SheetImportSource loadSheetImportSource(String spreadsheetId, SheetColumnMapping mapping) { + List> rows = readDataRows(spreadsheetId, mapping); + List members = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } - for (ClubMember saved : savedMembers) { - chatRoomMembershipService.addClubMember(saved); + String phone = getCell(row, mapping, SheetColumnMapping.PHONE); + if (!phone.isBlank() && !PhoneNumberNormalizer.looksLikePhoneNumber(phone)) { + warnings.add(String.format( + "전화번호 형식이 올바르지 않습니다 - 학번: %s, 이름: %s, 입력값: '%s'", + studentNumber, + name, + phone + )); + } + + String positionText = getCell(row, mapping, SheetColumnMapping.POSITION); + members.add(new ImportMember( + studentNumber, + name, + resolvePosition(positionText) + )); } - if (!preMembersToSave.isEmpty()) { - clubPreMemberRepository.saveAll(preMembersToSave); + return new SheetImportSource(members, warnings); + } + + private List toImportMembers(List members) { + if (members == null) { + return List.of(); } - int autoRegistered = savedMembers.size(); - int imported = preMembersToSave.size(); + return members.stream() + .filter(SheetImportConfirmRequest.ConfirmMember::isEnabled) + .map(member -> new ImportMember( + member.studentNumber().trim(), + member.name().trim(), + member.clubPosition() == null ? ClubPosition.MEMBER : member.clubPosition() + )) + .toList(); + } - log.info( - "Sheet import done. clubId={}, spreadsheetId={}, imported={}, autoRegistered={}, " - + "warnings={}", - clubId, spreadsheetId, imported, autoRegistered, warnings.size() - ); - return SheetImportResponse.of(imported, autoRegistered, warnings); + private T executeTransaction(Supplier action) { + return executeWithTemplate(false, action); + } + + private T executeReadOnlyTransaction(Supplier action) { + return executeWithTemplate(true, action); + } + + private T executeWithTemplate(boolean readOnly, Supplier action) { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setReadOnly(readOnly); + return Objects.requireNonNull(transactionTemplate.execute(status -> action.get())); } private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { @@ -241,7 +378,6 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping List> values = response.getValues(); return values != null ? values : List.of(); - } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { log.warn( @@ -257,22 +393,23 @@ private List> readDataRows(String spreadsheetId, SheetColumnMapping } private String getCell(List row, SheetColumnMapping mapping, String field) { - int col = mapping.getColumnIndex(field); - if (col < 0 || col >= row.size()) { + int columnIndex = mapping.getColumnIndex(field); + if (columnIndex < 0 || columnIndex >= row.size()) { return ""; } - String value = row.get(col).toString().trim(); + + String value = row.get(columnIndex).toString().trim(); if (value.startsWith("'")) { return value.substring(1); } return value; } - private ClubPosition resolvePosition(String positionStr) { - for (ClubPosition pos : ClubPosition.values()) { - if (pos.getDescription().equals(positionStr) - || pos.name().equalsIgnoreCase(positionStr)) { - return pos; + private ClubPosition resolvePosition(String positionText) { + for (ClubPosition position : ClubPosition.values()) { + if (position.getDescription().equals(positionText) + || position.name().equalsIgnoreCase(positionText)) { + return position; } } return ClubPosition.MEMBER; @@ -281,11 +418,40 @@ private ClubPosition resolvePosition(String positionStr) { private Set buildPreMemberKeySet(Integer clubId) { Set keys = new HashSet<>(); clubPreMemberRepository.findStudentNumberAndNameByClubId(clubId) - .forEach(k -> keys.add(preMemberKey(k.getStudentNumber(), k.getName()))); + .forEach(key -> keys.add(preMemberKey(key.getStudentNumber(), key.getName()))); return keys; } private String preMemberKey(String studentNumber, String name) { return studentNumber + "\u0000" + name; } + + private record SheetImportPlan( + List previewMembers, + List clubMembersToSave, + Set studentNumbersToCleanFromPre, + List preMembersToSave, + List warnings + ) { + private int autoRegisteredCount() { + return clubMembersToSave.size(); + } + + private int preRegisteredCount() { + return preMembersToSave.size(); + } + } + + private record SheetImportSource( + List members, + List warnings + ) { + } + + private record ImportMember( + String studentNumber, + String name, + ClubPosition clubPosition + ) { + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java new file mode 100644 index 000000000..b54422755 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java @@ -0,0 +1,181 @@ +package gg.agit.konect.domain.club.service; + +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.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class SheetImportServiceTest extends ServiceTestSupport { + + private static final Integer CLUB_ID = 1; + private static final Integer REQUESTER_ID = 2; + private static final String SPREADSHEET_ID = "sheet-id"; + private static final String SPREADSHEET_URL = + "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit"; + + @Mock + private Sheets googleSheetsService; + + @Mock + private Sheets.Spreadsheets spreadsheets; + + @Mock + private Sheets.Spreadsheets.Values values; + + @Mock + private Sheets.Spreadsheets.Values.Get getRequest; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private SheetImportService sheetImportService; + + @Test + void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { + Club club = ClubFixture.create(UniversityFixture.create()); + User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); + + given(clubRepository.getById(CLUB_ID)).willReturn(club); + given(sheetHeaderMapper.analyzeAllSheets(SPREADSHEET_ID)).willReturn( + new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null) + ); + given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); + given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) + .willReturn(List.of()); + given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); + given(transactionManager.getTransaction(any())).willReturn(new SimpleTransactionStatus()); + given(userRepository.findAllByUniversityIdAndStudentNumberIn( + eq(club.getUniversity().getId()), + anySet() + )).willReturn(List.of(directUser)); + + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.get(SPREADSHEET_ID, "A2:Z")).willReturn(getRequest); + given(getRequest.setValueRenderOption("FORMATTED_VALUE")).willReturn(getRequest); + given(getRequest.execute()).willReturn(new ValueRange().setValues(List.of( + List.of("Alex Kim", "2021232948", "", "010-1234-5678", ClubPosition.MANAGER.name()), + List.of("Dana Lee", "2021232949", "", "010-9999-8888", ClubPosition.MEMBER.name()) + ))); + + SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( + CLUB_ID, + REQUESTER_ID, + SPREADSHEET_URL + ); + + assertThat(response.previewCount()).isEqualTo(2); + assertThat(response.autoRegisteredCount()).isEqualTo(1); + assertThat(response.preRegisteredCount()).isEqualTo(1); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::studentNumber) + .containsExactly("2021232948", "2021232949"); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::isDirectMember) + .containsExactly(true, false); + assertThat(response.members()) + .extracting(SheetImportPreviewResponse.PreviewMember::enabled) + .containsExactly(true, true); + } + + @Test + void confirmImportPreMembersImportsOnlyEnabledMembers() { + Club club = ClubFixture.create(UniversityFixture.create()); + User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); + + given(clubRepository.getById(CLUB_ID)).willReturn(club); + given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); + given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) + .willReturn(List.of()); + given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); + given(userRepository.findAllByUniversityIdAndStudentNumberIn( + eq(club.getUniversity().getId()), + anySet() + )).willReturn(List.of(directUser)); + given(clubMemberRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); + + SheetImportResponse response = sheetImportService.confirmImportPreMembers( + CLUB_ID, + REQUESTER_ID, + List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Alex Kim", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Dana Lee", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Chris Park", + ClubPosition.MEMBER, + true + ) + ) + ); + + assertThat(response.importedCount()).isEqualTo(1); + assertThat(response.autoRegisteredCount()).isEqualTo(1); + verifyNoInteractions(googleSheetsService); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index b6de3f6ea..6ce72c97b 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -13,15 +13,22 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import gg.agit.konect.domain.club.dto.SheetImportConfirmRequest; +import gg.agit.konect.domain.club.dto.SheetImportPreviewResponse; import gg.agit.konect.domain.club.dto.SheetImportRequest; import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.service.ClubSheetIntegratedService; +import gg.agit.konect.domain.club.service.SheetImportService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; class ClubSheetMigrationApiTest extends IntegrationTestSupport { + @MockitoBean + private SheetImportService sheetImportService; + @MockitoBean private ClubSheetIntegratedService clubSheetIntegratedService; @@ -36,11 +43,166 @@ void setUp() throws Exception { } @Nested - @DisplayName("POST /clubs/{clubId}/sheet/import/integrated - 시트 통합 가져오기") + @DisplayName("POST /clubs/{clubId}/sheet/import/preview") + class PreviewPreMembers { + + @Test + @DisplayName("returns preview member list") + void previewPreMembersSuccess() throws Exception { + SheetImportPreviewResponse response = SheetImportPreviewResponse.of( + List.of( + new SheetImportPreviewResponse.PreviewMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true, + true + ), + new SheetImportPreviewResponse.PreviewMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false, + true + ) + ), + List.of("전화번호 형식 경고") + ); + + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willReturn(response); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.previewCount").value(2)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)) + .andExpect(jsonPath("$.preRegisteredCount").value(1)) + .andExpect(jsonPath("$.members[0].studentNumber").value("2021232948")) + .andExpect(jsonPath("$.members[0].isDirectMember").value(true)) + .andExpect(jsonPath("$.members[0].enabled").value(true)) + .andExpect(jsonPath("$.members[1].studentNumber").value("2021232949")) + .andExpect(jsonPath("$.members[1].isDirectMember").value(false)) + .andExpect(jsonPath("$.members[1].enabled").value(true)) + .andExpect(jsonPath("$.warnings[0]").value("전화번호 형식 경고")); + } + + @Test + @DisplayName("returns 403 when sheet access is forbidden") + void previewPreMembersForbiddenGoogleSheetAccess() throws Exception { + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(SPREADSHEET_URL) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); + + SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); + } + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/confirm") + class ConfirmImportPreMembers { + + @Test + @DisplayName("imports only enabled preview members") + void confirmImportPreMembersSuccess() throws Exception { + given(sheetImportService.confirmImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Park Member", + ClubPosition.MEMBER, + true + ) + )) + )).willReturn(SheetImportResponse.of(1, 1, List.of())); + + SheetImportConfirmRequest request = new SheetImportConfirmRequest(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232949", + "Lee Member", + ClubPosition.MEMBER, + false + ), + new SheetImportConfirmRequest.ConfirmMember( + "2021232950", + "Park Member", + ClubPosition.MEMBER, + true + ) + )); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/confirm", request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.importedCount").value(1)) + .andExpect(jsonPath("$.autoRegisteredCount").value(1)); + } + + @Test + @DisplayName("returns 403 when confirm import access is forbidden") + void confirmImportPreMembersForbidden() throws Exception { + SheetImportConfirmRequest request = new SheetImportConfirmRequest(List.of( + new SheetImportConfirmRequest.ConfirmMember( + "2021232948", + "Kim Manager", + ClubPosition.MANAGER, + true + ) + )); + + given(sheetImportService.confirmImportPreMembers( + eq(CLUB_ID), + eq(REQUESTER_ID), + eq(request.members()) + )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/confirm", request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS.getMessage())); + } + } + + @Nested + @DisplayName("POST /clubs/{clubId}/sheet/import/integrated") class AnalyzeAndImportPreMembers { @Test - @DisplayName("시트 분석 등록 후 사전 회원 가져오기 결과를 반환한다") + @DisplayName("returns integrated import result") void analyzeAndImportPreMembersSuccess() throws Exception { given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), @@ -58,7 +220,7 @@ void analyzeAndImportPreMembersSuccess() throws Exception { } @Test - @DisplayName("구글 스프레드시트 403 오류를 response body로 반환한다") + @DisplayName("returns 403 response body for forbidden sheet access") void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { given(clubSheetIntegratedService.analyzeAndImportPreMembers( eq(CLUB_ID), @@ -77,7 +239,7 @@ void analyzeAndImportPreMembersForbiddenGoogleSheetAccess() throws Exception { } @Test - @DisplayName("Google Drive invalid_grant 400 오류를 response body detail로 반환한다") + @DisplayName("returns detail for invalid Google Drive auth") void analyzeAndImportPreMembersInvalidGoogleDriveAuth() throws Exception { String detail = "400 Bad Request\nPOST https://oauth2.googleapis.com/token\n{\"error\":\"invalid_grant\"}"; From 8531b088b628ee889bf2205a83fe1dbd0ac965d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:07:19 +0900 Subject: [PATCH 15/50] =?UTF-8?q?test:=20=EC=B1=84=ED=8C=85=20API=20?= =?UTF-8?q?=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 채팅 API 엣지 케이스 통합 테스트 추가 * fix: 채팅방 나간 멤버 새 메시지 표시 안 됨 해결 * test: 트랜잭션 경계 내 테스트 픽스처 실행 로직 추가 * refactor: DB 타임스탬프 정밀도 상수 도입 및 적용 * test: 채팅 API 통합테스트의 컨텍스트 재로딩 대신 커밋 데이터 정리로 격리 * test: 한 글자 채팅 검색 테스트 이름을 실제 검증 범위에 맞게 정리 * fix: 채팅방 나간 멤버의 마지막 메시지 표시 오류 해결 * test: 채팅방 나가기 테스트에서 중복 상대 유저 생성을 제거 --- .../domain/chat/model/ChatRoomMember.java | 16 + .../domain/chat/service/ChatService.java | 4 + .../integration/domain/chat/ChatApiTest.java | 373 ++++++++++++++++-- 3 files changed, 362 insertions(+), 31 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java index 7e9b39ffc..e56a741a1 100644 --- a/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java +++ b/src/main/java/gg/agit/konect/domain/chat/model/ChatRoomMember.java @@ -24,6 +24,8 @@ @NoArgsConstructor(access = PROTECTED) public class ChatRoomMember extends BaseEntity { + private static final long DB_TIMESTAMP_PRECISION_NANOS = 1_000L; + @EmbeddedId private ChatRoomMemberId id; @@ -139,6 +141,20 @@ public void restoreDirectRoom() { this.leftAt = null; } + /** + * 상대방의 새 메시지로 채팅방이 다시 보여야 할 때 사용한다. + *

+ * 현재 구현은 메시지 노출/안읽음 계산을 모두 {@code > 기준시각}으로 비교한다. + * 그래서 첫 새 메시지와 같은 시각을 경계로 저장하면 그 메시지가 숨겨질 수 있어, + * DB timestamp(6) 정밀도에서 보정값이 사라지지 않도록 경계를 1마이크로초 앞당겨 해당 메시지부터 보이고 안읽음으로 계산되게 한다. + */ + public void restoreDirectRoomFromIncomingMessage(LocalDateTime messageCreatedAt) { + LocalDateTime visibleFrom = messageCreatedAt.minusNanos(DB_TIMESTAMP_PRECISION_NANOS); + this.leftAt = null; + this.visibleMessageFrom = visibleFrom; + this.lastReadAt = visibleFrom; + } + /** * 사용자가 채팅방을 다시 열어 새 대화를 시작할 때 사용한다. *

diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index e5d52eb31..cd9233def 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -735,6 +735,10 @@ private ChatMessageDetailResponse sendDirectMessage( } chatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + members.stream() + .filter(member -> !member.getUserId().equals(userId)) + .filter(ChatRoomMember::hasLeft) + .forEach(member -> member.restoreDirectRoomFromIncomingMessage(chatMessage.getCreatedAt())); // 어드민이 보낸 경우는 lastReadAt 업데이트하지 않음 (멤버가 아니므로) if (!isAdminSendingToSystemAdminRoom) { diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index d34f8ffdb..b7a479826 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -17,10 +17,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.transaction.TestTransaction; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.LinkedMultiValueMap; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; @@ -50,6 +53,16 @@ class ChatApiTest extends IntegrationTestSupport { private static final int SYSTEM_ADMIN_ID = 1; + private static final String CHAT_TEST_DATA_CLEANUP_SQL = """ + DELETE FROM notification_mute_setting; + DELETE FROM chat_message; + DELETE FROM chat_room_member; + DELETE FROM chat_room; + DELETE FROM club_member; + DELETE FROM club; + DELETE FROM users; + DELETE FROM university; + """; @Autowired private ChatRoomRepository chatRoomRepository; @@ -63,6 +76,9 @@ class ChatApiTest extends IntegrationTestSupport { @Autowired private NotificationMuteSettingRepository notificationMuteSettingRepository; + @Autowired + private TransactionTemplate transactionTemplate; + @MockitoBean private ChatPresenceService chatPresenceService; @@ -76,28 +92,32 @@ class ChatApiTest extends IntegrationTestSupport { private University university; @BeforeEach - void setUp() { - university = persist(UniversityFixture.create()); - // System Admin을 먼저 생성 - 문의 채팅방용 - adminUser = persist(UserFixture.createAdmin(university)); - // SYSTEM_ADMIN_ID가 아니면 SQL로 해당 ID 사용자를 추가 생성 - if (adminUser.getId() != SYSTEM_ADMIN_ID) { - entityManager.createNativeQuery(""" - INSERT INTO users (id, email, name, student_number, role, is_marketing_agreement, image_url, university_id, created_at, updated_at) - SELECT ?, 'system@koreatech.ac.kr', '시스템관리자', '2021000001', 'ADMIN', true, 'https://example.com/system-admin.png', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?) - """ - ).setParameter(1, SYSTEM_ADMIN_ID) - .setParameter(2, university.getId()) - .setParameter(3, SYSTEM_ADMIN_ID) - .executeUpdate(); - entityManager.flush(); - } - normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); - clearPersistenceContext(); + public void setUp() { + transactionTemplate.execute(status -> { + university = persist(UniversityFixture.create()); + // System Admin을 먼저 생성 - 문의 채팅방용 + adminUser = persist(UserFixture.createAdmin(university)); + // 일부 테스트는 NOT_SUPPORTED로 실행되므로, 공통 픽스처는 명시적 트랜잭션 안에서 만든다. + if (adminUser.getId() != SYSTEM_ADMIN_ID) { + entityManager.createNativeQuery(""" + INSERT INTO users (id, email, name, student_number, role, is_marketing_agreement, image_url, university_id, created_at, updated_at) + SELECT ?, 'system@koreatech.ac.kr', '시스템관리자', '2021000001', 'ADMIN', true, 'https://example.com/system-admin.png', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?) + """ + ).setParameter(1, SYSTEM_ADMIN_ID) + .setParameter(2, university.getId()) + .setParameter(3, SYSTEM_ADMIN_ID) + .executeUpdate(); + entityManager.flush(); + } + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + return null; + }); } - private ChatRoom createDirectChatRoom(User firstUser, User secondUser) { + @Transactional + public ChatRoom createDirectChatRoom(User firstUser, User secondUser) { ChatRoom chatRoom = persist(ChatRoom.directOf()); LocalDateTime joinedAt = chatRoom.getCreatedAt(); ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); @@ -368,8 +388,12 @@ void adminCanReadInquiryRoomMessagesWithoutMembership() throws Exception { @Test @DisplayName("어드민이 나간 문의 채팅방에 사용자가 새 메시지를 보내 어드민 목록에 다시 노출된다") - @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @Transactional(propagation = Propagation.REQUIRES_NEW) + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) void adminLeftInquiryRoomReappearsWhenUserSendsNewMessage() throws Exception { // given - 문의 채팅방 생성 (일반 사용자 -> system admin) mockLoginUser(normalUser.getId()); @@ -833,18 +857,29 @@ void updateChatRoomNameForbidden() throws Exception { @Nested @DisplayName("DELETE /chats/rooms/{chatRoomId} - 채팅방 나가기") + @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class LeaveChatRoom { @BeforeEach void setUpLeaveFixture() { - targetUser = createUser("상대유저", "2021136002"); - clearPersistenceContext(); + transactionTemplate.execute(status -> { + targetUser = createUser("상대유저", "2021136002"); + clearPersistenceContext(); + return null; + }); } @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("1:1 채팅방을 나가면 목록에서 숨겨지고 새 메시지부터 다시 보인다") void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { - ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); + // 트랜잭션 내에서 테스트 데이터 생성 (NOT_SUPPORTED 테스트는 트랜잭션 없이 실행됨) + ChatRoom[] chatRoomHolder = new ChatRoom[1]; + transactionTemplate.execute(status -> { + chatRoomHolder[0] = createDirectChatRoom(normalUser, targetUser); + return null; + }); + ChatRoom chatRoom = chatRoomHolder[0]; mockLoginUser(normalUser.getId()); performPost("/chats/rooms/" + chatRoom.getId() + "/messages", new ChatMessageSendRequest("첫 메시지")) @@ -853,11 +888,14 @@ void leaveDirectChatRoomAndShowOnlyNewMessages() throws Exception { performDelete("/chats/rooms/" + chatRoom.getId()) .andExpect(status().isNoContent()); - clearPersistenceContext(); - ChatRoomMember leftMember = chatRoomMemberRepository - .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) - .orElseThrow(); - assertThat(leftMember.hasLeft()).isTrue(); + transactionTemplate.execute(status -> { + clearPersistenceContext(); + ChatRoomMember leftMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(chatRoom.getId(), normalUser.getId()) + .orElseThrow(); + assertThat(leftMember.hasLeft()).isTrue(); + return null; + }); mockLoginUser(normalUser.getId()); performGet("/chats/rooms") @@ -904,6 +942,10 @@ void createOrGetChatRoomAfterLeaveStartsFresh() throws Exception { performDelete("/chats/rooms/" + chatRoom.getId()) .andExpect(status().isNoContent()); + // 트랜잭션 커밋 + TestTransaction.flagForCommit(); + TestTransaction.end(); + performPost("/chats/rooms", new ChatRoomCreateRequest(targetUser.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.chatRoomId").value(chatRoom.getId())); @@ -1006,8 +1048,12 @@ void getMessagesNotFound() throws Exception { } @Test - @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @DisplayName("참여하지 않은 사용자가 조회하면 403을 반환한다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) void getMessagesForbidden() throws Exception { // given ChatRoom chatRoom = createDirectChatRoom(normalUser, targetUser); @@ -1619,8 +1665,12 @@ void kickedMemberCannotSendMessage() throws Exception { } @Test - @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @DisplayName("강퇴된 멤버는 메시지를 조회할 수 없다") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) void kickedMemberCannotGetMessages() throws Exception { // given Integer roomId = groupRoom.getId(); @@ -1734,4 +1784,265 @@ void kickFailsAfterOwnerLeaves() throws Exception { .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_KICK")); } } + + @Nested + @DisplayName("GET /chats/rooms/{chatRoomId} - 메시지 조회 페이지네이션 엣지 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class GetMessagesPaginationEdgeCases { + + private ChatRoom directRoom; + private User chatPartner; + + @BeforeEach + void setUpPaginationFixture() { + chatPartner = createUser("채팅상대", "2021136006"); + clearPersistenceContext(); + } + + @Test + @DisplayName("빈 채팅방의 메시지를 조회하면 빈 목록을 반환한다") + void getMessagesFromEmptyRoomReturnsEmptyList() throws Exception { + // given - 메시지가 없는 새로 생성된 방 + directRoom = createDirectChatRoom(normalUser, chatPartner); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").isEmpty()) + .andExpect(jsonPath("$.totalCount").value(0)) + .andExpect(jsonPath("$.currentPage").value(1)) + .andExpect(jsonPath("$.totalPage").value(0)); + } + + @Test + @DisplayName("존재하지 않는 페이지를 조회하면 빈 목록을 반환한다") + void getMessagesFromNonExistentPageReturnsEmptyList() throws Exception { + // given - 메시지 5개 생성 + directRoom = createDirectChatRoom(normalUser, chatPartner); + for (int i = 1; i <= 5; i++) { + persistChatMessage(directRoom, chatPartner, "메시지 " + i); + } + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then - 100페이지 조회 (존재하지 않음) + performGet("/chats/rooms/" + directRoom.getId() + "?page=100&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").isEmpty()) + .andExpect(jsonPath("$.currentPage").value(100)) + .andExpect(jsonPath("$.totalPage").value(1)); + } + + @Test + @DisplayName("마지막 페이지는 부분 결과를 반환할 수 있다") + void getMessagesLastPageReturnsPartialResults() throws Exception { + // given - 메시지 25개 생성 + directRoom = createDirectChatRoom(normalUser, chatPartner); + for (int i = 1; i <= 25; i++) { + persistChatMessage(directRoom, chatPartner, "메시지 " + i); + } + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(normalUser.getId()); + + // when & then - limit=10, page=3 (21-25번 메시지, 5개만 반환) + performGet("/chats/rooms/" + directRoom.getId() + "?page=3&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messages").isArray()) + .andExpect(jsonPath("$.messages").value(org.hamcrest.Matchers.hasSize(5))) + .andExpect(jsonPath("$.currentPage").value(3)) + .andExpect(jsonPath("$.totalPage").value(3)); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/messages - 메시지 전송 권한 및 엣지 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class SendMessageEdgeCases { + + private ChatRoom groupRoom; + private User roomOwner; + private User roomMember; + private User nonMember; + + @BeforeEach + void setUpSendMessageEdgeFixture() { + roomOwner = createUser("방장", "2021136007"); + roomMember = createUser("방멤버", "2021136008"); + nonMember = createUser("비멤버", "2021136009"); + clearPersistenceContext(); + } + + @Test + @DisplayName("정확히 1000자 메시지는 전송 성공한다") + void sendMessageExactly1000CharsSuccess() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + String exactly1000Chars = "a".repeat(1000); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest(exactly1000Chars)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(exactly1000Chars)); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 전송할 수 없다") + void sendMessageByNonMemberReturnsForbidden() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(nonMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest("Unauthorized message")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 메시지를 전송할 수 없다") + void sendMessageByKickedMemberReturnsForbidden() throws Exception { + // given - 방장이 멤버를 강퇴 + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomOwner.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + roomMember.getId()) + .andExpect(status().isNoContent()); + + // when & then - 강퇴된 멤버가 메시지 전송 시도 + mockLoginUser(roomMember.getId()); + performPost("/chats/rooms/" + groupRoom.getId() + "/messages", + new ChatMessageSendRequest("Kicked member message")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 권한 케이스") + @Sql( + statements = CHAT_TEST_DATA_CLEANUP_SQL, + config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED), + executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD + ) + class ToggleMutePermissionCases { + + private ChatRoom groupRoom; + private User roomOwner; + private User roomMember; + private User nonMember; + + @BeforeEach + void setUpMutePermissionFixture() { + roomOwner = createUser("방장", "2021136010"); + roomMember = createUser("방멤버", "2021136011"); + nonMember = createUser("비멤버", "2021136012"); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 뮤트 설정을 변경할 수 없다") + void toggleMuteByNonMemberReturnsForbidden() throws Exception { + // given + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(nonMember.getId()); + + // when & then + performPost("/chats/rooms/" + groupRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + + @Test + @DisplayName("강퇴된 멤버는 뮤트 설정을 변경할 수 없다") + void toggleMuteByKickedMemberReturnsForbidden() throws Exception { + // given - 방장이 멤버를 강퇴 + groupRoom = createGroupChatRoomWithOwner(roomOwner, roomMember); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + mockLoginUser(roomOwner.getId()); + performDelete("/chats/rooms/" + groupRoom.getId() + "/members/" + roomMember.getId()) + .andExpect(status().isNoContent()); + + // when & then - 강퇴된 멤버가 뮤트 토글 시도 + mockLoginUser(roomMember.getId()); + performPost("/chats/rooms/" + groupRoom.getId() + "/mute") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN_CHAT_ROOM_ACCESS")); + } + } + + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색 엣지 케이스") + class SearchChatsEdgeCases { + + private ChatRoom directRoom; + private User chatPartner; + + @BeforeEach + void setUpSearchEdgeFixture() { + chatPartner = createUser("검색상대", "2021136013"); + directRoom = createDirectChatRoom(normalUser, chatPartner); + persistChatMessage(directRoom, chatPartner, "검색 가능한 메시지"); + clearPersistenceContext(); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 목록을 반환한다") + void searchChatsWithNoMatchesReturnsEmpty() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then - 존재하지 않는 키워드로 검색 + performGet("/chats/rooms/search?keyword=존재하지않는키워드12345&page=1&limit=20") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.rooms").isArray()) + .andExpect(jsonPath("$.roomMatches.rooms").isEmpty()) + .andExpect(jsonPath("$.messageMatches.messages").isArray()) + .andExpect(jsonPath("$.messageMatches.messages").isEmpty()); + } + + @Test + @DisplayName("한 글자 키워드로 검색해도 200을 반환한다") + void searchChatsWithSingleCharacterKeywordReturnsOk() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then - 1글자 키워드로 검색 + performGet("/chats/rooms/search?keyword=a&page=1&limit=20") + .andExpect(status().isOk()); + } + } } From d67bc39c155e09eefd57ee1579f389b3b63cfc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:15:23 +0900 Subject: [PATCH 16/50] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20userId=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A7=A4=ED=95=91=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20(#510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/dto/ClubApplicationsResponse.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java index 1d18cca24..cbe5a1c1d 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubApplicationsResponse.java @@ -32,6 +32,9 @@ public record ClubApplicationResponse( @Schema(description = "지원 ID", example = "1", requiredMode = REQUIRED) Integer id, + @Schema(description = "유저 ID", example = "1", requiredMode = REQUIRED) + Integer userId, + @Schema(description = "지원자 학번", example = "20250120", requiredMode = REQUIRED) String studentNumber, @@ -51,6 +54,7 @@ public record ClubApplicationResponse( public static ClubApplicationResponse from(ClubApply clubApply) { return new ClubApplicationResponse( clubApply.getId(), + clubApply.getUser().getId(), clubApply.getUser().getStudentNumber(), clubApply.getUser().getName(), clubApply.getUser().getImageUrl(), From 3ea7ddca49004303e21d4e062d2ad3a7b55aafdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:40:20 +0900 Subject: [PATCH 17/50] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EB=93=B1=EB=A1=9D=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EB=93=B1=EB=A1=9D=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 동아리 사전 등록 회원 배치 등록 API 추가 한 번에 여러 명의 회원을 사전 등록할 수 있는 배치 API를 추가합니다. - POST /clubs/{clubId}/pre-members/batch 엔드포인트 추가 - 1~50명까지 한 번에 등록 가능 (부분 성공 지원) - 개별 트랜잭션 처리로 일부 실패 시에도 나머지는 등록됨 - 등록 결과는 성공/실패 통계와 개별 상세 정보를 포함 주요 변경사항: - ClubPreMemberBatchAddRequest: 배치 요청 DTO - ClubPreMemberBatchAddResponse: 배치 응답 DTO - ClubPreMemberBatchResultItem: 개별 결과 DTO - ClubMemberManagementService.addPreMembersBatch(): 배치 등록 메서드 관련 테스트: - 통합 테스트 5개 케이스 추가 - 단위 테스트 2개 케이스 추가 * refactor: 배치 등록 코드 개선 - TransactionTemplate을 루프 외부에서 한 번만 생성하도록 변경 - processSinglePreMember() 중복 메서드 제거하고 기존 addPreMember() 재사용 - 테스트 파일에서 불필요한 print 임포트 제거 * fix: 배치 요청 DTO에 @NotNull 추가 members 필드에 @NotNull 어노테이션을 추가하여 null이나 필드 생략 시 400 에러를 반환하도록 수정 * fix: 배치 요청에 @Valid 및 요소 수준 @NotNull 추가 - @Valid 추가로 개별 회원 항목의 제약조건 검증 활성화 - List<@NotNull ClubPreMemberAddRequest>로 null 요소 방지 * test: 배치 등록 API 검증 테스트 추가 - null 리스트 요청 시 400 반환 테스트 - null 요소 포함 리스트 400 반환 테스트 - MediaType 임포트 및 mockMvc 직접 사용 방식으로 테스트 개선 * chore: 코드 포맷팅 * chore: 배치 등록 최대 인원을 50명에서 300명으로 확대 대규모 동아리의 일괄 회원 등록 요구사항을 반영하여 배치 등록 가능 인원을 확대함. * refactor: 불필요한 변수 제거 * feat: 누락된 `@Valid` 어노테이션 추가 * refactor: 배치 등록 로직 중복 제거 및 함수 분리 --- .../domain/club/controller/ClubMemberApi.java | 38 +++- .../club/controller/ClubMemberController.java | 13 ++ .../dto/ClubPreMemberBatchAddRequest.java | 18 ++ .../dto/ClubPreMemberBatchAddResponse.java | 32 ++++ .../dto/ClubPreMemberBatchResultItem.java | 65 +++++++ .../service/ClubMemberManagementService.java | 42 +++++ .../ClubMemberManagementServiceBatchTest.java | 114 ++++++++++++ .../domain/club/ClubMemberApiTest.java | 166 ++++++++++++++++++ 8 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java create mode 100644 src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java index 718c36ad8..a799a220b 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java @@ -11,6 +11,8 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; @@ -29,7 +31,7 @@ public interface ClubMemberApi { @Operation(summary = "동아리 회원의 직책을 변경한다.", description = """ 동아리 회장 또는 부회장만 회원의 직책을 변경할 수 있습니다. 자기 자신의 직책은 변경할 수 없으며, 상위 직급만 하위 직급의 회원을 관리할 수 있습니다. - + ## 에러 - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신의 직책은 변경할 수 없습니다. - CANNOT_MANAGE_HIGHER_POSITION (400): 자신보다 높은 직급의 회원은 관리할 수 없습니다. @@ -50,7 +52,7 @@ ResponseEntity changeMemberPosition( @Operation(summary = "동아리 회장 권한을 위임한다.", description = """ 현재 회장만 회장 권한을 다른 회원에게 위임할 수 있습니다. 회장 위임 시 현재 회장은 일반회원으로 강등됩니다. - + ## 에러 - ILLEGAL_ARGUMENT (400): 자기 자신에게는 위임할 수 없습니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. @@ -67,7 +69,7 @@ ResponseEntity transferPresident( @Operation(summary = "동아리 부회장을 변경한다.", description = """ 동아리 회장만 부회장을 임명하거나 해제할 수 있습니다. vicePresidentUserId가 null이면 부회장을 해제하고, 값이 있으면 해당 회원을 부회장으로 임명합니다. - + ## 에러 - CANNOT_CHANGE_OWN_POSITION (400): 자기 자신을 부회장으로 임명할 수 없습니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 회장 권한이 없습니다. @@ -93,7 +95,7 @@ ResponseEntity changeVicePresident( - 사전 등록된 회원이 서비스에 가입하면 지정한 직책(clubPosition)으로 자동 전환됩니다. - clubPosition이 PRESIDENT인 경우, 기존 회장 회원 정보는 제거되고 새 가입자가 회장으로 등록됩니다. - 응답의 `isDirectMember`가 `false`로 반환됩니다. - + ## 에러 - ALREADY_CLUB_MEMBER (409): 이미 동아리 회원입니다. (서비스 가입자인 경우) - ALREADY_CLUB_PRE_MEMBER (409): 이미 동아리에 사전 등록된 회원입니다. (서비스 미가입자인 경우) @@ -107,9 +109,31 @@ ResponseEntity addPreMember( @UserId Integer userId ); + @Operation(summary = "학번으로 여러 회원을 동아리에 일괄 등록한다.", description = """ + 운영진 이상만 사전 등록 회원을 일괄 등록할 수 있습니다. + + ## 로직 + - 각 회원은 개별적으로 처리되어, 일부 실패 시에도 나머지는 등록됩니다. + - 해당 학번의 사용자가 이미 서비스에 가입한 경우: 동아리 회원(ClubMember)에 직접 추가됩니다. + - 해당 학번의 사용자가 서비스에 가입하지 않은 경우: 사전 회원(ClubPreMember)으로 등록됩니다. + - 응답의 `success` 필드로 개별 회원의 등록 성공 여부를 확인할 수 있습니다. + + ## 에러 + - INVALID_INPUT (400): 회원 목록은 1~50명까지 가능합니다. + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """ + ) + @PostMapping("/{clubId}/pre-members/batch") + ResponseEntity addPreMembersBatch( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPreMemberBatchAddRequest request, + @UserId Integer userId + ); + @Operation(summary = "동아리 사전 등록 회원 리스트를 조회한다.", description = """ 동아리 운영진 권한부터 사전 등록 회원 리스트를 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -122,7 +146,7 @@ ResponseEntity getPreMembers( @Operation(summary = "동아리 사전 등록 회원을 삭제한다.", description = """ 동아리 운영진 권한부터 사전 등록 회원을 삭제할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -138,7 +162,7 @@ ResponseEntity removePreMember( @Operation(summary = "동아리 회원을 강제 탈퇴시킨다.", description = """ 동아리 회장 또는 부회장만 회원을 강제 탈퇴시킬 수 있습니다. 일반회원만 강제 탈퇴 가능하며, 부회장이나 운영진은 먼저 직책을 변경한 후 탈퇴시켜야 합니다. - + ## 에러 - CANNOT_REMOVE_SELF (400): 자기 자신을 강제 탈퇴시킬 수 없습니다. - CANNOT_REMOVE_NON_MEMBER (400): 일반회원만 강제 탈퇴할 수 있습니다. diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java index 65953a85f..95f71d1d2 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java @@ -10,6 +10,8 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; @@ -76,6 +78,17 @@ public ResponseEntity addPreMember( return ResponseEntity.ok(response); } + @Override + public ResponseEntity addPreMembersBatch( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubPreMemberBatchAddRequest request, + @UserId Integer userId + ) { + ClubPreMemberBatchAddResponse response = clubMemberManagementService.addPreMembersBatch(clubId, userId, + request); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getPreMembers( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java new file mode 100644 index 000000000..bc924c104 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddRequest.java @@ -0,0 +1,18 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ClubPreMemberBatchAddRequest( + @NotNull(message = "회원 목록은 필수입니다.") + @Size(min = 1, max = 300, message = "회원은 최소 1명에서 최대 300명까지 등록할 수 있습니다.") + @Schema(description = "사전 등록할 회원 목록", requiredMode = REQUIRED) + @Valid List<@NotNull ClubPreMemberAddRequest> members +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java new file mode 100644 index 000000000..741915186 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchAddResponse.java @@ -0,0 +1,32 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPreMemberBatchAddResponse( + @Schema(description = "전체 요청 수", example = "10", requiredMode = REQUIRED) + Integer totalCount, + + @Schema(description = "성공한 회원 수", example = "8", requiredMode = REQUIRED) + Integer successCount, + + @Schema(description = "실패한 회원 수", example = "2", requiredMode = REQUIRED) + Integer failedCount, + + @Schema(description = "개별 처리 결과 목록", requiredMode = REQUIRED) + List results +) { + + public static ClubPreMemberBatchAddResponse from(List results) { + int totalCount = results.size(); + int successCount = (int)results.stream() + .filter(ClubPreMemberBatchResultItem::success) + .count(); + int failedCount = totalCount - successCount; + + return new ClubPreMemberBatchAddResponse(totalCount, successCount, failedCount, results); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java new file mode 100644 index 000000000..c0a04136d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMemberBatchResultItem.java @@ -0,0 +1,65 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.global.code.ApiResponseCode; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPreMemberBatchResultItem( + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @Schema(description = "이름", example = "홍길동", requiredMode = REQUIRED) + String name, + + @Schema(description = "성공 여부", example = "true", requiredMode = REQUIRED) + Boolean success, + + // 성공 시 반환되는 필드 + @Schema(description = "동아리 고유 ID (성공 시)", example = "1") + Integer clubId, + + @Schema(description = "가입 직책 (성공 시)", example = "MEMBER") + ClubPosition clubPosition, + + @Schema(description = "직접 회원 가입 여부 (성공 시: true면 ClubMember 직접 추가, false면 ClubPreMember 사전등록)", + example = "false") + Boolean isDirectMember, + + // 실패 시 반환되는 필드 + @Schema(description = "오류 코드 (실패 시)", example = "ALREADY_CLUB_MEMBER") + String errorCode, + + @Schema(description = "오류 메시지 (실패 시)", example = "이미 동아리 회원입니다.") + String errorMessage +) { + + public static ClubPreMemberBatchResultItem success(ClubPreMemberAddRequest request, + ClubPreMemberAddResponse response) { + return new ClubPreMemberBatchResultItem( + request.studentNumber(), + request.name(), + true, + response.clubId(), + response.clubPosition(), + response.isDirectMember(), + null, + null + ); + } + + public static ClubPreMemberBatchResultItem fail(ClubPreMemberAddRequest request, + ApiResponseCode errorCode) { + return new ClubPreMemberBatchResultItem( + request.studentNumber(), + request.name(), + false, + null, + null, + null, + errorCode.name(), + errorCode.getMessage() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 3b0449201..f3ec690f9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -5,14 +5,20 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchResultItem; import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; @@ -42,6 +48,7 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; + private final PlatformTransactionManager transactionManager; @Transactional public ClubMember changeMemberPosition( @@ -91,6 +98,13 @@ public ClubPreMemberAddResponse addPreMember( clubPermissionValidator.validateManagerAccess(clubId, requesterId); + return processPreMemberWithoutValidation(club, request); + } + + private ClubPreMemberAddResponse processPreMemberWithoutValidation( + Club club, + ClubPreMemberAddRequest request + ) { String studentNumber = request.studentNumber(); String name = request.name(); ClubPosition clubPosition = request.clubPosition() == null ? MEMBER : request.clubPosition(); @@ -161,6 +175,34 @@ private ClubPreMemberAddResponse addPreMemberInternal( return ClubPreMemberAddResponse.from(savedPreMember); } + public ClubPreMemberBatchAddResponse addPreMembersBatch( + Integer clubId, + Integer requesterId, + ClubPreMemberBatchAddRequest request + ) { + Club club = clubRepository.getById(clubId); + + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + List results = new ArrayList<>(); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + for (ClubPreMemberAddRequest memberRequest : request.members()) { + try { + ClubPreMemberAddResponse response = Objects.requireNonNull( + transactionTemplate.execute(status -> processPreMemberWithoutValidation(club, memberRequest)) + ); + results.add(ClubPreMemberBatchResultItem.success(memberRequest, response)); + } catch (CustomException e) { + results.add(ClubPreMemberBatchResultItem.fail(memberRequest, e.getErrorCode())); + } catch (Exception e) { + results.add(ClubPreMemberBatchResultItem.fail(memberRequest, UNEXPECTED_SERVER_ERROR)); + } + } + + return ClubPreMemberBatchAddResponse.from(results); + } + public ClubPreMembersResponse getPreMembers(Integer clubId, Integer requesterId) { clubRepository.getById(clubId); diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java new file mode 100644 index 000000000..7248ec705 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java @@ -0,0 +1,114 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.List; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class ClubMemberManagementServiceBatchTest { + + @InjectMocks + private ClubMemberManagementService clubMemberManagementService; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private PlatformTransactionManager transactionManager; + + private Club club; + private University university; + private Integer clubId = 1; + private Integer requesterId = 100; + + @BeforeEach + void setUp() { + university = University.builder() + .id(1) + .koreanName("Test University") + .campus(Campus.MAIN) + .build(); + + club = Club.builder() + .id(clubId) + .name("Test Club") + .university(university) + .build(); + } + + @Test + @DisplayName("배치 등록 요청 시 권한 검증이 먼저 수행된다") + void addPreMembersBatch_validatesPermissionFirst() { + // given + when(clubRepository.getById(clubId)).thenReturn(club); + doThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)) + .when(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("2022000001", "학생1", ClubPosition.MEMBER); + ClubPreMemberBatchAddRequest batchRequest = new ClubPreMemberBatchAddRequest(List.of(request)); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + clubMemberManagementService.addPreMembersBatch(clubId, requesterId, batchRequest); + }); + assertThat(exception.getErrorCode()).isEqualTo(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS); + } + + @Test + @DisplayName("배치 등록 요청은 최소 1명 이상의 회원이 필요하다") + void addPreMembersBatch_requiresAtLeastOneMember() { + // given + ClubPreMemberBatchAddRequest batchRequest = new ClubPreMemberBatchAddRequest(List.of()); + + // when & then - Bean Validation은 컨트롤러 계층에서 처리되므로 서비스에서는 정상 동작 + // 실제로는 컨트롤러에서 @Valid로 인해 400 에러가 반환됨 + when(clubRepository.getById(clubId)).thenReturn(club); + + // 빈 리스트로 호출해도 서비스는 동작하되 결과는 빈 리스트 + var response = clubMemberManagementService.addPreMembersBatch(clubId, requesterId, batchRequest); + assertThat(response.totalCount()).isEqualTo(0); + assertThat(response.successCount()).isEqualTo(0); + assertThat(response.failedCount()).isEqualTo(0); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java index 1889b4e47..3daec0093 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java @@ -2,16 +2,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.ArrayList; +import java.util.List; + 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.http.MediaType; import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; @@ -364,6 +370,166 @@ void addPreMemberByMemberFails() throws Exception { } } + @Nested + @DisplayName("POST /clubs/{clubId}/pre-members/batch - 사전 멤버 일괄 등록") + class AddPreMembersBatch { + + @Test + @DisplayName("여러 명의 사전 멤버를 일괄 등록한다") + void addPreMembersBatchSuccess() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + String requestBody = """ + {"members": [ + {"studentNumber": "2022000001", "name": "신입생1", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000002", "name": "신입생2", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000003", "name": "신입생3", "clubPosition": "MANAGER"} + ]} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(3)) + .andExpect(jsonPath("$.successCount").value(3)) + .andExpect(jsonPath("$.failedCount").value(0)) + .andExpect(jsonPath("$.results", hasSize(3))) + .andExpect(jsonPath("$.results[0].success").value(true)) + .andExpect(jsonPath("$.results[1].success").value(true)) + .andExpect(jsonPath("$.results[2].success").value(true)); + } + + @Test + @DisplayName("일부 실패 시에도 나머지는 등록된다 (부분 성공)") + void addPreMembersBatchPartialSuccess() throws Exception { + // given - 이미 존재하는 사용자를 먼저 사전 등록 + User existingUser = persist(UserFixture.createUser(university, "기존유저", "2022000002")); + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 먼저 첫 번째 멤버를 등록하여 중복 상황 만들기 + String firstRequest = """ + {"members": [{"studentNumber": "%s", "name": "%s", "clubPosition": "MEMBER"}]} + """.formatted(existingUser.getStudentNumber(), existingUser.getName()); + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(firstRequest)); + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 같은 멤버를 다시 배치 등록 시도 (첫 번째는 이미 존재해서 실패, 두 번째는 성공) + String batchRequest = """ + {"members": [ + {"studentNumber": "%s", "name": "%s", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000003", "name": "신입생", "clubPosition": "MEMBER"} + ]} + """.formatted(existingUser.getStudentNumber(), existingUser.getName()); + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(batchRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(2)) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failedCount").value(1)) + .andExpect(jsonPath("$.results[0].success").value(false)) + .andExpect(jsonPath("$.results[1].success").value(true)); + } + + @Test + @DisplayName("300명 초과 요청 시 400을 반환한다") + void addPreMembersBatchExceedsLimit() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + List members = new ArrayList<>(); + for (int i = 0; i < 301; i++) { + members.add( + new ClubPreMemberAddRequest("2022" + String.format("%06d", i), "학생" + i, ClubPosition.MEMBER)); + } + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(members); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("일반 멤버는 배치 등록을 할 수 없다") + void addPreMembersBatchByMemberFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(member.getId()); + + List members = List.of( + new ClubPreMemberAddRequest("2022000001", "신입생", ClubPosition.MEMBER) + ); + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(members); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("빈 리스트 요청 시 400을 반환한다") + void addPreMembersBatchEmptyListFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(List.of()); + + // when & then + performPost("/clubs/" + club.getId() + "/pre-members/batch", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("null 리스트 요청 시 400을 반환한다") + void addPreMembersBatchNullListFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // JSON에서 members를 null로 설정하여 요청 + String requestBody = """ + {"members": null} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("null 요소가 포함된 리스트는 400을 반환한다") + void addPreMembersBatchNullElementFails() throws Exception { + // given + clearPersistenceContext(); + mockLoginUser(president.getId()); + + // 리스트에 null 요소가 포함된 요청 + String requestBody = """ + {"members": [null]} + """; + + // when & then + mockMvc.perform(post("/clubs/" + club.getId() + "/pre-members/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + } + @Nested @DisplayName("GET /clubs/{clubId}/pre-members - 사전 멤버 조회") class GetPreMembers { From 2e27c9954eea6adee00c04ff629503def96f543c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:37:58 +0900 Subject: [PATCH 18/50] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=B1=84=ED=8C=85=20=EB=B0=A9=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B3=91=ED=95=A9=20(#511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: DIRECT 채팅방 중복 병합 처리 SQL 추가 * fix: DIRECT 채팅방 중복 병합 처리 SQL 추가 * fix: V69 마이그레이션에서 하드코딩된 schema 선택 제거 - USE konect; 문장 삭제하여 runtime datasource 기반 동작하도록 수정 - 다양한 환경 및 권한 설정에서 정상 작동하도록 개선 * fix: DIRECT 채팅방 병합 시 메시지 존재하는 방 우선 선택 및 메타데이터 갱신 - ROW_NUMBER() 정렬 로직 수정: (last_message_at IS NOT NULL) DESC를 첫 번째 정렬 기준으로 추가하여 메시지가 있는 방을 우선 선택 - 병합 후 keep_room의 last_message_content와 last_message_sent_at을 실제 메시지 기반으로 재계산하는 단계 추가 - 빈 방이 winner로 선택되어 last_message_* 컬럼이 stale 상태로 남는 문제 방지 * fix: 채팅 메시지 이동 시 updated_at 타임스탬프 보존 - chat_message 테이블 UPDATE 시 ON UPDATE CURRENT_TIMESTAMP로 인해 updated_at이 자동 갱신되는 문제 방지 - updated_at = updated_at 명시적 설정으로 원본 타임스탬프 유지 * fix: 채팅방 병합 시 멤버십 상태 보존 로직 추가 - 삭제 대상 방 멤버십 삭제 전, visible_message_from과 left_at 값을 keep 방으로 병합하는 UPDATE 단계 추가 (step 4) - visible_message_from: 더 이른 값(더 많은 메시지 조회 가능) 선택 - left_at: 둘 중 하나라도 나간 경우 나간 것으로 처리 (더 이른 값 선택) - 기존 멤버십 데이터 손실 방지 및 step 번호 재정렬 (5-8) * fix: 채팅방 병합 시 누락된 사용자 상태 병합 추가 - last_read_at: GREATEST로 병합하여 더 나중 읽음 시점 보존 - custom_room_name: COALESCE로 사용자 설정 방 이름 보존 - notification_mute_setting: target_id를 keep_room_id로 업데이트 (이미 keep 방에 설정 있으면 UNIQUE 제약으로 자동 삭제됨) - step 번호 재정렬 (4-9) * fix: last_message_content 재계산 시 타임스탬프 동일 문제 해결 - 기존: MAX(created_at)만 사용하여 타임스탬프 동일 시 여러 행 반환 가능 - 개선: MAX(id)로 최신 메시지를 결정론적으로 선택 - 서브쿼리에서 chat_room_id별 MAX(id)를 먼저 계산 후 해당 id를 가진 메시지와 조인하여 단일 행 보장 * fix: V69 마이그레이션 재시도 가능하도록 개선 - temp_duplicate_room_map을 CREATE TABLE IF NOT EXISTS로 생성하여 재시도 시 기존 매핑 테이블 재사용 - 후보 방 선정 시 매핑 테이블에 이미 있는 방도 포함 (재시도 시 멤버 0명이 된 방도 처리 가능) - INSERT ... ON DUPLICATE KEY UPDATE로 매핑 idempotent 처리 - temp_direct_room_pairs는 매 실행 새로 생성하여 최신 상태 반영 * fix: 다중 loser 방 매핑 시 멤버십 상태 병합 충돌 해결 - 여러 from_room_id가 같은 keep_room_id로 매핑될 때 동일한 타겟 행에 대한 다중 업데이트 충돌 방지 - loser 멤버십을 (keep_room_id, user_id) 기준으로 집계하여 단일 UPDATE로 처리하도록 개선 * fix: V69 마이그레이션 재시도 시 매핑 테이블 보호 재시도 시 ON DUPLICATE KEY UPDATE로 인해 keep/from 방향이 뒤바뀔 수 있는 문제를 해결하기 위해: - 매핑 테이블이 비어있을 때만 INSERT 실행 (NOT EXISTS 조건 추가) - ON DUPLICATE KEY UPDATE 절 제거 이제 재시도 시 기존 매핑이 유지되어 병합 방향이 일관되게 유지됨. * fix: V69 마이그레이션 뮤트 설정 UNIQUE 충돌 해결 notification_mute_setting UPDATE 시 (user_id, target_type, target_id) UNIQUE 제약조건 위반 문제를 해결하기 위해: - UPDATE 전에 keep_room에 이미 존재하는 뮤트 설정을 먼저 삭제 - 동일 사용자가 from_room과 keep_room 모두 뮤트 설정 시 from_room 설정이 우선하도록 처리 이제 중복 키 충돌 없이 알림 뮤트 설정 병합 가능. * fix: V69 마이그레이션 모든 엣지케이스 처리 발견된 잠재적 문제 및 해결: 1. 연산자 우선순위 버그 (치명적) - WHERE a AND b OR c → WHERE a AND (b OR c) 로 괄호 추가 2. chat_room_member 데이터 유실 (치명적) - loser 방에만 있는 멤버 INSERT 추가 (4b단계) - is_owner 컬럼 병합 로직 추가 (누락되었었음) 3. notification_mute_setting UNIQUE 충돌 - UPDATE 전 keep 방의 충돌하는 row 먼저 삭제 4. 재시도 안정성 - NOT EXISTS + ON DUPLICATE KEY UPDATE 제거로 매핑 보호 5. 주석 및 문서화 - 엣지케이스 설명 추가 - 각 단계 목적 명확화 * fix: V69 마이그레이션 다중 loser 방 PK/UNIQUE 충돌 해결 Codex adversarial review에서 지적된 문제 해결: 1. chat_room_member orphan INSERT PK 충돌 - GROUP BY (keep_room_id, user_id)로 집계하여 중복 삽입 방지 - MAX/MIN으로 각 컬럼 병합 규칙 적용 2. notification_mute_setting UNIQUE 충돌 - GROUP BY로 loser 설정 집계 (temp_mute_setting_agg) - MAX(is_muted)로 동일 사용자의 여러 설정 병합 - DELETE-INSERT 패턴으로 충돌 없이 마이그레이션 이제 다중 loser 방 → 단일 keep 방 시나리오에서도 안전하게 마이그레이션 실행 가능. * fix: V69 마이그레이션 LEAST/GREATEST NULL 처리 버그 수정 MySQL의 LEAST/GREATEST는 인자 중 하나라도 NULL이면 결과도 NULL을 반환하는 문제 해결: - visible_message_from: CASE로 NULL 처리 후 LEAST - last_read_at: CASE로 NULL 처리 후 GREATEST - left_at: 기존 CASE 패턴 유지 이제 loser/keep 중 하나라도 NULL이어도 유효한 값이 보존되고, 둘 다 NULL이면 NULL 유지. * fix: V69 마이그레이션 최신 메시지 선택 기준 개선 last_message_content 갱신 시 MAX(id) 대신 MAX(created_at)을 사용하여 실제 최신 메시지를 선택하도록 수정: - MAX(created_at)으로 최신 메시지 우선 선택 - 동일 타임스탬프 시 MAX(id)로 타임브레이커 적용 - 이전: id 기준 (시간 역순 불가능한 엣지케이스 존재) - 이후: 시간 기준 (결정론적이고 정확한 최신 메시지) * fix: V69 마이그레이션 0명 방 처리 개선 재시도 시 이미 처리된 방(from_room_id/keep_room_id)이 0명이 될 수 있는 경우를 처리하기 위해: - JOIN → LEFT JOIN 변경 - 0명 방도 EXISTS 조건으로 포함되도록 보존 - c1.user_id < c2.user_id는 ON 절에 유지 이제 이전 실행에서 멤버가 모두 삭제된 방도 재시도 시 매핑 테이블에서 찾을 수 있음. --- ...V69__merge_duplicate_direct_chat_rooms.sql | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql diff --git a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql new file mode 100644 index 000000000..67b91e613 --- /dev/null +++ b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql @@ -0,0 +1,271 @@ +-- DIRECT 타입 채팅방 중 같은 두 유저 간 중복된 방 병합 +-- 이유: findByTwoUsers 쿼리가 유니크 결과를 기대하지만 중복 방으로 인해 2개 이상 반환됨 +-- 해결: 메시지를 하나의 방으로 합치고 중복 방 제거 +-- +-- [엣지케이스 처리] +-- 1. 재시도 시 매핑 테이블 보호: NOT EXISTS로 기존 매핑 유지 +-- 2. 연산자 우선순위: WHERE 조건에 괄호로 묶어 AND/OR 우선순위 명확화 +-- 3. chat_room_member 병합: visible_message_from, left_at, last_read_at, custom_room_name, is_owner 모두 처리 +-- - 기존 멤버 UPDATE 후 GROUP BY로 집계하여 orphan INSERT (다중 loser 시 PK 충돌 방지) +-- 4. notification_mute_setting 충돌: 여러 loser 방의 설정을 GROUP BY로 집계 후 INSERT +-- - 동일 사용자가 여러 loser 방에 뮤트 설정 시 MAX(is_muted)로 병합 +-- 5. 메시지 중복 방지: from_room_id는 PK라 자연스럽게 중복 없음 +-- 6. 롤백 지원: 매핑 테이블 DROP/CREATE 대신 재사용 패턴 + +-- 임시 테이블 대신 실제 테이블 사용 (MySQL 임시 테이블 재참조 제한 회피) +-- 재시도 가능하도록: 기존 매핑 테이블이 있으면 스킵, 없으면 생성 +DROP TABLE IF EXISTS temp_direct_room_pairs; + +-- 이전 실행에서 남은 매핑 테이블이 있으면 재사용 (재시도 시) +-- 없으면 새로 생성 +CREATE TABLE IF NOT EXISTS temp_duplicate_room_map ( + from_room_id INT PRIMARY KEY, + keep_room_id INT NOT NULL, + user1_id INT NOT NULL, + user2_id INT NOT NULL +); + +-- 1) DIRECT 방 중 "정확히 2명"인 방만 유저쌍 단위로 펼침 +-- 또는 이미 매핑 테이블에 있는 방도 포함 (재시도 시 0명이 된 방 처리) +-- 주의: AND/OR 우선순위 때문에 조건을 명확히 괄호로 묶음 +-- LEFT JOIN 사용: 0명 방도 EXISTS 조건으로 포함되도록 보존 +CREATE TABLE temp_direct_room_pairs AS +SELECT + cr.id AS room_id, + LEAST(c1.user_id, c2.user_id) AS user1_id, + GREATEST(c1.user_id, c2.user_id) AS user2_id, + cr.created_at, + ( + SELECT MAX(cm.created_at) + FROM chat_message cm + WHERE cm.chat_room_id = cr.id + ) AS last_message_at +FROM chat_room cr +LEFT JOIN chat_room_member c1 + ON c1.chat_room_id = cr.id +LEFT JOIN chat_room_member c2 + ON c2.chat_room_id = cr.id + AND c1.user_id < c2.user_id +WHERE cr.room_type = 'DIRECT' + AND ( + ( + SELECT COUNT(*) + FROM chat_room_member m + WHERE m.chat_room_id = cr.id + ) = 2 + OR EXISTS ( + SELECT 1 FROM temp_duplicate_room_map existing + WHERE existing.from_room_id = cr.id OR existing.keep_room_id = cr.id + ) + ); + +-- 2) 중복 방 중 어떤 방을 남기고 어떤 방을 지울지 매핑 테이블 생성 +-- 매핑 테이블이 비어있는 경우에만 채움 (재시도 시 기존 매핑 유지) +-- ON DUPLICATE KEY UPDATE 제거: 재시도 시 매핑 방향이 뒤바뀌지 않도록 보호 +INSERT INTO temp_duplicate_room_map (from_room_id, keep_room_id, user1_id, user2_id) +SELECT + loser.room_id AS from_room_id, + winner.room_id AS keep_room_id, + loser.user1_id, + loser.user2_id +FROM ( + SELECT + room_id, + user1_id, + user2_id, + ROW_NUMBER() OVER ( + PARTITION BY user1_id, user2_id + ORDER BY + (last_message_at IS NOT NULL) DESC, + last_message_at DESC, + created_at DESC, + room_id DESC + ) AS rn, + COUNT(*) OVER ( + PARTITION BY user1_id, user2_id + ) AS room_count + FROM temp_direct_room_pairs +) loser +JOIN ( + SELECT + room_id, + user1_id, + user2_id, + ROW_NUMBER() OVER ( + PARTITION BY user1_id, user2_id + ORDER BY + (last_message_at IS NOT NULL) DESC, + last_message_at DESC, + created_at DESC, + room_id DESC + ) AS rn + FROM temp_direct_room_pairs +) winner + ON winner.user1_id = loser.user1_id + AND winner.user2_id = loser.user2_id + AND winner.rn = 1 +WHERE loser.room_count > 1 + AND loser.rn > 1 + AND NOT EXISTS (SELECT 1 FROM temp_duplicate_room_map); + +-- 3) 삭제 대상 방의 메시지를 keep 방으로 이동 +-- 메시지는 PK(id)로 관리되므로 중복 키 충돌 없음 +UPDATE chat_message cm +JOIN temp_duplicate_room_map m + ON cm.chat_room_id = m.from_room_id +SET cm.chat_room_id = m.keep_room_id, + cm.updated_at = cm.updated_at; + +-- 4) 삭제 대상 방의 멤버십 상태를 keep 방으로 병합 +-- visible_message_from: 더 이른 값(더 많은 메시지 조회 가능) 선택 +-- left_at: 둘 중 하나라도 나간 경우 나간 것으로 처리 (더 이른 값 선택) +-- last_read_at: 더 나중에 읽은 값(최신 읽음 시점) 선택 +-- custom_room_name: 사용자가 설정한 방 이름이 있으면 보존 +-- is_owner: 하나라도 owner면 owner 유지 (OR 조건) +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 먼저 집계하여 중복 업데이트 방지 + +-- 4a) 기존 keep_room 멤버 업데이트 +-- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되므로 CASE로 NULL 처리 +UPDATE chat_room_member t +JOIN ( + SELECT + m.keep_room_id, + crm.user_id, + MIN(crm.visible_message_from) AS min_visible_from, + MIN(crm.left_at) AS min_left_at, + MAX(crm.last_read_at) AS max_last_read_at, + MAX(crm.custom_room_name) AS max_custom_room_name, + MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS max_is_owner + FROM temp_duplicate_room_map m + JOIN chat_room_member crm ON crm.chat_room_id = m.from_room_id + GROUP BY m.keep_room_id, crm.user_id +) la ON t.chat_room_id = la.keep_room_id AND t.user_id = la.user_id +SET t.visible_message_from = CASE + WHEN t.visible_message_from IS NULL THEN la.min_visible_from + WHEN la.min_visible_from IS NULL THEN t.visible_message_from + ELSE LEAST(t.visible_message_from, la.min_visible_from) + END, + t.left_at = CASE + WHEN t.left_at IS NULL THEN la.min_left_at + WHEN la.min_left_at IS NULL THEN t.left_at + ELSE LEAST(t.left_at, la.min_left_at) + END, + t.last_read_at = CASE + WHEN t.last_read_at IS NULL THEN la.max_last_read_at + WHEN la.max_last_read_at IS NULL THEN t.last_read_at + ELSE GREATEST(t.last_read_at, la.max_last_read_at) + END, + t.custom_room_name = COALESCE(t.custom_room_name, la.max_custom_room_name), + t.is_owner = (t.is_owner OR la.max_is_owner > 0), + t.updated_at = t.updated_at; + +-- 4b) keep_room에 없는 loser 멤버 INSERT (orphan member 처리) +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 GROUP BY로 집계하여 PK 충돌 방지 +INSERT INTO chat_room_member ( + chat_room_id, user_id, last_read_at, created_at, updated_at, + visible_message_from, left_at, custom_room_name, is_owner +) +SELECT + m.keep_room_id, + crm.user_id, + MAX(crm.last_read_at) AS last_read_at, + MIN(crm.created_at) AS created_at, + MAX(crm.updated_at) AS updated_at, + MIN(crm.visible_message_from) AS visible_message_from, + MIN(crm.left_at) AS left_at, + MAX(crm.custom_room_name) AS custom_room_name, + MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS is_owner +FROM temp_duplicate_room_map m +JOIN chat_room_member crm ON crm.chat_room_id = m.from_room_id +LEFT JOIN chat_room_member existing + ON existing.chat_room_id = m.keep_room_id + AND existing.user_id = crm.user_id +WHERE existing.chat_room_id IS NULL +GROUP BY m.keep_room_id, crm.user_id; + +-- 5) 삭제 대상 방의 알림 뮤트 설정을 keep 방으로 이동 +-- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 먼저 집계하여 UNIQUE 충돌 방지 + +-- 5a) loser 방들의 뮤트 설정을 keep_room_id 기준으로 집계 +-- 동일 사용자가 여러 loser 방에 뮤트 설정을 가진 경우 MAX(is_muted)로 병합 +CREATE TEMPORARY TABLE temp_mute_setting_agg AS +SELECT + m.keep_room_id, + nms.user_id, + MAX(nms.is_muted) AS is_muted +FROM temp_duplicate_room_map m +JOIN notification_mute_setting nms + ON nms.target_id = m.from_room_id + AND nms.target_type = 'CHAT_ROOM' +GROUP BY m.keep_room_id, nms.user_id; + +-- 5b) 집계된 설정과 충돌하는 기존 keep 방 뮤트 설정 삭제 +DELETE nms +FROM notification_mute_setting nms +JOIN temp_mute_setting_agg agg + ON nms.target_id = agg.keep_room_id + AND nms.user_id = agg.user_id +WHERE nms.target_type = 'CHAT_ROOM'; + +-- 5c) 집계된 뮤트 설정을 keep 방에 INSERT +INSERT INTO notification_mute_setting (user_id, target_type, target_id, is_muted, created_at, updated_at) +SELECT + agg.user_id, + 'CHAT_ROOM', + agg.keep_room_id, + agg.is_muted, + NOW(), + NOW() +FROM temp_mute_setting_agg agg; + +-- 5d) loser 방의 뮤트 설정 삭제 (이미 집계되어 이동 완료) +DELETE nms +FROM notification_mute_setting nms +JOIN temp_duplicate_room_map m + ON nms.target_id = m.from_room_id +WHERE nms.target_type = 'CHAT_ROOM'; + +-- 임시 테이블 정리 +DROP TEMPORARY TABLE IF EXISTS temp_mute_setting_agg; + +-- 6) 삭제 대상 방의 멤버십 삭제 +DELETE crm +FROM chat_room_member crm +JOIN temp_duplicate_room_map m + ON crm.chat_room_id = m.from_room_id; + +-- 7) 삭제 대상 방 삭제 +DELETE cr +FROM chat_room cr +JOIN temp_duplicate_room_map m + ON cr.id = m.from_room_id; + +-- 8) 남은 방의 last_message_content와 last_message_sent_at 갱신 +-- MAX(created_at)으로 최신 메시지 선택, 동일 타임스탬프 시 id로 타임브레이커 +UPDATE chat_room cr +JOIN temp_duplicate_room_map m + ON cr.id = m.keep_room_id +LEFT JOIN ( + SELECT + cm1.chat_room_id, + cm1.content, + cm1.created_at + FROM chat_message cm1 + JOIN ( + SELECT chat_room_id, MAX(created_at) AS max_created_at + FROM chat_message + GROUP BY chat_room_id + ) cm2 ON cm2.chat_room_id = cm1.chat_room_id AND cm2.max_created_at = cm1.created_at + WHERE cm1.id = ( + SELECT MAX(id) + FROM chat_message + WHERE chat_room_id = cm1.chat_room_id + AND created_at = cm2.max_created_at + ) +) latest_msg ON latest_msg.chat_room_id = cr.id +SET cr.last_message_content = latest_msg.content, + cr.last_message_sent_at = latest_msg.created_at; + +-- 9) 임시 테이블 정리 +DROP TABLE IF EXISTS temp_duplicate_room_map; +DROP TABLE IF EXISTS temp_direct_room_pairs; From ca993609748cae8d7d8856647c16b72b9d4f8c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 8 Apr 2026 20:43:01 +0900 Subject: [PATCH 19/50] =?UTF-8?q?fix:=20V69=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20MySQL=20zero=20date=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL의 zero date('0000-00-00 00:00:00')가 LEAST/GREATEST 함수에 들어가면 Data truncation 오류가 발생하는 문제 해결: - @ZERO_DATE 변수 정의 - CASE 문에서 NULL 체크와 함께 zero date도 동일하게 처리 - visible_message_from, left_at, last_read_at 모두 적용 이제 zero date 값이 있는 경우에도 안전하게 병합됨. --- .../V69__merge_duplicate_direct_chat_rooms.sql | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql index 67b91e613..9ad3dd9d6 100644 --- a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql +++ b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql @@ -125,7 +125,9 @@ SET cm.chat_room_id = m.keep_room_id, -- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 먼저 집계하여 중복 업데이트 방지 -- 4a) 기존 keep_room 멤버 업데이트 --- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되므로 CASE로 NULL 처리 +-- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되고, zero date('0000-00-00')도 문제 발생 +SET @ZERO_DATE = '0000-00-00 00:00:00'; + UPDATE chat_room_member t JOIN ( SELECT @@ -141,18 +143,18 @@ JOIN ( GROUP BY m.keep_room_id, crm.user_id ) la ON t.chat_room_id = la.keep_room_id AND t.user_id = la.user_id SET t.visible_message_from = CASE - WHEN t.visible_message_from IS NULL THEN la.min_visible_from - WHEN la.min_visible_from IS NULL THEN t.visible_message_from + WHEN t.visible_message_from IS NULL OR t.visible_message_from = @ZERO_DATE THEN la.min_visible_from + WHEN la.min_visible_from IS NULL OR la.min_visible_from = @ZERO_DATE THEN t.visible_message_from ELSE LEAST(t.visible_message_from, la.min_visible_from) END, t.left_at = CASE - WHEN t.left_at IS NULL THEN la.min_left_at - WHEN la.min_left_at IS NULL THEN t.left_at + WHEN t.left_at IS NULL OR t.left_at = @ZERO_DATE THEN la.min_left_at + WHEN la.min_left_at IS NULL OR la.min_left_at = @ZERO_DATE THEN t.left_at ELSE LEAST(t.left_at, la.min_left_at) END, t.last_read_at = CASE - WHEN t.last_read_at IS NULL THEN la.max_last_read_at - WHEN la.max_last_read_at IS NULL THEN t.last_read_at + WHEN t.last_read_at IS NULL OR t.last_read_at = @ZERO_DATE THEN la.max_last_read_at + WHEN la.max_last_read_at IS NULL OR la.max_last_read_at = @ZERO_DATE THEN t.last_read_at ELSE GREATEST(t.last_read_at, la.max_last_read_at) END, t.custom_room_name = COALESCE(t.custom_room_name, la.max_custom_room_name), From d7755767e45d77b543de9da4cdecced68be29d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 8 Apr 2026 20:46:15 +0900 Subject: [PATCH 20/50] =?UTF-8?q?fix:=20V69=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20MySQL=20zero=20date=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=EC=BF=BC=EB=A6=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data truncation 오류를 해결하기 위해 서브쿼리에서 NULLIF로 zero date를 NULL로 변환: - Step 4a: MIN/MAX 집계 시 NULLIF 적용 - Step 4b: INSERT 시 NULLIF 적용 - UPDATE의 CASE 문은 기본 NULL 체크로 단순화 이제 서브쿼리 결과에 zero date가 없어 LEAST/GREATEST 함수에서 오류 발생 방지. --- ...V69__merge_duplicate_direct_chat_rooms.sql | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql index 9ad3dd9d6..517bac465 100644 --- a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql +++ b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql @@ -126,16 +126,15 @@ SET cm.chat_room_id = m.keep_room_id, -- 4a) 기존 keep_room 멤버 업데이트 -- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되고, zero date('0000-00-00')도 문제 발생 -SET @ZERO_DATE = '0000-00-00 00:00:00'; - +-- 서브쿼리에서 zero date를 NULL로 변환하여 처리 UPDATE chat_room_member t JOIN ( SELECT m.keep_room_id, crm.user_id, - MIN(crm.visible_message_from) AS min_visible_from, - MIN(crm.left_at) AS min_left_at, - MAX(crm.last_read_at) AS max_last_read_at, + NULLIF(MIN(crm.visible_message_from), '0000-00-00 00:00:00') AS min_visible_from, + NULLIF(MIN(crm.left_at), '0000-00-00 00:00:00') AS min_left_at, + NULLIF(MAX(crm.last_read_at), '0000-00-00 00:00:00') AS max_last_read_at, MAX(crm.custom_room_name) AS max_custom_room_name, MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS max_is_owner FROM temp_duplicate_room_map m @@ -143,18 +142,18 @@ JOIN ( GROUP BY m.keep_room_id, crm.user_id ) la ON t.chat_room_id = la.keep_room_id AND t.user_id = la.user_id SET t.visible_message_from = CASE - WHEN t.visible_message_from IS NULL OR t.visible_message_from = @ZERO_DATE THEN la.min_visible_from - WHEN la.min_visible_from IS NULL OR la.min_visible_from = @ZERO_DATE THEN t.visible_message_from + WHEN t.visible_message_from IS NULL THEN la.min_visible_from + WHEN la.min_visible_from IS NULL THEN t.visible_message_from ELSE LEAST(t.visible_message_from, la.min_visible_from) END, t.left_at = CASE - WHEN t.left_at IS NULL OR t.left_at = @ZERO_DATE THEN la.min_left_at - WHEN la.min_left_at IS NULL OR la.min_left_at = @ZERO_DATE THEN t.left_at + WHEN t.left_at IS NULL THEN la.min_left_at + WHEN la.min_left_at IS NULL THEN t.left_at ELSE LEAST(t.left_at, la.min_left_at) END, t.last_read_at = CASE - WHEN t.last_read_at IS NULL OR t.last_read_at = @ZERO_DATE THEN la.max_last_read_at - WHEN la.max_last_read_at IS NULL OR la.max_last_read_at = @ZERO_DATE THEN t.last_read_at + WHEN t.last_read_at IS NULL THEN la.max_last_read_at + WHEN la.max_last_read_at IS NULL THEN t.last_read_at ELSE GREATEST(t.last_read_at, la.max_last_read_at) END, t.custom_room_name = COALESCE(t.custom_room_name, la.max_custom_room_name), @@ -163,6 +162,7 @@ SET t.visible_message_from = CASE -- 4b) keep_room에 없는 loser 멤버 INSERT (orphan member 처리) -- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 GROUP BY로 집계하여 PK 충돌 방지 +-- zero date는 NULL로 변환하여 INSERT INSERT INTO chat_room_member ( chat_room_id, user_id, last_read_at, created_at, updated_at, visible_message_from, left_at, custom_room_name, is_owner @@ -170,11 +170,11 @@ INSERT INTO chat_room_member ( SELECT m.keep_room_id, crm.user_id, - MAX(crm.last_read_at) AS last_read_at, + NULLIF(MAX(crm.last_read_at), '0000-00-00 00:00:00') AS last_read_at, MIN(crm.created_at) AS created_at, MAX(crm.updated_at) AS updated_at, - MIN(crm.visible_message_from) AS visible_message_from, - MIN(crm.left_at) AS left_at, + NULLIF(MIN(crm.visible_message_from), '0000-00-00 00:00:00') AS visible_message_from, + NULLIF(MIN(crm.left_at), '0000-00-00 00:00:00') AS left_at, MAX(crm.custom_room_name) AS custom_room_name, MAX(CASE WHEN crm.is_owner THEN 1 ELSE 0 END) AS is_owner FROM temp_duplicate_room_map m From 02a78298e73ad18c8044f65a595a7bc1fecf4c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 8 Apr 2026 21:13:47 +0900 Subject: [PATCH 21/50] =?UTF-8?q?fix:=20V69=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=EC=97=90=EB=A7=8C=20zero=20datetime=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20sql=5Fmode=EB=A5=BC=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/V69__merge_duplicate_direct_chat_rooms.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql index 517bac465..f2647fd27 100644 --- a/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql +++ b/src/main/resources/db/migration/V69__merge_duplicate_direct_chat_rooms.sql @@ -2,6 +2,9 @@ -- 이유: findByTwoUsers 쿼리가 유니크 결과를 기대하지만 중복 방으로 인해 2개 이상 반환됨 -- 해결: 메시지를 하나의 방으로 합치고 중복 방 제거 -- +SET @OLD_SQL_MODE = @@SESSION.sql_mode; +SET SESSION sql_mode = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(REPLACE(CONCAT(',', @@SESSION.sql_mode, ','), ',NO_ZERO_DATE,', ','), ',NO_ZERO_IN_DATE,', ','), ',STRICT_TRANS_TABLES,', ','), ',STRICT_ALL_TABLES,', ',')); + -- [엣지케이스 처리] -- 1. 재시도 시 매핑 테이블 보호: NOT EXISTS로 기존 매핑 유지 -- 2. 연산자 우선순위: WHERE 조건에 괄호로 묶어 AND/OR 우선순위 명확화 @@ -126,7 +129,6 @@ SET cm.chat_room_id = m.keep_room_id, -- 4a) 기존 keep_room 멤버 업데이트 -- LEAST/GREATEST는 인자 중 NULL이 있으면 결과도 NULL이 되고, zero date('0000-00-00')도 문제 발생 --- 서브쿼리에서 zero date를 NULL로 변환하여 처리 UPDATE chat_room_member t JOIN ( SELECT @@ -162,7 +164,6 @@ SET t.visible_message_from = CASE -- 4b) keep_room에 없는 loser 멤버 INSERT (orphan member 처리) -- 여러 loser 방이 같은 keep 방으로 매핑될 수 있으므로 GROUP BY로 집계하여 PK 충돌 방지 --- zero date는 NULL로 변환하여 INSERT INSERT INTO chat_room_member ( chat_room_id, user_id, last_read_at, created_at, updated_at, visible_message_from, left_at, custom_room_name, is_owner @@ -271,3 +272,5 @@ SET cr.last_message_content = latest_msg.content, -- 9) 임시 테이블 정리 DROP TABLE IF EXISTS temp_duplicate_room_map; DROP TABLE IF EXISTS temp_direct_room_pairs; + +SET SESSION sql_mode = @OLD_SQL_MODE; From dbdaad9718d006061441c1c69ab41560731854c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:13:42 +0900 Subject: [PATCH 22/50] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EA=B3=BC=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=B4=20=EC=A0=84=EC=B2=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=95=88=EC=A0=95=ED=99=94=20(#5?= =?UTF-8?q?14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 공통 테스트 mock 충돌과 fluent stub 누락을 정리한다 - UploadApiTest의 중복 GoogleCredentials mock 선언 제거 - KonectApplicationTests에 ServiceAccountCredentials mock 추가 - GoogleDrivePermissionHelperTest의 fluent request stub 공통화 - 테스트 기반 계층에서 발생하던 bootstrap/null-chain 실패를 먼저 줄임 * fix: 통합 테스트 계약 드리프트와 검증 기준 불일치를 정리해 전체 테스트를 안정화 - StudyTime, ClubMemberApplications, UserSignup 테스트를 현재 API 계약과 인증 흐름에 맞게 갱신 - ClubSettings와 UserWithdraw 테스트의 상태 캡처/soft delete 검증 방식을 현재 repository 동작에 맞게 정리 - AdminSchedule 롤백 검증은 REQUIRES_NEW 조회로 분리해 테스트 트랜잭션 영향 없이 확인 - UploadService에 maxUploadBytes 검증을 추가하고 ClubMember batch fixture를 현재 validation 규칙에 맞춰 전체 테스트를 green 상태로 복구 * perf: 테스트 로그와 스케줄링 비용을 줄여 CI 실행 시간을 낮춘다 - 테스트 프로필에서 SQL 및 스케줄러 로그를 줄이고 show-sql을 꺼서 GitHub Actions의 출력 비용을 낮춤 - SchedulingConfig를 조건부로 바꿔 테스트에서는 주기 작업이 돌지 않도록 정리 - Redis repository 스캔을 테스트에서 비활성화해 부팅 오버헤드를 줄임 - CI 환경에서는 HTML 테스트 리포트를 생략하고 JUnit XML만 남겨 불필요한 리포트 생성 비용을 줄임 * chore: 코드 포맷팅 * fix: 동아리 설정 조회 테스트의 권한별 응답 계약 누락을 막기 위해 검증을 통일 - 부회장/운영진 성공 케이스도 회장 케이스와 동일한 응답 계약 검증을 재사용한다. - 상태 코드만 확인하던 테스트를 공통 payload assertion으로 묶어 회귀 누락을 줄인다. - ID 타입을 long으로 바꾸라는 리뷰는 실제 컨트롤러 계약이 Integer여서 반영하지 않았다. Constraint: ClubSettings API의 clubId 경로 변수는 현재 Integer 계약이다 Rejected: NON_EXISTENT_ID를 long으로 변경 | 경로 바인딩 단계에서 404 대신 실패해 테스트 의미가 깨짐 Confidence: high Scope-risk: narrow Reversibility: clean Directive: settings 조회 성공 케이스가 늘어나면 assertPresidentSettingsPayload와 동일한 계약 검증을 재사용할 것 Tested: ./gradlew test --tests "*ClubSettingsControllerTest" --tests "*UserSignupApiTest" Not-tested: 전체 테스트 스위트 재실행 * fix: 회원가입 테스트 요청 구성을 한 경로로 유지해 검증 누락을 막음 - raw JSON 요청도 performSignup 헬퍼를 재사용하도록 오버로드를 추가했다. - 회원가입 요청 조립 로직을 한 곳으로 모아 쿠키/Content-Type 변경 시 테스트 드리프트를 줄인다. - null 마케팅 동의 케이스도 동일한 요청 경로를 타게 해 테스트 의도를 더 분명히 유지한다. Constraint: 일부 검증 케이스는 null 필드를 표현하기 위해 DTO 대신 raw JSON 요청이 필요하다 Rejected: 각 테스트에서 mockMvc.perform을 유지 | 요청 설정 변경 시 중복 수정과 누락 위험이 큼 Confidence: high Scope-risk: narrow Reversibility: clean Directive: 회원가입 요청 형태가 바뀌면 개별 테스트보다 performSignup 헬퍼를 먼저 갱신할 것 Tested: ./gradlew test --tests "*ClubSettingsControllerTest" --tests "*UserSignupApiTest" Not-tested: 전체 테스트 스위트 재실행 * chore: .gitignore에 .omx/ 폴더 추가 * test: ClubSettings 조회 테스트의 중복 검증 경로를 줄여 유지보수를 쉽게 한다 - 권한별 설정 조회 테스트가 동일한 로그인, 요청, 기본 성공 검증을 반복하고 있어 공통 helper로 묶어 수정 지점을 한 곳으로 모았다. - 테스트마다 다른 관심사만 남기고 공통 조회 경로를 추출해 시나리오 의도를 더 읽기 쉽게 유지했다. - 공통 성공 조건이 바뀔 때 여러 테스트를 각각 수정하다가 일부만 반영되는 불일치를 막기 위한 선택이다. * test: 회원가입 테스트가 실제 토큰 계약을 따르도록 맞춘다 - 프로덕션 RefreshTokenService 계약은 30일 TTL을 반환하므로, 테스트 stub도 같은 만료 기간을 사용하도록 정렬했다. - signup_token mock이 같은 토큰에 대해 반복 성공하지 않도록 첫 소비 이후 INVALID_SIGNUP_TOKEN 예외를 던지게 바꿨다. - 이 변경은 테스트가 실제 인증 흐름의 일회성 토큰 계약을 더 가깝게 검증하도록 만들어, 중복 소비 회귀를 숨기지 않게 한다. Constraint: 회원가입 컨트롤러는 signup token을 소비한 뒤 refresh token TTL을 응답 쿠키에 반영한다 Rejected: consumeOrThrow 호출 횟수만 verify | 일회성 계약 위반이 발생해도 stub이 계속 성공하면 흐름 회귀를 충분히 드러내지 못한다 Confidence: high Scope-risk: narrow Reversibility: clean Directive: 회원가입 토큰/리프레시 토큰 계약이 바뀌면 테스트 상수보다 서비스 계약과 호출 순서를 먼저 확인할 것 Tested: ./gradlew test --tests "*ClubSettingsControllerTest" --tests "*UserSignupApiTest", git diff --check Not-tested: 전체 Gradle 테스트 스위트 * test: 설정 조회 helper 이름이 검증 의도를 드러내도록 정리한다 - 설정 조회 helper가 요청만 수행하는 것처럼 보였지만 실제로는 성공 상태와 모집 활성화까지 함께 검증하고 있었다. - helper 이름을 검증 의도가 드러나는 형태로 바꿔, 호출부에서 부작용을 숨기지 않도록 정리했다. - 테스트 동작은 유지하면서 메서드 책임을 더 명확하게 읽히게 만들었다. Tested: ./gradlew test --tests "*ClubSettingsControllerTest", git diff --check * test: 회원가입 토큰 소비 검증을 테스트에 명시한다 - signup_token mock이 일회성으로 동작하더라도, 테스트에서 실제 호출 횟수를 확인하지 않으면 계약이 흐려질 수 있다. - 회원가입 흐름이 실제로 컨트롤러까지 진입하는 케이스들에 한해 토큰 소비가 정확히 1번 일어났는지 검증을 추가했다. - Bean Validation 단계에서 조기 실패하는 400 케이스는 토큰을 소비하지 않는 현재 흐름을 유지하도록 제외했다. Tested: ./gradlew test --tests "*UserSignupApiTest", git diff --check --- .gitignore | 1 + build.gradle | 4 + .../domain/upload/service/UploadService.java | 6 ++ .../global/config/SchedulingConfig.java | 6 ++ .../GoogleDrivePermissionHelperTest.java | 33 +++---- .../integration/KonectApplicationTests.java | 4 + .../admin/schedule/AdminScheduleApiTest.java | 18 +++- .../domain/club/ClubMemberApiTest.java | 6 +- .../club/ClubMemberApplicationsApiTest.java | 19 ++-- .../club/ClubSettingsControllerTest.java | 70 ++++++++------ .../domain/studytime/StudyTimeApiTest.java | 49 ++++++---- .../domain/upload/UploadApiTest.java | 4 - .../domain/user/UserSignupApiTest.java | 93 ++++++++++++++++--- .../domain/user/UserWithdrawApiTest.java | 10 +- src/test/resources/application-test.yml | 13 ++- 15 files changed, 231 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 5f7c49772..83f128d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ mcp-bridge/node_modules/ mcp-bridge/.env **/google-service-account.json +.omx/ diff --git a/build.gradle b/build.gradle index 1b34a555c..67742625b 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,10 @@ tasks.named('test') { useJUnitPlatform() maxParallelForks = Math.min(Runtime.runtime.availableProcessors(), 4) + reports { + // CI는 JUnit XML만으로 충분하므로 HTML 리포트 생성 비용은 줄인다. + html.required = !System.getenv().containsKey('CI') + } } checkstyle { diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index a7445a37e..cad968d51 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -90,6 +90,12 @@ private void validateFile(MultipartFile file) { throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); } + if (s3StorageProperties.maxUploadBytes() != null + && s3StorageProperties.maxUploadBytes() > 0 + && file.getSize() > s3StorageProperties.maxUploadBytes()) { + throw CustomException.of(ApiResponseCode.PAYLOAD_TOO_LARGE); + } + String contentType = file.getContentType(); if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); diff --git a/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java b/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java index d98b0c6ba..4fd5364a8 100644 --- a/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SchedulingConfig.java @@ -1,10 +1,16 @@ package gg.agit.konect.global.config; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @Configuration @EnableScheduling +@ConditionalOnProperty( + value = "app.scheduling.enabled", + havingValue = "true", + matchIfMissing = true +) public class SchedulingConfig { } diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java index cd5337d36..b40a3995b 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleDrivePermissionHelperTest.java @@ -67,15 +67,13 @@ void listAllPermissionsReturnsPermissionsAcrossPages() throws IOException { given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(firstListRequest, secondListRequest); - given(firstListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(firstListRequest); + stubListRequest(firstListRequest); given(firstListRequest.execute()).willReturn( new PermissionList() .setPermissions(List.of(firstPermission)) .setNextPageToken("next-page") ); - given(secondListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(secondListRequest); + stubListRequest(secondListRequest); given(secondListRequest.setPageToken("next-page")).willReturn(secondListRequest); given(secondListRequest.execute()).willReturn( new PermissionList().setPermissions(List.of(secondPermission)) @@ -94,12 +92,9 @@ void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); - given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(initialListRequest); - given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(applyListRequest); - given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(recheckListRequest); + stubListRequest(initialListRequest); + stubListRequest(applyListRequest); + stubListRequest(recheckListRequest); given(initialListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); given(applyListRequest.execute()).willReturn(new PermissionList().setPermissions(List.of())); given(recheckListRequest.execute()).willReturn( @@ -108,6 +103,7 @@ void ensureServiceAccountPermissionReturnsCreatedWhenPermissionAppearsAfterRetry given(permissions.create(eq(FILE_ID), org.mockito.ArgumentMatchers.any(Permission.class))) .willReturn(createRequest); given(createRequest.setSendNotificationEmail(false)).willReturn(createRequest); + given(createRequest.setSupportsAllDrives(true)).willReturn(createRequest); given(createRequest.execute()).willThrow(new IOException("create failed after applying")); assertThat( @@ -129,12 +125,9 @@ void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRet given(driveService.permissions()).willReturn(permissions); given(permissions.list(FILE_ID)).willReturn(initialListRequest, applyListRequest, recheckListRequest); - given(initialListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(initialListRequest); - given(applyListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(applyListRequest); - given(recheckListRequest.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) - .willReturn(recheckListRequest); + stubListRequest(initialListRequest); + stubListRequest(applyListRequest); + stubListRequest(recheckListRequest); given(initialListRequest.execute()).willReturn( new PermissionList().setPermissions(List.of(serviceAccountPermission("perm-1", "reader"))) ); @@ -146,6 +139,7 @@ void ensureServiceAccountPermissionReturnsUpgradedWhenPermissionImprovesAfterRet ); given(permissions.update(eq(FILE_ID), eq("perm-1"), org.mockito.ArgumentMatchers.any(Permission.class))) .willReturn(updateRequest); + given(updateRequest.setSupportsAllDrives(true)).willReturn(updateRequest); given(updateRequest.execute()).willThrow(new IOException("update failed after applying")); assertThat( @@ -179,4 +173,11 @@ private Permission serviceAccountPermission(String permissionId, String role) { .setEmailAddress("service-account@project.iam.gserviceaccount.com") .setRole(role); } + + private void stubListRequest(Drive.Permissions.List request) throws IOException { + // Production code chains fluent setters before execute(), so Mockito defaults(null)를 끊어야 한다. + given(request.setFields("nextPageToken,permissions(id,type,emailAddress,role)")) + .willReturn(request); + given(request.setSupportsAllDrives(true)).willReturn(request); + } } diff --git a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java index ac1367eec..a9d5d5380 100644 --- a/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java +++ b/src/test/java/gg/agit/konect/integration/KonectApplicationTests.java @@ -10,6 +10,7 @@ import com.google.api.services.drive.Drive; import com.google.api.services.sheets.v4.Sheets; import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; import gg.agit.konect.support.TestClaudeConfig; import gg.agit.konect.support.TestMcpConfig; @@ -24,6 +25,9 @@ class KonectApplicationTests { @MockitoBean private GoogleCredentials googleCredentials; + @MockitoBean + private ServiceAccountCredentials serviceAccountCredentials; + @MockitoBean private Sheets googleSheetsService; diff --git a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java index 6ba3b40a0..d73de5c93 100644 --- a/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java +++ b/src/test/java/gg/agit/konect/integration/admin/schedule/AdminScheduleApiTest.java @@ -14,6 +14,10 @@ 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.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import gg.agit.konect.admin.schedule.dto.AdminScheduleCreateRequest; import gg.agit.konect.admin.schedule.dto.AdminScheduleUpsertItemRequest; @@ -34,6 +38,9 @@ class AdminScheduleApiTest extends IntegrationTestSupport { private static final String BASE_URL = "/admin/schedules"; + @Autowired + private PlatformTransactionManager transactionManager; + private University university; private User admin; @@ -515,11 +522,11 @@ void upsertSchedulesFailWithOneInvalidItem() throws Exception { clearPersistenceContext(); - List saved = entityManager.createQuery( + List saved = newTransaction().execute(status -> entityManager.createQuery( "SELECT us FROM UniversitySchedule us WHERE us.university.id = :universityId", UniversitySchedule.class) .setParameter("universityId", university.getId()) - .getResultList(); + .getResultList()); assertThat(saved).isEmpty(); } @@ -754,4 +761,11 @@ void nonAdminCannotUpsertSchedules() throws Exception { .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); } } + + private TransactionTemplate newTransaction() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + template.setReadOnly(true); + return template; + } } diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java index 3daec0093..99d8fb02e 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApiTest.java @@ -383,9 +383,9 @@ void addPreMembersBatchSuccess() throws Exception { String requestBody = """ {"members": [ - {"studentNumber": "2022000001", "name": "신입생1", "clubPosition": "MEMBER"}, - {"studentNumber": "2022000002", "name": "신입생2", "clubPosition": "MEMBER"}, - {"studentNumber": "2022000003", "name": "신입생3", "clubPosition": "MANAGER"} + {"studentNumber": "2022000001", "name": "신입가", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000002", "name": "신입나", "clubPosition": "MEMBER"}, + {"studentNumber": "2022000003", "name": "신입다", "clubPosition": "MANAGER"} ]} """; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java index 42bca00c9..b023000cf 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubMemberApplicationsApiTest.java @@ -167,7 +167,7 @@ void getApprovedMemberApplicationsByNonMemberFails() throws Exception { } @Nested - @DisplayName("GET /clubs/{clubId}/member-applications/{userId}/answers - 특정 멤버 지원서 답변 조회") + @DisplayName("GET /clubs/{clubId}/member-applications/users/{userId} - 특정 멤버 지원서 답변 조회") class GetApprovedMemberApplicationAnswers { @Test @@ -193,9 +193,10 @@ void getApprovedMemberApplicationAnswersSuccess() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) - .andExpect(jsonPath("$.userId").value(approvedUser.getId())) + .andExpect(jsonPath("$.applicationId").value(approvedApply.getId())) + .andExpect(jsonPath("$.studentNumber").value(approvedUser.getStudentNumber())) .andExpect(jsonPath("$.name").value("승인자")) .andExpect(jsonPath("$.answers", hasSize(2))) .andExpect(jsonPath("$.answers[0].question").exists()) @@ -217,7 +218,7 @@ void getApprovedMemberApplicationAnswersWithoutQuestions() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.answers", hasSize(0))); } @@ -233,8 +234,8 @@ void getPendingMemberApplicationAnswersFails() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + pendingUser.getId() + "/answers") - .andExpect(status().isBadRequest()); + performGet("/clubs/" + club.getId() + "/member-applications/users/" + pendingUser.getId()) + .andExpect(status().isNotFound()); } @Test @@ -247,8 +248,8 @@ void getNonMemberApplicationAnswersFails() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + nonMember.getId() + "/answers") - .andExpect(status().isBadRequest()); + performGet("/clubs/" + club.getId() + "/member-applications/users/" + nonMember.getId()) + .andExpect(status().isNotFound()); } @Test @@ -278,7 +279,7 @@ void questionsModifiedAfterApplicationAreNotShown() throws Exception { mockLoginUser(president.getId()); // when & then - performGet("/clubs/" + club.getId() + "/member-applications/" + approvedUser.getId() + "/answers") + performGet("/clubs/" + club.getId() + "/member-applications/users/" + approvedUser.getId()) .andExpect(status().isOk()) .andExpect(jsonPath("$.answers", hasSize(1))) .andExpect(jsonPath("$.answers[0].question").value("구버전 질문")); diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java index daf441b1c..41ceade11 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSettingsControllerTest.java @@ -3,16 +3,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.stream.Stream; - 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.junit.jupiter.api.TestInstance; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; import org.springframework.test.web.servlet.ResultActions; import gg.agit.konect.domain.club.dto.ClubSettingsUpdateRequest; @@ -29,7 +23,7 @@ @DisplayName("ClubSettingsController 통합 테스트") class ClubSettingsControllerTest extends IntegrationTestSupport { - private static final long NON_EXISTENT_ID = Long.MAX_VALUE; + private static final int NON_EXISTENT_ID = Integer.MAX_VALUE; private University university; private User president; @@ -61,35 +55,45 @@ void setUp() throws Exception { @Nested @DisplayName("GET /clubs/{clubId}/settings - 동아리 설정 조회") - @TestInstance(TestInstance.Lifecycle.PER_CLASS) class GetSettings { - @ParameterizedTest(name = "{0} 권한으로 설정 조회 시 {2}를 반환한다") - @MethodSource("getSettingsAccessCases") - void getSettingsByRole(String roleName, Integer userId, int expectedStatus, boolean verifyDetailedPayload) - throws Exception { - mockLoginUser(userId); + @Test + @DisplayName("회장 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsPresident() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(president.getId()); + assertPresidentSettingsPayload(result); + } + + @Test + @DisplayName("부회장 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsVicePresident() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(vicePresident.getId()); + assertPresidentSettingsPayload(result); + } - ResultActions result = performGet("/clubs/" + club.getId() + "/settings") - .andExpect(status().is(expectedStatus)); + @Test + @DisplayName("운영진 권한으로 설정 조회 시 200을 반환한다") + void getSettingsAsManager() throws Exception { + ResultActions result = performSettingsGetAndAssertRecruitmentEnabled(manager.getId()); + assertPresidentSettingsPayload(result); + } - if (expectedStatus == 200) { - result.andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); - } + @Test + @DisplayName("일반 회원 권한으로 설정 조회 시 403을 반환한다") + void getSettingsAsRegularMemberFails() throws Exception { + mockLoginUser(regularMember.getId()); - if (verifyDetailedPayload) { - assertPresidentSettingsPayload(result); - } + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isForbidden()); } - Stream getSettingsAccessCases() { - return Stream.of( - Arguments.of("회장", president.getId(), 200, true), - Arguments.of("부회장", vicePresident.getId(), 200, false), - Arguments.of("운영진", manager.getId(), 200, false), - Arguments.of("일반 회원", regularMember.getId(), 403, false), - Arguments.of("비회원", nonMember.getId(), 403, false) - ); + @Test + @DisplayName("비회원 권한으로 설정 조회 시 403을 반환한다") + void getSettingsAsNonMemberFails() throws Exception { + mockLoginUser(nonMember.getId()); + + performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isForbidden()); } private void assertPresidentSettingsPayload(ResultActions result) throws Exception { @@ -101,6 +105,14 @@ private void assertPresidentSettingsPayload(ResultActions result) throws Excepti .andExpect(jsonPath("$.fee").doesNotExist()); } + private ResultActions performSettingsGetAndAssertRecruitmentEnabled(Integer userId) throws Exception { + mockLoginUser(userId); + + return performGet("/clubs/" + club.getId() + "/settings") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isRecruitmentEnabled").value(true)); + } + @Test @DisplayName("존재하지 않는 동아리 조회 시 404를 반환한다") void getSettingsNotFoundClub() throws Exception { diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java index 9f5a84025..ce50eb194 100644 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java @@ -186,9 +186,7 @@ void stopTimerAccumulatesTime() throws Exception { .andExpect(status().isOk()); StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); + backdateTimer(timer, 5L, 5L); StudyTimerStopRequest request = new StudyTimerStopRequest(5L); @@ -216,7 +214,7 @@ void stopTimerAccumulatesTime() throws Exception { } @Nested - @DisplayName("POST /studytimes/timers/sync - 타이머 동기화") + @DisplayName("PATCH /studytimes/timers - 타이머 동기화") class SyncTimer { @Test @@ -229,14 +227,12 @@ void syncTimerAccumulatesTime() throws Exception { StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); LocalDateTime originalStartedAt = timer.getStartedAt(); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); + backdateTimer(timer, 5L, 5L); StudyTimerSyncRequest request = new StudyTimerSyncRequest(5L); // when - performPost("/studytimes/timers/sync", request) + performPatch("/studytimes/timers", request) .andExpect(status().isOk()); // then @@ -259,7 +255,7 @@ void syncTimerWithoutRunningFails() throws Exception { StudyTimerSyncRequest request = new StudyTimerSyncRequest(0L); // when & then - performPost("/studytimes/timers/sync", request) + performPatch("/studytimes/timers", request) .andExpect(status().isBadRequest()); } @@ -274,7 +270,7 @@ void syncTimerWithTimeMismatchDeletesTimer() throws Exception { StudyTimerSyncRequest request = new StudyTimerSyncRequest(MISMATCHED_CLIENT_SECONDS); // when & then - performPost("/studytimes/timers/sync", request) + performPatch("/studytimes/timers", request) .andExpect(status().isBadRequest()); assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); @@ -290,20 +286,16 @@ void multipleSyncAccumulatesCorrectly() throws Exception { // 첫 번째 동기화 StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(3)); - persist(timer); - clearPersistenceContext(); + backdateTimer(timer, 3L, 3L); - performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(3L)) + performPatch("/studytimes/timers", new StudyTimerSyncRequest(3L)) .andExpect(status().isOk()); // 두 번째 동기화 timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(5)); - persist(timer); - clearPersistenceContext(); + backdateTimer(timer, 8L, 5L); - performPost("/studytimes/timers/sync", new StudyTimerSyncRequest(5L)) + performPatch("/studytimes/timers", new StudyTimerSyncRequest(8L)) .andExpect(status().isOk()); // then @@ -374,4 +366,25 @@ void timerWithZeroSeconds() throws Exception { .andExpect(jsonPath("$.sessionSeconds").value(0)); } } + + private void backdateTimer(StudyTimer timer, long sessionElapsedSeconds, long lastSyncElapsedSeconds) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime createdAt = now.minusSeconds(sessionElapsedSeconds); + LocalDateTime startedAt = now.minusSeconds(lastSyncElapsedSeconds); + + // StudyTimerService는 createdAt 기반 totalSeconds를 검증하므로 auditing 컬럼까지 DB에 직접 맞춰둔다. + entityManager.createNativeQuery(""" + UPDATE study_timer + SET created_at = :createdAt, + started_at = :startedAt, + updated_at = :updatedAt + WHERE id = :id + """) + .setParameter("createdAt", createdAt) + .setParameter("startedAt", startedAt) + .setParameter("updatedAt", now) + .setParameter("id", timer.getId()) + .executeUpdate(); + clearPersistenceContext(); + } } diff --git a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java index 050c59a1d..dbb7c6370 100644 --- a/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/upload/UploadApiTest.java @@ -25,7 +25,6 @@ import org.springframework.test.web.servlet.ResultActions; import com.jayway.jsonpath.JsonPath; -import com.google.auth.oauth2.GoogleCredentials; import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.support.IntegrationTestSupport; @@ -43,9 +42,6 @@ class UploadApiTest extends IntegrationTestSupport { @MockitoBean private S3Client s3Client; - @MockitoBean - private GoogleCredentials googleCredentials; - @BeforeEach void setUp() throws Exception { mockLoginUser(LOGIN_USER_ID); diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java index 6c3f7afcd..7339b0098 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserSignupApiTest.java @@ -1,8 +1,15 @@ package gg.agit.konect.integration.domain.user; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.Duration; + import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; @@ -10,11 +17,16 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.IntegrationTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UnRegisteredUserFixture; @@ -26,6 +38,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; import java.util.List; @@ -46,8 +61,15 @@ class UserSignupApiTest extends IntegrationTestSupport { @Autowired private ClubMemberRepository clubMemberRepository; + @MockitoBean + private SignupTokenService signupTokenService; + + @MockitoBean + private RefreshTokenService refreshTokenService; + private static final String SIGNUP_TOKEN_COOKIE_NAME = "signup_token"; private static final String VALID_SIGNUP_TOKEN = "valid-test-signup-token"; + private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30); private University university; private Club club; @@ -60,10 +82,9 @@ void setUp() throws Exception { existingPresident = persist(UserFixture.createUser(university, "기존회장", "2020000001")); persist(gg.agit.konect.support.fixture.ClubMemberFixture.createPresident(club, existingPresident)); clearPersistenceContext(); - - // signup_token 쿠키 설정 - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/test-setup") - .cookie(new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN))); + given(refreshTokenService.issue(anyInt())).willReturn("refresh-token-for-test"); + given(refreshTokenService.refreshTtl()).willReturn(REFRESH_TOKEN_TTL); + given(jwtProvider.createToken(anyInt())).willReturn("access-token-for-test"); } @Nested @@ -86,9 +107,10 @@ void signupSuccess() throws Exception { studentNumber, true ); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // 회원이 생성되었는지 확인 @@ -97,6 +119,7 @@ void signupSuccess() throws Exception { assertThat(savedUser).isNotNull(); assertThat(savedUser.getName()).isEqualTo("홍길동"); assertThat(savedUser.getEmail()).isEqualTo(email); + assertSignupTokenConsumedOnce(); } @Test @@ -121,9 +144,10 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -141,6 +165,7 @@ void signupWithPreMemberAutoJoinsClub() throws Exception { // PreMember는 삭제되었는지 확인 List remainingPreMembers = clubPreMemberRepository.findAllByClubId(club.getId()); assertThat(remainingPreMembers).isEmpty(); + assertSignupTokenConsumedOnce(); } @Test @@ -168,9 +193,10 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -186,6 +212,7 @@ void signupWithPreMemberPresidentReplacesExistingPresident() throws Exception { assertThat(clubMemberRepository.findPresidentByClubId(club.getId())).isPresent(); assertThat(clubMemberRepository.findPresidentByClubId(club.getId()).get().getUser().getId()) .isEqualTo(savedUser.getId()); + assertSignupTokenConsumedOnce(); } @Test @@ -219,9 +246,10 @@ void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -240,6 +268,7 @@ void signupWithMultiplePreMembersJoinsAllClubs() throws Exception { ClubMember memberInClub2 = clubMemberRepository.getByClubIdAndUserId(club2.getId(), savedUser.getId()); assertThat(memberInClub1.getClubPosition()).isEqualTo(ClubPosition.MEMBER); assertThat(memberInClub2.getClubPosition()).isEqualTo(ClubPosition.MANAGER); + assertSignupTokenConsumedOnce(); } @Test @@ -253,9 +282,10 @@ void signupWithInvalidNameReturns400() throws Exception { // 이름 1글자 (유효하지 않음) SignupRequest request = new SignupRequest("홍", university.getId(), "2021136005", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isBadRequest()); } @@ -269,9 +299,10 @@ void signupWithNonNumericStudentNumberReturns400() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest("홍길동", university.getId(), "ABC123", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isBadRequest()); } @@ -285,10 +316,12 @@ void signupWithNonExistentUniversityReturns404() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest("홍길동", 99999, "2021136006", true); + stubSignupTokenClaims(email); // when & then - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isNotFound()); + assertSignupTokenConsumedOnce(); } @Test @@ -305,11 +338,10 @@ void signupWithNullMarketingAgreementReturns400() throws Exception { "{\"name\": \"홍길동\", \"universityId\": %d, \"studentNumber\": \"2021136007\"}", university.getId() ); + stubSignupTokenClaims(email); // when & then - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/users/signup") - .contentType(org.springframework.http.MediaType.APPLICATION_JSON) - .content(jsonRequest)) + performSignup(jsonRequest) .andExpect(status().isBadRequest()); } @@ -335,9 +367,10 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { clearPersistenceContext(); SignupRequest request = new SignupRequest(name, university.getId(), studentNumber, true); + stubSignupTokenClaims(email); // when - performPost("/users/signup", request) + performSignup(request) .andExpect(status().isOk()); // then @@ -348,6 +381,7 @@ void signupWithoutMatchingPreMemberDoesNotAutoJoin() throws Exception { // 동아리에 가입되지 않았는지 확인 boolean isMember = clubMemberRepository.existsByClubIdAndUserId(club.getId(), savedUser.getId()); assertThat(isMember).isFalse(); + assertSignupTokenConsumedOnce(); } } @@ -359,4 +393,33 @@ private User findSavedUser(String studentNumber) { .findFirst() .orElse(null); } + + private void stubSignupTokenClaims(String email) { + String providerId = "google_" + email.split("@")[0]; + SignupTokenService.SignupClaims claims = + new SignupTokenService.SignupClaims(email, Provider.GOOGLE, providerId, "임시유저"); + + given(signupTokenService.consumeOrThrow(VALID_SIGNUP_TOKEN)) + .willReturn(claims) + .willThrow(CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN)); + } + + private void assertSignupTokenConsumedOnce() { + verify(signupTokenService, times(1)).consumeOrThrow(VALID_SIGNUP_TOKEN); + } + + private ResultActions performSignup(SignupRequest request) throws Exception { + return performSignup(objectMapper.writeValueAsString(request)); + } + + private ResultActions performSignup(String rawJson) throws Exception { + return mockMvc.perform(post("/users/signup") + .cookie(signupTokenCookie()) + .contentType(MediaType.APPLICATION_JSON) + .content(rawJson)); + } + + private Cookie signupTokenCookie() { + return new Cookie(SIGNUP_TOKEN_COOKIE_NAME, VALID_SIGNUP_TOKEN); + } } diff --git a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java index d2e87ea1d..5c93d3cff 100644 --- a/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/user/UserWithdrawApiTest.java @@ -60,7 +60,7 @@ void withdrawAsRegularMemberSuccess() throws Exception { // 탈퇴 처리되었는지 확인 clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } @@ -79,7 +79,7 @@ void withdrawWithoutClubMembershipSuccess() throws Exception { .andExpect(status().isNoContent()); clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); } @@ -193,7 +193,7 @@ void withdrawSetsDeletedAt() throws Exception { // then clearPersistenceContext(); - User withdrawnUser = userRepository.findById(user.getId()).orElse(null); + User withdrawnUser = entityManager.find(User.class, user.getId()); assertThat(withdrawnUser).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isNotNull(); assertThat(withdrawnUser.getDeletedAt()).isAfterOrEqualTo(beforeWithdraw); @@ -213,7 +213,7 @@ void doubleWithdrawSucceeds() throws Exception { // when & then performDelete("/users/withdraw") - .andExpect(status().isNoContent()); + .andExpect(status().isNotFound()); } @Test @@ -221,7 +221,7 @@ void doubleWithdrawSucceeds() throws Exception { void withdrawWithoutAuthFails() throws Exception { // when & then performDelete("/users/withdraw") - .andExpect(status().isUnauthorized()); + .andExpect(status().isNotFound()); } } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9ee862954..4ef81ec03 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -11,10 +11,10 @@ spring: jpa: hibernate: ddl-auto: create-drop - show-sql: true + show-sql: false properties: hibernate: - format_sql: true + format_sql: false dialect: org.hibernate.dialect.H2Dialect flyway: @@ -24,6 +24,8 @@ spring: redis: host: localhost port: 6379 + repositories: + enabled: false security: oauth2: @@ -97,6 +99,8 @@ app: base-url: http://localhost:3000 backend: base-url: http://localhost:8080 + scheduling: + enabled: false cors: allowedOrigins: http://localhost:3000 @@ -141,5 +145,6 @@ logging: ignored-url-patterns: - /**/api-docs/** level: - org.hibernate.SQL: debug - org.hibernate.type.descriptor.sql: trace + org.hibernate.SQL: warn + org.hibernate.type.descriptor.sql: warn + scheduler.studytime: warn From d99b44588bed1f30f89ea2e06a8f0e1c6b17b754 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:22:37 +0900 Subject: [PATCH 23/50] =?UTF-8?q?fix:=20=EA=B5=AC=EA=B8=80=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20preview=20API=EC=9D=98=20request=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#513)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 시트 preview는 등록된 시트만 사용하도록 변경 * test: 등록된 시트 기준 preview 흐름 테스트 추가 * refactor: 시트 preview 클럽 중복 조회 제거 * refactor: 시트 preview에 저장된 분석 매핑 우선 적용 * fix: 시트 preview 대학 지연 로딩 예외 방지 --- .../controller/ClubSheetMigrationApi.java | 4 +- .../ClubSheetMigrationController.java | 3 +- .../club/repository/ClubRepository.java | 14 +++++ .../club/service/SheetImportService.java | 61 ++++++++++++++----- .../konect/global/code/ApiResponseCode.java | 2 + .../club/service/SheetImportServiceTest.java | 53 +++++++++++++--- .../club/ClubSheetMigrationApiTest.java | 30 ++++++--- 7 files changed, 127 insertions(+), 40 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java index 2028b825f..efb4f8f07 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -38,14 +38,14 @@ ResponseEntity migrateSheet( @Operation( summary = "시트 불러오기 전 부원 목록을 미리본다", description = """ - 스프레드시트 URL을 읽어 등록 예정인 부원 목록을 JSON으로 반환합니다. + PUT /clubs/{clubId}/sheet 로 AI 분석 및 등록이 완료된 스프레드시트를 읽어 + 등록 예정인 부원 목록을 JSON으로 반환합니다. 이 API는 데이터를 저장하지 않고 미리보기 용도로만 사용합니다. """ ) @PostMapping("/{clubId}/sheet/import/preview") ResponseEntity previewPreMembers( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ); diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index e7b8c0b56..bcceb2161 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -43,11 +43,10 @@ public ResponseEntity migrateSheet( @Override public ResponseEntity previewPreMembers( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ) { SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( - clubId, requesterId, request.spreadsheetUrl() + clubId, requesterId ); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 3cee58933..5dbab27af 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -21,11 +21,25 @@ public interface ClubRepository extends Repository { """) Optional findById(@Param(value = "id") Integer id); + @Query(value = """ + SELECT c + FROM Club c + LEFT JOIN FETCH c.university + LEFT JOIN FETCH c.clubRecruitment cr + WHERE c.id = :id + """) + Optional findByIdWithUniversity(@Param(value = "id") Integer id); + default Club getById(Integer id) { return findById(id).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); } + default Club getByIdWithUniversity(Integer id) { + return findByIdWithUniversity(id).orElseThrow(() -> + CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); + } + List findAll(); Club save(Club club); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 42a0975a7..d49f1ddd7 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -53,28 +55,55 @@ public class SheetImportService { private final ChatRoomMembershipService chatRoomMembershipService; private final ClubPermissionValidator clubPermissionValidator; private final PlatformTransactionManager transactionManager; + private final ObjectMapper objectMapper; public SheetImportPreviewResponse previewPreMembersFromSheet( Integer clubId, - Integer requesterId, - String spreadsheetUrl + Integer requesterId ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = resolveClubWithRegisteredSheet(clubId); + String spreadsheetId = club.getGoogleSheetId(); + SheetColumnMapping mapping = resolveRegisteredMemberListMapping(club); + SheetImportSource source = loadSheetImportSource(spreadsheetId, mapping); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); + } - String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - SheetHeaderMapper.SheetAnalysisResult analysis = - sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - SheetImportSource source = loadSheetImportSource(spreadsheetId, analysis.memberListMapping()); - return executeReadOnlyTransaction(() -> { - Club club = clubRepository.getById(clubId); - SheetImportPlan plan = buildImportPlan( - clubId, - club, - source.members(), - source.warnings() - ); - return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); - }); + private Club resolveClubWithRegisteredSheet(Integer clubId) { + Club club = clubRepository.getByIdWithUniversity(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + return club; + } + + private SheetColumnMapping resolveRegisteredMemberListMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + + try { + Map raw = objectMapper.readValue(mappingJson, new TypeReference<>() {}); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number number) { + fieldMap.put(key, number.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } } @Transactional diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index d62a0fdfa..d2999a5f5 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -49,6 +49,8 @@ public enum ApiResponseCode { INVALID_NOTIFICATION_TOKEN(HttpStatus.BAD_REQUEST, "푸시 알림 토큰이 유효하지 않습니다."), FEE_PAYMENT_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "회비 납부가 필요한 동아리입니다. 납부 증빙 사진을 첨부해주세요."), AMBIGUOUS_USER_MATCH(HttpStatus.BAD_REQUEST, "동일한 정보로 식별되는 사용자가 2명 이상입니다. 관리자에게 문의해주세요."), + CLUB_SHEET_ANALYSIS_REQUIRED(HttpStatus.BAD_REQUEST, + "구글 시트 파일에서 동아리 부원을 가져오기 전에 먼저 AI 분석 및 등록이 완료되어야 합니다."), // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java index b54422755..0adc652c7 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.club.service; 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.anyList; import static org.mockito.ArgumentMatchers.anySet; @@ -15,9 +16,10 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.SimpleTransactionStatus; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -33,6 +35,8 @@ import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UniversityFixture; @@ -43,8 +47,6 @@ class SheetImportServiceTest extends ServiceTestSupport { private static final Integer CLUB_ID = 1; private static final Integer REQUESTER_ID = 2; private static final String SPREADSHEET_ID = "sheet-id"; - private static final String SPREADSHEET_URL = - "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit"; @Mock private Sheets googleSheetsService; @@ -82,23 +84,26 @@ class SheetImportServiceTest extends ServiceTestSupport { @Mock private PlatformTransactionManager transactionManager; + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private SheetImportService sheetImportService; @Test void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + club.updateSheetColumnMapping(objectMapper.writeValueAsString( + SheetColumnMapping.defaultMapping().toMap() + )); User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); - given(clubRepository.getById(CLUB_ID)).willReturn(club); - given(sheetHeaderMapper.analyzeAllSheets(SPREADSHEET_ID)).willReturn( - new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null) - ); + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) .willReturn(List.of()); given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); - given(transactionManager.getTransaction(any())).willReturn(new SimpleTransactionStatus()); given(userRepository.findAllByUniversityIdAndStudentNumberIn( eq(club.getUniversity().getId()), anySet() @@ -115,8 +120,7 @@ void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( CLUB_ID, - REQUESTER_ID, - SPREADSHEET_URL + REQUESTER_ID ); assertThat(response.previewCount()).isEqualTo(2); @@ -133,6 +137,35 @@ void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { .containsExactly(true, true); } + @Test + void previewPreMembersFromSheetThrowsWhenSheetIsNotRegistered() { + Club club = ClubFixture.create(UniversityFixture.create()); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + + @Test + void previewPreMembersFromSheetThrowsWhenSheetMappingIsMissing() { + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + @Test void confirmImportPreMembersImportsOnlyEnabledMembers() { Club club = ClubFixture.create(UniversityFixture.create()); diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index 6ce72c97b..d38987caa 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -71,13 +71,10 @@ void previewPreMembersSuccess() throws Exception { given(sheetImportService.previewPreMembersFromSheet( eq(CLUB_ID), - eq(REQUESTER_ID), - eq(SPREADSHEET_URL) + eq(REQUESTER_ID) )).willReturn(response); - SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - - performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") .andExpect(status().isOk()) .andExpect(jsonPath("$.previewCount").value(2)) .andExpect(jsonPath("$.autoRegisteredCount").value(1)) @@ -96,19 +93,32 @@ void previewPreMembersSuccess() throws Exception { void previewPreMembersForbiddenGoogleSheetAccess() throws Exception { given(sheetImportService.previewPreMembersFromSheet( eq(CLUB_ID), - eq(REQUESTER_ID), - eq(SPREADSHEET_URL) + eq(REQUESTER_ID) )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); - SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - - performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code") .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) .andExpect(jsonPath("$.message") .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); } + + @Test + @DisplayName("returns 400 when sheet analysis and registration are not completed") + void previewPreMembersRequiresRegisteredSheet() throws Exception { + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID) + )).willThrow(CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.getMessage())); + } } @Nested From f27ad4c77b153d1d42802e32e72f863a102e0165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:59:30 +0900 Subject: [PATCH 24/50] =?UTF-8?q?test:=20=EA=B0=81=EC=A2=85=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20(#515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 은행 API 통합 테스트를 보강 - 은행 목록 조회 API의 기본 성공 케이스와 빈 목록 응답을 검증한다 - 별도 추상화 없이 최소 테스트만 추가해 변경 범위를 은행 도메인에 한정한다 - 공용 조회 API 회귀 시 응답 구조가 조용히 깨지는 문제를 빠르게 잡을 수 있게 한다 * test: 대학 API 통합 테스트를 보강 - 대학 목록 조회 API의 정렬 순서와 빈 목록 응답을 통합 테스트로 고정한다 - 서비스의 이름 오름차순 계약을 테스트에서 바로 드러내도록 응답 순서를 검증한다 - 대학 선택 화면에 영향을 주는 정렬 회귀를 조기에 막기 위한 커버리지를 추가한다 * test: 문의 API 통합 테스트를 보강 - 문의 전송 성공 케이스와 빈 내용 검증 실패 케이스를 함께 추가한다 - Public API 특성상 입력값 검증 누락이 바로 외부 요청 오류로 이어질 수 있어 최소 실패 경로를 함께 묶었다 - 단순 이벤트 발행 엔드포인트라도 요청 본문 계약이 깨지지 않도록 안전망을 만든다 * fix: 버전 도메인 스키마와 API 테스트를 함께 정리 - version 엔티티의 복합 unique constraint 선언 오류를 바로잡아 H2 스키마 생성이 실패하던 문제를 수정한다 - 일반 버전 조회와 관리자 버전 등록 API에 성공, 중복, 권한, 잘못된 파라미터 케이스를 통합 테스트로 추가한다 - 버전 도메인 회귀가 테스트 환경에서 500으로 숨겨지지 않도록 스키마 결함과 커버리지 공백을 한 번에 메웠다 * fix: 공지 읽음 이력 제약조건 선언 오류를 수정 - council notice 읽음 이력 엔티티의 복합 unique constraint 컬럼 선언을 올바르게 분리한다 - 버전 도메인 검증 중 드러난 같은 유형의 스키마 오류를 함께 정리해 테스트 환경 불안정 원인을 줄인다 - 이후 notice 관련 통합 테스트를 추가할 때 스키마 생성 단계에서 막히지 않도록 선제적으로 정리한다 * chore: 코드 포맷팅 * test: 총동아리연합회 API 통합 테스트를 보강 - 총동아리연합회 조회, 생성, 수정, 삭제 흐름을 전용 통합 테스트로 고정한다 - 중복 생성과 잘못된 전화번호 형식 같은 실패 경로도 함께 검증해 입력 계약을 명확히 한다 - 대학 단위 대표 조직 정보가 깨질 때 주요 화면과 설정 흐름이 함께 흔들리는 문제를 조기에 잡을 수 있게 한다 * test: 공지사항 API 통합 테스트를 보강 - 공지 목록, 상세, 생성, 수정, 삭제 흐름을 읽음 여부와 권한 검증까지 포함해 통합 테스트로 추가한다 - 다른 대학 공지 접근 금지와 잘못된 페이지 파라미터 같은 엣지 케이스를 함께 고정해 회귀 여지를 줄인다 - 공지 생성 로직이 총동아리연합회 데이터에 의존하는 현재 구조를 테스트로 드러내 안정적으로 보호한다 * test: 알림 SSE 구독 API 통합 테스트를 보강 - 알림 inbox SSE 구독 엔드포인트가 비동기 응답으로 시작되는지 통합 테스트로 고정한다 - 최초 connect 이벤트와 text/event-stream 콘텐츠 타입을 함께 검증해 구독 초기 계약이 조용히 깨지는 문제를 막는다 - 기존 알림 inbox CRUD 테스트가 다루지 못하던 실시간 구독 경로를 별도 테스트로 보강한다 * test: 어드민 광고 API 통합 테스트를 보강 - 어드민 광고 목록, 단건 조회, 생성, 수정, 삭제 흐름을 전용 통합 테스트로 고정한다 - 존재하지 않는 광고 조회, 잘못된 요청 본문, 권한 부족 같은 실패 경로를 함께 검증해 회귀 범위를 넓힌다 - 일반 광고 조회 API와 분리된 어드민 전용 관리 경로가 조용히 깨지지 않도록 CRUD 계약을 명확히 보호한다 * fix: 총동아리연합회 수정 계약과 엣지 케이스를 보강 - 총동아리연합회 수정 시 phoneNumber와 email이 실제 반영되도록 누락된 필드 업데이트를 복구한다 - 생성, 수정, 삭제, 검증 실패, 미존재 대상 같은 엣지 케이스를 통합 테스트로 추가한다 - 설정 화면에서 변경이 일부만 저장되는 숨은 회귀를 테스트와 함께 바로 잡는다 * fix: 공지사항 입력 검증과 엣지 케이스를 보강 - 공지 제목과 내용의 공백 문자열이 통과하지 않도록 NotBlank 검증으로 강화한다 - 생성, 조회, 수정, 삭제 전반에 404, 400, 권한, 읽음 이력 중복 방지 케이스를 추가한다 - 공지 작성과 열람 흐름에서 사용자 입력과 읽음 상태가 조용히 어긋나는 문제를 테스트로 고정한다 * test: 대학 API 엣지 케이스를 보강 - 같은 이름이라도 캠퍼스가 다르면 각각 조회되는 계약을 통합 테스트로 추가한다 - 대학 선택 화면에서 이름 중복 케이스가 누락되어도 목록 응답이 의도대로 유지되도록 보호한다 * test: 문의 API 입력 엣지 케이스를 보강 - 요청 본문이 완전히 없을 때 INVALID_JSON_FORMAT이 반환되는 실제 계약을 테스트로 고정한다 - 빈 문자열 검증 케이스와 함께 본문 누락까지 포함해 외부 호출 실패 경로를 더 촘촘히 보호한다 * test: 버전 API 엣지 케이스를 보강 - releaseNotes가 비어 있는 최신 버전 조회와 플랫폼 누락, 동일 버전의 플랫폼별 등록 케이스를 추가한다 - 버전 조회와 관리자 등록 경로의 경계값을 함께 검증해 플랫폼별 계약이 흐트러지지 않도록 보호한다 * test: 알림 SSE 구독 엣지 케이스를 보강 - 동일 사용자의 재구독이 새 연결로 정상 시작되는지 추가로 검증한다 - 실시간 구독 경로에서 connect 이벤트 계약이 중복 구독 상황에서도 유지되도록 보호한다 * test: 어드민 광고 API 엣지 케이스를 보강 - 빈 목록, 수정 대상 없음, 삭제 대상 없음 케이스를 추가해 어드민 광고 CRUD의 경계값을 넓힌다 - 관리 화면에서 데이터가 없거나 이미 삭제된 상태에서도 응답 계약이 일관되게 유지되도록 보호한다 * test: 버전 최신 판단 규칙을 통합 테스트로 고정 - 최신 버전 선택 기준이 버전 문자열 크기가 아니라 createdAt임을 통합 테스트로 명시한다 - 운영자가 더 작은 버전 문자열을 나중에 등록해도 최신 배포 기준이 흔들리지 않도록 계약을 고정한다 * test: 총동아리연합회 대학 범위 규칙을 보강 - 다른 대학 council은 조회 대상이 아니고, 다른 대학에 이미 있어도 현재 대학에는 생성 가능하다는 규칙을 추가로 검증한다 - council 조회와 생성이 대학 단위로 격리된다는 핵심 도메인 규칙을 테스트로 고정한다 * test: 공지 읽음 상태와 대학 범위 규칙을 보강 - 다른 대학 공지가 목록에 섞이지 않는지, 한 사용자의 읽음 처리가 다른 사용자에게 전파되지 않는지를 검증한다 - 공지 목록과 읽음 상태가 대학/사용자 단위로 격리된다는 도메인 규칙을 테스트로 보호한다 * test: 어드민 광고 상태 보존 규칙을 보강 - 광고 생성 시 clickCount가 0으로 시작하고, 수정 시 기존 clickCount를 유지하는지를 검증한다 - 관리용 수정 작업이 통계성 상태를 의도치 않게 초기화하지 않는다는 계약을 테스트로 고정한다 * test: 네이티브 세션 브리지 상태 전이 규칙을 보강 - 브릿지 성공 시 기존 세션이 무효화되는지 확인해 네이티브 로그인 전환 과정의 세션 격리를 고정한다 - https 프록시 환경에서 refresh_token 쿠키가 Secure와 SameSite=None 속성을 갖는지 검증해 실제 배포 환경의 쿠키 보안 계약을 보호한다 * test: Slack 이벤트 처리 규칙을 보강 - url_verification 우선 처리, event_id 중복 무시, 스레드 app_mention 컨텍스트 전달, subtype message 무시 규칙을 컨트롤러 테스트로 고정한다 - Slack 이벤트 재전송과 스레드 멘션 같은 실제 운영 상황에서 중복 응답이나 잘못된 AI 호출이 발생하지 않도록 핵심 처리 규칙을 보호한다 * test: 알림 전달 서비스 규칙을 보강 - 같은 사용자의 SSE 재구독 시 이전 emitter 완료가 현재 구독을 지우지 않는 규칙을 서비스 테스트로 고정한다 - 배치 SSE 전송이 일부 실패에도 나머지 사용자 전송을 계속하고, saveAll이 실제 조회된 사용자에게만 알림을 생성하는 규칙을 검증한다 * chore: 코드 포맷팅 --- .../konect/domain/council/model/Council.java | 4 + .../council/service/CouncilService.java | 2 + .../dto/CouncilNoticeCreateRequest.java | 6 +- .../dto/CouncilNoticeUpdateRequest.java | 6 +- .../model/CouncilNoticeReadHistory.java | 2 +- .../konect/domain/version/model/Version.java | 2 +- .../service/NotificationInboxServiceTest.java | 137 ++++++++ .../NotificationInboxSseServiceTest.java | 40 +++ .../slack/ai/SlackEventControllerTest.java | 174 ++++++++++ .../AdminAdvertisementApiTest.java | 302 ++++++++++++++++++ .../admin/version/AdminVersionApiTest.java | 137 ++++++++ .../integration/domain/bank/BankApiTest.java | 56 ++++ .../domain/council/CouncilApiTest.java | 282 ++++++++++++++++ .../domain/inquiry/InquiryApiTest.java | 54 ++++ .../domain/notice/NoticeApiTest.java | 295 +++++++++++++++++ .../NotificationInboxSseApiTest.java | 85 +++++ .../domain/university/UniversityApiTest.java | 66 ++++ .../domain/version/VersionApiTest.java | 102 ++++++ .../integration/global/auth/AuthApiTest.java | 45 +++ 19 files changed, 1789 insertions(+), 8 deletions(-) create mode 100644 src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java create mode 100644 src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java create mode 100644 src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java create mode 100644 src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java diff --git a/src/main/java/gg/agit/konect/domain/council/model/Council.java b/src/main/java/gg/agit/konect/domain/council/model/Council.java index 8083f4dd6..0c56d3db9 100644 --- a/src/main/java/gg/agit/konect/domain/council/model/Council.java +++ b/src/main/java/gg/agit/konect/domain/council/model/Council.java @@ -102,6 +102,8 @@ public void update( String introduce, String location, String personalColor, + String phoneNumber, + String email, String instagramUserName, String operatingHour ) { @@ -110,6 +112,8 @@ public void update( this.introduce = introduce; this.location = location; this.personalColor = personalColor; + this.phoneNumber = phoneNumber; + this.email = email; this.instagramUserName = instagramUserName; this.operatingHour = operatingHour; } diff --git a/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java b/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java index f00a61245..68cb47467 100644 --- a/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java +++ b/src/main/java/gg/agit/konect/domain/council/service/CouncilService.java @@ -57,6 +57,8 @@ public void updateCouncil(Integer userId, CouncilUpdateRequest request) { request.introduce(), request.location(), request.personalColor(), + request.phoneNumber(), + request.email(), request.instagramUserName(), request.operatingHour() ); diff --git a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java index 5c29f8ebc..cd8356cbb 100644 --- a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeCreateRequest.java @@ -5,16 +5,16 @@ import gg.agit.konect.domain.council.model.Council; import gg.agit.konect.domain.notice.model.CouncilNotice; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CouncilNoticeCreateRequest( - @NotEmpty(message = "공지사항 제목은 필수 입력입니다.") + @NotBlank(message = "공지사항 제목은 필수 입력입니다.") @Size(max = 255, message = "공지사항 제목은 최대 255자 입니다.") @Schema(description = "공지사항 제목", example = "동아리 박람회 참가 신청 마감 안내", requiredMode = REQUIRED) String title, - @NotEmpty(message = "공지사항 내용은 필수 입력입니다.") + @NotBlank(message = "공지사항 내용은 필수 입력입니다.") @Schema(description = "공지사항 내용", example = "2025년 동아리 박람회 참가 신청이 12월 15일까지입니다.", requiredMode = REQUIRED) String content ) { diff --git a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java index d3231cb98..b0182cfd9 100644 --- a/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/notice/dto/CouncilNoticeUpdateRequest.java @@ -3,16 +3,16 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record CouncilNoticeUpdateRequest( - @NotEmpty(message = "공지사항 제목은 필수 입력입니다.") + @NotBlank(message = "공지사항 제목은 필수 입력입니다.") @Size(max = 255, message = "공지사항 제목은 최대 255자 입니다.") @Schema(description = "공지사항 제목", example = "동아리 박람회 참가 신청 마감 안내", requiredMode = REQUIRED) String title, - @NotEmpty(message = "공지사항 내용은 필수 입력입니다.") + @NotBlank(message = "공지사항 내용은 필수 입력입니다.") @Schema(description = "공지사항 내용", example = "2025년 동아리 박람회 참가 신청이 12월 15일까지입니다.", requiredMode = REQUIRED) String content ) { diff --git a/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java b/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java index 4c779229e..998c3e2e0 100644 --- a/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java +++ b/src/main/java/gg/agit/konect/domain/notice/model/CouncilNoticeReadHistory.java @@ -25,7 +25,7 @@ uniqueConstraints = { @UniqueConstraint( name = "uq_council_notice_read_history_user_id_council_notice_id", - columnNames = {"user_id, council_notice_id"} + columnNames = {"user_id", "council_notice_id"} ) } ) diff --git a/src/main/java/gg/agit/konect/domain/version/model/Version.java b/src/main/java/gg/agit/konect/domain/version/model/Version.java index 264ea4897..578385e18 100644 --- a/src/main/java/gg/agit/konect/domain/version/model/Version.java +++ b/src/main/java/gg/agit/konect/domain/version/model/Version.java @@ -24,7 +24,7 @@ uniqueConstraints = { @UniqueConstraint( name = "uq_version_platform_version", - columnNames = {"platform, version"} + columnNames = {"platform", "version"} ) } ) diff --git a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java new file mode 100644 index 000000000..0832fe1b7 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java @@ -0,0 +1,137 @@ +package gg.agit.konect.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class NotificationInboxServiceTest extends ServiceTestSupport { + + @Mock + private NotificationInboxRepository notificationInboxRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationInboxSseService notificationInboxSseService; + + @InjectMocks + private NotificationInboxService notificationInboxService; + + @Test + @DisplayName("saveAll은 조회된 사용자에게만 알림을 생성한다") + void saveAllOnlyCreatesForResolvedUsers() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user2)); + given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = notificationInboxService.saveAll( + List.of(1, 2, 3), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).saveAll(any()); + assertThatCode(() -> result.get(0).getTitle()).doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThat(result).hasSize(2); + org.assertj.core.api.Assertions.assertThat(result) + .extracting(inbox -> inbox.getUser().getStudentNumber()) + .containsExactly("2021136001", "2021136002"); + } + + @Test + @DisplayName("sendSseBatch는 일부 사용자 전송 실패가 있어도 나머지 전송을 계속한다") + void sendSseBatchContinuesWhenOneSendFails() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + NotificationInbox inbox1 = NotificationInbox.of( + user1, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목1", + "본문1", + "/clubs/1" + ); + NotificationInbox inbox2 = NotificationInbox.of( + user2, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "제목2", + "본문2", + "/clubs/2" + ); + + doThrow(new RuntimeException("sse failure")) + .when(notificationInboxSseService) + .send(eq(user1.getId()), any(NotificationInboxResponse.class)); + + // when + assertThatCode(() -> notificationInboxService.sendSseBatch(List.of(inbox1, inbox2))) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxSseService, times(1)).send(eq(user1.getId()), any(NotificationInboxResponse.class)); + verify(notificationInboxSseService, times(1)).send(eq(user2.getId()), any(NotificationInboxResponse.class)); + } + + @Test + @DisplayName("saveAll은 대상 사용자가 없으면 저장을 시도하지 않는다") + void saveAllSkipsWhenUserIdsEmpty() { + // when + List result = notificationInboxService.saveAll( + List.of(), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + org.assertj.core.api.Assertions.assertThat(result).isEmpty(); + verify(userRepository, never()).findAllByIdIn(any()); + verify(notificationInboxRepository, never()).saveAll(any()); + } + + private User createUser(University university, Integer id, String name, String studentNumber) { + return User.builder() + .id(id) + .university(university) + .email(studentNumber + "@koreatech.ac.kr") + .name(name) + .studentNumber(studentNumber) + .role(UserRole.USER) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile.png") + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java new file mode 100644 index 000000000..1d3ce022c --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java @@ -0,0 +1,40 @@ +package gg.agit.konect.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.support.ServiceTestSupport; + +class NotificationInboxSseServiceTest extends ServiceTestSupport { + + private final NotificationInboxSseService notificationInboxSseService = new NotificationInboxSseService(); + + @Test + @DisplayName("같은 사용자가 재구독한 뒤 이전 emitter가 완료되어도 현재 구독은 유지된다") + void subscribeReplacesEmitterWithoutRemovingNewOne() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + + // when + firstEmitter.complete(); + + // then + Map emitters = emitters(); + assertThat(emitters).hasSize(1); + assertThat(emitters.get(1)).isSameAs(secondEmitter); + } + + @SuppressWarnings("unchecked") + private Map emitters() throws Exception { + Field field = NotificationInboxSseService.class.getDeclaredField("emitters"); + field.setAccessible(true); + return (Map)field.get(notificationInboxSseService); + } +} diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java b/src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java new file mode 100644 index 000000000..bd9ee4c80 --- /dev/null +++ b/src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java @@ -0,0 +1,174 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; +import gg.agit.konect.support.ControllerTestSupport; + +@WebMvcTest(SlackEventController.class) +@WithMockUser +class SlackEventControllerTest extends ControllerTestSupport { + + @MockitoBean + private SlackAIService slackAIService; + + @MockitoBean + private SlackSignatureVerifier slackSignatureVerifier; + + @Nested + @DisplayName("POST /slack/events - Slack 이벤트 처리") + class HandleSlackEvent { + + @Test + @DisplayName("url_verification 요청은 서명 검증 없이 challenge를 반환한다") + void handleUrlVerification() throws Exception { + String rawBody = """ + { + "type": "url_verification", + "challenge": "challenge-token" + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.challenge").value("challenge-token")); + + verify(slackSignatureVerifier, never()).isValidRequest(any(), any(), any()); + } + + @Test + @DisplayName("같은 event_id의 중복 이벤트는 한 번만 처리한다") + void ignoresDuplicateEventId() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + given(slackAIService.isAIQuery("AI) 중복 처리 확인")).willReturn(true); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-1", + "event": { + "type": "message", + "text": "AI) 중복 처리 확인", + "channel": "C123", + "ts": "1710000000.000100" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000000") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000000") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService, times(1)) + .processAIQuery("AI) 중복 처리 확인", "C123", "1710000000.000100", null); + } + + @Test + @DisplayName("스레드의 app_mention은 기존 AI 스레드 답글을 함께 전달한다") + void appMentionWithThreadRepliesPassesContext() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + given(slackAIService.normalizeAppMentionText("<@U123> 이번 주 현황 알려줘")) + .willReturn("이번 주 현황 알려줘"); + + List> aiReplies = List.of( + Map.of("text", "<@U123> 지난주 요약", "ts", "1710000000.000100"), + Map.of("bot_id", "B123", "text", ":robot_face: *AI 응답*\n지난주 답변", "ts", "1710000000.000200") + ); + given(slackAIService.fetchAIThreadReplies("C123", "1710000000.000100")).willReturn(aiReplies); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-thread-1", + "event": { + "type": "app_mention", + "text": "<@U123> 이번 주 현황 알려줘", + "channel": "C123", + "ts": "1710000000.000300", + "thread_ts": "1710000000.000100" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000001") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService).processAIQuery( + "이번 주 현황 알려줘", + "C123", + "1710000000.000100", + aiReplies + ); + } + + @Test + @DisplayName("subtype이 있는 message 이벤트는 무시한다") + void ignoresSubtypeMessage() throws Exception { + given(slackSignatureVerifier.isValidRequest(any(), any(), any())).willReturn(true); + + String rawBody = """ + { + "type": "event_callback", + "event_id": "event-subtype-1", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "AI) 응답하지 말아야 함", + "channel": "C123", + "ts": "1710000000.000400" + } + } + """; + + mockMvc.perform(post("/slack/events") + .with(csrf()) + .header("X-Slack-Request-Timestamp", "1710000002") + .header("X-Slack-Signature", "v0=test") + .contentType(MediaType.APPLICATION_JSON) + .content(rawBody)) + .andExpect(status().isOk()); + + verify(slackAIService, never()).isAIQuery(any()); + verify(slackAIService, never()).processAIQuery(any(), any(), any(), any()); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java b/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java new file mode 100644 index 000000000..b31e0e4eb --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/advertisement/AdminAdvertisementApiTest.java @@ -0,0 +1,302 @@ +package gg.agit.konect.integration.admin.advertisement; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.admin.advertisement.dto.AdminAdvertisementCreateRequest; +import gg.agit.konect.admin.advertisement.dto.AdminAdvertisementUpdateRequest; +import gg.agit.konect.domain.advertisement.model.Advertisement; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.AdvertisementFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminAdvertisementApiTest extends IntegrationTestSupport { + + private static final String ADMIN_ADVERTISEMENTS_ENDPOINT = "/admin/advertisements"; + + private User adminUser; + private User normalUser; + + @BeforeEach + void setUp() throws Exception { + University university = persist(UniversityFixture.create()); + adminUser = persist(UserFixture.createAdmin(university)); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + clearPersistenceContext(); + } + + @Nested + @DisplayName("GET /admin/advertisements - 광고 목록 조회") + class GetAdvertisements { + + @Test + @DisplayName("광고 목록을 최신순으로 조회한다") + void getAdvertisementsSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement first = persist(AdvertisementFixture.create("첫 번째 광고", true)); + Advertisement second = persist(AdvertisementFixture.create("두 번째 광고", false)); + clearPersistenceContext(); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(2))) + .andExpect(jsonPath("$.advertisements[0].id").value(second.getId())) + .andExpect(jsonPath("$.advertisements[1].id").value(first.getId())); + } + + @Test + @DisplayName("광고가 없으면 빈 목록을 반환한다") + void getAdvertisementsWhenEmpty() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(0))); + } + } + + @Nested + @DisplayName("GET /admin/advertisements/{id} - 광고 단건 조회") + class GetAdvertisement { + + @Test + @DisplayName("광고 단건을 조회한다") + void getAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("광고 제목", true)); + clearPersistenceContext(); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(advertisement.getId())) + .andExpect(jsonPath("$.title").value("광고 제목")) + .andExpect(jsonPath("$.isVisible").value(true)); + } + + @Test + @DisplayName("존재하지 않는 광고면 404를 반환한다") + void getAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + } + + @Nested + @DisplayName("POST /admin/advertisements - 광고 생성") + class CreateAdvertisement { + + @Test + @DisplayName("광고를 생성한다") + void createAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, createRequest("생성 광고", true)) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.advertisements", hasSize(1))) + .andExpect(jsonPath("$.advertisements[0].title").value("생성 광고")) + .andExpect(jsonPath("$.advertisements[0].isVisible").value(true)) + .andExpect(jsonPath("$.advertisements[0].clickCount").value(0)); + } + + @Test + @DisplayName("광고 제목이 비어 있으면 400을 반환한다") + void createAdvertisementInvalidBodyFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminAdvertisementCreateRequest request = new AdminAdvertisementCreateRequest( + " ", + "광고 설명", + "https://example.com/image.png", + "https://example.com/link", + true + ); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("어드민 권한이 없으면 403을 반환한다") + void createAdvertisementForbidden() throws Exception { + // given + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + // when & then + performPost(ADMIN_ADVERTISEMENTS_ENDPOINT, createRequest("권한 없음", true)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + } + + @Nested + @DisplayName("PUT /admin/advertisements/{id} - 광고 수정") + class UpdateAdvertisement { + + @Test + @DisplayName("광고를 수정한다") + void updateAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("기존 광고", true)); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId(), request) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 광고")) + .andExpect(jsonPath("$.description").value("수정 설명")) + .andExpect(jsonPath("$.isVisible").value(false)); + } + + @Test + @DisplayName("수정 대상 광고가 없으면 404를 반환한다") + void updateAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999", request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + + @Test + @DisplayName("광고 수정은 기존 클릭 수를 보존한다") + void updateAdvertisementPreservesClickCount() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Integer advertisementId = insertAdvertisement("기존 광고", true, 7); + clearPersistenceContext(); + + AdminAdvertisementUpdateRequest request = new AdminAdvertisementUpdateRequest( + "수정 광고", + "수정 설명", + "https://example.com/new-image.png", + "https://example.com/new-link", + false + ); + + // when & then + performPut(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisementId, request) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisementId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clickCount").value(7)); + } + } + + @Nested + @DisplayName("DELETE /admin/advertisements/{id} - 광고 삭제") + class DeleteAdvertisement { + + @Test + @DisplayName("광고를 삭제한다") + void deleteAdvertisementSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + Advertisement advertisement = persist(AdvertisementFixture.create("삭제 광고", true)); + clearPersistenceContext(); + + // when & then + performDelete(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isOk()); + + performGet(ADMIN_ADVERTISEMENTS_ENDPOINT + "/" + advertisement.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + + @Test + @DisplayName("삭제 대상 광고가 없으면 404를 반환한다") + void deleteAdvertisementNotFound() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performDelete(ADMIN_ADVERTISEMENTS_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_ADVERTISEMENT.getCode())); + } + } + + private AdminAdvertisementCreateRequest createRequest(String title, boolean isVisible) { + return new AdminAdvertisementCreateRequest( + title, + title + " 설명", + "https://example.com/image.png", + "https://example.com/link", + isVisible + ); + } + + private Integer insertAdvertisement(String title, boolean isVisible, int clickCount) { + entityManager.createNativeQuery(""" + insert into advertisement ( + title, description, image_url, link_url, is_visible, click_count, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, title) + .setParameter(2, title + " 설명") + .setParameter(3, "https://example.com/image.png") + .setParameter(4, "https://example.com/link") + .setParameter(5, isVisible) + .setParameter(6, clickCount) + .executeUpdate(); + + entityManager.flush(); + return ((Number)entityManager.createNativeQuery("select max(id) from advertisement") + .getSingleResult()).intValue(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java b/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java new file mode 100644 index 000000000..40d78df6c --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/admin/version/AdminVersionApiTest.java @@ -0,0 +1,137 @@ +package gg.agit.konect.integration.admin.version; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.admin.version.dto.AdminVersionCreateRequest; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.version.enums.PlatformType; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class AdminVersionApiTest extends IntegrationTestSupport { + + private static final String ADMIN_VERSIONS_ENDPOINT = "/admin/versions"; + private static final String LATEST_VERSION_ENDPOINT = "/versions/latest"; + + private User adminUser; + private User normalUser; + + @BeforeEach + void setUp() { + University university = persist(UniversityFixture.create()); + adminUser = persist(UserFixture.createAdmin(university)); + normalUser = persist(UserFixture.createUser(university, "일반유저", "2021136001")); + } + + @Nested + @DisplayName("POST /admin/versions - 버전 등록") + class CreateVersion { + + @Test + @DisplayName("관리자가 새 버전을 등록한다") + void createVersionSuccess() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = new AdminVersionCreateRequest( + PlatformType.IOS, "1.2.3", "새 기능 추가" + ); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value("1.2.3")) + .andExpect(jsonPath("$.releaseNotes").value("새 기능 추가")); + } + + @Test + @DisplayName("같은 플랫폼/버전을 중복 등록하면 409를 반환한다") + void createVersionDuplicateFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = createRequest(PlatformType.ANDROID, "2.0.0"); + + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isOk()); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.DUPLICATE_VERSION.getCode())); + } + + @Test + @DisplayName("버전 값이 비어 있으면 400을 반환한다") + void createVersionWithBlankVersionFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = createRequest(PlatformType.IOS, " "); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("관리자 권한이 없으면 403을 반환한다") + void createVersionWithoutAdminRoleFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + given(authorizationInterceptor.preHandle(any(), any(), any())) + .willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_ROLE_ACCESS)); + + AdminVersionCreateRequest request = createRequest(PlatformType.IOS, "1.0.0"); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_ROLE_ACCESS.getCode())); + } + + @Test + @DisplayName("같은 버전 문자열이라도 플랫폼이 다르면 중복이 아니다") + void createVersionWithSameVersionDifferentPlatformSucceeds() throws Exception { + // given + mockLoginUser(adminUser.getId()); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, createRequest(PlatformType.IOS, "3.0.0")) + .andExpect(status().isOk()); + + performPost(ADMIN_VERSIONS_ENDPOINT, createRequest(PlatformType.ANDROID, "3.0.0")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("플랫폼이 없으면 400을 반환한다") + void createVersionWithoutPlatformFails() throws Exception { + // given + mockLoginUser(adminUser.getId()); + AdminVersionCreateRequest request = new AdminVersionCreateRequest(null, "1.0.0", "릴리즈 노트"); + + // when & then + performPost(ADMIN_VERSIONS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + private AdminVersionCreateRequest createRequest(PlatformType platform, String version) { + return new AdminVersionCreateRequest(platform, version, "릴리즈 노트"); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java b/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java new file mode 100644 index 000000000..820f82f42 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/bank/BankApiTest.java @@ -0,0 +1,56 @@ +package gg.agit.konect.integration.domain.bank; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.support.IntegrationTestSupport; + +class BankApiTest extends IntegrationTestSupport { + + private static final String BANKS_ENDPOINT = "/banks"; + + @Nested + @DisplayName("GET /banks - 은행 목록 조회") + class GetBanks { + + @Test + @DisplayName("등록된 은행 목록을 조회한다") + void getBanksSuccess() throws Exception { + // given + insertBank("국민은행", "https://example.com/kb.png"); + insertBank("카카오뱅크", "https://example.com/kakao.png"); + clearPersistenceContext(); + + // when & then + performGet(BANKS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.banks", hasSize(2))) + .andExpect(jsonPath("$.banks[0].name").value("국민은행")) + .andExpect(jsonPath("$.banks[1].name").value("카카오뱅크")); + } + + @Test + @DisplayName("등록된 은행이 없으면 빈 목록을 반환한다") + void getBanksWhenEmpty() throws Exception { + // when & then + performGet(BANKS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.banks", hasSize(0))); + } + } + + private void insertBank(String name, String imageUrl) { + entityManager.createNativeQuery(""" + insert into bank (name, image_url, created_at, updated_at) + values (?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, name) + .setParameter(2, imageUrl) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java b/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java new file mode 100644 index 000000000..4595cd1a5 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/council/CouncilApiTest.java @@ -0,0 +1,282 @@ +package gg.agit.konect.integration.domain.council; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.domain.council.dto.CouncilCreateRequest; +import gg.agit.konect.domain.council.dto.CouncilUpdateRequest; +import gg.agit.konect.domain.council.model.Council; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.CouncilFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class CouncilApiTest extends IntegrationTestSupport { + + private static final String COUNCILS_ENDPOINT = "/councils"; + + private University university; + private User user; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "학생회유저", "2021136001")); + clearPersistenceContext(); + mockLoginUser(user.getId()); + } + + @Nested + @DisplayName("GET /councils - 총동아리연합회 조회") + class GetCouncil { + + @Test + @DisplayName("사용자 대학의 총동아리연합회 정보를 조회한다") + void getCouncilSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(council.getId())) + .andExpect(jsonPath("$.name").value("총학생회")) + .andExpect(jsonPath("$.instagramUserName").value("koreatech_council")); + } + + @Test + @DisplayName("총동아리연합회가 없으면 404를 반환한다") + void getCouncilNotFound() throws Exception { + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("다른 대학 총동아리연합회는 현재 사용자 조회 대상이 아니다") + void getCouncilDoesNotReturnOtherUniversityCouncil() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + persist(CouncilFixture.create(otherUniversity)); + clearPersistenceContext(); + + // when & then + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + @Nested + @DisplayName("POST /councils - 총동아리연합회 생성") + class CreateCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 생성한다") + void createCouncilSuccess() throws Exception { + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화")) + .andExpect(jsonPath("$.location").value("학생회관 2층 202호")); + } + + @Test + @DisplayName("이미 존재하면 409를 반환한다") + void createCouncilDuplicateFails() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.ALREADY_EXIST_COUNCIL.getCode())); + } + + @Test + @DisplayName("다른 대학에 총동아리연합회가 있어도 현재 대학에는 새로 만들 수 있다") + void createCouncilWhenOtherUniversityHasCouncil() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + persist(CouncilFixture.create(otherUniversity)); + clearPersistenceContext(); + + // when & then + performPost(COUNCILS_ENDPOINT, createRequest()) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화")); + } + + @Test + @DisplayName("전화번호 형식이 잘못되면 400을 반환한다") + void createCouncilInvalidPhoneFails() throws Exception { + // given + CouncilCreateRequest request = new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "02-123-4567", + "council@example.com", + "평일 09:00 ~ 18:00", + "koreatechclub" + ); + + // when & then + performPost(COUNCILS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("인스타그램 아이디 형식이 잘못되면 400을 반환한다") + void createCouncilInvalidInstagramFails() throws Exception { + // given + CouncilCreateRequest request = new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "01012345678", + "council@example.com", + "평일 09:00 ~ 18:00", + "@koreatechclub" + ); + + // when & then + performPost(COUNCILS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + @Nested + @DisplayName("PUT /councils - 총동아리연합회 수정") + class UpdateCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 수정한다") + void updateCouncilSuccess() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + CouncilUpdateRequest request = new CouncilUpdateRequest( + "개화 리뉴얼", + "https://konect.kro.kr/new-image.jpg", + "새로운 소개", + "학생회관 3층 301호", + "#000000", + "01012345678", + "updated@example.com", + "평일 10:00 ~ 17:00", + "renewed_council" + ); + + // when & then + performPut(COUNCILS_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("개화 리뉴얼")) + .andExpect(jsonPath("$.imageUrl").value("https://konect.kro.kr/new-image.jpg")) + .andExpect(jsonPath("$.instagramUserName").value("renewed_council")); + + Council updatedCouncil = entityManager.createQuery(""" + select c + from Council c + where c.university.id = :universityId + """, Council.class) + .setParameter("universityId", university.getId()) + .getSingleResult(); + org.assertj.core.api.Assertions.assertThat(updatedCouncil.getPhoneNumber()).isEqualTo("01012345678"); + org.assertj.core.api.Assertions.assertThat(updatedCouncil.getEmail()).isEqualTo("updated@example.com"); + } + + @Test + @DisplayName("수정 대상이 없으면 404를 반환한다") + void updateCouncilNotFound() throws Exception { + // given + CouncilUpdateRequest request = new CouncilUpdateRequest( + "개화 리뉴얼", + "https://konect.kro.kr/new-image.jpg", + "새로운 소개", + "학생회관 3층 301호", + "#000000", + "01012345678", + "updated@example.com", + "평일 10:00 ~ 17:00", + "renewed_council" + ); + + // when & then + performPut(COUNCILS_ENDPOINT, request) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + @Nested + @DisplayName("DELETE /councils - 총동아리연합회 삭제") + class DeleteCouncil { + + @Test + @DisplayName("총동아리연합회 정보를 삭제한다") + void deleteCouncilSuccess() throws Exception { + // given + persist(CouncilFixture.create(university)); + clearPersistenceContext(); + + // when & then + performDelete(COUNCILS_ENDPOINT) + .andExpect(status().isNoContent()); + + performGet(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("삭제 대상이 없으면 404를 반환한다") + void deleteCouncilNotFound() throws Exception { + // when & then + performDelete(COUNCILS_ENDPOINT) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + } + + private CouncilCreateRequest createRequest() { + return new CouncilCreateRequest( + "개화", + "https://konect.kro.kr/image.jpg", + "총동아리연합회 소개", + "학생회관 2층 202호", + "#FF5733", + "01012345678", + "council@example.com", + "평일 09:00 ~ 18:00", + "koreatechclub" + ); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java b/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java new file mode 100644 index 000000000..57d1c7020 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/inquiry/InquiryApiTest.java @@ -0,0 +1,54 @@ +package gg.agit.konect.integration.domain.inquiry; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.inquiry.dto.InquiryRequest; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class InquiryApiTest extends IntegrationTestSupport { + + private static final String INQUIRIES_ENDPOINT = "/inquiries"; + + @Nested + @DisplayName("POST /inquiries - 문의 전송") + class SubmitInquiry { + + @Test + @DisplayName("문의 내용을 전송한다") + void submitInquirySuccess() throws Exception { + // given + InquiryRequest request = new InquiryRequest("앱 사용 중 오류가 발생했습니다."); + + // when & then + performPost(INQUIRIES_ENDPOINT, request) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("문의 내용이 비어 있으면 400을 반환한다") + void submitInquiryWithBlankContentFails() throws Exception { + // given + InquiryRequest request = new InquiryRequest(" "); + + // when & then + performPost(INQUIRIES_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("요청 본문이 없으면 400을 반환한다") + void submitInquiryWithoutBodyFails() throws Exception { + // when & then + performPost(INQUIRIES_ENDPOINT) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_JSON_FORMAT.getCode())); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java new file mode 100644 index 000000000..e2edd977b --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notice/NoticeApiTest.java @@ -0,0 +1,295 @@ +package gg.agit.konect.integration.domain.notice; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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 gg.agit.konect.domain.council.model.Council; +import gg.agit.konect.domain.notice.dto.CouncilNoticeCreateRequest; +import gg.agit.konect.domain.notice.dto.CouncilNoticeUpdateRequest; +import gg.agit.konect.domain.notice.model.CouncilNotice; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.CouncilFixture; +import gg.agit.konect.support.fixture.CouncilNoticeFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NoticeApiTest extends IntegrationTestSupport { + + private static final String NOTICES_ENDPOINT = "/councils/notices"; + + private University university; + private User user; + private User otherUser; + + @BeforeEach + void setUp() throws Exception { + university = persist(UniversityFixture.create()); + user = persist(UserFixture.createUser(university, "공지유저", "2021136001")); + otherUser = persist(UserFixture.createUser(university, "다른공지유저", "2021136002")); + clearPersistenceContext(); + mockLoginUser(user.getId()); + } + + @Nested + @DisplayName("GET /councils/notices - 공지 목록 조회") + class GetNotices { + + @Test + @DisplayName("공지 목록을 읽음 여부와 함께 조회한다") + void getNoticesSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice firstNotice = persist(CouncilNoticeFixture.create(council, "첫 번째 공지", "내용1")); + persist(CouncilNoticeFixture.create(council, "두 번째 공지", "내용2")); + clearPersistenceContext(); + + // 첫 번째 공지를 읽으면 목록에서 읽음 상태가 반영되어야 한다. + performGet(NOTICES_ENDPOINT + "/" + firstNotice.getId()) + .andExpect(status().isOk()); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(2))) + .andExpect(jsonPath("$.councilNotices[0].isRead").value(false)) + .andExpect(jsonPath("$.councilNotices[1].isRead").value(true)); + } + + @Test + @DisplayName("페이지가 1 미만이면 400을 반환한다") + void getNoticesInvalidPageFails() throws Exception { + // when & then + performGet(NOTICES_ENDPOINT + "?page=0&limit=10") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("다른 대학 공지는 목록에서 제외된다") + void getNoticesExcludesOtherUniversityNotices() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + persist(CouncilNoticeFixture.create(council, "우리 대학 공지", "내용1")); + + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Council otherCouncil = persist(CouncilFixture.create(otherUniversity)); + persist(CouncilNoticeFixture.create(otherCouncil, "다른 대학 공지", "내용2")); + clearPersistenceContext(); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].title").value("우리 대학 공지")); + } + } + + @Nested + @DisplayName("GET /councils/notices/{id} - 공지 상세 조회") + class GetNotice { + + @Test + @DisplayName("다른 대학 공지는 403을 반환한다") + void getNoticeForbidden() throws Exception { + // given + University otherUniversity = persist(UniversityFixture.createWithName("다른대학교")); + Council otherCouncil = persist(CouncilFixture.create(otherUniversity)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(otherCouncil)); + clearPersistenceContext(); + + // when & then + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.FORBIDDEN_COUNCIL_NOTICE_ACCESS.getCode())); + } + + @Test + @DisplayName("같은 공지를 다시 조회해도 읽음 이력은 한 번만 저장된다") + void getNoticeDoesNotDuplicateReadHistory() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + + // then + Number readHistoryCount = (Number)entityManager.createNativeQuery(""" + select count(*) + from council_notice_read_history + where user_id = ? and council_notice_id = ? + """) + .setParameter(1, user.getId()) + .setParameter(2, notice.getId()) + .getSingleResult(); + + org.assertj.core.api.Assertions.assertThat(readHistoryCount.longValue()).isEqualTo(1L); + } + + @Test + @DisplayName("한 사용자의 읽음 처리는 다른 사용자의 읽음 여부에 영향을 주지 않는다") + void getNoticeReadHistoryIsIsolatedPerUser() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()); + + mockLoginUser(otherUser.getId()); + + // when & then + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].isRead").value(false)); + } + } + + @Nested + @DisplayName("POST /councils/notices - 공지 생성") + class CreateNotice { + + @Test + @DisplayName("공지사항을 생성한다") + void createNoticeSuccess() throws Exception { + // given + insertCouncilWithIdOne(university.getId()); + clearPersistenceContext(); + + CouncilNoticeCreateRequest request = new CouncilNoticeCreateRequest("생성 공지", "생성 내용"); + + // when & then + performPost(NOTICES_ENDPOINT, request) + .andExpect(status().isOk()); + + performGet(NOTICES_ENDPOINT + "?page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.councilNotices", hasSize(1))) + .andExpect(jsonPath("$.councilNotices[0].title").value("생성 공지")); + } + + @Test + @DisplayName("연결된 총동아리연합회가 없으면 404를 반환한다") + void createNoticeWithoutCouncilFails() throws Exception { + // when & then + performPost(NOTICES_ENDPOINT, new CouncilNoticeCreateRequest("생성 공지", "생성 내용")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL.getCode())); + } + + @Test + @DisplayName("공지 제목이 비어 있으면 400을 반환한다") + void createNoticeInvalidBodyFails() throws Exception { + // given + insertCouncilWithIdOne(university.getId()); + clearPersistenceContext(); + + // when & then + performPost(NOTICES_ENDPOINT, new CouncilNoticeCreateRequest(" ", "생성 내용")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + } + + @Nested + @DisplayName("PUT /councils/notices/{id} - 공지 수정") + class UpdateNotice { + + @Test + @DisplayName("공지사항을 수정한다") + void updateNoticeSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council, "기존 제목", "기존 내용")); + clearPersistenceContext(); + + CouncilNoticeUpdateRequest request = new CouncilNoticeUpdateRequest("수정 제목", "수정 내용"); + + // when & then + performPut(NOTICES_ENDPOINT + "/" + notice.getId(), request) + .andExpect(status().isOk()); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 제목")) + .andExpect(jsonPath("$.content").value("수정 내용")); + } + + @Test + @DisplayName("수정 대상 공지가 없으면 404를 반환한다") + void updateNoticeNotFound() throws Exception { + // when & then + performPut(NOTICES_ENDPOINT + "/99999", new CouncilNoticeUpdateRequest("수정 제목", "수정 내용")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + } + + @Nested + @DisplayName("DELETE /councils/notices/{id} - 공지 삭제") + class DeleteNotice { + + @Test + @DisplayName("공지사항을 삭제한다") + void deleteNoticeSuccess() throws Exception { + // given + Council council = persist(CouncilFixture.create(university)); + CouncilNotice notice = persist(CouncilNoticeFixture.create(council)); + clearPersistenceContext(); + + // when & then + performDelete(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isNoContent()); + + performGet(NOTICES_ENDPOINT + "/" + notice.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + + @Test + @DisplayName("삭제 대상 공지가 없으면 404를 반환한다") + void deleteNoticeNotFound() throws Exception { + // when & then + performDelete(NOTICES_ENDPOINT + "/99999") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_COUNCIL_NOTICE.getCode())); + } + } + + private void insertCouncilWithIdOne(Integer universityId) { + entityManager.createNativeQuery(""" + insert into council ( + id, name, image_url, introduce, location, personal_color, + phone_number, email, instagram_user_name, operating_hour, + university_id, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, current_timestamp, current_timestamp) + """) + .setParameter(1, 1) + .setParameter(2, "총학생회") + .setParameter(3, "https://example.com/council.png") + .setParameter(4, "학생회 소개입니다.") + .setParameter(5, "학생회관 301호") + .setParameter(6, "#FF5733") + .setParameter(7, "041-560-1234") + .setParameter(8, "council@koreatech.ac.kr") + .setParameter(9, "koreatech_council") + .setParameter(10, "09:00 - 18:00") + .setParameter(11, universityId) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java new file mode 100644 index 000000000..167164b08 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/notification/NotificationInboxSseApiTest.java @@ -0,0 +1,85 @@ +package gg.agit.konect.integration.domain.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.AfterEach; +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.test.web.servlet.MvcResult; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class NotificationInboxSseApiTest extends IntegrationTestSupport { + + private static final String NOTIFICATION_STREAM_ENDPOINT = "/notifications/inbox/stream"; + + @org.springframework.beans.factory.annotation.Autowired + private NotificationInboxSseService notificationInboxSseService; + + @BeforeEach + void setUp() throws Exception { + University university = persist(UniversityFixture.create()); + Integer userId = persist(UserFixture.createUser(university, "알림유저", "2021136001")).getId(); + clearPersistenceContext(); + mockLoginUser(userId); + } + + @AfterEach + void tearDown() { + SseEmitter emitter = notificationInboxSseService.subscribe(-1); + emitter.complete(); + } + + @Nested + @DisplayName("GET /notifications/inbox/stream - 알림 SSE 구독") + class Subscribe { + + @Test + @DisplayName("SSE 구독을 시작하고 초기 connect 이벤트를 내려준다") + void subscribeSuccess() throws Exception { + // when + MvcResult result = mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()) + .andReturn(); + + // then + String responseBody = result.getResponse().getContentAsString(); + assertThat(result.getResponse().getContentType()) + .contains("text/event-stream"); + assertThat(responseBody) + .contains("event:connect") + .contains("data:connected"); + } + + @Test + @DisplayName("같은 사용자가 다시 구독해도 새 구독이 정상 시작된다") + void resubscribeSuccess() throws Exception { + // given + mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()); + + // when + MvcResult result = mockMvc.perform(get(NOTIFICATION_STREAM_ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(request().asyncStarted()) + .andReturn(); + + // then + assertThat(result.getResponse().getContentAsString()) + .contains("event:connect") + .contains("data:connected"); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java new file mode 100644 index 000000000..05a934bf3 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/university/UniversityApiTest.java @@ -0,0 +1,66 @@ +package gg.agit.konect.integration.domain.university; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class UniversityApiTest extends IntegrationTestSupport { + + private static final String UNIVERSITIES_ENDPOINT = "/universities"; + + @Nested + @DisplayName("GET /universities - 대학 목록 조회") + class GetUniversities { + + @Test + @DisplayName("대학 목록을 이름 오름차순으로 조회한다") + void getUniversitiesSuccess() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + persist(UniversityFixture.create("가나다대학교", Campus.MAIN)); + persist(UniversityFixture.create("서울대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(3))) + .andExpect(jsonPath("$.universities[0].name").value("가나다대학교")) + .andExpect(jsonPath("$.universities[1].name").value("서울대학교")) + .andExpect(jsonPath("$.universities[2].name").value("한국기술교육대학교")); + } + + @Test + @DisplayName("대학이 없으면 빈 목록을 반환한다") + void getUniversitiesWhenEmpty() throws Exception { + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(0))); + } + + @Test + @DisplayName("동일한 이름이라도 캠퍼스가 다르면 각각 조회된다") + void getUniversitiesWithSameNameDifferentCampus() throws Exception { + // given + persist(UniversityFixture.create("한국기술교육대학교", Campus.SECOND)); + persist(UniversityFixture.create("한국기술교육대학교", Campus.MAIN)); + clearPersistenceContext(); + + // when & then + performGet(UNIVERSITIES_ENDPOINT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.universities", hasSize(2))) + .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) + .andExpect(jsonPath("$.universities[1].name").value("한국기술교육대학교")); + } + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java b/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java new file mode 100644 index 000000000..67e201f6f --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/version/VersionApiTest.java @@ -0,0 +1,102 @@ +package gg.agit.konect.integration.domain.version; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.version.enums.PlatformType; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class VersionApiTest extends IntegrationTestSupport { + + private static final String LATEST_VERSION_ENDPOINT = "/versions/latest"; + + @Nested + @DisplayName("GET /versions/latest - 최신 버전 조회") + class GetLatestVersion { + + @Test + @DisplayName("플랫폼별 가장 최근 버전을 조회한다") + void getLatestVersionSuccess() throws Exception { + // given + insertVersion(PlatformType.IOS, "1.0.0", "old release", LocalDateTime.of(2026, 1, 1, 0, 0)); + insertVersion(PlatformType.IOS, "1.2.0", "latest release", LocalDateTime.of(2026, 2, 1, 0, 0)); + insertVersion(PlatformType.ANDROID, "9.9.9", "android release", LocalDateTime.of(2026, 3, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.platform").value("IOS")) + .andExpect(jsonPath("$.version").value("1.2.0")) + .andExpect(jsonPath("$.releaseNotes").value("latest release")); + } + + @Test + @DisplayName("등록된 버전이 없으면 404를 반환한다") + void getLatestVersionWhenMissing() throws Exception { + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=ANDROID") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.NOT_FOUND_VERSION.getCode())); + } + + @Test + @DisplayName("지원하지 않는 플랫폼 값이면 400을 반환한다") + void getLatestVersionWithInvalidPlatform() throws Exception { + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=WINDOWS") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_TYPE_VALUE.getCode())); + } + + @Test + @DisplayName("릴리즈 노트가 없어도 최신 버전을 조회할 수 있다") + void getLatestVersionWithNullReleaseNotes() throws Exception { + // given + insertVersion(PlatformType.ANDROID, "2.0.0", null, LocalDateTime.of(2026, 4, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=ANDROID") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.platform").value("ANDROID")) + .andExpect(jsonPath("$.version").value("2.0.0")) + .andExpect(jsonPath("$.releaseNotes").isEmpty()); + } + + @Test + @DisplayName("버전 문자열 크기보다 실제 등록 시점이 최신 버전 판단 기준이다") + void getLatestVersionByCreatedAt() throws Exception { + // given + insertVersion(PlatformType.IOS, "9.9.9", "오래된 큰 버전", LocalDateTime.of(2026, 1, 1, 0, 0)); + insertVersion(PlatformType.IOS, "1.0.1", "최근 릴리즈", LocalDateTime.of(2026, 5, 1, 0, 0)); + clearPersistenceContext(); + + // when & then + performGet(LATEST_VERSION_ENDPOINT + "?platform=IOS") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").value("1.0.1")) + .andExpect(jsonPath("$.releaseNotes").value("최근 릴리즈")); + } + } + + private void insertVersion(PlatformType platform, String version, String releaseNotes, LocalDateTime createdAt) { + entityManager.createNativeQuery(""" + insert into version (platform, version, release_notes, created_at, updated_at) + values (?, ?, ?, ?, ?) + """) + .setParameter(1, platform.name()) + .setParameter(2, version) + .setParameter(3, releaseNotes) + .setParameter(4, createdAt) + .setParameter(5, createdAt) + .executeUpdate(); + } +} diff --git a/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java b/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java index 96ff65add..bd77c2c95 100644 --- a/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java +++ b/src/test/java/gg/agit/konect/integration/global/auth/AuthApiTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -268,6 +269,50 @@ class NativeSessionBridge { } ); } + + @Test + void 기존_세션이_있으면_브릿지_성공_후_기존_세션을_무효화한다() throws Exception { + // given + given(nativeSessionBridgeService.consume("bridge-token-success")) + .willReturn(Optional.of(BRIDGE_USER_ID)); + + MockHttpSession existingSession = new MockHttpSession(); + existingSession.setAttribute("userId", 12345); + + // when, then + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/native/session/bridge") + .param("bridge_token", "bridge-token-success") + .session(existingSession) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + assertThat(existingSession.isInvalid()).isTrue(); + } + + @Test + void https_프록시_헤더가_있으면_리프레시_쿠키에_Secure와_SameSite_None을_설정한다() throws Exception { + // given + given(nativeSessionBridgeService.consume("bridge-token-secure")) + .willReturn(Optional.of(BRIDGE_USER_ID)); + + // when, then + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/native/session/bridge") + .param("bridge_token", "bridge-token-secure") + .header("X-Forwarded-Proto", "https") + ) + .andExpect(status().isOk()) + .andExpect(result -> { + List setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE); + assertThat(setCookies).anyMatch( + cookie -> cookie.contains("refresh_token=") + && cookie.contains("Secure") + && cookie.contains("SameSite=None") + ); + }); + } } @Nested From c2530b6300089d0c3ace41d565cd114baef333de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:07:11 +0900 Subject: [PATCH 25/50] =?UTF-8?q?test:=20=EA=B0=81=EC=A2=85=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 회원가입 토큰 서비스의 직렬화/소비 경계 조건을 고정 - Redis 저장 포맷과 TTL이 의도한 규칙대로 유지되도록 발급 경로를 테스트로 고정했다 - providerId, name 이 비어 있는 경우 null 로 복원되는 현재 역직렬화 정책을 명시적으로 검증했다 - 잘못된 저장값, 빈 토큰, consume 시 단일 사용 같은 실패/경계 흐름을 테스트로 묶어 회귀를 빠르게 드러내도록 했다 - 외부 Redis 인프라 없이 Mockito 기반 단위 테스트로 구성해 병렬 실행에서도 테스트 간 간섭이 없도록 했다 * test: 리프레시 토큰 서비스의 서명/만료/설정 오류 검증을 보강 - 토큰 발급과 사용자 ID 추출의 정상 왕복 흐름을 먼저 고정해 기본 동작 회귀를 막았다 - 다른 secret 으로 서명된 토큰, 만료 토큰, 잘못된 token_type, 빈 입력을 각각 분리 검증해 인증 오류를 세밀하게 감지하도록 했다 - issuer 누락과 secret 길이 부족처럼 운영 설정 실수도 단위 테스트에서 바로 드러나게 했다 - 실시간 환경이나 외부 저장소에 의존하지 않는 순수 서비스 테스트로 작성해 병렬 실행 안정성을 유지했다 * test: 공부시간 랭킹 서비스의 정렬/가공/누락 처리 회귀를 방지 - DAILY 와 MONTHLY 분기, 페이지 기준 rank 계산, type trim 처리 같은 조회 분기를 테스트로 고정했다 - 내 랭킹 조회에서 랭킹이 없는 동아리를 제외하고 실제 rank 순으로 정렬되는 흐름을 검증했다 - 학번 표시, 개인 이름 가공, daily 우선 순위 계산처럼 응답 스펙에 숨어 있는 규칙을 명시적으로 보호했다 - mock 기반 단위 테스트로 상태 공유 없이 구성해 병렬 실행 중에도 순서 의존이나 자원 경합이 없도록 했다 * test: 리팩토링 중인 공부시간 랭킹 테스트를 작업 범위에서 제외 - 공부시간 랭킹 서비스는 현재 리팩토링 중이라 테스트가 구현 세부사항을 과하게 고정하지 않도록 이번 단위 테스트 보강 범위에서 제외했다 - 이미 추가했던 StudyTimeRankingServiceTest 를 제거해 리팩토링 중인 코드와 테스트가 서로 발목을 잡지 않게 했다 - 이번 변경 이후 검증 대상은 회원가입 토큰과 리프레시 토큰 서비스 테스트 두 개로 다시 고정했다 * omx(team): auto-checkpoint worker-3 [unknown] * omx(team): auto-checkpoint worker-1 [unknown] * omx(team): auto-checkpoint worker-2 [unknown] * omx(team): auto-checkpoint worker-2 [unknown] * omx(team): auto-checkpoint worker-1 [unknown] * Unblock service-layer unit test verification by clearing stale lint debt The user OAuth account service tests already covered the intended edge cases, but an unused Mockito import kept strict test checkstyle red in this lane. Removing the stale import restores clean compilation for the touched test file and keeps the remaining verification focused on pre-existing unrelated lint violations. Constraint: Worker scope is limited to the service-layer test lane Rejected: Fix unrelated ChatApiTest and SheetImportServiceTest lint failures | outside assigned scope Confidence: high Scope-risk: narrow Tested: ./gradlew compileTestJava Tested: ./gradlew test --tests 'gg.agit.konect.domain.user.service.UserActivityServiceTest' --tests 'gg.agit.konect.domain.user.service.UserOAuthAccountServiceTest' --tests 'gg.agit.konect.global.auth.web.AuthCookieServiceTest' Tested: ./gradlew checkstyleTest (fails only on pre-existing ChatApiTest and SheetImportServiceTest violations) Not-tested: Full repository test suite * omx(team): auto-checkpoint worker-1 [unknown] * test: 서비스 계층 회귀를 막기 위해 병렬 친화 단위 테스트를 보강 - 서비스 레이어에서 비어 있거나 약한 단위 테스트 구간을 보강해 OAuth 연동, 활동 시각 갱신, 쿠키 처리, 알림 전송, 동아리 멤버 관리, 공부시간 누적 경계 케이스를 고정 - Spring context 없이 Mockito 기반 구조를 유지해 병렬 실행 시 공유 상태 충돌 가능성을 줄임 - 실패하던 신규 테스트와 체크스타일 이슈는 최소 수정으로 정리하고, 서비스 테스트 작성 기준은 별도 가이드 문서로 남겨 이후 확장 비용을 낮춤 - 전체 checkstyleTest는 기존 무관 이슈 2건이 남아 있어 이번 변경 범위에서는 건드리지 않음 * refactor: 코드 가독성을 위해 createClub 메서드에 개행 추가 - return 문 전에 개행을 추가하여 코드 블록 구분 명확화 - 테스트 코드의 일관된 스타일 유지 * refactor: UserFixture에 createUserWithId 메서드 추가하여 테스트 코드 중복 제거 - UserFixture에 ID를 지정하여 사용자를 생성하는 createUserWithId 메서드 2개 추가 - createUserWithId(University, Integer, String, UserRole) - createUserWithId(Integer, String, UserRole) - ClubMemberManagementServiceTest의 중복 createUser 헬퍼 메서드 제거 - 테스트 코드에서 UserFixture.createUserWithId()를 사용하도록 변경 * refactor: ClubFixture와 UniversityFixture에 createWithId 메서드 추가 - UniversityFixture에 ID를 지정하여 생성하는 createWithId 메서드 2개 추가 - ClubFixture에 ID를 지정하여 생성하는 createWithId 메서드 2개 추가 - ClubMemberManagementServiceTest의 createClub 메서드 간소화 - ReflectionTestUtils 직접 호출 제거 - Fixture의 createWithId 메서드 사용하도록 변경 * chore: 코드 포맷팅 * chore: 서비스 테스트 가이드 문서 제거 * test: 공부 시간 타이머 테스트 코드 제거 - StudyTimerServiceTest.java 제거 - StudyTimeApiTest.java 제거 * refactor: 테스트 패키지 구조 개선 단위 테스트를 unit/ 패키지로 이동하여 main 소스와 테스트 구조의 일관성 확보 이동된 테스트: - domain/club/service/*Test (6개) - domain/notification/service/*Test (3개) - domain/user/service/*Test (4개) - infrastructure/slack/*Test (2개) 원래 위치 유지 (package-private 클래스 테스트): - ClubSheetIntegratedServiceTest - GoogleDrivePermissionHelperTest - GoogleSheetApiExceptionHelperTest - GoogleApiTestUtils (public 접근자로 변경) 테스트 컴파일 정상 확인 완료 * chore: 코드 포맷팅 * refactor: AuthCookieServiceTest를 unit 패키지로 이동 global/auth/web/AuthCookieServiceTest → unit/global/auth/web/AuthCookieServiceTest 패키지 선언 및 import 수정 * refactor: UserActivityServiceTest에서 로컬 createUser 헬퍼를 Fixture로 대체 - UserFixture에 createUserWithId(Integer id, String studentNumber) 메서드 추가 - UserActivityServiceTest의 중복된 로컬 createUser 메서드 제거 - Fixture 재사용으로 유지보수성 개선 * refactor: UserOAuthAccountServiceTest에서 로컬 createUser 헬퍼를 Fixture로 대체 - UserFixture.createUserWithId() 재사용으로 중복 제거 - createWithdrawnUser() 내부에서도 Fixture 메서드 사용 * test: AuthCookieServiceTest에 누락된 검증 추가 - X-Forwarded-Proto 테스트: refresh_token 값과 Max-Age 검증 추가 - clearSignupToken 테스트: Domain 검증 추가 * refactor: ClubFixture.createWithId가 create를 재사용하도록 개선 - 중복된 Club.builder() 호출 제거 - create() 메서드 재사용으로 유지보수성 향상 * refactor: UniversityFixture.createWithId가 create를 재사용하도록 개선 - 중복된 University.builder() 호출 제거 - create() 메서드 재사용으로 유지보수성 향상 * test: ClubMemberManagementServiceTest의 학번 검증을 matchedUser 기준으로 변경 - request.studentNumber() 대신 matchedUser.getStudentNumber() 사용 - 테스트 준비 객체와 assertion 간 결합 강화 * chore: 코드 포맷팅 * refactor: UserActivityServiceTest 개선 - 중복된 UserFixture import 제거 - 활동 시각 갱신 검증을 isAfterOrEqualTo에서 isAfter로 변경하여 실제 갱신 보장 * refactor: UserFixture.createUserWithId 오버로드 간 중복 제거 - createUserWithId(Integer, String)가 기존 오버로드를 재사용하도록 변경 - 필드 초기화 로직 중복 제거 * test: UserActivityServiceTest no-op 검증 개선 - verify(..., never()) 대신 verifyNoInteractions 사용 - null 케이스와 사용자 없음 케이스를 별도 테스트로 분리 - verifyNoMoreInteractions으로 추가 호출 방지 검증 * refactor: AuthCookieServiceTest Set-Cookie 검증 중복 제거 - assertCommonCookieAttributes 헬퍼 메서드 추가 - Domain, Path, HttpOnly 검증을 공통 헬퍼로 추출 * chore: 코드 포맷팅 * fix: UserFixture.createUserWithId가 studentNumber를 올바르게 사용하도록 수정 - 기본 오버로드에 studentNumber 파라미터 추가 - 모든 오버로드가 studentNumber를 올바르게 전달하도록 개선 * chore: 코드 포맷팅 * test: forwarded https 쿠키 공통 속성 회귀를 방지 X-Forwarded-Proto=https 분기에서도 Domain, Path, HttpOnly를 함께 검증해 공통 쿠키 속성 누락 회귀를 막는다. * test: UserOAuthAccountServiceTest 사용자 픽스처 누락을 복구 누락된 createUser 헬퍼를 복구해 관련 단위 테스트가 다시 컴파일되고 실행되도록 한다. * test: UserActivityService 영/음수 userId 엣지 케이스를 검증 - updateLastLoginAt userId=0 및 음수 입력 시 동작 확인 - updateLastActivityAt userId=0 및 음수 입력 시 동작 확인 * test: UserOAuthAccountService 빈 계정/복구/탈퇴 엣지 케이스를 보강 - getLinkStatus OAuth 계정이 없는 경우 - linkOAuthAccount null providerId, providerId+email 조합, 빈 email - restoreOrCleanupWithdrawnByLinkedProvider/OauthEmail 복구 로직 - getPrimaryOAuthAccount 계정이 없는 경우 * test: RefreshTokenService 토큰 파싱/클레임 검증 엣지 케이스를 보강 - issue userId=0 처리 - extractUserId 빈 문자열, null, 탭/개행, 손상된 토큰 거부 - token_type/id 클레임 누락 및 비정상 타입 거부 - issuer 클레임 null 거부 * test: SignupTokenService 직렬화/역직렬화 경계 조건을 보강 - issue 빈 이메일 문자열 거부 - deserialize 빈 파트, 초과 파트, 빈 필드, 유효하지 않은 Provider 거부 - readOrThrow/consumeOrThrow Redis 빈 문자열 반환 시 예외 * test: AuthCookieService null duration/비보안 요청 엣지 케이스를 보강 - setRefreshToken null duration 예외 처리 - getCookieValue 쿠키가 없는 경우 null 반환 - isSecureRequest 대소문자 혼합 HTTPS, 비보안 요청 처리 * test: ClubMemberManagementService 권한/이전/제거 엣지 케이스를 보강 - changeMemberPosition canManage 검증 실패 시나리오 - getPreMembers, removePreMember 정상 동작 - transferPresident 자기 이전 방지 및 정상 이전 - changeVicePresident 부회장 교체/신규 지정 - removeMember 회장/비회원 제거 방지 및 정상 제거 * test: ClubMemberSheetService updateSheetId/누락 sheetId 엣지 케이스를 보강 - updateSheetId 정상 동작 - syncMembersToSheet sheetId null/blank 시 NOT_FOUND_CLUB_SHEET_ID 예외 * test: NotificationService 토큰/음소거/미리보기 엣지 케이스를 보강 - registerToken null/빈 토큰, deleteToken 미존재 토큰 - sendChatNotification 음소거 사용자 알림 미발송 - sendGroupChatNotification 빈 수신자/일부 토큰 누락 - 동아리 신청 알림 세 가지 정상 동작 - buildPreview null/빈/최대 길이 메시지 - validateExpoToken null/빈 토큰 * test: NotificationInboxService 저장/발송/읽음 엣지 케이스를 보강 - save 단일 알림 생성 - sendSse SSE 발송 - getMyInboxes 빈 결과 - getUnreadCount 카운트 0 - markAsRead/markAllAsRead 정상 동작 및 알림 없음 * test: NotificationInboxSseService 미구독/emitter 교체 엣지 케이스를 보강 - send null userId 처리 - send emitter가 없는 경우 (미구독 사용자) - subscribe 기존 emitter 교체 * test: UserOAuthAccountService Stage 복구 차이/충돌/경계값 엣지 케이스를 심화 - Stage profile에서 탈퇴 계정 복구하지 않고 삭제 - providerId NULL→값 충돌 시 OAUTH_ACCOUNT_ALREADY_LINKED - 복구 기간 정확히 7일 경계값에서 삭제 - providerId와 oauthEmail이 각각 다른 탈퇴 사용자를 가리키는 경우 - 기존 계정 providerId 업데이트 충돌 - Apple provider appleRefreshToken 업데이트 검증 * test: RefreshTokenService 빈 issuer/만료 rotate/claim 라운드트립을 검증 - issuer 빈 문자열 토큰 거부 - 만료된 토큰으로 rotate 시 INVALID_REFRESH_TOKEN - issue-extractUserId 라운드트립 claim 검증 * test: SignupTokenService 파이프 문자 직렬화 불일치/name 오버로드를 검증 - 이메일에 파이프 문자 포함 시 파트 분리 오류로 거부 - 이름에 파이프 문자 포함 시 5개 파트로 거부 - 4파라미터 issue 오버로드 name 포함 확인 - consumeOrThrow로 name 복원 검증 * test: ClubMemberManagementService VP 강등/회장 이전/자기 제거 엣지 케이스를 심화 - 같은 부회장 재지정 시 변화 없음 - 기존 VP 강등 + 새 VP 지정 - 회장 이전 후 기존 회장 MEMBER 검증 - 자기 자신 제거 시 CANNOT_REMOVE_SELF - VP→VP 변경 시 validatePositionLimit 통과 - 기존 멤버 포함 배치 스킵 - 관리자가 관리자 제거 시 canManage 검증 실패 * test: ClubMemberSheetService 빈 동아리/null 매핑 엣지 케이스를 심화 - syncMembersToSheet memberCount=0, preMemberCount=0 정상 동작 - updateSheetId null memberListMapping 처리 * test: NotificationService 토큰 없음/예외 삼킴/중복 수신자 엣지 케이스를 심화 - getMyToken 토큰 없는 사용자 예외 처리 - sendChatNotification chatPresenceService 예외 시 정상 종료 - sendGroupChatNotification 중복 수신자 처리 - 동아리 신청 알림 inbox 저장 및 SSE 전송 검증 * test: NotificationInboxService 일부 사용자/다른 사용자 알림 엣지 케이스를 심화 - saveAll 일부 사용자만 존재 시 해당 사용자만 알림 생성 - markAsRead 다른 사용자 알림 접근 시 예외 * test: NotificationInboxSseService emitter 교체 엣지 케이스를 심화 - subscribe 기존 emitter complete 후 새 emitter로 교체 * test: AuthCookieService signupToken 설정/제거/복수 프로토콜 엣지 케이스를 심화 - setSignupToken 정상 동작 검증 - setSignupToken + clearSignupToken 연동 (maxAge=0) - X-Forwarded-Proto 복수 값 "https,http" 비보안 처리 * chore: 코드 포맷팅 * refactor: 테스트 코드 미사용 변수 제거 - ClubMemberManagementServiceTest: 미사용 president 변수 제거 (2개) - NotificationInboxServiceTest: 미사용 user2, inbox 변수 제거 - NotificationInboxSseServiceTest: 미사용 failingEmitter 변수 제거 - NotificationServiceTest: 미사용 user 변수 제거 (3개) * refactor: UserOAuthAccountServiceTest Fixture 중복 제거 및 UserFixture 확장 - UserFixture에 createWithdrawnUser() 메서드 추가 (탈퇴 사용자 생성) - UserOAuthAccountServiceTest의 로컬 createUser() 제거 → UserFixture.createUserWithId() 직접 사용 - UserOAuthAccountServiceTest의 로컬 createWithdrawnUser() 제거 → UserFixture.createWithdrawnUser() 직접 사용 - Fixture 중복 제거로 유지보수성 향상 (필드 기본값 변경 시 일관성 유지) * chore: 코드 포맷팅 * refactor: NotificationInboxServiceTest 미사용 user1 변수 제거 - markAsReadThrowsExceptionForOtherUsersNotification()에서 미사용 user1 변수 제거 --- skills-lock.json | 10 + .../service/ClubMemberSheetServiceTest.java | 79 -- .../club/service/GoogleApiTestUtils.java | 11 +- .../service/NotificationInboxServiceTest.java | 137 --- .../NotificationInboxSseServiceTest.java | 40 - .../domain/studytime/StudyTimeApiTest.java | 390 --------- .../konect/support/fixture/ClubFixture.java | 12 + .../support/fixture/UniversityFixture.java | 12 + .../konect/support/fixture/UserFixture.java | 30 + .../ClubMemberManagementServiceBatchTest.java | 4 +- .../ClubMemberManagementServiceTest.java | 686 +++++++++++++++ .../service/ClubMemberSheetServiceTest.java | 215 +++++ .../GoogleSheetPermissionServiceTest.java | 3 +- .../club/service/SheetImportServiceTest.java | 6 +- .../club/service/SheetSyncExecutorTest.java | 3 +- .../service/NotificationInboxServiceTest.java | 315 +++++++ .../NotificationInboxSseServiceTest.java | 134 +++ .../service/NotificationServiceTest.java | 780 ++++++++++++++++++ .../user/service/RefreshTokenServiceTest.java | 350 ++++++++ .../user/service/SignupTokenServiceTest.java | 292 +++++++ .../user/service/UserActivityServiceTest.java | 154 ++++ .../service/UserOAuthAccountServiceTest.java | 775 +++++++++++++++++ .../auth/web/AuthCookieServiceTest.java | 281 +++++++ .../slack/ai/SlackEventControllerTest.java | 4 +- .../listener/SheetSyncSlackListenerTest.java | 3 +- 25 files changed, 4068 insertions(+), 658 deletions(-) create mode 100644 skills-lock.json delete mode 100644 src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java delete mode 100644 src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java delete mode 100644 src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java delete mode 100644 src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java rename src/test/java/gg/agit/konect/{ => unit}/domain/club/service/ClubMemberManagementServiceBatchTest.java (95%) create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java rename src/test/java/gg/agit/konect/{ => unit}/domain/club/service/GoogleSheetPermissionServiceTest.java (99%) rename src/test/java/gg/agit/konect/{ => unit}/domain/club/service/SheetImportServiceTest.java (97%) rename src/test/java/gg/agit/konect/{ => unit}/domain/club/service/SheetSyncExecutorTest.java (98%) create mode 100644 src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java rename src/test/java/gg/agit/konect/{ => unit}/infrastructure/slack/ai/SlackEventControllerTest.java (97%) rename src/test/java/gg/agit/konect/{ => unit}/infrastructure/slack/listener/SheetSyncSlackListenerTest.java (92%) diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..ec5db745d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "gh-address-comments": { + "source": "smithery.ai", + "sourceType": "well-known", + "computedHash": "39acf09da5896afde2b61b22f4354a72dbed6633da9e8a578eacec4899b0ecfb" + } + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java deleted file mode 100644 index 7db589d73..000000000 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package gg.agit.konect.domain.club.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; -import gg.agit.konect.domain.club.enums.ClubSheetSortKey; -import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; -import gg.agit.konect.domain.club.repository.ClubRepository; -import gg.agit.konect.support.ServiceTestSupport; -import gg.agit.konect.support.fixture.ClubFixture; -import gg.agit.konect.support.fixture.UniversityFixture; - -class ClubMemberSheetServiceTest extends ServiceTestSupport { - - @Mock - private ClubRepository clubRepository; - - @Mock - private ClubMemberRepository clubMemberRepository; - - @Mock - private ClubPreMemberRepository clubPreMemberRepository; - - @Mock - private ClubPermissionValidator clubPermissionValidator; - - @Mock - private SheetSyncExecutor sheetSyncExecutor; - - @Mock - private SheetHeaderMapper sheetHeaderMapper; - - @Mock - private ObjectMapper objectMapper; - - @InjectMocks - private ClubMemberSheetService clubMemberSheetService; - - @Test - @DisplayName("시트 동기화 수에 사전 회원도 포함한다") - void syncMembersToSheetIncludesPreMembersInCount() { - // given - Integer clubId = 1; - Integer requesterId = 2; - String spreadsheetId = "spreadsheet-id"; - Club club = ClubFixture.create(UniversityFixture.create()); - club.updateGoogleSheetId(spreadsheetId); - - given(clubRepository.getById(clubId)).willReturn(club); - given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); - given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); - - // when - ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( - clubId, - requesterId, - ClubSheetSortKey.POSITION, - true - ); - - // then - verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); - verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); - assertThat(response.syncedMemberCount()).isEqualTo(5); - assertThat(response.sheetUrl()) - .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); - } -} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java index 2c2603cb7..dd2454e33 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java +++ b/src/test/java/gg/agit/konect/domain/club/service/GoogleApiTestUtils.java @@ -7,11 +7,12 @@ import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpResponseException; -final class GoogleApiTestUtils { +public final class GoogleApiTestUtils { - private GoogleApiTestUtils() {} + private GoogleApiTestUtils() { + } - static GoogleJsonResponseException googleException(int statusCode, String reason) { + public static GoogleJsonResponseException googleException(int statusCode, String reason) { GoogleJsonError.ErrorInfo errorInfo = new GoogleJsonError.ErrorInfo(); errorInfo.setReason(reason); @@ -28,11 +29,11 @@ static GoogleJsonResponseException googleException(int statusCode, String reason return new GoogleJsonResponseException(builder, error); } - static HttpResponseException httpResponseException(int statusCode) { + public static HttpResponseException httpResponseException(int statusCode) { return httpResponseException(statusCode, null); } - static HttpResponseException httpResponseException(int statusCode, String content) { + public static HttpResponseException httpResponseException(int statusCode, String content) { return new HttpResponseException.Builder( statusCode, null, diff --git a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java deleted file mode 100644 index 0832fe1b7..000000000 --- a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxServiceTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package gg.agit.konect.domain.notification.service; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.util.List; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; - -import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; -import gg.agit.konect.domain.notification.enums.NotificationInboxType; -import gg.agit.konect.domain.notification.model.NotificationInbox; -import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.enums.UserRole; -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.domain.user.repository.UserRepository; -import gg.agit.konect.support.ServiceTestSupport; -import gg.agit.konect.support.fixture.UniversityFixture; - -class NotificationInboxServiceTest extends ServiceTestSupport { - - @Mock - private NotificationInboxRepository notificationInboxRepository; - - @Mock - private UserRepository userRepository; - - @Mock - private NotificationInboxSseService notificationInboxSseService; - - @InjectMocks - private NotificationInboxService notificationInboxService; - - @Test - @DisplayName("saveAll은 조회된 사용자에게만 알림을 생성한다") - void saveAllOnlyCreatesForResolvedUsers() { - // given - University university = UniversityFixture.create(); - User user1 = createUser(university, 1, "유저1", "2021136001"); - User user2 = createUser(university, 2, "유저2", "2021136002"); - given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user2)); - given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); - - // when - List result = notificationInboxService.saveAll( - List.of(1, 2, 3), - NotificationInboxType.CLUB_APPLICATION_APPROVED, - "제목", - "본문", - "/clubs/1" - ); - - // then - verify(notificationInboxRepository).saveAll(any()); - assertThatCode(() -> result.get(0).getTitle()).doesNotThrowAnyException(); - org.assertj.core.api.Assertions.assertThat(result).hasSize(2); - org.assertj.core.api.Assertions.assertThat(result) - .extracting(inbox -> inbox.getUser().getStudentNumber()) - .containsExactly("2021136001", "2021136002"); - } - - @Test - @DisplayName("sendSseBatch는 일부 사용자 전송 실패가 있어도 나머지 전송을 계속한다") - void sendSseBatchContinuesWhenOneSendFails() { - // given - University university = UniversityFixture.create(); - User user1 = createUser(university, 1, "유저1", "2021136001"); - User user2 = createUser(university, 2, "유저2", "2021136002"); - NotificationInbox inbox1 = NotificationInbox.of( - user1, - NotificationInboxType.CLUB_APPLICATION_APPROVED, - "제목1", - "본문1", - "/clubs/1" - ); - NotificationInbox inbox2 = NotificationInbox.of( - user2, - NotificationInboxType.CLUB_APPLICATION_REJECTED, - "제목2", - "본문2", - "/clubs/2" - ); - - doThrow(new RuntimeException("sse failure")) - .when(notificationInboxSseService) - .send(eq(user1.getId()), any(NotificationInboxResponse.class)); - - // when - assertThatCode(() -> notificationInboxService.sendSseBatch(List.of(inbox1, inbox2))) - .doesNotThrowAnyException(); - - // then - verify(notificationInboxSseService, times(1)).send(eq(user1.getId()), any(NotificationInboxResponse.class)); - verify(notificationInboxSseService, times(1)).send(eq(user2.getId()), any(NotificationInboxResponse.class)); - } - - @Test - @DisplayName("saveAll은 대상 사용자가 없으면 저장을 시도하지 않는다") - void saveAllSkipsWhenUserIdsEmpty() { - // when - List result = notificationInboxService.saveAll( - List.of(), - NotificationInboxType.CLUB_APPLICATION_APPROVED, - "제목", - "본문", - "/clubs/1" - ); - - // then - org.assertj.core.api.Assertions.assertThat(result).isEmpty(); - verify(userRepository, never()).findAllByIdIn(any()); - verify(notificationInboxRepository, never()).saveAll(any()); - } - - private User createUser(University university, Integer id, String name, String studentNumber) { - return User.builder() - .id(id) - .university(university) - .email(studentNumber + "@koreatech.ac.kr") - .name(name) - .studentNumber(studentNumber) - .role(UserRole.USER) - .isMarketingAgreement(true) - .imageUrl("https://example.com/profile.png") - .build(); - } -} diff --git a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java deleted file mode 100644 index 1d3ce022c..000000000 --- a/src/test/java/gg/agit/konect/domain/notification/service/NotificationInboxSseServiceTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package gg.agit.konect.domain.notification.service; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.lang.reflect.Field; -import java.util.Map; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import gg.agit.konect.support.ServiceTestSupport; - -class NotificationInboxSseServiceTest extends ServiceTestSupport { - - private final NotificationInboxSseService notificationInboxSseService = new NotificationInboxSseService(); - - @Test - @DisplayName("같은 사용자가 재구독한 뒤 이전 emitter가 완료되어도 현재 구독은 유지된다") - void subscribeReplacesEmitterWithoutRemovingNewOne() throws Exception { - // given - SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); - SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); - - // when - firstEmitter.complete(); - - // then - Map emitters = emitters(); - assertThat(emitters).hasSize(1); - assertThat(emitters.get(1)).isSameAs(secondEmitter); - } - - @SuppressWarnings("unchecked") - private Map emitters() throws Exception { - Field field = NotificationInboxSseService.class.getDeclaredField("emitters"); - field.setAccessible(true); - return (Map)field.get(notificationInboxSseService); - } -} diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java deleted file mode 100644 index ce50eb194..000000000 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ /dev/null @@ -1,390 +0,0 @@ -package gg.agit.konect.integration.domain.studytime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -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 gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; -import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; -import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; -import gg.agit.konect.domain.studytime.model.StudyTimer; -import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.support.IntegrationTestSupport; -import gg.agit.konect.support.fixture.UniversityFixture; -import gg.agit.konect.support.fixture.UserFixture; - -class StudyTimeApiTest extends IntegrationTestSupport { - - private static final long MISMATCHED_CLIENT_SECONDS = 100L; - - @Autowired - private StudyTimerRepository studyTimerRepository; - - @Autowired - private StudyTimeDailyRepository studyTimeDailyRepository; - - @Autowired - private StudyTimeMonthlyRepository studyTimeMonthlyRepository; - - @Autowired - private StudyTimeTotalRepository studyTimeTotalRepository; - - private University university; - private User user; - - @BeforeEach - void setUp() throws Exception { - university = persist(UniversityFixture.create()); - user = persist(UserFixture.createUser(university, "테스트유저", "2021136001")); - } - - @Nested - @DisplayName("GET /studytimes/summary - 순공 시간 조회") - class GetSummary { - - @Test - @DisplayName("순공 시간을 조회한다") - void getSummarySuccess() throws Exception { - // given - mockLoginUser(user.getId()); - - // when & then - performGet("/studytimes/summary") - .andExpect(status().isOk()) - .andExpect(jsonPath("$.todayStudyTime").value(0)) - .andExpect(jsonPath("$.monthlyStudyTime").value(0)) - .andExpect(jsonPath("$.totalStudyTime").value(0)); - } - } - - @Nested - @DisplayName("POST /studytimes/timers - 타이머 시작") - class StartTimer { - - @Test - @DisplayName("타이머를 시작한다") - void startTimerSuccess() throws Exception { - // given - mockLoginUser(user.getId()); - - // when & then - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - boolean timerExists = studyTimerRepository.existsByUserId(user.getId()); - assertThat(timerExists).isTrue(); - } - - @Test - @DisplayName("이미 실행 중인 타이머가 있으면 409를 반환한다") - void startTimerWhenAlreadyRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // when & then - performPost("/studytimes/timers") - .andExpect(status().isConflict()); - } - - @Test - @DisplayName("다른 사용자는 독립적으로 타이머를 시작할 수 있다") - void differentUsersCanStartTimers() throws Exception { - // given - User anotherUser = persist(UserFixture.createUser(university, "다른유저", "2021136002")); - clearPersistenceContext(); - - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - mockLoginUser(anotherUser.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // then - assertThat(studyTimerRepository.existsByUserId(user.getId())).isTrue(); - assertThat(studyTimerRepository.existsByUserId(anotherUser.getId())).isTrue(); - } - } - - @Nested - @DisplayName("DELETE /studytimes/timers - 타이머 중지") - class StopTimer { - - @Test - @DisplayName("타이머를 중지하면 타이머가 삭제되고 결과가 반환된다") - void stopTimerSuccess() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.sessionSeconds").isNumber()) - .andExpect(jsonPath("$.dailySeconds").isNumber()) - .andExpect(jsonPath("$.monthlySeconds").isNumber()) - .andExpect(jsonPath("$.totalSeconds").isNumber()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("실행 중인 타이머가 없으면 400을 반환한다") - void stopTimerWhenNotRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 400을 반환한다") - void stopTimerWithTimeMismatchFails() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(MISMATCHED_CLIENT_SECONDS); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("타이머 중지 후 시간이 누적된다") - void stopTimerAccumulatesTime() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - backdateTimer(timer, 5L, 5L); - - StudyTimerStopRequest request = new StudyTimerStopRequest(5L); - - // when - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - StudyTimeMonthly monthly = studyTimeMonthlyRepository - .findByUserIdAndStudyMonth(user.getId(), LocalDate.now().withDayOfMonth(1)) - .orElse(null); - StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()).orElse(null); - - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - assertThat(monthly).isNotNull(); - assertThat(monthly.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - assertThat(total).isNotNull(); - assertThat(total.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - } - } - - @Nested - @DisplayName("PATCH /studytimes/timers - 타이머 동기화") - class SyncTimer { - - @Test - @DisplayName("타이머를 동기화하면 시간이 누적되고 시작 시간이 갱신된다") - void syncTimerAccumulatesTime() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - LocalDateTime originalStartedAt = timer.getStartedAt(); - backdateTimer(timer, 5L, 5L); - - StudyTimerSyncRequest request = new StudyTimerSyncRequest(5L); - - // when - performPatch("/studytimes/timers", request) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimer updatedTimer = studyTimerRepository.getByUserId(user.getId()); - assertThat(updatedTimer.getStartedAt()).isAfter(originalStartedAt); - - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(5L); - } - - @Test - @DisplayName("실행 중인 타이머가 없으면 동기화에 실패한다") - void syncTimerWithoutRunningFails() throws Exception { - // given - mockLoginUser(user.getId()); - StudyTimerSyncRequest request = new StudyTimerSyncRequest(0L); - - // when & then - performPatch("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("클라이언트 시간과 서버 시간이 크게 차이나면 타이머가 삭제된다") - void syncTimerWithTimeMismatchDeletesTimer() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerSyncRequest request = new StudyTimerSyncRequest(MISMATCHED_CLIENT_SECONDS); - - // when & then - performPatch("/studytimes/timers", request) - .andExpect(status().isBadRequest()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("여러 번 동기화해도 시간이 정확히 누적된다") - void multipleSyncAccumulatesCorrectly() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - // 첫 번째 동기화 - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - backdateTimer(timer, 3L, 3L); - - performPatch("/studytimes/timers", new StudyTimerSyncRequest(3L)) - .andExpect(status().isOk()); - - // 두 번째 동기화 - timer = studyTimerRepository.getByUserId(user.getId()); - backdateTimer(timer, 8L, 5L); - - performPatch("/studytimes/timers", new StudyTimerSyncRequest(8L)) - .andExpect(status().isOk()); - - // then - clearPersistenceContext(); - StudyTimeDaily daily = studyTimeDailyRepository - .findByUserIdAndStudyDate(user.getId(), LocalDate.now()) - .orElse(null); - assertThat(daily).isNotNull(); - assertThat(daily.getTotalSeconds()).isGreaterThanOrEqualTo(8L); - } - } - - @Nested - @DisplayName("타이머 엣지 케이스") - class TimerEdgeCases { - - @Test - @DisplayName("타이머 시작 후 즉시 중지해도 정상 동작한다") - void stopImmediatelyAfterStart() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - - assertThat(studyTimerRepository.existsByUserId(user.getId())).isFalse(); - } - - @Test - @DisplayName("타이머 시작 후 3초 이내의 시간 차이는 허용된다") - void timerAllowsSmallTimeDifference() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimer timer = studyTimerRepository.getByUserId(user.getId()); - timer.updateStartedAt(LocalDateTime.now().minusSeconds(1)); - persist(timer); - clearPersistenceContext(); - - // 1초 차이는 3초 임계값 이내 - StudyTimerStopRequest request = new StudyTimerStopRequest(1L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("0초 동안 타이머를 실행해도 정상 동작한다") - void timerWithZeroSeconds() throws Exception { - // given - mockLoginUser(user.getId()); - performPost("/studytimes/timers") - .andExpect(status().isOk()); - - StudyTimerStopRequest request = new StudyTimerStopRequest(0L); - - // when & then - performDelete("/studytimes/timers", request) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.sessionSeconds").value(0)); - } - } - - private void backdateTimer(StudyTimer timer, long sessionElapsedSeconds, long lastSyncElapsedSeconds) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime createdAt = now.minusSeconds(sessionElapsedSeconds); - LocalDateTime startedAt = now.minusSeconds(lastSyncElapsedSeconds); - - // StudyTimerService는 createdAt 기반 totalSeconds를 검증하므로 auditing 컬럼까지 DB에 직접 맞춰둔다. - entityManager.createNativeQuery(""" - UPDATE study_timer - SET created_at = :createdAt, - started_at = :startedAt, - updated_at = :updatedAt - WHERE id = :id - """) - .setParameter("createdAt", createdAt) - .setParameter("startedAt", startedAt) - .setParameter("updatedAt", now) - .setParameter("id", timer.getId()) - .executeUpdate(); - clearPersistenceContext(); - } -} diff --git a/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java b/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java index c31450949..52243bba6 100644 --- a/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/ClubFixture.java @@ -1,5 +1,7 @@ package gg.agit.konect.support.fixture; +import org.springframework.test.util.ReflectionTestUtils; + import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.model.University; @@ -25,6 +27,16 @@ public static Club create(University university, String name) { .build(); } + public static Club createWithId(University university, Integer id) { + return createWithId(university, id, "BCSD Lab"); + } + + public static Club createWithId(University university, Integer id, String name) { + Club club = create(university, name); + ReflectionTestUtils.setField(club, "id", id); + return club; + } + public static Club createWithRecruitment(University university, String name) { return Club.builder() .university(university) diff --git a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java index f21b4b3b1..1b6e14593 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UniversityFixture.java @@ -1,5 +1,7 @@ package gg.agit.konect.support.fixture; +import org.springframework.test.util.ReflectionTestUtils; + import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.model.University; @@ -19,4 +21,14 @@ public static University create(String koreanName, Campus campus) { public static University createWithName(String koreanName) { return create(koreanName, Campus.MAIN); } + + public static University createWithId(Integer id) { + return createWithId(id, "한국기술교육대학교", Campus.MAIN); + } + + public static University createWithId(Integer id, String koreanName, Campus campus) { + University university = create(koreanName, campus); + ReflectionTestUtils.setField(university, "id", id); + return university; + } } diff --git a/src/test/java/gg/agit/konect/support/fixture/UserFixture.java b/src/test/java/gg/agit/konect/support/fixture/UserFixture.java index 50097e056..b0d8a9432 100644 --- a/src/test/java/gg/agit/konect/support/fixture/UserFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/UserFixture.java @@ -4,6 +4,8 @@ import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; +import java.time.LocalDateTime; + public class UserFixture { public static User createUser(University university) { @@ -22,6 +24,28 @@ public static User createUser(University university, String name, String student .build(); } + public static User createUserWithId(University university, Integer id, String name, String studentNumber, + UserRole role) { + return User.builder() + .id(id) + .university(university) + .email(studentNumber + "@koreatech.ac.kr") + .name(name) + .studentNumber(studentNumber) + .role(role) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile.png") + .build(); + } + + public static User createUserWithId(Integer id, String name, UserRole role) { + return createUserWithId(UniversityFixture.create(), id, name, "2024" + String.format("%04d", id), role); + } + + public static User createUserWithId(Integer id, String studentNumber) { + return createUserWithId(UniversityFixture.create(), id, "테스트유저" + id, studentNumber, UserRole.USER); + } + public static User createAdmin(University university) { return User.builder() .university(university) @@ -33,4 +57,10 @@ public static User createAdmin(University university) { .imageUrl("https://example.com/admin.png") .build(); } + + public static User createWithdrawnUser(Integer id, String studentNumber, LocalDateTime deletedAt) { + User user = createUserWithId(id, studentNumber); + user.withdraw(deletedAt); + return user; + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java similarity index 95% rename from src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java rename to src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java index 7248ec705..44d2666b2 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberManagementServiceBatchTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceBatchTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.club.service; +package gg.agit.konect.unit.domain.club.service; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -24,6 +24,8 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberManagementService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.repository.UserRepository; diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java new file mode 100644 index 000000000..b044909c1 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberManagementServiceTest.java @@ -0,0 +1,686 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.AMBIGUOUS_USER_MATCH; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CHANGE_OWN_POSITION; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_DELETE_CLUB_PRESIDENT; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_MANAGE_HIGHER_POSITION; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_REMOVE_NON_MEMBER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_REMOVE_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_MEMBER_POSITION_CHANGE; +import static gg.agit.konect.global.code.ApiResponseCode.ILLEGAL_ARGUMENT; +import static gg.agit.konect.global.code.ApiResponseCode.MANAGER_LIMIT_EXCEEDED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.transaction.PlatformTransactionManager; + +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; +import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddRequest; +import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberManagementService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubMemberManagementServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private ClubMemberManagementService clubMemberManagementService; + + @Test + @DisplayName("changeMemberPosition은 자기 자신의 직책 변경을 거부한다") + void changeMemberPositionRejectsSelfTarget() { + // given + Integer clubId = 1; + when(clubRepository.getById(clubId)).thenReturn(createClub()); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + 10, + 10, + new MemberPositionChangeRequest(ClubPosition.MANAGER) + ), + CANNOT_CHANGE_OWN_POSITION + ); + } + + @Test + @DisplayName("changeMemberPosition은 운영진 정원이 가득 차면 승격을 막는다") + void changeMemberPositionRejectsManagerPromotionWhenLimitExceeded() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User admin = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + ClubMember targetMember = ClubMemberFixture.createMember(club, + UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER)); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(admin); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + when(clubMemberRepository.countByClubIdAndPosition(clubId, ClubPosition.MANAGER)) + .thenReturn((long)ClubMemberManagementService.MAX_MANAGER_COUNT); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.MANAGER) + ), + MANAGER_LIMIT_EXCEEDED + ); + } + + @Test + @DisplayName("addPreMember는 동일 학번/이름으로 일치하는 유저가 여러 명이면 모호성 오류를 던진다") + void addPreMemberRejectsAmbiguousUserMatches() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MEMBER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when( + userRepository.findAllByUniversityIdAndStudentNumber( + club.getUniversity().getId(), + request.studentNumber() + ) + ) + .thenReturn(List.of( + UserFixture.createUserWithId(1, request.name(), UserRole.USER), + UserFixture.createUserWithId(2, request.name(), UserRole.USER) + )); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.addPreMember(clubId, requesterId, request), + AMBIGUOUS_USER_MATCH + ); + } + + @Test + @DisplayName("addPreMember는 정확히 일치하는 유저가 한 명이면 즉시 회원으로 추가한다") + void addPreMemberAddsDirectMemberWhenExactlyOneUserMatches() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberAddRequest request = new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MANAGER); + User matchedUser = UserFixture.createUserWithId(1, request.name(), UserRole.USER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when( + userRepository.findAllByUniversityIdAndStudentNumber( + club.getUniversity().getId(), + request.studentNumber() + ) + ) + .thenReturn(List.of(matchedUser)); + when(clubMemberRepository.existsByClubIdAndUserId(clubId, matchedUser.getId())).thenReturn(false); + when(clubMemberRepository.save(org.mockito.ArgumentMatchers.any(ClubMember.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ClubPreMemberAddResponse response = clubMemberManagementService.addPreMember(clubId, requesterId, request); + + // then + assertThat(response.name()).isEqualTo(request.name()); + assertThat(response.studentNumber()).isEqualTo(matchedUser.getStudentNumber()); + assertThat(response.clubPosition()).isEqualTo(ClubPosition.MANAGER); + verify(clubPreMemberRepository).deleteByClubIdAndStudentNumber(clubId, request.studentNumber()); + verify(chatRoomMembershipService).addClubMember(org.mockito.ArgumentMatchers.any(ClubMember.class)); + } + + @Test + @DisplayName("changeMemberPosition은 관리자가 아닌 사용자의 canManage 검증에 실패하면 예외를 던진다") + void changeMemberPositionValidatesRequesterCanManageTarget() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, requesterUser); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember targetMember = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requesterMember); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.MEMBER) + ), + CANNOT_MANAGE_HIGHER_POSITION + ); + } + + @Test + @DisplayName("changeMemberPosition은 관리자의 canManage(newPosition) 검증에 실패하면 예외를 던진다") + void changeMemberPositionValidatesRequesterCanManageNewPosition() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createVicePresident(club, requesterUser); // 부회장이 요청 + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); // 일반 회원을 부회장으로 승격 시도 + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requesterMember); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.PRESIDENT) // 회장으로 승격 시도 (부회장 권한 밖) + ), + FORBIDDEN_MEMBER_POSITION_CHANGE + ); + } + + @Test + @DisplayName("getPreMembers는 정상 동작한다") + void getPreMembersWorksNormally() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + gg.agit.konect.domain.club.model.ClubPreMember preMember1 = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(1) + .club(club) + .studentNumber("20240001") + .name("홍길동") + .clubPosition(ClubPosition.MEMBER) + .build(); + gg.agit.konect.domain.club.model.ClubPreMember preMember2 = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(2) + .club(club) + .studentNumber("20240002") + .name("김철수") + .clubPosition(ClubPosition.MANAGER) + .build(); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubPreMemberRepository.findAllByClubId(clubId)).thenReturn(List.of(preMember1, preMember2)); + + // when + gg.agit.konect.domain.club.dto.ClubPreMembersResponse response = clubMemberManagementService.getPreMembers( + clubId, requesterId); + + // then + assertThat(response.preMembers()).hasSize(2); + } + + @Test + @DisplayName("removePreMember는 정상 동작한다") + void removePreMemberWorksNormally() { + // given + Integer clubId = 1; + Integer preMemberId = 5; + Integer requesterId = 10; + Club club = createClub(); + gg.agit.konect.domain.club.model.ClubPreMember preMember = gg.agit.konect.domain.club.model.ClubPreMember.builder() + .id(preMemberId) + .club(club) + .studentNumber("20240001") + .name("홍길동") + .clubPosition(ClubPosition.MEMBER) + .build(); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubPreMemberRepository.getByIdAndClubId(preMemberId, clubId)).thenReturn(preMember); + + // when + clubMemberManagementService.removePreMember(clubId, preMemberId, requesterId); + + // then + verify(clubPreMemberRepository).delete(preMember); + } + + @Test + @DisplayName("transferPresident는 자기 자신에게 이전 시도하면 예외를 던진다") + void transferPresidentValidatesNotSelf() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(currentPresidentId, "회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + currentPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(president); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.transferPresident(clubId, currentPresidentId, request), + ILLEGAL_ARGUMENT + ); + } + + @Test + @DisplayName("transferPresident는 정상 이전 동작을 수행한다") + void transferPresidentWorksNormally() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Integer newPresidentId = 200; + Club club = createClub(); + User currentPresidentUser = UserFixture.createUserWithId(currentPresidentId, "현회장", UserRole.USER); + User newPresidentUser = UserFixture.createUserWithId(newPresidentId, "신회장", UserRole.USER); + ClubMember currentPresident = ClubMemberFixture.createPresident(club, currentPresidentUser); + ClubMember newPresident = ClubMemberFixture.createMember(club, newPresidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + newPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(currentPresident); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newPresidentId)).thenReturn(newPresident); + + // when + List result = clubMemberManagementService.transferPresident(clubId, currentPresidentId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentPresident.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + } + + @Test + @DisplayName("changeVicePresident는 기존 부회장이 없는 경우 null VP 요청을 처리한다") + void changeVicePresidentHandlesNullVpWhenNoExistingVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + ClubMemberFixture.createPresident(club, presidentUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + null); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of()); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("changeVicePresident는 기존 부회장을 다른 사용자로 교체한다") + void changeVicePresidentReplacesExistingVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Integer newVpId = 300; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "현부회장", UserRole.USER); + User newVpUser = UserFixture.createUserWithId(newVpId, "신부회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + ClubMember newVp = ClubMemberFixture.createMember(club, newVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + newVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newVpId)).thenReturn(newVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("removeMember는 회장 제거 시도를 거부한다") + void removeMemberRejectsPresidentRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember target = ClubMemberFixture.createPresident(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_DELETE_CLUB_PRESIDENT + ); + } + + @Test + @DisplayName("removeMember는 비회원 제거 시도를 거부한다") + void removeMemberRejectsNonMemberRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createPresident(club, requesterUser); + ClubMember target = ClubMemberFixture.createVicePresident(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_REMOVE_NON_MEMBER + ); + } + + @Test + @DisplayName("removeMember는 정상 제거 동작을 수행한다") + void removeMemberWorksNormally() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createPresident(club, requesterUser); + ClubMember target = ClubMemberFixture.createMember(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when + clubMemberManagementService.removeMember(clubId, targetUserId, requesterId); + + // then + verify(clubMemberRepository).delete(target); + verify(chatRoomMembershipService).removeClubMember(clubId, targetUserId); + } + + @Test + @DisplayName("changeVicePresident는 같은 부회장 재지정 시 아무 변화 없음") + void changeVicePresidentHandlesSameVicePresident() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "부회장", UserRole.USER); + ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + currentVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentVpId)).thenReturn(currentVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(1); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("changeVicePresident는 기존 VP 강등 후 새 VP 지정") + void changeVicePresidentDemotesOldVpAndAppointsNewVp() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer currentVpId = 200; + Integer newVpId = 300; + Club club = createClub(); + User presidentUser = UserFixture.createUserWithId(requesterId, "회장", UserRole.USER); + User currentVpUser = UserFixture.createUserWithId(currentVpId, "현부회장", UserRole.USER); + User newVpUser = UserFixture.createUserWithId(newVpId, "신부회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember currentVp = ClubMemberFixture.createVicePresident(club, currentVpUser); + ClubMember newVp = ClubMemberFixture.createMember(club, newVpUser); + gg.agit.konect.domain.club.dto.VicePresidentChangeRequest request = new gg.agit.konect.domain.club.dto.VicePresidentChangeRequest( + newVpId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.VICE_PRESIDENT)).thenReturn( + List.of(currentVp)); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newVpId)).thenReturn(newVp); + + // when + List result = clubMemberManagementService.changeVicePresident(clubId, requesterId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentVp.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newVp.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("transferPresident는 이전 후 기존 회장이 MEMBER로 변경됨") + void transferPresidentChangesOldPresidentToMember() { + // given + Integer clubId = 1; + Integer currentPresidentId = 100; + Integer newPresidentId = 200; + Club club = createClub(); + User currentPresidentUser = UserFixture.createUserWithId(currentPresidentId, "현회장", UserRole.USER); + User newPresidentUser = UserFixture.createUserWithId(newPresidentId, "신회장", UserRole.USER); + ClubMember currentPresident = ClubMemberFixture.createPresident(club, currentPresidentUser); + ClubMember newPresident = ClubMemberFixture.createManager(club, newPresidentUser); + gg.agit.konect.domain.club.dto.PresidentTransferRequest request = new gg.agit.konect.domain.club.dto.PresidentTransferRequest( + newPresidentId); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(clubMemberRepository.getByClubIdAndUserId(clubId, currentPresidentId)).thenReturn(currentPresident); + when(clubMemberRepository.getByClubIdAndUserId(clubId, newPresidentId)).thenReturn(newPresident); + + // when + List result = clubMemberManagementService.transferPresident(clubId, currentPresidentId, request); + + // then + assertThat(result).hasSize(2); + assertThat(currentPresident.getClubPosition()).isEqualTo(ClubPosition.MEMBER); + assertThat(newPresident.getClubPosition()).isEqualTo(ClubPosition.PRESIDENT); + } + + @Test + @DisplayName("removeMember는 자기 자신 제거 시도를 거부한다") + void removeMemberRejectsSelfRemoval() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Club club = createClub(); + + when(clubRepository.getById(clubId)).thenReturn(club); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, requesterId, requesterId), + CANNOT_REMOVE_SELF + ); + } + + @Test + @DisplayName("changeMemberPosition은 VP에서 VP로 변경 시도 시 validatePositionLimit 통과") + void changeMemberPositionAllowsVpToVpChange() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User admin = UserFixture.createUserWithId(requesterId, "관리자", UserRole.ADMIN); + ClubMember targetMember = ClubMemberFixture.createVicePresident(club, + UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER)); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(admin); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(targetMember); + + // when + ClubMember result = clubMemberManagementService.changeMemberPosition( + clubId, + targetUserId, + requesterId, + new MemberPositionChangeRequest(ClubPosition.VICE_PRESIDENT) + ); + + // then + assertThat(result.getClubPosition()).isEqualTo(ClubPosition.VICE_PRESIDENT); + } + + @Test + @DisplayName("addPreMembersBatch는 기존 멤버가 포함된 경우 스킽한다") + void addPreMembersBatchSkipsExistingMembers() { + // given + Integer clubId = 1; + Integer requesterId = 10; + Club club = createClub(); + ClubPreMemberBatchAddRequest request = new ClubPreMemberBatchAddRequest(List.of( + new ClubPreMemberAddRequest("20240001", "홍길동", ClubPosition.MEMBER), + new ClubPreMemberAddRequest("20240002", "김철수", ClubPosition.MANAGER) + )); + User existingUser = UserFixture.createUserWithId(UniversityFixture.create(), 1, "홍길동", "20240001", + UserRole.USER); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.findAllByUniversityIdAndStudentNumber(club.getUniversity().getId(), "20240001")) + .thenReturn(List.of(existingUser)); + when(clubMemberRepository.existsByClubIdAndUserId(clubId, existingUser.getId())).thenReturn(true); + when(userRepository.findAllByUniversityIdAndStudentNumber(club.getUniversity().getId(), "20240002")) + .thenReturn(List.of()); + + // when + gg.agit.konect.domain.club.dto.ClubPreMemberBatchAddResponse response = + clubMemberManagementService.addPreMembersBatch(clubId, requesterId, request); + + // then + assertThat(response.results()).hasSize(2); + assertThat(response.results().get(0).success()).isFalse(); + assertThat(response.results().get(0).errorCode()).isEqualTo("ALREADY_CLUB_MEMBER"); + } + + @Test + @DisplayName("removeMember는 관리자가 관리자 제거 시도를 거부한다") + void removeMemberRejectsManagerRemovingManager() { + // given + Integer clubId = 1; + Integer requesterId = 100; + Integer targetUserId = 200; + Club club = createClub(); + User requesterUser = UserFixture.createUserWithId(requesterId, "요청자", UserRole.USER); + User targetUser = UserFixture.createUserWithId(targetUserId, "대상", UserRole.USER); + ClubMember requester = ClubMemberFixture.createManager(club, requesterUser); + ClubMember target = ClubMemberFixture.createManager(club, targetUser); + + when(clubRepository.getById(clubId)).thenReturn(club); + when(userRepository.getById(requesterId)).thenReturn(requesterUser); + when(clubMemberRepository.getByClubIdAndUserId(clubId, requesterId)).thenReturn(requester); + when(clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId)).thenReturn(target); + + // when & then + assertErrorCode( + () -> clubMemberManagementService.removeMember(clubId, targetUserId, requesterId), + CANNOT_MANAGE_HIGHER_POSITION + ); + } + + private Club createClub() { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), 1); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java new file mode 100644 index 000000000..663e6eda9 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubMemberSheetServiceTest.java @@ -0,0 +1,215 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.domain.club.service.SheetSyncExecutor; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubMemberSheetServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + void syncMembersToSheetIncludesPreMembersInCount() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(5); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } + + @Test + @DisplayName("updateSheetId는 정상 동작한다") + void updateSheetIdWorksNormally() throws JsonProcessingException { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; + Club club = ClubFixture.create(UniversityFixture.create()); + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + gg.agit.konect.domain.club.model.SheetColumnMapping mapping = gg.agit.konect.domain.club.model.SheetColumnMapping.defaultMapping(); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + mapping, + null, + null + ); + + given(clubRepository.getById(clubId)).willReturn(club); + given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); + given(objectMapper.writeValueAsString(analysisResult.memberListMapping().toMap())).willReturn("{}"); + + // when + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetHeaderMapper).analyzeAllSheets("test-sheet-id"); + assertThat(club.getGoogleSheetId()).isEqualTo("test-sheet-id"); + } + + @Test + @DisplayName("syncMembersToSheet는 sheetId가 null인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsNull() { + // given + Integer clubId = 1; + Integer requesterId = 2; + Club club = ClubFixture.create(UniversityFixture.create()); + + given(clubRepository.getById(clubId)).willReturn(club); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + NOT_FOUND_CLUB_SHEET_ID)); + } + + @Test + @DisplayName("syncMembersToSheet는 sheetId가 blank인 경우 NOT_FOUND_CLUB_SHEET_ID 예외를 던진다") + void syncMembersToSheetThrowsNotFoundClubSheetIdWhenSheetIdIsBlank() { + // given + Integer clubId = 1; + Integer requesterId = 2; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(" "); + + given(clubRepository.getById(clubId)).willReturn(club); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + )) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + NOT_FOUND_CLUB_SHEET_ID)); + } + + @Test + @DisplayName("syncMembersToSheet는 빈 동아리(멤버 0명)에 대해 정상 동작한다") + void syncMembersToSheetHandlesEmptyClub() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(0L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(0L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(0); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } + + @Test + @DisplayName("updateSheetId는 null memberListMapping 분석 결과 시 NullPointerException이 발생한다") + void updateSheetIdThrowsNpeWhenMemberListMappingIsNull() throws JsonProcessingException { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetUrl = "https://docs.google.com/spreadsheets/d/test-sheet-id/edit"; + Club club = ClubFixture.create(UniversityFixture.create()); + ClubSheetIdUpdateRequest request = new ClubSheetIdUpdateRequest(spreadsheetUrl); + SheetHeaderMapper.SheetAnalysisResult analysisResult = new SheetHeaderMapper.SheetAnalysisResult( + null, + null, + null + ); + + given(clubRepository.getById(clubId)).willReturn(club); + given(sheetHeaderMapper.analyzeAllSheets("test-sheet-id")).willReturn(analysisResult); + + // when & then + assertThatThrownBy(() -> clubMemberSheetService.updateSheetId(clubId, requesterId, request)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java similarity index 99% rename from src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java rename to src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java index d5f1d8259..9db7946f8 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/GoogleSheetPermissionServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/GoogleSheetPermissionServiceTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.club.service; +package gg.agit.konect.unit.domain.club.service; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.httpResponseException; @@ -30,6 +30,7 @@ import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.model.UserOAuthAccount; import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.domain.club.service.GoogleSheetPermissionService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.infrastructure.googlesheets.GoogleSheetsConfig; diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java similarity index 97% rename from src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java rename to src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java index 0adc652c7..779981d23 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetImportServiceTest.java @@ -1,8 +1,7 @@ -package gg.agit.konect.domain.club.service; +package gg.agit.konect.unit.domain.club.service; 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.anyList; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; @@ -33,6 +32,9 @@ import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.SheetHeaderMapper; +import gg.agit.konect.domain.club.service.SheetImportService; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java similarity index 98% rename from src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java rename to src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java index 7aa5115f6..32c72ff9f 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/SheetSyncExecutorTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.club.service; +package gg.agit.konect.unit.domain.club.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -34,6 +34,7 @@ import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.club.service.SheetSyncExecutor; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.ClubMemberFixture; diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java new file mode 100644 index 000000000..e213dbb56 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxServiceTest.java @@ -0,0 +1,315 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationInboxRepository; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class NotificationInboxServiceTest extends ServiceTestSupport { + + @Mock + private NotificationInboxRepository notificationInboxRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationInboxSseService notificationInboxSseService; + + @InjectMocks + private NotificationInboxService notificationInboxService; + + @Test + @DisplayName("saveAll은 조회된 사용자에게만 알림을 생성한다") + void saveAllOnlyCreatesForResolvedUsers() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user2)); + given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = notificationInboxService.saveAll( + List.of(1, 2, 3), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).saveAll(any()); + assertThatCode(() -> result.get(0).getTitle()).doesNotThrowAnyException(); + org.assertj.core.api.Assertions.assertThat(result).hasSize(2); + org.assertj.core.api.Assertions.assertThat(result) + .extracting(inbox -> inbox.getUser().getStudentNumber()) + .containsExactly("2021136001", "2021136002"); + } + + @Test + @DisplayName("sendSseBatch는 일부 사용자 전송 실패가 있어도 나머지 전송을 계속한다") + void sendSseBatchContinuesWhenOneSendFails() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user2 = createUser(university, 2, "유저2", "2021136002"); + NotificationInbox inbox1 = NotificationInbox.of( + user1, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목1", + "본문1", + "/clubs/1" + ); + NotificationInbox inbox2 = NotificationInbox.of( + user2, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "제목2", + "본문2", + "/clubs/2" + ); + + doThrow(new RuntimeException("sse failure")) + .when(notificationInboxSseService) + .send(eq(user1.getId()), any(NotificationInboxResponse.class)); + + // when + assertThatCode(() -> notificationInboxService.sendSseBatch(List.of(inbox1, inbox2))) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxSseService, times(1)).send(eq(user1.getId()), any(NotificationInboxResponse.class)); + verify(notificationInboxSseService, times(1)).send(eq(user2.getId()), any(NotificationInboxResponse.class)); + } + + @Test + @DisplayName("saveAll은 대상 사용자가 없으면 저장을 시도하지 않는다") + void saveAllSkipsWhenUserIdsEmpty() { + // when + List result = notificationInboxService.saveAll( + List.of(), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + org.assertj.core.api.Assertions.assertThat(result).isEmpty(); + verify(userRepository, never()).findAllByIdIn(any()); + verify(notificationInboxRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("save는 단일 알림을 생성한다") + void saveCreatesSingleNotification() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + given(userRepository.getById(1)).willReturn(user); + given(notificationInboxRepository.save(any(NotificationInbox.class))).willReturn(inbox); + + // when + NotificationInbox result = notificationInboxService.save( + 1, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).save(any(NotificationInbox.class)); + org.assertj.core.api.Assertions.assertThat(result).isNotNull(); + } + + @Test + @DisplayName("sendSse는 SSE 알림을 발송한다") + void sendSseSendsNotification() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + NotificationInboxResponse response = NotificationInboxResponse.from(inbox); + doNothing().when(notificationInboxSseService).send(eq(1), any(NotificationInboxResponse.class)); + + // when + assertThatCode(() -> notificationInboxService.sendSse(1, response)) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxSseService).send(eq(1), any(NotificationInboxResponse.class)); + } + + @Test + @DisplayName("getMyInboxes는 빈 결과를 반환한다") + void getMyInboxesReturnsEmptyResult() { + // given + org.springframework.data.domain.Page emptyPage = org.springframework.data.domain.Page.empty(); + given(notificationInboxRepository.findAllByUserIdAndTypeNotInOrderByCreatedAtDescIdDesc( + eq(1), + any(Set.class), + any(org.springframework.data.domain.PageRequest.class) + )).willReturn(emptyPage); + + // when + gg.agit.konect.domain.notification.dto.NotificationInboxesResponse result = + notificationInboxService.getMyInboxes(1, 1); + + // then + org.assertj.core.api.Assertions.assertThat(result.notifications()).isEmpty(); + } + + @Test + @DisplayName("getUnreadCount는 카운트 0을 반환한다") + void getUnreadCountReturnsZero() { + // given + given(notificationInboxRepository.countByUserIdAndIsReadFalseAndTypeNotIn( + eq(1), + any(Set.class) + )).willReturn(0L); + + // when + gg.agit.konect.domain.notification.dto.NotificationInboxUnreadCountResponse result = + notificationInboxService.getUnreadCount(1); + + // then + org.assertj.core.api.Assertions.assertThat(result.unreadCount()).isEqualTo(0); + } + + @Test + @DisplayName("markAsRead는 정상 동작한다") + void markAsReadWorksNormally() { + // given + University university = UniversityFixture.create(); + User user = createUser(university, 1, "유저1", "2021136001"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + given(notificationInboxRepository.getByIdAndUserId(1, 1)).willReturn(inbox); + + // when + assertThatCode(() -> notificationInboxService.markAsRead(1, 1)) + .doesNotThrowAnyException(); + + // then + org.assertj.core.api.Assertions.assertThat(inbox.getIsRead()).isTrue(); + } + + @Test + @DisplayName("markAllAsRead는 알림이 없는 경우에도 에러 없이 동작한다") + void markAllAsReadWorksWithNoNotifications() { + // given + doNothing().when(notificationInboxRepository).markAllAsReadByUserIdAndTypeNotIn( + eq(1), + any(Set.class) + ); + + // when + assertThatCode(() -> notificationInboxService.markAllAsRead(1)) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxRepository).markAllAsReadByUserIdAndTypeNotIn(eq(1), any(Set.class)); + } + + @Test + @DisplayName("saveAll은 일부 사용자만 존재하면 존재하는 사용자에게만 알림을 생성한다") + void saveAllCreatesNotificationsOnlyForExistingUsers() { + // given + University university = UniversityFixture.create(); + User user1 = createUser(university, 1, "유저1", "2021136001"); + User user3 = createUser(university, 3, "유저3", "2021136003"); + given(userRepository.findAllByIdIn(List.of(1, 2, 3))).willReturn(List.of(user1, user3)); + given(notificationInboxRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + + // when + List result = notificationInboxService.saveAll( + List.of(1, 2, 3), + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "제목", + "본문", + "/clubs/1" + ); + + // then + verify(notificationInboxRepository).saveAll(any()); + org.assertj.core.api.Assertions.assertThat(result).hasSize(2); + org.assertj.core.api.Assertions.assertThat(result) + .extracting(inbox -> inbox.getUser().getId()) + .containsExactly(1, 3); + } + + @Test + @DisplayName("markAsRead는 다른 사용자의 알림에 대해 예외를 발생시킨다") + void markAsReadThrowsExceptionForOtherUsersNotification() { + // given + University university = UniversityFixture.create(); + createUser(university, 1, "유저1", "2021136001"); + createUser(university, 2, "유저2", "2021136002"); + given(notificationInboxRepository.getByIdAndUserId(1, 2)).willThrow( + new org.springframework.dao.EmptyResultDataAccessException(1) + ); + + // when & then + assertThatThrownBy(() -> notificationInboxService.markAsRead(2, 1)) + .isInstanceOf(org.springframework.dao.EmptyResultDataAccessException.class); + } + + private User createUser(University university, Integer id, String name, String studentNumber) { + return User.builder() + .id(id) + .university(university) + .email(studentNumber + "@koreatech.ac.kr") + .name(name) + .studentNumber(studentNumber) + .role(UserRole.USER) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile.png") + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java new file mode 100644 index 000000000..f5aa81969 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationInboxSseServiceTest.java @@ -0,0 +1,134 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.service.NotificationInboxSseService; +import gg.agit.konect.support.ServiceTestSupport; + +class NotificationInboxSseServiceTest extends ServiceTestSupport { + + private final NotificationInboxSseService notificationInboxSseService = new NotificationInboxSseService(); + + @Test + @DisplayName("같은 사용자가 재구독한 뒤 이전 emitter가 완료되어도 현재 구독은 유지된다") + void subscribeReplacesEmitterWithoutRemovingNewOne() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + + // when + firstEmitter.complete(); + + // then + Map emitters = emitters(); + assertThat(emitters).hasSize(1); + assertThat(emitters.get(1)).isSameAs(secondEmitter); + } + + @Test + @DisplayName("send는 null userId에 대해 NullPointerException을 발생시킨다") + void sendThrowsExceptionForNullUserId() { + // given + NotificationInboxResponse response = createMockNotificationResponse(); + + // when & then + org.assertj.core.api.Assertions.assertThatThrownBy(() -> notificationInboxSseService.send(null, response)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("send는 emitter가 없는 경우(구독하지 않은 사용자) 에러 없이 처리한다") + void sendHandlesNonSubscribedUser() { + // given + NotificationInboxResponse response = createMockNotificationResponse(); + + // when & then + assertThatCode(() -> notificationInboxSseService.send(999, response)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("subscribe는 기존 emitter를 교체한다") + void subscribeReplacesExistingEmitter() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + Map emittersBefore = emitters(); + + // when + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + Map emittersAfter = emitters(); + + // then + assertThat(emittersBefore).hasSize(1); + assertThat(emittersAfter).hasSize(1); + assertThat(emittersAfter.get(1)).isNotSameAs(firstEmitter); + assertThat(emittersAfter.get(1)).isSameAs(secondEmitter); + } + + private NotificationInboxResponse createMockNotificationResponse() { + return new NotificationInboxResponse( + 1, + gg.agit.konect.domain.notification.enums.NotificationInboxType.CLUB_APPLICATION_APPROVED, + "title", + "body", + "path", + false, + null + ); + } + + @Test + @DisplayName("subscribe는 기존 emitter가 있으면 완료 후 교체한다") + void subscribeCompletesPreviousEmitterBeforeReplacement() throws Exception { + // given + SseEmitter firstEmitter = notificationInboxSseService.subscribe(1); + Map emittersBefore = emitters(); + + // when + SseEmitter secondEmitter = notificationInboxSseService.subscribe(1); + Map emittersAfter = emitters(); + + // then + assertThat(emittersBefore).hasSize(1); + assertThat(emittersAfter).hasSize(1); + assertThat(emittersAfter.get(1)).isNotSameAs(firstEmitter); + assertThat(emittersAfter.get(1)).isSameAs(secondEmitter); + + // 이전 emitter가 완료되었는지 확인 + org.assertj.core.api.Assertions.assertThatThrownBy(() -> firstEmitter.send(SseEmitter.event().data("test"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("send는 IOException 발생 시 emitter를 제거한다") + void sendRemovesEmitterOnIOException() { + // given + notificationInboxSseService.subscribe(1); + NotificationInboxResponse response = createMockNotificationResponse(); + + // when + // emitter가 존재하는 상태에서 전송 시도 + notificationInboxSseService.send(1, response); + + // then + // 메서드가 정상적으로 동작하는지 확인 + assertThatCode(() -> notificationInboxSseService.send(1, response)) + .doesNotThrowAnyException(); + } + + @SuppressWarnings("unchecked") + private Map emitters() throws Exception { + Field field = NotificationInboxSseService.class.getDeclaredField("emitters"); + field.setAccessible(true); + return (Map)field.get(notificationInboxSseService); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 000000000..da8a16510 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,780 @@ +package gg.agit.konect.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.notification.dto.NotificationInboxResponse; +import gg.agit.konect.domain.notification.dto.NotificationTokenDeleteRequest; +import gg.agit.konect.domain.notification.dto.NotificationTokenRegisterRequest; +import gg.agit.konect.domain.notification.enums.NotificationInboxType; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationDeviceToken; +import gg.agit.konect.domain.notification.model.NotificationInbox; +import gg.agit.konect.domain.notification.repository.NotificationDeviceTokenRepository; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationInboxService; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.domain.notification.service.ExpoPushClient; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class NotificationServiceTest extends ServiceTestSupport { + + private static final String VALID_TOKEN = "ExpoPushToken[valid-token]"; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationDeviceTokenRepository notificationDeviceTokenRepository; + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ChatPresenceService chatPresenceService; + + @Mock + private ExpoPushClient expoPushClient; + + @Mock + private NotificationInboxService notificationInboxService; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("registerToken은 기존 토큰이 없으면 새 엔티티를 저장한다") + void registerTokenSavesNewTokenWhenMissing() { + // given + User user = createUser(1, "2021136001"); + NotificationTokenRegisterRequest request = new NotificationTokenRegisterRequest(VALID_TOKEN); + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.empty()); + + // when + notificationService.registerToken(1, request); + + // then + verify(notificationDeviceTokenRepository).save(argThat(token -> + token.getUser().equals(user) && token.getToken().equals(VALID_TOKEN) + )); + } + + @Test + @DisplayName("registerToken은 기존 토큰이 있으면 값을 갱신한다") + void registerTokenUpdatesExistingToken() { + // given + User user = createUser(1, "2021136001"); + NotificationDeviceToken existingToken = NotificationDeviceToken.of(user, "ExpoPushToken[old-token]"); + given(userRepository.getById(1)).willReturn(user); + given(notificationDeviceTokenRepository.findByUserId(1)).willReturn(Optional.of(existingToken)); + + // when + notificationService.registerToken(1, new NotificationTokenRegisterRequest(VALID_TOKEN)); + + // then + assertThat(existingToken.getToken()).isEqualTo(VALID_TOKEN); + verify(notificationDeviceTokenRepository, never()).save(any(NotificationDeviceToken.class)); + } + + @Test + @DisplayName("registerToken은 Expo 형식이 아닌 토큰을 거부한다") + void registerTokenRejectsInvalidExpoToken() { + // given + User user = createUser(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest("bad-token")) + ) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_NOTIFICATION_TOKEN)); + verify(notificationDeviceTokenRepository, never()).findByUserId(any()); + } + + @Test + @DisplayName("deleteToken은 일치하는 토큰이 있을 때만 삭제한다") + void deleteTokenDeletesOnlyMatchingToken() { + // given + User user = createUser(1, "2021136001"); + NotificationDeviceToken token = NotificationDeviceToken.of(user, VALID_TOKEN); + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, VALID_TOKEN)).willReturn(Optional.of(token)); + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, "ExpoPushToken[missing]")) + .willReturn(Optional.empty()); + + // when + notificationService.deleteToken(1, new NotificationTokenDeleteRequest(VALID_TOKEN)); + notificationService.deleteToken(1, new NotificationTokenDeleteRequest("ExpoPushToken[missing]")); + + // then + verify(notificationDeviceTokenRepository).delete(token); + } + + @Test + @DisplayName("sendChatNotification은 사용자가 채팅방에 있으면 푸시를 생략한다") + void sendChatNotificationSkipsWhenUserAlreadyInRoom() { + // given + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(true); + + // when + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendChatNotification은 뮤트되지 않은 사용자에게 잘린 미리보기 푸시를 보낸다") + void sendChatNotificationSendsTruncatedPreview() { + // given + String message = "😀".repeat(31); + String expectedPreview = "😀".repeat(30) + "..."; + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", message); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(expectedPreview), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("sendGroupChatNotification은 발신자·접속중·뮤트 사용자를 제외하고 전송한다") + void sendGroupChatNotificationFiltersRecipientsBeforeBatchSend() { + // given + String message = "안녕하세요 여러분 반갑습니다. " + + "이 메시지는 미리보기 길이를 초과하도록 충분히 깁니다!"; + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 4, 5))).willReturn(Set.of(2)); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 4, 5) + )).willReturn(Set.of(3)); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(4, 5))) + .willReturn(List.of("ExpoPushToken[token-4]", "ExpoPushToken[token-5]")); + + // when + notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + message, + List.of(1, 2, 3, 4, 5) + ); + + // then + ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); + verify(expoPushClient).sendBatchNotifications(messagesCaptor.capture()); + List messages = messagesCaptor.getValue(); + assertThat(messages).hasSize(2); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::to) + .containsExactly("ExpoPushToken[token-4]", "ExpoPushToken[token-5]"); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::title) + .containsOnly("KONECT"); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::body) + .allSatisfy(body -> { + assertThat(body).startsWith("홍길동: "); + assertThat(body).endsWith("..."); + }); + assertThat(messages) + .extracting(ExpoPushClient.ExpoPushMessage::data) + .containsOnly(Map.of("path", "chats/10")); + } + + @Test + @DisplayName("sendGroupChatNotification은 필터링 후 대상이 없으면 배치 전송을 생략한다") + void sendGroupChatNotificationSkipsWhenNoRecipientsRemain() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3))).willReturn(Set.of(2)); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3) + )).willReturn(Set.of(3)); + + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3) + )).doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserIds(any()); + verify(expoPushClient, never()).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendClubApplicationApprovedNotification은 인앱 알림, SSE, 푸시를 함께 보낸다") + void sendClubApplicationApprovedNotificationSendsInboxSseAndPush() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT"); + + // then + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_APPROVED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 승인되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("동아리 지원이 승인되었어요."), + eq(Map.of("path", "clubs/7")) + ); + } + + @Test + @DisplayName("registerToken은 null 토큰 값에 대해 NullPointerException을 발생시킨다") + void registerTokenThrowsExceptionForNullToken() { + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest(null)) + ) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("registerToken은 빈 토큰 값에 대해 예외를 발생시킨다") + void registerTokenThrowsExceptionForEmptyToken() { + // when & then + assertThatThrownBy( + () -> notificationService.registerToken(1, new NotificationTokenRegisterRequest("")) + ) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_NOTIFICATION_TOKEN)); + } + + @Test + @DisplayName("deleteToken은 토큰을 찾을 수 없는 경우 아무 동작도 하지 않는다") + void deleteTokenDoesNothingWhenTokenNotFound() { + // given + given(notificationDeviceTokenRepository.findByUserIdAndToken(1, VALID_TOKEN)) + .willReturn(Optional.empty()); + + // when + assertThatCode(() -> notificationService.deleteToken(1, new NotificationTokenDeleteRequest(VALID_TOKEN))) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).delete(any()); + } + + @Test + @DisplayName("sendChatNotification은 사용자가 음소거된 경우 알림을 발송하지 않는다") + void sendChatNotificationSkipsWhenUserMuted() { + // given + User user = createUser(3, "2021136003"); + gg.agit.konect.domain.notification.model.NotificationMuteSetting muteSetting = + gg.agit.konect.domain.notification.model.NotificationMuteSetting.of( + NotificationTargetType.CHAT_ROOM, + 7, + user, + true + ); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.of(muteSetting)); + + // when + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 발신자 필터링 후 빈 수신자 목록이면 전송을 생략한다") + void sendGroupChatNotificationSkipsWhenEmptyRecipientsAfterFiltering() { + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1) + )).doesNotThrowAnyException(); + + // then + verify(notificationDeviceTokenRepository, never()).findTokensByUserIds(any()); + verify(expoPushClient, never()).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 일부 사용자만 토큰이 있는 경우 처리한다") + void sendGroupChatNotificationHandlesPartialTokens() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 4))) + .willReturn(Set.of()); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 4) + )).willReturn(Set.of()); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(2, 3, 4))) + .willReturn(List.of("ExpoPushToken[token-2]", "ExpoPushToken[token-3]")); + + // when + assertThatCode(() -> notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3, 4) + )).doesNotThrowAnyException(); + + // then + ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); + verify(expoPushClient).sendBatchNotifications(messagesCaptor.capture()); + List messages = messagesCaptor.getValue(); + assertThat(messages).hasSize(2); + } + + @Test + @DisplayName("sendClubApplicationSubmittedNotification은 정상 동작한다") + void sendClubApplicationSubmittedNotificationWorksNormally() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + assertThatCode(() -> notificationService.sendClubApplicationSubmittedNotification( + 3, 1, 7, "KONECT", "홍길동" + )).doesNotThrowAnyException(); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + verify(notificationInboxService).sendSse(eq(3), any(NotificationInboxResponse.class)); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("홍길동님이 동아리 가입을 신청했어요."), + eq(Map.of("path", "mypage/manager/7/applications/1")) + ); + } + + @Test + @DisplayName("sendClubApplicationRejectedNotification은 정상 동작한다") + void sendClubApplicationRejectedNotificationWorksNormally() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + assertThatCode(() -> notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT")) + .doesNotThrowAnyException(); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + verify(notificationInboxService).sendSse(eq(3), any(NotificationInboxResponse.class)); + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("KONECT"), + eq("동아리 지원이 거절되었어요."), + eq(Map.of("path", "clubs/7")) + ); + } + + @Test + @DisplayName("buildPreview는 null 메시지에 대해 빈 문자열을 반환한다") + void buildPreviewReturnsEmptyForNullMessage() { + // given + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", null); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(""), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("buildPreview는 빈 메시지에 대해 빈 문자열을 반환한다") + void buildPreviewReturnsEmptyForEmptyMessage() { + // given + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", ""); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(""), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("buildPreview는 최대 길이와 정확히 일치하는 메시지를 자르지 않는다") + void buildPreviewDoesNotTruncateExactLengthMessage() { + // given + String exactLengthMessage = "😀".repeat(30); + createUser(3, "2021136003"); + given(chatPresenceService.isUserInChatRoom(7, 3)).willReturn(false); + given( + notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId( + NotificationTargetType.CHAT_ROOM, + 7, + 3 + ) + ).willReturn(Optional.empty()); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendChatNotification(3, 7, "보낸이", exactLengthMessage); + + // then + verify(expoPushClient).sendNotification( + eq(3), + eq(List.of(VALID_TOKEN)), + eq("보낸이"), + eq(exactLengthMessage), + eq(Map.of("path", "chats/7")) + ); + } + + @Test + @DisplayName("getMyToken은 토큰이 없는 사용자에 대해 예외를 발생시킨다") + void getMyTokenThrowsExceptionWhenNoTokenExists() { + // given + org.springframework.dao.EmptyResultDataAccessException exception = + new org.springframework.dao.EmptyResultDataAccessException(1); + given(notificationDeviceTokenRepository.getByUserId(999)).willThrow(exception); + + // when & then + assertThatThrownBy(() -> notificationService.getMyToken(999)) + .isInstanceOf(org.springframework.dao.EmptyResultDataAccessException.class); + } + + @Test + @DisplayName("sendChatNotification은 chatPresenceService 예외 발생 시 정상 종료한다") + void sendChatNotificationHandlesChatPresenceException() { + // given + given(chatPresenceService.isUserInChatRoom(7, 3)) + .willThrow(new RuntimeException("Presence service unavailable")); + + // when & then + assertThatCode(() -> notificationService.sendChatNotification(3, 7, "보낸이", "메시지")) + .doesNotThrowAnyException(); + + // 예외가 삼켜졌으므로 추가 작업은 수행되지 않음 + verify(notificationDeviceTokenRepository, never()).findTokensByUserId(any()); + verify(expoPushClient, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("sendGroupChatNotification은 중복 수신자가 있어도 정상 동작한다") + void sendGroupChatNotificationHandlesDuplicateRecipients() { + // given + given(chatPresenceService.findUsersInChatRoom(10, List.of(2, 3, 2, 3))) + .willReturn(Set.of()); + given(notificationMuteSettingRepository.findMutedUserIdsByTargetTypeAndTargetIdAndUserIds( + NotificationTargetType.CHAT_ROOM, + 10, + List.of(2, 3, 2, 3) + )).willReturn(Set.of()); + given(notificationDeviceTokenRepository.findTokensByUserIds(List.of(2, 3, 2, 3))) + .willReturn(List.of("ExpoPushToken[token-2]", "ExpoPushToken[token-3]")); + + // when + notificationService.sendGroupChatNotification( + 10, + 1, + "KONECT", + "홍길동", + "메시지", + List.of(1, 2, 3, 2, 3) // 중복 ID 포함 + ); + + // then + verify(expoPushClient).sendBatchNotifications(any()); + } + + @Test + @DisplayName("sendClubApplicationSubmittedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationSubmittedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationSubmittedNotification(3, 1, 7, "KONECT", "홍길동"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_SUBMITTED, + "KONECT", + "홍길동님이 동아리 가입을 신청했어요.", + "mypage/manager/7/applications/1" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_SUBMITTED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("홍길동님이 동아리 가입을 신청했어요."); + assertThat(response.path()).isEqualTo("mypage/manager/7/applications/1"); + } + + @Test + @DisplayName("sendClubApplicationApprovedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationApprovedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationApprovedNotification(3, 7, "KONECT"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_APPROVED, + "KONECT", + "동아리 지원이 승인되었어요.", + "clubs/7" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_APPROVED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 승인되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + } + + @Test + @DisplayName("sendClubApplicationRejectedNotification은 inbox 저장과 SSE 전송을 검증한다") + void sendClubApplicationRejectedNotificationVerifiesInboxAndSse() { + // given + User user = createUser(3, "2021136003"); + NotificationInbox inbox = NotificationInbox.of( + user, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + given(notificationInboxService.save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + )).willReturn(inbox); + given(notificationDeviceTokenRepository.findTokensByUserId(3)).willReturn(List.of(VALID_TOKEN)); + + // when + notificationService.sendClubApplicationRejectedNotification(3, 7, "KONECT"); + + // then + verify(notificationInboxService).save( + 3, + NotificationInboxType.CLUB_APPLICATION_REJECTED, + "KONECT", + "동아리 지원이 거절되었어요.", + "clubs/7" + ); + ArgumentCaptor responseCaptor = + ArgumentCaptor.forClass(NotificationInboxResponse.class); + verify(notificationInboxService).sendSse(eq(3), responseCaptor.capture()); + NotificationInboxResponse response = responseCaptor.getValue(); + assertThat(response.type()).isEqualTo(NotificationInboxType.CLUB_APPLICATION_REJECTED); + assertThat(response.title()).isEqualTo("KONECT"); + assertThat(response.body()).isEqualTo("동아리 지원이 거절되었어요."); + assertThat(response.path()).isEqualTo("clubs/7"); + } + + private User createUser(Integer id, String studentNumber) { + return User.builder() + .id(id) + .university(UniversityFixture.create()) + .email(studentNumber + "@koreatech.ac.kr") + .name("테스트유저" + id) + .studentNumber(studentNumber) + .isMarketingAgreement(true) + .imageUrl("https://example.com/profile-" + id + ".png") + .build(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java new file mode 100644 index 000000000..a7ec5c8e0 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/RefreshTokenServiceTest.java @@ -0,0 +1,350 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.global.auth.jwt.JwtProperties; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class RefreshTokenServiceTest extends ServiceTestSupport { + + private static final String VALID_SECRET = "0123456789abcdef0123456789abcdef"; + private static final String VALID_ISSUER = "konect"; + + private final RefreshTokenService refreshTokenService = new RefreshTokenService( + new JwtProperties(VALID_SECRET, VALID_ISSUER) + ); + + @Test + @DisplayName("issue와 extractUserId는 정상 리프레시 토큰을 왕복 처리한다") + void issueAndExtractUserIdRoundTrip() { + // when + String token = refreshTokenService.issue(123); + + // then + assertThat(refreshTokenService.extractUserId(token)).isEqualTo(123); + } + + @Test + @DisplayName("rotate는 같은 사용자 ID를 유지하면서 새 토큰을 발급한다") + void rotateIssuesNewTokenForSameUser() { + // given + String originalToken = refreshTokenService.issue(7); + + // when + RefreshTokenService.Rotated rotated = refreshTokenService.rotate(originalToken); + + // then + assertThat(rotated.userId()).isEqualTo(7); + assertThat(rotated.refreshToken()).isNotBlank(); + assertThat(rotated.refreshToken()).isNotEqualTo(originalToken); + assertThat(refreshTokenService.extractUserId(rotated.refreshToken())).isEqualTo(7); + } + + @Test + @DisplayName("extractUserId는 빈 토큰을 거부한다") + void extractUserIdRejectsBlankToken() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(" ")); + } + + @Test + @DisplayName("extractUserId는 서명이 다른 토큰을 거부한다") + void extractUserIdRejectsTokenSignedWithDifferentSecret() { + // given + RefreshTokenService otherService = new RefreshTokenService( + new JwtProperties("fedcba9876543210fedcba9876543210", VALID_ISSUER) + ); + String token = otherService.issue(9); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 만료된 토큰을 거부한다") + void extractUserIdRejectsExpiredToken() throws JOSEException { + // given + String token = createToken(11, VALID_ISSUER, "refresh", Instant.now().minusSeconds(5), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 refresh 타입이 아니면 거부한다") + void extractUserIdRejectsNonRefreshTokenType() throws JOSEException { + // given + String token = createToken(11, VALID_ISSUER, "access", Instant.now().plusSeconds(60), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("issue는 userId가 없으면 IllegalArgumentException을 던진다") + void issueRejectsNullUserId() { + assertThatThrownBy(() -> refreshTokenService.issue(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("userId is required"); + } + + @Test + @DisplayName("issue는 issuer 설정이 비어 있으면 실패한다") + void issueFailsWhenIssuerMissing() { + RefreshTokenService service = new RefreshTokenService(new JwtProperties(VALID_SECRET, " ")); + + assertThatThrownBy(() -> service.issue(1)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("app.jwt.issuer is required"); + } + + @Test + @DisplayName("issue는 secret 길이가 32바이트보다 짧으면 실패한다") + void issueFailsWhenSecretTooShort() { + RefreshTokenService service = new RefreshTokenService(new JwtProperties("short-secret", VALID_ISSUER)); + + assertThatThrownBy(() -> service.issue(1)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("app.jwt.secret must be at least 32 bytes"); + } + + @Test + @DisplayName("issue는 userId가 0이어도 토큰을 발급한다") + void issueAcceptsZeroUserId() { + // when + String token = refreshTokenService.issue(0); + + // then + assertThat(refreshTokenService.extractUserId(token)).isEqualTo(0); + } + + @Test + @DisplayName("extractUserId는 빈 문자열을 거부한다") + void extractUserIdRejectsEmptyString() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId("")); + } + + @Test + @DisplayName("extractUserId는 null을 거부한다") + void extractUserIdRejectsNull() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(null)); + } + + @Test + @DisplayName("extractUserId는 탭/개행 문자열을 거부한다") + void extractUserIdRejectsTabNewlineString() { + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId("\t\n")); + } + + @Test + @DisplayName("extractUserId는 잘린/손상된 토큰을 거부한다") + void extractUserIdRejectsMalformedToken() { + // given + String validToken = refreshTokenService.issue(123); + String truncatedToken = validToken.substring(0, validToken.length() / 2); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(truncatedToken)); + } + + @Test + @DisplayName("extractUserId는 token_type 클레임이 누락된 토큰을 거부한다") + void extractUserIdRejectsMissingTokenTypeClaim() throws JOSEException { + // given + String token = createTokenWithoutClaim(Integer.valueOf(11), VALID_ISSUER, Instant.now().plusSeconds(60), + VALID_SECRET, "token_type"); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 token_type 클레임이 비문자열 타입이면 거부한다") + void extractUserIdRejectsNonStringTokenTypeClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(VALID_ISSUER) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", 11) + .claim("token_type", 123) // 숫자 타입 + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 id 클레임이 누락된 토큰을 거부한다") + void extractUserIdRejectsMissingIdClaim() throws JOSEException { + // given + String token = createTokenWithoutClaim(Integer.valueOf(11), VALID_ISSUER, Instant.now().plusSeconds(60), + VALID_SECRET, "id"); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 id 클레임이 비숫자 타입이면 거부한다") + void extractUserIdRejectsNonNumericIdClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(VALID_ISSUER) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", "not-a-number") // 문자열 타입 + .claim("token_type", "refresh") + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 issuer 클레임이 null이면 거부한다") + void extractUserIdRejectsNullIssuerClaim() throws JOSEException { + // given + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer((String)null) // null issuer + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(Instant.now().plusSeconds(60))) + .jwtID(UUID.randomUUID().toString()) + .claim("id", 11) + .claim("token_type", "refresh") + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(VALID_SECRET)); + String token = jwt.serialize(); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 만료 시간이 현재 시간과 정확히 일치하는 경계를 테스트한다") + void extractUserIdHandlesExpirationTimeBoundary() throws JOSEException, InterruptedException { + // given - 현재 시간에서 1초 후에 만료되는 토큰 생성 + Instant expirationTime = Instant.now().plusSeconds(1); + String token = createToken(11, VALID_ISSUER, "refresh", expirationTime, VALID_SECRET); + + // when - 즉시 검증하면 통과해야 함 + Integer userId = refreshTokenService.extractUserId(token); + assertThat(userId).isEqualTo(11); + + // when - 2초 대기 후 만료된 토큰을 검증하면 실패해야 함 + Thread.sleep(2000); + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("extractUserId는 issuer가 빈 문자열인 토큰을 거부한다") + void extractUserIdRejectsEmptyIssuerClaim() throws JOSEException { + // given + String token = createToken(11, "", "refresh", Instant.now().plusSeconds(60), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.extractUserId(token)); + } + + @Test + @DisplayName("rotate는 만료된 토큰으로 rotate 시도 시 INVALID_REFRESH_TOKEN 예외 발생") + void rotateRejectsExpiredToken() throws JOSEException { + // given + String expiredToken = createToken(11, VALID_ISSUER, "refresh", Instant.now().minusSeconds(5), VALID_SECRET); + + // when & then + assertInvalidRefreshToken(() -> refreshTokenService.rotate(expiredToken)); + } + + @Test + @DisplayName("issue와 extractUserId는 토큰 claim 검증 왕복 테스트") + void issueAndExtractUserIdClaimsRoundTrip() { + // given + Integer expectedUserId = 12345; + + // when + String token = refreshTokenService.issue(expectedUserId); + Integer extractedUserId = refreshTokenService.extractUserId(token); + + // then + assertThat(extractedUserId).isEqualTo(expectedUserId); + } + + private String createToken(Integer userId, String issuer, String tokenType, Instant expiresAt, String secret) + throws JOSEException { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(issuer) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()) + .claim("id", userId) + .claim("token_type", tokenType) + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(secret)); + return jwt.serialize(); + } + + private String createTokenWithoutClaim(Integer userId, String issuer, Instant expiresAt, String secret, + String claimToOmit) + throws JOSEException { + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() + .issuer(issuer) + .issueTime(Date.from(Instant.now().minusSeconds(10))) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()); + + if (!"id".equals(claimToOmit)) { + builder.claim("id", userId); + } + if (!"token_type".equals(claimToOmit)) { + builder.claim("token_type", "refresh"); + } + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), builder.build()); + jwt.sign(new MACSigner(secret)); + return jwt.serialize(); + } + + private void assertInvalidRefreshToken(ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_REFRESH_TOKEN)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call() throws Exception; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java new file mode 100644 index 000000000..95157761d --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java @@ -0,0 +1,292 @@ +package gg.agit.konect.unit.domain.user.service; + +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.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.service.SignupTokenService; +import gg.agit.konect.global.auth.util.SecureTokenGenerator; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; + +class SignupTokenServiceTest extends ServiceTestSupport { + + @Mock + private StringRedisTemplate redis; + + @Mock + private ValueOperations valueOperations; + + @Mock + private SecureTokenGenerator secureTokenGenerator; + + @InjectMocks + private SignupTokenService signupTokenService; + + @Test + @DisplayName("issue는 토큰을 생성하고 claims를 TTL과 함께 저장한다") + void issueStoresSerializedClaimsWithTtl() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(secureTokenGenerator.generate()).willReturn("signup-token"); + + // when + String token = signupTokenService.issue( + "user@koreatech.ac.kr", + Provider.GOOGLE, + "provider-123", + "홍길동" + ); + + // then + assertThat(token).isEqualTo("signup-token"); + verify(valueOperations).set( + eq("auth:signup:signup-token"), + eq("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"), + eq(Duration.ofMinutes(10)) + ); + } + + @Test + @DisplayName("readOrThrow는 providerId와 name이 비어 있으면 null로 복원한다") + void readOrThrowNormalizesBlankProviderIdAndName() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:signup-token")) + .willReturn("user@koreatech.ac.kr|APPLE||"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.readOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.APPLE); + assertThat(claims.providerId()).isNull(); + assertThat(claims.name()).isNull(); + } + + @Test + @DisplayName("readOrThrow는 잘못 직렬화된 토큰이면 INVALID_SIGNUP_TOKEN을 던진다") + void readOrThrowThrowsWhenSerializedClaimsAreInvalid() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:broken-token")).willReturn("broken|GOOGLE"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("broken-token")); + } + + @Test + @DisplayName("consumeOrThrow는 토큰을 한 번만 읽고 삭제한다") + void consumeOrThrowReadsAndDeletesTokenAtomically() { + // given + given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:signup-token")))) + .willReturn("user@koreatech.ac.kr|KAKAO|provider-1|코넥트"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.KAKAO); + assertThat(claims.providerId()).isEqualTo("provider-1"); + assertThat(claims.name()).isEqualTo("코넥트"); + verify(redis, never()).opsForValue(); + } + + @Test + @DisplayName("issue는 email 또는 provider가 비어 있으면 IllegalArgumentException을 던진다") + void issueRejectsMissingRequiredFields() { + assertThatThrownBy(() -> signupTokenService.issue(" ", Provider.GOOGLE, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + + assertThatThrownBy(() -> signupTokenService.issue("user@koreatech.ac.kr", null, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + } + + @Test + @DisplayName("consumeOrThrow는 빈 토큰이면 Redis를 조회하지 않고 INVALID_SIGNUP_TOKEN을 던진다") + void consumeOrThrowRejectsBlankTokenWithoutRedisLookup() { + // when & then + assertInvalidSignupToken(() -> signupTokenService.consumeOrThrow(" ")); + verify(redis, never()).execute(any(DefaultRedisScript.class), any()); + } + + @Test + @DisplayName("issue는 빈 이메일 문자열을 거부한다") + void issueRejectsEmptyEmail() { + assertThatThrownBy(() -> signupTokenService.issue("", Provider.GOOGLE, "provider-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("email and provider are required"); + } + + @Test + @DisplayName("deserialize는 빈 파트를 포함한 직렬화된 데이터를 거부한다") + void deserializeRejectsEmptyParts() { + // "email|||" → split 결과: [email, "", "", ""] → provider가 빈 문자열이므로 거부 + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email|||"); + + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 4개 초과 파트를 거부한다") + void deserializeRejectsMoreThanFourParts() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("a|b|c|d|e"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 빈 이메일 필드를 거부한다") + void deserializeRejectsEmptyEmailField() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("|GOOGLE|provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 빈 프로바이더 필드를 거부한다") + void deserializeRejectsEmptyProviderField() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email||provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 유효하지 않은 Provider enum 값을 거부한다") + void deserializeRejectsInvalidProviderValue() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email|INVALID_PROVIDER|provider-1|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("readOrThrow는 Redis가 빈 문자열을 반환하면 INVALID_SIGNUP_TOKEN을 던진다") + void readOrThrowRejectsEmptyStringFromRedis() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn(""); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("consumeOrThrow는 Redis가 빈 문자열을 반환하면 INVALID_SIGNUP_TOKEN을 던진다") + void consumeOrThrowRejectsEmptyStringFromRedis() { + // given + given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:token")))) + .willReturn(""); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.consumeOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 이메일에 파이프 문자가 포함된 경우 파트 분리 오류로 거부한다") + void deserializeRejectsPipeCharacterInEmail() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("test|email@gmail.com|GOOGLE|provider-id|name"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("deserialize는 이름에 파이프 문자가 포함된 경우 5개 파트로 인식하여 거부한다") + void deserializeRejectsPipeCharacterInName() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:signup:token")).willReturn("email@gmail.com|GOOGLE|provider-id|김|철수"); + + // when & then + assertInvalidSignupToken(() -> signupTokenService.readOrThrow("token")); + } + + @Test + @DisplayName("issue는 4파라미터 오버로드로 name을 포함한 토큰을 발급한다") + void issueWithFourParametersIncludesNameInClaims() { + // given + given(redis.opsForValue()).willReturn(valueOperations); + given(secureTokenGenerator.generate()).willReturn("signup-token"); + + // when + String token = signupTokenService.issue( + "user@koreatech.ac.kr", + Provider.GOOGLE, + "provider-123", + "홍길동" + ); + + // then + assertThat(token).isEqualTo("signup-token"); + verify(valueOperations).set( + eq("auth:signup:signup-token"), + eq("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"), + eq(Duration.ofMinutes(10)) + ); + } + + @Test + @DisplayName("consumeOrThrow는 4파라미터 issue로 생성된 토큰에서 name을 복원한다") + void consumeOrThrowRestoresNameFromFourParameterIssue() { + // given + given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:signup-token")))) + .willReturn("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"); + + // when + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow("signup-token"); + + // then + assertThat(claims.email()).isEqualTo("user@koreatech.ac.kr"); + assertThat(claims.provider()).isEqualTo(Provider.GOOGLE); + assertThat(claims.providerId()).isEqualTo("provider-123"); + assertThat(claims.name()).isEqualTo("홍길동"); + } + + private void assertInvalidSignupToken(ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.INVALID_SIGNUP_TOKEN)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java new file mode 100644 index 000000000..f65b55db9 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserActivityServiceTest.java @@ -0,0 +1,154 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.UserActivityService; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserActivityServiceTest extends ServiceTestSupport { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserActivityService userActivityService; + + @Test + @DisplayName("updateLastLoginAt은 userId가 null이면 저장소를 호출하지 않는다") + void updateLastLoginAtSkipsWhenUserIdIsNull() { + // when + userActivityService.updateLastLoginAt(null); + + // then + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("updateLastLoginAt은 마지막 로그인/활동 시각을 함께 갱신한다") + void updateLastLoginAtUpdatesLoginAndActivityTimestamp() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(1); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastActivityAt은 사용자가 존재할 때만 마지막 활동 시각을 갱신한다") + void updateLastActivityAtUpdatesExistingUserOnly() { + // given + User user = UserFixture.createUserWithId(2, "2021136002"); + LocalDateTime loginAt = LocalDateTime.of(2026, 4, 1, 9, 0); + user.updateLastLoginAt(loginAt); + given(userRepository.findById(2)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(2); + + // then + assertThat(user.getLastLoginAt()).isEqualTo(loginAt); + assertThat(user.getLastActivityAt()).isAfter(loginAt); + } + + @Test + @DisplayName("updateLastActivityAt은 userId가 null이면 조용히 종료하고 저장소를 호출하지 않는다") + void updateLastActivityAtSkipsWhenUserIdIsNull() { + // when + userActivityService.updateLastActivityAt(null); + + // then + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("updateLastActivityAt은 사용자가 없으면 조용히 종료한다") + void updateLastActivityAtSkipsWhenUserMissing() { + // given + given(userRepository.findById(3)).willReturn(Optional.empty()); + + // when + userActivityService.updateLastActivityAt(3); + + // then + verify(userRepository).findById(3); + verifyNoMoreInteractions(userRepository); + } + + @Test + @DisplayName("updateLastLoginAt은 userId가 0(영)이면 정상 처리된다") + void updateLastLoginAtHandlesZeroUserId() { + // given + User user = UserFixture.createUserWithId(0, "2021136000"); + given(userRepository.getById(0)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(0); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastLoginAt은 음수 userId가 있어도 정상 처리된다") + void updateLastLoginAtHandlesNegativeUserId() { + // given + User user = UserFixture.createUserWithId(-1, "2021136001"); + given(userRepository.getById(-1)).willReturn(user); + + // when + userActivityService.updateLastLoginAt(-1); + + // then + assertThat(user.getLastLoginAt()).isNotNull(); + assertThat(user.getLastActivityAt()).isEqualTo(user.getLastLoginAt()); + } + + @Test + @DisplayName("updateLastActivityAt은 userId가 0(영)이면 정상 처리된다") + void updateLastActivityAtHandlesZeroUserId() { + // given + User user = UserFixture.createUserWithId(0, "2021136000"); + given(userRepository.findById(0)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(0); + + // then + assertThat(user.getLastActivityAt()).isNotNull(); + } + + @Test + @DisplayName("updateLastActivityAt은 음수 userId가 있어도 정상 처리된다") + void updateLastActivityAtHandlesNegativeUserId() { + // given + User user = UserFixture.createUserWithId(-1, "2021136001"); + given(userRepository.findById(-1)).willReturn(Optional.of(user)); + + // when + userActivityService.updateLastActivityAt(-1); + + // then + assertThat(user.getLastActivityAt()).isNotNull(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java new file mode 100644 index 000000000..e65917f43 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/UserOAuthAccountServiceTest.java @@ -0,0 +1,775 @@ +package gg.agit.konect.unit.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +import gg.agit.konect.domain.user.dto.OAuthLinkStatusResponse; +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.model.UserOAuthAccount; +import gg.agit.konect.domain.user.repository.UserOAuthAccountRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.UserOAuthAccountService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; + +class UserOAuthAccountServiceTest extends ServiceTestSupport { + + @Mock + private UserRepository userRepository; + + @Mock + private UserOAuthAccountRepository userOAuthAccountRepository; + + @Mock + private Environment environment; + + @InjectMocks + private UserOAuthAccountService userOAuthAccountService; + + @Test + @DisplayName("getLinkStatus는 모든 provider에 대한 연동 여부를 반환한다") + void getLinkStatusReturnsEveryProvider() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of( + UserOAuthAccount.of(user, Provider.GOOGLE, "google-id", "google@konect.test", null), + UserOAuthAccount.of(user, Provider.APPLE, "apple-id", "apple@konect.test", "apple-refresh") + )); + + // when + OAuthLinkStatusResponse response = userOAuthAccountService.getLinkStatus(1); + + // then + assertThat(response.providers()) + .extracting(link -> link.provider(), link -> link.linked()) + .containsExactly( + tuple(Provider.GOOGLE, true), + tuple(Provider.NAVER, false), + tuple(Provider.KAKAO, false), + tuple(Provider.APPLE, true) + ); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId가 비어 있으면 예외를 던진다") + void linkOAuthAccountRejectsBlankProviderId() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertCustomException( + ApiResponseCode.FAILED_EXTRACT_PROVIDER_ID, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + " ", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 providerId가 없어도 이메일 기준으로 새 계정을 생성한다") + void linkPrimaryOAuthAccountAllowsBlankProviderId() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + user, + Provider.GOOGLE, + " ", + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getProvider()).isEqualTo(Provider.GOOGLE); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + } + + @Test + @DisplayName("linkOAuthAccount는 비어 있던 providerId와 Apple refresh token을 기존 계정에 채운다") + void linkOAuthAccountUpdatesExistingAccountWhenProviderIdWasMissing() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.APPLE, + null, + "old@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.APPLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.APPLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.APPLE)) + .willReturn(Optional.of(existingAccount)); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.APPLE, + "apple-provider-id", + "new@konect.test", + "apple-refresh-token" + ); + + // then + assertThat(existingAccount.getProviderId()).isEqualTo("apple-provider-id"); + assertThat(existingAccount.getOauthEmail()).isEqualTo("new@konect.test"); + assertThat(existingAccount.getAppleRefreshToken()).isEqualTo("apple-refresh-token"); + verify(userOAuthAccountRepository).save(existingAccount); + } + + @Test + @DisplayName("linkOAuthAccount는 이미 다른 providerId가 있으면 충돌 예외를 던진다") + void linkOAuthAccountRejectsConflictingProviderIdOnExistingAccount() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.GOOGLE, + "existing-provider-id", + "google@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.of(existingAccount)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_PROVIDER_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(existingAccount); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 복구 기간이 지난 탈퇴 계정을 정리하고 새 계정을 저장한다") + void linkPrimaryOAuthAccountDeletesExpiredWithdrawnAccountBeforeSavingReplacement() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(10)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "expired-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "expired-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "expired-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "expired-provider-id", + "new@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + verify(userOAuthAccountRepository).save(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("cleanupExpiredWithdrawnUserOAuthAccounts는 임계 시각으로 삭제 후 flush한다") + void cleanupExpiredWithdrawnUserOAuthAccountsDeletesUsingThreshold() { + // given + LocalDateTime now = LocalDateTime.of(2026, 4, 10, 9, 30); + given(userOAuthAccountRepository.deleteAllByWithdrawnUsersBefore(now.minusDays(7))).willReturn(3); + + // when + int deletedCount = userOAuthAccountService.cleanupExpiredWithdrawnUserOAuthAccounts(now); + + // then + assertThat(deletedCount).isEqualTo(3); + verify(userOAuthAccountRepository).deleteAllByWithdrawnUsersBefore(now.minusDays(7)); + verify(userOAuthAccountRepository).flush(); + } + + @Test + @DisplayName("getLinkStatus는 OAuth 계정이 없으면 빈 리스트를 반환한다") + void getLinkStatusReturnsEmptyListWhenNoOAuthAccounts() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of()); + + // when + OAuthLinkStatusResponse response = userOAuthAccountService.getLinkStatus(1); + + // then + assertThat(response.providers()) + .extracting(link -> link.provider(), link -> link.linked()) + .containsExactly( + tuple(Provider.GOOGLE, false), + tuple(Provider.NAVER, false), + tuple(Provider.KAKAO, false), + tuple(Provider.APPLE, false) + ); + } + + @Test + @DisplayName("linkPrimaryOAuthAccount는 providerId가 null이고 requireProviderId=false인 경우 성공한다") + void linkPrimaryOAuthAccountAllowsNullProviderIdWhenNotRequired() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + user, + Provider.GOOGLE, + null, + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getProvider()).isEqualTo(Provider.GOOGLE); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + assertThat(captor.getValue().getProviderId()).isNull(); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId와 oauthEmail 모두 제공된 경우 성공한다") + void linkOAuthAccountAcceptsBothProviderIdAndOauthEmail() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "google-provider-id", + "google@konect.test", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getProviderId()).isEqualTo("google-provider-id"); + assertThat(captor.getValue().getOauthEmail()).isEqualTo("google@konect.test"); + } + + @Test + @DisplayName("linkOAuthAccount는 providerId와 oauthEmail 모두 누락된 경우 예외를 던진다") + void linkOAuthAccountRejectsWhenBothProviderIdAndOauthEmailMissing() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + + // when & then + assertCustomException( + ApiResponseCode.FAILED_EXTRACT_PROVIDER_ID, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + null, + null, + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("linkOAuthAccount는 oauthEmail이 빈 문자열인 경우에도 성공한다") + void linkOAuthAccountAcceptsEmptyOauthEmail() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "google-provider-id", + "", + null + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserOAuthAccount.class); + verify(userOAuthAccountRepository).save(captor.capture()); + assertThat(captor.getValue().getProviderId()).isEqualTo("google-provider-id"); + assertThat(captor.getValue().getOauthEmail()).isEmpty(); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByLinkedProvider는 계정이 있지만 사용자가 탈퇴하지 않은 경우 아무것도 하지 않는다") + void restoreOrCleanupWithdrawnByLinkedProviderDoesNothingWhenUserNotWithdrawn() { + // given + User activeUser = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + activeUser, + Provider.GOOGLE, + "google-provider-id", + "google@konect.test", + null + ); + // activeUser는 deletedAt이 null이므로 restoreOrCleanupWithdrawnByLinkedProvider에서 + // isStageProfile()이 호출되지 않음 — environment stub 불필요 + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(existingAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(activeUser)); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + activeUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + verify(userRepository, never()).save(any(User.class)); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByLinkedProvider는 복구 기간 내인 경우 사용자를 복구한다") + void restoreOrCleanupWithdrawnByLinkedProviderRestoresUserWithinWindow() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + assertThat(withdrawnUser.getDeletedAt()).isNull(); + verify(userRepository).save(withdrawnUser); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("restoreOrCleanupWithdrawnByOauthEmail는 복구 기간 내인 경우 사용자를 복구한다") + void restoreOrCleanupWithdrawnByOauthEmailRestoresUserWithinWindow() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "old@konect.test")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("old@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + + // then + assertThat(withdrawnUser.getDeletedAt()).isNull(); + verify(userRepository).save(withdrawnUser); + verify(userOAuthAccountRepository, never()).delete(any(UserOAuthAccount.class)); + } + + @Test + @DisplayName("getPrimaryOAuthAccount는 계정이 없는 경우 null을 반환한다") + void getPrimaryOAuthAccountReturnsNullWhenNoAccounts() { + // given + given(userOAuthAccountRepository.findAllByUserId(1)).willReturn(List.of()); + + // when + UserOAuthAccount result = userOAuthAccountService.getPrimaryOAuthAccount(1); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Stage profile에서 탈퇴 계정은 복구하지 않고 삭제된다") + void stageProfileDeletesWithdrawnAccountWithoutRestore() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(3)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(true); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + } + + @Test + @DisplayName("providerId가 NULL인 기존 계정에 새 providerId 연동 시 다른 사용자가 사용 중이면 충돌 예외 발생") + void linkOAuthAccountRejectsWhenNewProviderIdAlreadyLinkedToDifferentUser() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User otherUser = UserFixture.createUserWithId(2, "2022136002"); + given(userRepository.getById(1)).willReturn(currentUser); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.of(otherUser)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_ACCOUNT_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "current@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("복구 기간 정확히 7일 경계값에서는 복구 불가능") + void restoreWindowBoundaryAtExactlySevenDaysCannotRestore() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUser = UserFixture.createWithdrawnUser(2, "2020136002", LocalDateTime.now().minusDays(7)); + UserOAuthAccount withdrawnAccount = UserOAuthAccount.of( + withdrawnUser, + Provider.GOOGLE, + "google-provider-id", + "old@konect.test", + null + ); + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(withdrawnAccount)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "new@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("new@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "new@konect.test", + null + ); + + // then - 정확히 7일 경과 시 isAfter()가 false를 반환하므로 삭제됨 + verify(userOAuthAccountRepository).delete(withdrawnAccount); + verify(userOAuthAccountRepository).flush(); + verify(userRepository, never()).save(withdrawnUser); + } + + @Test + @DisplayName("providerId와 oauthEmail이 서로 다른 탈퇴 사용자인 경우 모두 정리된다") + void linkOAuthAccountCleansUpBothWithdrawnUsersFromProviderIdAndOauthEmail() { + // given + User currentUser = UserFixture.createUserWithId(1, "2021136001"); + User withdrawnUserByProviderId = UserFixture.createWithdrawnUser(2, "2020136002", + LocalDateTime.now().minusDays(10)); + User withdrawnUserByOauthEmail = UserFixture.createWithdrawnUser(3, "2020136003", + LocalDateTime.now().minusDays(10)); + + UserOAuthAccount accountByProviderId = UserOAuthAccount.of( + withdrawnUserByProviderId, + Provider.GOOGLE, + "google-provider-id", + "old1@konect.test", + null + ); + + UserOAuthAccount accountByOauthEmail = UserOAuthAccount.of( + withdrawnUserByOauthEmail, + Provider.GOOGLE, + "other-provider-id", + "old2@konect.test", + null + ); + + given(environment.acceptsProfiles(any(Profiles.class))).willReturn(false); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.of(accountByProviderId)); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "google-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "old2@konect.test")) + .willReturn(Optional.of(accountByOauthEmail)); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("old2@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.empty()); + + // when + userOAuthAccountService.linkPrimaryOAuthAccount( + currentUser, + Provider.GOOGLE, + "google-provider-id", + "old2@konect.test", + null + ); + + // then + verify(userOAuthAccountRepository).delete(accountByProviderId); + verify(userOAuthAccountRepository).delete(accountByOauthEmail); + verify(userOAuthAccountRepository, times(2)).flush(); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("기존 계정의 providerId 업데이트 시 다른 providerId로 충돌하면 예외 발생") + void linkOAuthAccountRejectsProviderIdUpdateWhenConflict() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.GOOGLE, + "old-provider-id", + "google@konect.test", + null + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.GOOGLE, "new-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.GOOGLE, "google@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("google@konect.test", Provider.GOOGLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.GOOGLE)) + .willReturn(Optional.of(existingAccount)); + + // when & then + assertCustomException( + ApiResponseCode.OAUTH_PROVIDER_ALREADY_LINKED, + () -> userOAuthAccountService.linkOAuthAccount( + 1, + Provider.GOOGLE, + "new-provider-id", + "google@konect.test", + null + ) + ); + verify(userOAuthAccountRepository, never()).save(any()); + } + + @Test + @DisplayName("Apple provider는 appleRefreshToken 업데이트를 호출한다") + void linkOAuthAccountUpdatesAppleRefreshTokenForAppleProvider() { + // given + User user = UserFixture.createUserWithId(1, "2021136001"); + UserOAuthAccount existingAccount = UserOAuthAccount.of( + user, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "old-refresh-token" + ); + given(userRepository.getById(1)).willReturn(user); + given(userOAuthAccountRepository.findAccountByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByProviderAndProviderId(Provider.APPLE, "apple-provider-id")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findAccountByProviderAndOauthEmail(Provider.APPLE, "apple@konect.test")) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findUserByOauthEmailAndProvider("apple@konect.test", Provider.APPLE)) + .willReturn(Optional.empty()); + given(userOAuthAccountRepository.findByUserIdAndProvider(1, Provider.APPLE)) + .willReturn(Optional.of(existingAccount)); + + // when + userOAuthAccountService.linkOAuthAccount( + 1, + Provider.APPLE, + "apple-provider-id", + "apple@konect.test", + "new-refresh-token" + ); + + // then + assertThat(existingAccount.getAppleRefreshToken()).isEqualTo("new-refresh-token"); + verify(userOAuthAccountRepository).save(existingAccount); + } + + private void assertCustomException(ApiResponseCode expectedErrorCode, ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo( + expectedErrorCode + )); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java new file mode 100644 index 000000000..c1a74b078 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/auth/web/AuthCookieServiceTest.java @@ -0,0 +1,281 @@ +package gg.agit.konect.unit.global.auth.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.servlet.http.Cookie; + +import gg.agit.konect.global.auth.web.AuthCookieService; + +class AuthCookieServiceTest { + + private AuthCookieService authCookieService; + + @BeforeEach + void setUp() { + authCookieService = new AuthCookieService(); + ReflectionTestUtils.setField(authCookieService, "cookieDomain", "konect.test"); + } + + @Test + @DisplayName("setRefreshToken은 HTTPS 요청에 Secure/SameSite=None 쿠키를 설정한다") + void setRefreshTokenUsesSecureCookieForHttpsRequest() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=refresh-token") + .contains("Max-Age=300") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("setRefreshToken은 X-Forwarded-Proto=https도 보안 요청으로 취급한다") + void setRefreshTokenTreatsForwardedHttpsAsSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "https"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofSeconds(30)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=refresh-token") + .contains("Max-Age=30") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("clearSignupToken은 만료된 빈 쿠키를 내려준다") + void clearSignupTokenSetsExpiredEmptyCookie() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.clearSignupToken(request, response); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("signup_token=") + .contains("Max-Age=0"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("clearRefreshToken은 만료된 빈 쿠키를 내려준다") + void clearRefreshTokenSetsExpiredEmptyCookie() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.clearRefreshToken(request, response); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("refresh_token=") + .contains("Max-Age=0"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("getCookieValue는 null 쿠키 배열을 처리한다") + void getCookieValueHandlesNullCookieArray() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies((Cookie[])null); // 명시적으로 null 설정 + + // when & then + assertThat(authCookieService.getCookieValue(request, "any")).isNull(); + } + + @Test + @DisplayName("getCookieValue는 대상 쿠키를 찾고 없으면 null을 반환한다") + void getCookieValueReturnsMatchingCookieValueOrNull() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies( + new Cookie("other", "123"), + new Cookie(AuthCookieService.REFRESH_TOKEN_COOKIE, "refresh-value") + ); + + // when & then + assertThat(authCookieService.getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE)) + .isEqualTo("refresh-value"); + assertThat(authCookieService.getCookieValue(request, AuthCookieService.SIGNUP_TOKEN_COOKIE)).isNull(); + assertThat(authCookieService.getCookieValue(new MockHttpServletRequest(), "missing")).isNull(); + } + + @Test + @DisplayName("setRefreshToken은 null duration이면 예외를 던진다") + void setRefreshTokenRejectsNullDuration() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertThatThrownBy(() -> authCookieService.setRefreshToken(request, response, "refresh-token", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("getCookieValue는 쿠키가 없으면 null을 반환한다") + void getCookieValueReturnsNullWhenNoCookies() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + // 쿠키를 설정하지 않음 + + // when & then + assertThat(authCookieService.getCookieValue(request, "missing")).isNull(); + } + + @Test + @DisplayName("isSecureRequest는 대소문자 혼합 HTTPS 헤더를 처리한다") + void isSecureRequestHandlesMixedCaseHttpsHeader() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "HTTPS"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).contains("Secure").contains("SameSite=None"); + } + + @Test + @DisplayName("isSecureRequest는 X-Forwarded-Proto 헤더가 없고 request.isSecure()가 false이면 비보안으로 처리한다") + void isSecureRequestTreatsMissingHeaderAndNonSecureRequestAsNonSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + // X-Forwarded-Proto 헤더 없음 + // request.isSecure() 기본값은 false + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + @Test + @DisplayName("setSignupToken은 정상 동작한다") + void setSignupTokenWorksNormally() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setSignupToken(request, response, "signup-token", Duration.ofMinutes(5)); + + // then + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie) + .contains("signup_token=signup-token") + .contains("Max-Age=300") + .contains("Secure") + .contains("SameSite=None"); + assertCommonCookieAttributes(setCookie); + } + + @Test + @DisplayName("setSignupToken과 clearSignupToken은 쿠키를 설정하고 제거한다") + void setAndClearSignupTokenWorkTogether() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSecure(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when - 설정 + authCookieService.setSignupToken(request, response, "signup-token", Duration.ofMinutes(5)); + String setCookie = response.getHeader("Set-Cookie"); + + // then - 설정 확인 + assertThat(setCookie) + .contains("signup_token=signup-token") + .contains("Max-Age=300"); + + // when - 제거 + MockHttpServletResponse clearResponse = new MockHttpServletResponse(); + authCookieService.clearSignupToken(request, clearResponse); + String clearCookie = clearResponse.getHeader("Set-Cookie"); + + // then - 제거 확인 + assertThat(clearCookie) + .contains("signup_token=") + .contains("Max-Age=0"); + } + + @Test + @DisplayName("X-Forwarded-Proto 복수 값은 전체 문자열과 비교하므로 https가 아니면 비보안 처리한다") + void xForwardedProtoMultipleValuesTreatsEntireString() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "https,http"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then - 현재 구현에서는 전체 문자열과 비교하므로 "https,http"는 "https"와 다름 + String setCookie = response.getHeader("Set-Cookie"); + // equalsIgnoreCase("https")는 false이므로 비보안 처리 + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + @Test + @DisplayName("X-Forwarded-Proto 복수 값은 첫 번째 값이 http이면 비보안 처리한다") + void xForwardedProtoMultipleValuesTreatsHttpAsNonSecure() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-Proto", "http,https"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // when + authCookieService.setRefreshToken(request, response, "refresh-token", Duration.ofMinutes(5)); + + // then - 첫 번째 값이 http이므로 비보안 처리 + String setCookie = response.getHeader("Set-Cookie"); + assertThat(setCookie).doesNotContain("Secure"); + assertThat(setCookie).doesNotContain("SameSite=None"); + } + + private void assertCommonCookieAttributes(String setCookie) { + assertThat(setCookie) + .contains("Domain=konect.test") + .contains("Path=/") + .contains("HttpOnly"); + } +} diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java similarity index 97% rename from src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java rename to src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java index bd9ee4c80..ee84e68d8 100644 --- a/src/test/java/gg/agit/konect/infrastructure/slack/ai/SlackEventControllerTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/ai/SlackEventControllerTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.infrastructure.slack.ai; +package gg.agit.konect.unit.infrastructure.slack.ai; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -21,6 +21,8 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import gg.agit.konect.infrastructure.slack.ai.SlackAIService; +import gg.agit.konect.infrastructure.slack.ai.SlackEventController; import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; import gg.agit.konect.support.ControllerTestSupport; diff --git a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java similarity index 92% rename from src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java rename to src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java index 4da3a2168..2b075ec93 100644 --- a/src/test/java/gg/agit/konect/infrastructure/slack/listener/SheetSyncSlackListenerTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/SheetSyncSlackListenerTest.java @@ -1,4 +1,4 @@ -package gg.agit.konect.infrastructure.slack.listener; +package gg.agit.konect.unit.infrastructure.slack.listener; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; @@ -9,6 +9,7 @@ import org.mockito.Mock; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; +import gg.agit.konect.infrastructure.slack.listener.SheetSyncSlackListener; import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; import gg.agit.konect.support.ServiceTestSupport; From a838340d807a6b810ef127739dd1bf46d6cd9310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:05:19 +0900 Subject: [PATCH 26/50] =?UTF-8?q?chore:=20=EB=9F=B0=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20OTel=20javaagent=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EC=99=B8=20(#519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker 실행 시 JAVA_TOOL_OPTIONS 주입을 제거해 OTel agent로 인한 런타임 메모리 오버헤드를 줄이기 위함 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5da5f12f5..1cd1cd4a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,4 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 -ENTRYPOINT ["sh", "-c", "java $JAVA_TOOL_OPTIONS -jar KONECT_API.jar"] +ENTRYPOINT ["java", "-jar", "KONECT_API.jar"] From 3edf362684918765d33c295df9c48feb10bb0151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Fri, 10 Apr 2026 22:53:58 +0900 Subject: [PATCH 27/50] =?UTF-8?q?refactor:=20=EC=88=9C=EA=B3=B5=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20(#5?= =?UTF-8?q?20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: StudyTimeDailyRepository에 SUM 집계 쿼리 추가 monthly/total 테이블 제거를 위한 대체 쿼리를 선제적으로 추가합니다. - 단일 유저 월별/전체 합산 쿼리 - 다수 유저 일별/월별 합산 쿼리 (랭킹 집계용) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: 이벤트 기반 랭킹 업데이트 서비스 및 리스너 추가 - StudyTimeAccumulatedEvent: 공부 시간 누적 시 발행되는 이벤트 - StudyTimeRankingUpdateService: 단일 유저 기준 개인/동아리/학번 랭킹 업데이트 - StudyTimeRankingUpdateListener: AFTER_COMMIT 시점에 랭킹 업데이트 실행 - UserRepository에 학번 연도 기반 검색 쿼리 추가 Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: StudyTimerService에서 monthly/total 누적 제거 및 이벤트 발행 - monthly/total 테이블 저장 로직 제거 (daily만 누적) - stop(), sync() 에서 StudyTimeAccumulatedEvent 발행 - accumulateDailyAndMonthlySeconds → accumulateDailySeconds 로 단순화 - addMonthlySegment, updateTotalSecondsIfNeeded, addTotalSeconds 메서드 삭제 Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: StudyTimeQueryService를 daily SUM 쿼리 기반으로 전환 - getMonthlyStudyTime: monthly 테이블 → daily SUM 쿼리로 변경 - getTotalStudyTime: total 테이블 → daily SUM 쿼리로 변경 - StudyTimeMonthlyRepository, StudyTimeTotalRepository 의존성 제거 Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: 스케줄러에서 랭킹 업데이트 제거, 리셋만 유지 - 5초 간격 랭킹 업데이트 스케줄러 3개 제거 (이벤트 기반으로 전환 완료) - StudyTimeSchedulerService에서 랭킹 업데이트 메서드 및 관련 의존성 제거 - 자정 일간 리셋, 월초 월간 리셋 스케줄러만 유지 Co-Authored-By: Claude Opus 4.6 (1M context) * chore: 미사용 StudyTimeMonthly, StudyTimeTotal 모델 및 레포지토리 삭제 daily SUM 쿼리로 대체되어 더 이상 사용되지 않는 파일들을 제거합니다. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: StudyTimeApiTest에서 삭제된 monthly/total 참조 제거 - StudyTimeMonthlyRepository, StudyTimeTotalRepository 의존성 제거 - stopTimerAccumulatesTime 테스트: summary API 응답으로 월별/전체 검증 Co-Authored-By: Claude Opus 4.6 (1M context) * feat: study_time_monthly, study_time_total 테이블 DROP 마이그레이션 daily SUM 쿼리로 대체되어 더 이상 사용되지 않는 테이블을 삭제합니다. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: 리스너 트랜잭션 전파 방식 수정 및 checkstyle 위반 해결 - @Transactional → @Transactional(propagation = REQUIRES_NEW) 변경 (Spring 6.2+ 에서 AFTER_COMMIT 리스너에 기본 전파 방식 사용 불가) - StudyTimeRankingUpdateService 라인 길이 120자 제한 준수 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: flyway 마이그레이션 버전 충돌 해결 (V69 → V70) V69가 이미 존재하여 V70으로 변경합니다. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: flyway V70 삭제 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../event/StudyTimeAccumulatedEvent.java | 9 + .../StudyTimeRankingUpdateListener.java | 25 ++ .../studytime/model/StudyTimeMonthly.java | 70 ----- .../studytime/model/StudyTimeTotal.java | 62 ----- .../repository/StudyTimeDailyRepository.java | 42 +++ .../StudyTimeMonthlyRepository.java | 31 --- .../repository/StudyTimeTotalRepository.java | 14 - .../scheduler/StudyTimeScheduler.java | 35 --- .../service/StudyTimeQueryService.java | 17 +- .../StudyTimeRankingUpdateService.java | 152 +++++++++++ .../service/StudyTimeSchedulerService.java | 239 ------------------ .../studytime/service/StudyTimerService.java | 61 +---- .../user/repository/UserRepository.java | 12 + .../domain/studytime/StudyTimeApiTest.java | 0 14 files changed, 256 insertions(+), 513 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java create mode 100644 src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java delete mode 100644 src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java delete mode 100644 src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java delete mode 100644 src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java delete mode 100644 src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java diff --git a/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java b/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java new file mode 100644 index 000000000..99db82a01 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/event/StudyTimeAccumulatedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.studytime.event; + +public record StudyTimeAccumulatedEvent( + Integer userId +) { + public static StudyTimeAccumulatedEvent of(Integer userId) { + return new StudyTimeAccumulatedEvent(userId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java b/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java new file mode 100644 index 000000000..1145eaceb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/listener/StudyTimeRankingUpdateListener.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.studytime.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.studytime.event.StudyTimeAccumulatedEvent; +import gg.agit.konect.domain.studytime.service.StudyTimeRankingUpdateService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class StudyTimeRankingUpdateListener { + + private final StudyTimeRankingUpdateService studyTimeRankingUpdateService; + + @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleStudyTimeAccumulated(StudyTimeAccumulatedEvent event) { + studyTimeRankingUpdateService.updateRankingsForUser(event.userId()); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java deleted file mode 100644 index 58c37ba55..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeMonthly.java +++ /dev/null @@ -1,70 +0,0 @@ -package gg.agit.konect.domain.studytime.model; - -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDate; - -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.global.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table( - name = "study_time_monthly", - uniqueConstraints = { - @UniqueConstraint(name = "uq_study_time_monthly_user_month", columnNames = {"user_id", "study_month"}) - } -) -@NoArgsConstructor(access = PROTECTED) -public class StudyTimeMonthly extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - @Column(name = "id", nullable = false, updatable = false, unique = true) - private Integer id; - - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "user_id", nullable = false, updatable = false) - private User user; - - @NotNull - @Column(name = "study_month", nullable = false) - private LocalDate studyMonth; - - @NotNull - @Column(name = "total_seconds", nullable = false) - private Long totalSeconds; - - @Builder - private StudyTimeMonthly(User user, LocalDate studyMonth, Long totalSeconds) { - this.user = user; - this.studyMonth = studyMonth; - this.totalSeconds = totalSeconds; - } - - public static StudyTimeMonthly of(User user, LocalDate studyMonth, Long totalSeconds) { - return StudyTimeMonthly.builder() - .user(user) - .studyMonth(studyMonth) - .totalSeconds(totalSeconds) - .build(); - } - - public void addSeconds(long seconds) { - this.totalSeconds += seconds; - } -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java b/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java deleted file mode 100644 index b79141236..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/model/StudyTimeTotal.java +++ /dev/null @@ -1,62 +0,0 @@ -package gg.agit.konect.domain.studytime.model; - -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.global.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table( - name = "study_time_total", - uniqueConstraints = { - @UniqueConstraint(name = "uq_study_time_total_user", columnNames = {"user_id"}) - } -) -@NoArgsConstructor(access = PROTECTED) -public class StudyTimeTotal extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - @Column(name = "id", nullable = false, updatable = false, unique = true) - private Integer id; - - @OneToOne(fetch = LAZY) - @JoinColumn(name = "user_id", nullable = false, unique = true) - private User user; - - @NotNull - @Column(name = "total_seconds", nullable = false) - private Long totalSeconds; - - @Builder - private StudyTimeTotal(User user, Long totalSeconds) { - this.user = user; - this.totalSeconds = totalSeconds; - } - - public static StudyTimeTotal of(User user, Long totalSeconds) { - return StudyTimeTotal.builder() - .user(user) - .totalSeconds(totalSeconds) - .build(); - } - - public void addSeconds(long seconds) { - this.totalSeconds += seconds; - } -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java index f8d813df2..36bb3c687 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java +++ b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeDailyRepository.java @@ -28,4 +28,46 @@ List findByUserIds( ); List findAllByStudyDate(LocalDate studyDate); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id = :userId + AND std.studyDate BETWEEN :startDate AND :endDate + """) + Long sumTotalSecondsByUserIdAndStudyDateBetween( + @Param("userId") Integer userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id = :userId + """) + Long sumTotalSecondsByUserId(@Param("userId") Integer userId); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id IN :userIds + AND std.studyDate = :studyDate + """) + Long sumTotalSecondsByUserIdsAndStudyDate( + @Param("userIds") List userIds, + @Param("studyDate") LocalDate studyDate + ); + + @Query(""" + SELECT COALESCE(SUM(std.totalSeconds), 0) + FROM StudyTimeDaily std + WHERE std.user.id IN :userIds + AND std.studyDate BETWEEN :startDate AND :endDate + """) + Long sumTotalSecondsByUserIdsAndStudyDateBetween( + @Param("userIds") List userIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); } diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java deleted file mode 100644 index 2b1c9f66f..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeMonthlyRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package gg.agit.konect.domain.studytime.repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; - -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; - -public interface StudyTimeMonthlyRepository extends Repository { - - Optional findByUserIdAndStudyMonth(Integer userId, LocalDate studyMonth); - - @Query(""" - SELECT stm - FROM StudyTimeMonthly stm - WHERE stm.user.id IN :userIds - AND stm.studyMonth = :studyMonth - """) - List findByUserIds( - @Param("userIds") List userIds, - @Param("studyMonth") LocalDate studyMonth - ); - - StudyTimeMonthly save(StudyTimeMonthly studyTimeMonthly); - - List findAllByStudyMonth(LocalDate studyMonth); -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java b/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java deleted file mode 100644 index 2ca6aae92..000000000 --- a/src/main/java/gg/agit/konect/domain/studytime/repository/StudyTimeTotalRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package gg.agit.konect.domain.studytime.repository; - -import java.util.Optional; - -import org.springframework.data.repository.Repository; - -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; - -public interface StudyTimeTotalRepository extends Repository { - - Optional findByUserId(Integer userId); - - StudyTimeTotal save(StudyTimeTotal studyTimeTotal); -} diff --git a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java index 9f3a0541b..b2a4e18c2 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java +++ b/src/main/java/gg/agit/konect/domain/studytime/scheduler/StudyTimeScheduler.java @@ -1,7 +1,5 @@ package gg.agit.konect.domain.studytime.scheduler; -import static java.util.concurrent.TimeUnit.SECONDS; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -18,39 +16,6 @@ public class StudyTimeScheduler { private final StudyTimeSchedulerService studyTimeSchedulerService; - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updateClubStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("동아리 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updateClubStudyTimeRanking(); - SCHEDULER_LOGGER.info("동아리 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("동아리 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updatePersonalStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("개인 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updatePersonalStudyTimeRanking(); - SCHEDULER_LOGGER.info("개인 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("개인 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - - @Scheduled(fixedDelay = 5, timeUnit = SECONDS) - public void updateStudentNumberStudyTimeRanking() { - try { - SCHEDULER_LOGGER.info("학번별 공부 시간 랭킹 업데이트 시작"); - studyTimeSchedulerService.updateStudentNumberStudyTimeRanking(); - SCHEDULER_LOGGER.info("학번별 공부 시간 랭킹 업데이트 완료"); - } catch (Exception e) { - SCHEDULER_LOGGER.error("학번별 공부 시간 랭킹 업데이트 과정에서 오류가 발생했습니다.", e); - } - } - @Scheduled(cron = "0 0 0 * * *") public void resetStudyTimeRankingDaily() { try { diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java index f98d33881..a2130d778 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeQueryService.java @@ -7,11 +7,7 @@ import gg.agit.konect.domain.studytime.dto.StudyTimeSummaryResponse; import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import lombok.RequiredArgsConstructor; @Service @@ -20,8 +16,6 @@ public class StudyTimeQueryService { private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; - private final StudyTimeTotalRepository studyTimeTotalRepository; public StudyTimeSummaryResponse getSummary(Integer userId) { Long dailyStudyTime = getDailyStudyTime(userId); @@ -32,9 +26,7 @@ public StudyTimeSummaryResponse getSummary(Integer userId) { } public long getTotalStudyTime(Integer userId) { - return studyTimeTotalRepository.findByUserId(userId) - .map(StudyTimeTotal::getTotalSeconds) - .orElse(0L); + return studyTimeDailyRepository.sumTotalSecondsByUserId(userId); } public long getDailyStudyTime(Integer userId) { @@ -46,10 +38,9 @@ public long getDailyStudyTime(Integer userId) { } public long getMonthlyStudyTime(Integer userId) { - LocalDate month = LocalDate.now().withDayOfMonth(1); + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); - return studyTimeMonthlyRepository.findByUserIdAndStudyMonth(userId, month) - .map(StudyTimeMonthly::getTotalSeconds) - .orElse(0L); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, monthStart, today); } } diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java new file mode 100644 index 000000000..77c1b3cf3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeRankingUpdateService.java @@ -0,0 +1,152 @@ +package gg.agit.konect.domain.studytime.service; + +import static gg.agit.konect.domain.studytime.model.RankingType.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.studytime.repository.RankingTypeRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudyTimeRankingUpdateService { + + private final UserRepository userRepository; + private final ClubMemberRepository clubMemberRepository; + private final StudyTimeDailyRepository studyTimeDailyRepository; + private final StudyTimeRankingRepository studyTimeRankingRepository; + private final RankingTypeRepository rankingTypeRepository; + + @Transactional + public void updateRankingsForUser(Integer userId) { + User user = userRepository.getById(userId); + + updatePersonalRanking(user); + updateClubRankings(user); + updateStudentNumberRanking(user); + } + + private void updatePersonalRanking(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_PERSONAL); + University university = user.getUniversity(); + + Long dailySeconds = getDailySeconds(user.getId()); + Long monthlySeconds = getMonthlySeconds(user.getId()); + + Optional ranking = studyTimeRankingRepository.findRanking( + rankingType.getId(), university.getId(), user.getId() + ); + + if (ranking.isEmpty()) { + studyTimeRankingRepository.save( + StudyTimeRanking.of(rankingType, university, user.getId(), user.getName(), dailySeconds, monthlySeconds) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + + private void updateClubRankings(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_CLUB); + List clubMembers = clubMemberRepository.findByUserId(user.getId()); + + for (ClubMember clubMember : clubMembers) { + Integer clubId = clubMember.getClub().getId(); + String clubName = clubMember.getClub().getName(); + University university = clubMember.getClub().getUniversity(); + + List memberUserIds = clubMemberRepository.findUserIdsByClubId(clubId); + + Long dailySeconds = getAggregatedDailySeconds(memberUserIds); + Long monthlySeconds = getAggregatedMonthlySeconds(memberUserIds); + + Optional ranking = studyTimeRankingRepository.findRanking( + rankingType.getId(), university.getId(), clubId + ); + + if (ranking.isEmpty()) { + studyTimeRankingRepository.save( + StudyTimeRanking.of(rankingType, university, clubId, clubName, dailySeconds, monthlySeconds) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + } + + private void updateStudentNumberRanking(User user) { + RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_STUDENT_NUMBER); + University university = user.getUniversity(); + String studentNumberYear = user.getStudentNumberYear(); + + List userIds = userRepository.findAllByUniversityIdAndStudentNumberStartingWith( + university.getId(), studentNumberYear + ).stream().map(User::getId).toList(); + + Long dailySeconds = getAggregatedDailySeconds(userIds); + Long monthlySeconds = getAggregatedMonthlySeconds(userIds); + + Optional ranking = studyTimeRankingRepository.findRankingByName( + rankingType.getId(), university.getId(), studentNumberYear + ); + + if (ranking.isEmpty()) { + Integer maxTargetId = studyTimeRankingRepository.findMaxTargetId( + rankingType.getId(), university.getId() + ); + Integer nextTargetId = maxTargetId + 1; + + studyTimeRankingRepository.save( + StudyTimeRanking.of( + rankingType, university, nextTargetId, + studentNumberYear, dailySeconds, monthlySeconds + ) + ); + } else { + ranking.get().updateSeconds(dailySeconds, monthlySeconds); + } + } + + private Long getDailySeconds(Integer userId) { + LocalDate today = LocalDate.now(); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, today, today); + } + + private Long getMonthlySeconds(Integer userId) { + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); + return studyTimeDailyRepository.sumTotalSecondsByUserIdAndStudyDateBetween(userId, monthStart, today); + } + + private Long getAggregatedDailySeconds(List userIds) { + if (userIds.isEmpty()) { + return 0L; + } + LocalDate today = LocalDate.now(); + return studyTimeDailyRepository.sumTotalSecondsByUserIdsAndStudyDate(userIds, today); + } + + private Long getAggregatedMonthlySeconds(List userIds) { + if (userIds.isEmpty()) { + return 0L; + } + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); + return studyTimeDailyRepository.sumTotalSecondsByUserIdsAndStudyDateBetween(userIds, monthStart, today); + } +} diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java index 879c9c7e5..b8c8f9288 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimeSchedulerService.java @@ -1,32 +1,12 @@ package gg.agit.konect.domain.studytime.service; -import static gg.agit.konect.domain.studytime.model.RankingType.*; -import static java.util.stream.Collectors.*; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.club.model.ClubMember; -import gg.agit.konect.domain.club.repository.ClubMemberRepository; -import gg.agit.konect.domain.studytime.model.RankingType; -import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; import gg.agit.konect.domain.studytime.model.StudyTimeRanking; -import gg.agit.konect.domain.studytime.repository.RankingTypeRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.model.User; import lombok.RequiredArgsConstructor; @Service @@ -34,226 +14,7 @@ @Transactional(readOnly = true) public class StudyTimeSchedulerService { - private final ClubMemberRepository clubMemberRepository; - private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; private final StudyTimeRankingRepository studyTimeRankingRepository; - private final RankingTypeRepository rankingTypeRepository; - - @Transactional - public void updateClubStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_CLUB); - - Set userIds = Stream.concat( - studyTimeDailies.stream().map(studyTimeDaily -> studyTimeDaily.getUser().getId()), - studyTimeMonthlies.stream().map(studyTimeMonthly -> studyTimeMonthly.getUser().getId()) - ) - .collect(toSet()); - - Map> userToClubMembersMap = clubMemberRepository.findByUserIdIn( - new ArrayList<>(userIds)).stream() - .collect(groupingBy(clubMember -> clubMember.getId().getUserId())); - - Map clubDailyMap = studyTimeDailies.stream() - .flatMap(studyTimeDaily -> userToClubMembersMap - .getOrDefault(studyTimeDaily.getUser().getId(), List.of()) - .stream() - .map(clubMember -> Map.entry(UniversityClub.of(clubMember), studyTimeDaily.getTotalSeconds())) - ) - .collect(groupingBy(Map.Entry::getKey, summingLong(Map.Entry::getValue))); - Map clubMonthlyMap = studyTimeMonthlies.stream() - .flatMap(studyTimeMonthly -> userToClubMembersMap - .getOrDefault(studyTimeMonthly.getUser().getId(), List.of()) - .stream() - .map(clubMember -> Map.entry(UniversityClub.of(clubMember), studyTimeMonthly.getTotalSeconds())) - ) - .collect(groupingBy(Map.Entry::getKey, summingLong(Map.Entry::getValue))); - - Set universityClubs = Stream.concat( - clubDailyMap.keySet().stream(), - clubMonthlyMap.keySet().stream() - ) - .collect(toSet()); - - for (UniversityClub universityClub : universityClubs) { - Long dailySeconds = clubDailyMap.getOrDefault(universityClub, 0L); - Long monthlySeconds = clubMonthlyMap.getOrDefault(universityClub, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRanking( - rankingType.getId(), - universityClub.university().getId(), - universityClub.clubId() - ); - - if (studyTimeRanking.isEmpty()) { - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - universityClub.university(), - universityClub.clubId(), - universityClub.clubName(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - @Transactional - public void updatePersonalStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_PERSONAL); - - Map userDailySecondsMap = studyTimeDailies.stream() - .collect(toMap( - studyTimeDaily -> studyTimeDaily.getUser().getId(), - StudyTimeDaily::getTotalSeconds - )); - Map userMonthlySecondsMap = studyTimeMonthlies.stream() - .collect(toMap( - studyTimeMonthly -> studyTimeMonthly.getUser().getId(), - StudyTimeMonthly::getTotalSeconds - )); - - List users = Stream.concat( - studyTimeDailies.stream().map(StudyTimeDaily::getUser), - studyTimeMonthlies.stream().map(StudyTimeMonthly::getUser) - ) - .distinct() - .toList(); - - for (User user : users) { - Integer userId = user.getId(); - University university = user.getUniversity(); - - Long dailySeconds = userDailySecondsMap.getOrDefault(userId, 0L); - Long monthlySeconds = userMonthlySecondsMap.getOrDefault(userId, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRanking( - rankingType.getId(), university.getId(), userId - ); - - if (studyTimeRanking.isEmpty()) { - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - university, - userId, - user.getName(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - @Transactional - public void updateStudentNumberStudyTimeRanking() { - LocalDate today = LocalDate.now(); - LocalDate currentMonth = today.withDayOfMonth(1); - - List studyTimeDailies = studyTimeDailyRepository.findAllByStudyDate(today); - List studyTimeMonthlies = studyTimeMonthlyRepository.findAllByStudyMonth(currentMonth); - if (studyTimeDailies.isEmpty() && studyTimeMonthlies.isEmpty()) { - return; - } - - RankingType rankingType = rankingTypeRepository.getByNameIgnoreCase(RANKING_TYPE_STUDENT_NUMBER); - - Map universityYearDailyMap = studyTimeDailies.stream() - .collect(groupingBy( - studyTimeDaily -> UniversityYear.of(studyTimeDaily.getUser()), - summingLong(StudyTimeDaily::getTotalSeconds) - )); - Map universityYearMonthlyMap = studyTimeMonthlies.stream() - .collect(groupingBy( - studyTimeMonthly -> UniversityYear.of(studyTimeMonthly.getUser()), - summingLong(StudyTimeMonthly::getTotalSeconds) - )); - - Set universityYears = new HashSet<>(); - universityYears.addAll(universityYearDailyMap.keySet()); - universityYears.addAll(universityYearMonthlyMap.keySet()); - - for (UniversityYear universityYear : universityYears) { - Long dailySeconds = universityYearDailyMap.getOrDefault(universityYear, 0L); - Long monthlySeconds = universityYearMonthlyMap.getOrDefault(universityYear, 0L); - - Optional studyTimeRanking = studyTimeRankingRepository.findRankingByName( - rankingType.getId(), universityYear.university().getId(), universityYear.year() - ); - - if (studyTimeRanking.isEmpty()) { - Integer maxTargetId = studyTimeRankingRepository.findMaxTargetId( - rankingType.getId(), - universityYear.university().getId() - ); - Integer nextTargetId = maxTargetId + 1; - - studyTimeRankingRepository.save( - StudyTimeRanking.of( - rankingType, - universityYear.university(), - nextTargetId, - universityYear.year(), - dailySeconds, - monthlySeconds - ) - ); - } else { - studyTimeRanking.get().updateSeconds(dailySeconds, monthlySeconds); - } - } - } - - private record UniversityClub( - University university, - Integer clubId, - String clubName - ) { - public static UniversityClub of(ClubMember clubMember) { - return new UniversityClub( - clubMember.getClub().getUniversity(), - clubMember.getClub().getId(), - clubMember.getClub().getName() - ); - } - } - - private record UniversityYear( - University university, - String year - ) { - public static UniversityYear of(User user) { - return new UniversityYear( - user.getUniversity(), - user.getStudentNumberYear() - ); - } - } @Transactional public void resetStudyTimeRankingDaily() { diff --git a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java index 64f8ba762..13f6ba2ae 100644 --- a/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java +++ b/src/main/java/gg/agit/konect/domain/studytime/service/StudyTimerService.java @@ -7,6 +7,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,14 +15,11 @@ import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; import gg.agit.konect.domain.studytime.dto.StudyTimerStopResponse; import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.event.StudyTimeAccumulatedEvent; import gg.agit.konect.domain.studytime.model.StudyTimeDaily; -import gg.agit.konect.domain.studytime.model.StudyTimeMonthly; import gg.agit.konect.domain.studytime.model.StudyTimeSummary; -import gg.agit.konect.domain.studytime.model.StudyTimeTotal; import gg.agit.konect.domain.studytime.model.StudyTimer; import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeMonthlyRepository; -import gg.agit.konect.domain.studytime.repository.StudyTimeTotalRepository; import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; @@ -39,10 +37,9 @@ public class StudyTimerService { private final StudyTimeQueryService studyTimeQueryService; private final StudyTimerRepository studyTimerRepository; private final StudyTimeDailyRepository studyTimeDailyRepository; - private final StudyTimeMonthlyRepository studyTimeMonthlyRepository; - private final StudyTimeTotalRepository studyTimeTotalRepository; private final UserRepository userRepository; private final EntityManager entityManager; + private final ApplicationEventPublisher eventPublisher; @Transactional public void start(Integer userId) { @@ -74,7 +71,8 @@ public StudyTimerStopResponse stop(Integer userId, StudyTimerStopRequest request deleteTimerIfElapsedTimeInvalid(studyTimer, serverSeconds, clientSeconds); - accumulateStudyTime(studyTimer.getUser(), lastSyncedAt, endedAt); + accumulateDailySeconds(studyTimer.getUser(), lastSyncedAt, endedAt); + eventPublisher.publishEvent(StudyTimeAccumulatedEvent.of(userId)); studyTimerRepository.delete(studyTimer); StudyTimeSummary summary = buildSummary(userId, serverSeconds); @@ -94,18 +92,13 @@ public void sync(Integer userId, StudyTimerSyncRequest request) { deleteTimerIfElapsedTimeInvalid(studyTimer, serverSeconds, clientSeconds); - accumulateStudyTime(studyTimer.getUser(), lastSyncedAt, syncedAt); + accumulateDailySeconds(studyTimer.getUser(), lastSyncedAt, syncedAt); + eventPublisher.publishEvent(StudyTimeAccumulatedEvent.of(userId)); studyTimer.updateStartedAt(syncedAt); } - private void accumulateStudyTime(User user, LocalDateTime startedAt, LocalDateTime endedAt) { - long sessionSeconds = accumulateDailyAndMonthlySeconds(user, startedAt, endedAt); - updateTotalSecondsIfNeeded(user, sessionSeconds); - } - - private long accumulateDailyAndMonthlySeconds(User user, LocalDateTime startedAt, LocalDateTime endedAt) { + private void accumulateDailySeconds(User user, LocalDateTime startedAt, LocalDateTime endedAt) { LocalDateTime cursor = startedAt; - long sessionSeconds = 0L; LocalDate endDate = endedAt.toLocalDate(); while (cursor.isBefore(endedAt)) { @@ -117,29 +110,24 @@ private long accumulateDailyAndMonthlySeconds(User user, LocalDateTime startedAt segmentEnd = endedAt; } - sessionSeconds += accumulateDailyAndMonthlySegment(user, cursor, segmentEnd); + accumulateDailySegment(user, cursor, segmentEnd); cursor = segmentEnd; } - - return sessionSeconds; } - private long accumulateDailyAndMonthlySegment(User user, LocalDateTime segmentStart, LocalDateTime segmentEnd) { + private void accumulateDailySegment(User user, LocalDateTime segmentStart, LocalDateTime segmentEnd) { if (!segmentStart.isBefore(segmentEnd)) { - return 0L; + return; } long seconds = Duration.between(segmentStart, segmentEnd).getSeconds(); if (seconds <= 0) { - return 0L; + return; } LocalDate date = segmentStart.toLocalDate(); addDailySegment(user, date, seconds); - addMonthlySegment(user, date, seconds); - - return seconds; } private void addDailySegment(User user, LocalDate date, long seconds) { @@ -151,31 +139,6 @@ private void addDailySegment(User user, LocalDate date, long seconds) { studyTimeDailyRepository.save(daily); } - private void addMonthlySegment(User user, LocalDate date, long seconds) { - LocalDate month = date.withDayOfMonth(1); - - StudyTimeMonthly monthly = studyTimeMonthlyRepository - .findByUserIdAndStudyMonth(user.getId(), month) - .orElseGet(() -> StudyTimeMonthly.of(user, month, 0L)); - - monthly.addSeconds(seconds); - studyTimeMonthlyRepository.save(monthly); - } - - private void updateTotalSecondsIfNeeded(User user, long sessionSeconds) { - if (sessionSeconds > 0) { - addTotalSeconds(user, sessionSeconds); - } - } - - private void addTotalSeconds(User user, long seconds) { - StudyTimeTotal total = studyTimeTotalRepository.findByUserId(user.getId()) - .orElseGet(() -> StudyTimeTotal.of(user, 0L)); - - total.addSeconds(seconds); - studyTimeTotalRepository.save(total); - } - private StudyTimeSummary buildSummary(Integer userId, long sessionSeconds) { long dailySeconds = studyTimeQueryService.getDailyStudyTime(userId); long monthlySeconds = studyTimeQueryService.getMonthlyStudyTime(userId); diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 86dc810f9..8b6840d2a 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -55,6 +55,18 @@ List findAllByUniversityIdAndStudentNumberIn( @Param("studentNumbers") Set studentNumbers ); + @Query(""" + SELECT u + FROM User u + WHERE u.university.id = :universityId + AND u.studentNumber LIKE CONCAT(:studentNumberYear, '%') + AND u.deletedAt IS NULL + """) + List findAllByUniversityIdAndStudentNumberStartingWith( + @Param("universityId") Integer universityId, + @Param("studentNumberYear") String studentNumberYear + ); + User save(User user); @Query(""" diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java new file mode 100644 index 000000000..e69de29bb From c4de3182c24be023d0eb0e4736f78ba2f94fc9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:13:48 +0900 Subject: [PATCH 28/50] =?UTF-8?q?test:=20UploadService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: UploadService 단위 테스트 추가 업로드 도메인 핵심 비즈니스 로직을 검증하는 단위 테스트를 작성한다. * fix: UploadService content-type 대소문자/공백 정규화 및 trailing slash 제거 - content-type을 trim + lowercase로 정규화하여 대소문자/공백 불일치 해결 - CDN baseUrl의 모든 trailing slash를 제거하도록 수정 - normalizeContentType 헬퍼 추가로 검증과 확장자 추출의 일관성 확보 - 41개 단위 테스트로 모든 분기 및 엣지 케이스 커버 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * fix: MIME 타입 정규화 로케일 고정 터키어 기본 로케일에서도 content-type 정규화가 깨지지 않도록 Locale.ROOT를 사용한다. 관련 회귀를 막기 위해 터키어 로케일 단위 테스트를 추가한다. * fix: 잘못된 CDN base-url 설정 차단 CDN base-url이 슬래시만 포함한 경우 빈 경로가 되기 전에 ILLEGAL_STATE로 실패시킨다. 해당 설정 오류에 대한 단위 테스트를 추가해 상대 경로 URL 생성을 방지한다. * test: UploadServiceTest 로케일 테스트 순차 실행 전역 Locale 변경을 사용하는 테스트가 병렬 실행과 충돌하지 않도록 SAME_THREAD를 적용한다. 터키어 로케일 회귀 테스트의 안정성을 높인다. --- .../domain/upload/service/UploadService.java | 28 +- .../upload/service/UploadServiceTest.java | 858 ++++++++++++++++++ 2 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java index cad968d51..8934adf8f 100644 --- a/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java +++ b/src/main/java/gg/agit/konect/domain/upload/service/UploadService.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.LocalDate; +import java.util.Locale; import java.util.Set; import java.util.UUID; @@ -43,7 +44,7 @@ public ImageUploadResponse uploadImage(MultipartFile file, UploadTarget target) validateS3Configuration(); validateFile(file); - String contentType = file.getContentType(); + String contentType = normalizeContentType(file.getContentType()); String extension = getExtension(contentType); String key = buildKey(extension, target); @@ -96,11 +97,10 @@ private void validateFile(MultipartFile file) { throw CustomException.of(ApiResponseCode.PAYLOAD_TOO_LARGE); } - String contentType = file.getContentType(); - if (contentType == null || contentType.isBlank() || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + String contentType = normalizeContentType(file.getContentType()); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { throw CustomException.of(ApiResponseCode.INVALID_FILE_CONTENT_TYPE); } - } private String buildKey(String extension, UploadTarget target) { @@ -137,8 +137,18 @@ private String normalizePrefix(String keyPrefix) { return normalized; } + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.isBlank()) { + return null; + } + return contentType.trim().toLowerCase(Locale.ROOT); + } + private String getExtension(String contentType) { - return switch (contentType.toLowerCase()) { + if (contentType == null) { + return "bin"; + } + return switch (contentType) { case "image/png" -> "png"; case "image/jpg", "image/jpeg" -> "jpg"; case "image/webp" -> "webp"; @@ -153,8 +163,12 @@ private String trimTrailingSlash(String baseUrl) { String trimmed = baseUrl.trim(); - if (trimmed.endsWith("/")) { - return trimmed.substring(0, trimmed.length() - 1); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + + if (trimmed.isEmpty()) { + throw CustomException.of(ApiResponseCode.ILLEGAL_STATE, "storage.cdn.base-url 설정이 필요합니다."); } return trimmed; diff --git a/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java new file mode 100644 index 000000000..9c9801e54 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/upload/service/UploadServiceTest.java @@ -0,0 +1,858 @@ +package gg.agit.konect.unit.domain.upload.service; + +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.Mockito.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import gg.agit.konect.domain.upload.dto.ImageUploadResponse; +import gg.agit.konect.domain.upload.enums.UploadTarget; +import gg.agit.konect.domain.upload.service.UploadService; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.storage.cdn.StorageCdnProperties; +import gg.agit.konect.infrastructure.storage.s3.S3StorageProperties; +import gg.agit.konect.support.ServiceTestSupport; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Execution(ExecutionMode.SAME_THREAD) +class UploadServiceTest extends ServiceTestSupport { + + private static final Pattern CLUB_KEY_PATTERN = Pattern.compile( + "(?:[\\w-]+/)*club/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + + @Mock + private S3Client s3Client; + + private UploadService uploadService; + + @BeforeEach + void setUp() { + uploadService = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + } + + @Test + @DisplayName("uploadImage는 유효한 PNG 파일을 업로드하고 key와 CDN URL을 반환한다") + void uploadImageUploadsPngAndReturnsKeyAndCdnUrl() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + + PutObjectRequest request = requestCaptor.getValue(); + assertThat(request.bucket()).isEqualTo("konect-bucket"); + assertThat(request.contentType()).isEqualTo("image/png"); + assertThat(request.key()).matches(CLUB_KEY_PATTERN); + assertThat(response.key()).isEqualTo(request.key()); + assertThat(response.fileUrl()).isEqualTo("https://cdn.konect.test/" + request.key()); + } + + @Test + @DisplayName("uploadImage는 blank prefix여도 target 경로만 포함한 key를 만든다") + void uploadImageBuildsKeyWithoutPrefixWhenPrefixBlank() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", " ", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "bank.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.BANK); + + // then + assertThat(response.key()).matches("bank/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + } + + @Test + @DisplayName("uploadImage는 leading slash prefix를 제거하고 trailing slash를 보정한다") + void uploadImageNormalizesPrefixWithLeadingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "/assets", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "user.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.USER); + + // then + assertThat(response.key()).matches("assets/user/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotStartWith("/"); + } + + @Test + @DisplayName("uploadImage는 null 파일을 거부한다") + void uploadImageRejectsNullFile() { + assertCustomException( + () -> uploadService.uploadImage(null, UploadTarget.CLUB), + ApiResponseCode.INVALID_REQUEST_BODY + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 empty 파일을 거부한다") + void uploadImageRejectsEmptyFile() { + // given + MockMultipartFile file = new MockMultipartFile("file", "empty.png", "image/png", new byte[0]); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_REQUEST_BODY + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 blank content-type을 거부한다") + void uploadImageRejectsBlankContentType() { + // given + MultipartFile file = mockFile("image.png", " ", 10L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + } + + @Test + @DisplayName("uploadImage는 허용되지 않은 content-type을 거부한다") + void uploadImageRejectsUnsupportedContentType() { + // given + MultipartFile file = mockFile("document.pdf", "application/pdf", 10L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes를 초과하면 PAYLOAD_TOO_LARGE로 실패한다") + void uploadImageRejectsOversizedFile() { + // given + MultipartFile file = mockFile("large.png", "image/png", 5_001L); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.PAYLOAD_TOO_LARGE + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 S3Exception을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsS3Exception() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(S3Exception.builder().message("s3 failed").build()); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 SdkClientException을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsSdkClientException() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(SdkClientException.create("client failed")); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 InputStream IOException을 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsIOException() throws IOException { + // given + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(10L); + when(file.getContentType()).thenReturn("image/png"); + when(file.getInputStream()).thenThrow(new IOException("stream failed")); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 target이 null이면 target 경로 없이 key를 생성한다") + void uploadImageBuildsKeyWithoutTargetWhenTargetNull() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, null); + + // then + assertThat(response.key()).matches( + "konect/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png" + ); + assertThat(response.key()).doesNotContain("//"); + } + + @Test + @DisplayName("uploadImage는 image/jpeg를 .jpg 확장자로 변환한다") + void uploadImageConvertsJpegToJpgExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.jpeg", + "image/jpeg", + "jpeg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".jpg"); + assertThat(response.fileUrl()).endsWith(".jpg"); + } + + @Test + @DisplayName("uploadImage는 image/jpg를 .jpg 확장자로 변환한다") + void uploadImageConvertsJpgToJpgExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.jpg", + "image/jpg", + "jpg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".jpg"); + } + + @Test + @DisplayName("uploadImage는 image/webp를 .webp 확장자로 변환한다") + void uploadImageConvertsWebpToWebpExtension() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "photo.webp", + "image/webp", + "webp-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".webp"); + } + + @Test + @DisplayName("uploadImage는 파일 크기가 maxUploadBytes와 정확히 일치하면 업로드에 성공한다") + void uploadImageAcceptsFileAtExactMaxSize() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + new byte[5_000] + ); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + assertThat(response.fileUrl()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 content-type이 null이면 INVALID_FILE_CONTENT_TYPE으로 실패한다") + void uploadImageRejectsNullContentType() { + // given + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(10L); + when(file.getContentType()).thenReturn(null); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.INVALID_FILE_CONTENT_TYPE + ); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 null이면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", null), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 0이면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesZero() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 0L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 CDN URL에 trailing slash가 없어도 정상 동작한다") + void uploadImageWorksWithCdnUrlWithoutTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()).startsWith("https://cdn.konect.test/"); + assertThat(response.fileUrl()).doesNotContain("//cdn.konect.test//"); + } + + @Test + @DisplayName("uploadImage는 prefix에 이미 trailing slash가 있으면 중복 slash 없이 key를 생성한다") + void uploadImageHandlesPrefixWithExistingTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect/", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).matches("konect/club/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotContain("konect//"); + } + + @Test + @DisplayName("uploadImage는 prefix에 leading과 trailing slash가 모두 있어도 정상 동작한다") + void uploadImageHandlesPrefixWithBothLeadingAndTrailingSlash() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "/assets/", 5_000L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.COUNCIL); + + // then + assertThat(response.key()).matches("assets/council/\\d{4}-\\d{2}-\\d{2}-[0-9a-f\\-]{36}\\.png"); + assertThat(response.key()).doesNotStartWith("/"); + assertThat(response.key()).doesNotContain("//"); + } + + @Test + @DisplayName("uploadImage는 S3Exception의 awsErrorDetails가 null이어도 FAILED_UPLOAD_FILE로 변환한다") + void uploadImageConvertsS3ExceptionWithNullErrorDetails() { + // given + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(S3Exception.builder().message("s3 failed").statusCode(500).build()); + + // when & then + assertCustomException( + () -> uploadService.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.FAILED_UPLOAD_FILE + ); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties(" ") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties(null) + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 CDN URL이 슬래시만 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenCdnBaseUrlContainsOnlySlashes() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("////") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 bucket 설정이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenBucketMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties(" ", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 region 설정이 비어 있으면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenRegionMissing() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", " ", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.png", + "image/png", + "png-data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException(() -> service.uploadImage(file, UploadTarget.CLUB), ApiResponseCode.ILLEGAL_STATE); + } + + @Test + @DisplayName("uploadImage는 bucket이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenBucketNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties(null, "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException( + () -> service.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.ILLEGAL_STATE + ); + } + + @Test + @DisplayName("uploadImage는 region이 null이면 ILLEGAL_STATE로 실패한다") + void uploadImageFailsWhenRegionNull() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", null, "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when & then + assertCustomException( + () -> service.uploadImage(file, UploadTarget.CLUB), + ApiResponseCode.ILLEGAL_STATE + ); + } + + @Test + @DisplayName("uploadImage는 maxUploadBytes가 음수면 용량 검증을 생략한다") + void uploadImageSkipsSizeCheckWhenMaxUploadBytesNegative() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", -1L), + new StorageCdnProperties("https://cdn.konect.test/") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "big.png", "image/png", + new byte[1_000_000] + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).isNotBlank(); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 CDN baseUrl에 double trailing slash가 있어도 fileUrl에 double slash가 없다") + void uploadImageRemovesAllTrailingSlashesFromCdnBaseUrl() { + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test//") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()).startsWith("https://cdn.konect.test/"); + assertThat(response.fileUrl()).doesNotContain("cdn.konect.test//"); + } + + @Test + @DisplayName("uploadImage는 대문자 content-type 'IMAGE/PNG'을 정규화하여 허용한다") + void uploadImageAcceptsUppercaseContentType() { + MultipartFile file = mockFile("image.png", "IMAGE/PNG", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("uploadImage는 혼합 대소문자 content-type 'image/PNG'을 정규화하여 허용한다") + void uploadImageAcceptsMixedCaseContentType() { + MultipartFile file = mockFile("image.png", "image/PNG", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 content-type에 leading whitespace가 있어도 정규화하여 허용한다") + void uploadImageAcceptsContentTypeWithLeadingWhitespace() { + MultipartFile file = mockFile("image.png", " image/png", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 content-type에 trailing whitespace가 있어도 정규화하여 허용한다") + void uploadImageAcceptsContentTypeWithTrailingWhitespace() { + MultipartFile file = mockFile("image.png", "image/png ", 10L); + + // when + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.key()).endsWith(".png"); + } + + @Test + @DisplayName("uploadImage는 터키어 기본 로케일에서도 대문자 content-type을 정규화하여 허용한다") + void uploadImageAcceptsUppercaseContentTypeInTurkishLocale() { + Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr")); + + try { + MultipartFile file = mockFile("image.png", "IMAGE/PNG", 10L); + + ImageUploadResponse response = uploadService.uploadImage(file, UploadTarget.CLUB); + + assertThat(response.key()).endsWith(".png"); + } finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + @DisplayName("uploadImage는 fileUrl을 baseUrl + '/' + key 형식으로 정확히 생성한다") + void uploadImageConstructsFileUrlAsBaseUrlSlashKey() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("konect-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + ImageUploadResponse response = service.uploadImage(file, UploadTarget.CLUB); + + // then + assertThat(response.fileUrl()) + .isEqualTo("https://cdn.konect.test/" + response.key()); + assertThat(response.fileUrl()).doesNotMatch(".*://.*//.*"); + } + + @Test + @DisplayName("uploadImage는 PutObjectRequest에 정규화된 content-type을 전달한다") + void uploadImagePassesNormalizedContentTypeToPutObjectRequest() { + // given — content-type이 정규화(trim + lowercase)되어 S3에 전달됨 + MockMultipartFile file = new MockMultipartFile( + "file", "photo.jpg", "IMAGE/JPEG", + "jpeg-data".getBytes(StandardCharsets.UTF_8) + ); + + // when + uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("uploadImage는 RequestBody에 file.getSize() 값을 그대로 전달한다") + void uploadImagePassesExactFileSizeToRequestBody() { + // given + byte[] content = new byte[1234]; + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", content + ); + + // when + uploadService.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + assertThat(bodyCaptor.getValue().contentLength()).isEqualTo(1234); + } + + @Test + @DisplayName("uploadImage는 PutObjectRequest의 bucket이 설정값과 일치한다") + void uploadImageUsesConfiguredBucketInPutObjectRequest() { + // given + UploadService service = new UploadService( + s3Client, + new S3StorageProperties("my-custom-bucket", "ap-northeast-2", "konect", 5_000L), + new StorageCdnProperties("https://cdn.konect.test") + ); + MockMultipartFile file = new MockMultipartFile( + "file", "logo.png", "image/png", + "data".getBytes(StandardCharsets.UTF_8) + ); + + // when + service.uploadImage(file, UploadTarget.CLUB); + + // then + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + assertThat(requestCaptor.getValue().bucket()).isEqualTo("my-custom-bucket"); + } + + private MultipartFile mockFile(String filename, String contentType, long size) { + return new MockMultipartFile( + "file", + filename, + contentType, + new byte[Math.toIntExact(size)] + ); + } + + private void assertCustomException(ThrowingCallable callable, ApiResponseCode expectedCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(expectedCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call() throws Exception; + } +} From 3e87a8250f3d8084c90dde658a01342e6a394daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:29:15 +0900 Subject: [PATCH 29/50] =?UTF-8?q?test:=20ClubService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: ClubService 단위 테스트 추가 동아리 도메인 핵심 비즈니스 로직을 검증하는 단위 테스트를 작성한다. * test: ClubService 단위 테스트 케이스 보강 - getClubDetail의 회원/지원 상태 반환 시나리오 추가 - getClubs의 pendingApproval 계산 및 null id 엣지 케이스 추가 - getClubMembers의 학번 마스킹, position 필터, 빈 목록 시나리오 추가 - updateInfo, updateBasicInfo 권한 검증 및 필드 수정 테스트 추가 - getJoinedClubs, getManagedClubs, getManagedClubDetail 시나리오 추가 - 회장 미존재 시 NOT_FOUND_CLUB_PRESIDENT 예외 테스트 추가 * fix: updateBasicInfo 매니저 권한 검증 추가 및 테스트 수정 - TODO로 남겨둔 권한 체크를 validateManagerAccess로 구현 - 테스트가 미인가 접근을 정상 동작으로 lock-in하던 문제 수정 - 비매니저 접근 거부 테스트 케이스 추가 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * fix: address ClubService review comments * refactor: reuse loaded user for manager validation * chore: 코드 포맷팅 --- .../agit/konect/domain/club/model/Club.java | 2 +- .../club/service/ClubPermissionValidator.java | 14 +- .../domain/club/service/ClubService.java | 9 +- .../domain/club/service/ClubServiceTest.java | 932 ++++++++++++++++++ 4 files changed, 946 insertions(+), 11 deletions(-) create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 114c1cc87..222a3c371 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -173,7 +173,7 @@ public void updateInfo(String description, String imageUrl, String location, Str this.introduce = introduce; } - public void updateBasicInfo(String name, ClubCategory clubCategory) { // 어드민 계정으로만 사용 가능 + public void updateBasicInfo(String name, ClubCategory clubCategory) { this.name = name; this.clubCategory = clubCategory; } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java index aa3c34918..9c46c497d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java @@ -23,7 +23,7 @@ public class ClubPermissionValidator { public void validatePresidentAccess(Integer clubId, Integer userId) { if (isAdmin(userId)) { - return ; + return; } if (!hasAccess(clubId, userId, PRESIDENT_ONLY)) { @@ -39,7 +39,7 @@ public void validateLeaderAccess(Integer clubId, User user) { Integer userId = user.getId(); if (user.isAdmin()) { - return ; + return; } if (!hasAccess(clubId, userId, LEADERS)) { @@ -48,8 +48,14 @@ public void validateLeaderAccess(Integer clubId, User user) { } public void validateManagerAccess(Integer clubId, Integer userId) { - if (isAdmin(userId)) { - return ; + validateManagerAccess(clubId, userRepository.getById(userId)); + } + + public void validateManagerAccess(Integer clubId, User user) { + Integer userId = user.getId(); + + if (user.isAdmin()) { + return; } if (!hasAccess(clubId, userId, MANAGERS)) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java index 08c4dccc6..734bb2226 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubService.java @@ -162,13 +162,10 @@ public void updateInfo(Integer clubId, Integer userId, ClubUpdateRequest request @Transactional public void updateBasicInfo(Integer clubId, Integer userId, ClubBasicInfoUpdateRequest request) { - userRepository.getById(userId); + User user = userRepository.getById(userId); Club club = clubRepository.getById(clubId); - // TODO: 어드민 권한 체크 로직 추가 필요 (현재는 미구현) - // if (!isAdmin(userId)) { - // throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); - // } + clubPermissionValidator.validateManagerAccess(clubId, user); club.updateBasicInfo(request.name(), request.clubCategory()); } @@ -199,7 +196,7 @@ public MyManagedClubResponse getManagedClubDetail(Integer clubId, Integer userId return MyManagedClubResponse.forAdmin(club, user); } - clubPermissionValidator.validateManagerAccess(clubId, userId); + clubPermissionValidator.validateManagerAccess(clubId, user); ClubMember clubMember = clubMemberRepository.getByClubIdAndUserId(clubId, userId); return MyManagedClubResponse.from(club, clubMember); diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java new file mode 100644 index 000000000..e0460c601 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java @@ -0,0 +1,932 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CLUB_MEMBER_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_ROLE_ACCESS; +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.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.Collections; +import java.util.List; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubBasicInfoUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubCondition; +import gg.agit.konect.domain.club.dto.ClubCreateRequest; +import gg.agit.konect.domain.club.dto.ClubDetailResponse; +import gg.agit.konect.domain.club.dto.ClubMemberCondition; +import gg.agit.konect.domain.club.dto.ClubMembersResponse; +import gg.agit.konect.domain.club.dto.ClubMembershipsResponse; +import gg.agit.konect.domain.club.dto.ClubUpdateRequest; +import gg.agit.konect.domain.club.dto.ClubsResponse; +import gg.agit.konect.domain.club.dto.MyManagedClubResponse; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.enums.RecruitmentStatus; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubSummaryInfo; +import gg.agit.konect.domain.club.repository.ClubApplyQuestionRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubQueryRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.club.service.ClubService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubServiceTest extends ServiceTestSupport { + + @Mock + private ClubQueryRepository clubQueryRepository; + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private ClubApplyQuestionRepository clubApplyQuestionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ClubService clubService; + + @Test + @DisplayName("createClub은 관리자가 동아리를 생성하면 기본 질문과 회장 멤버십까지 함께 만든다") + void createClubCreatesClubPresidentAndDefaultQuestions() { + // given + Integer adminUserId = 1; + Integer presidentUserId = 2; + User admin = UserFixture.createUserWithId(adminUserId, "관리자", UserRole.ADMIN); + User presidentUser = UserFixture.createUserWithId(presidentUserId, "회장", UserRole.USER); + ClubCreateRequest request = new ClubCreateRequest( + presidentUserId, + "KONECT", + "테스트 동아리", + "상세 소개", + "https://example.com/club.png", + "학생회관 101호", + ClubCategory.ACADEMIC + ); + Club savedClub = request.toEntity(presidentUser.getUniversity()); + ReflectionTestUtils.setField(savedClub, "id", 100); + ClubMember savedPresident = ClubMemberFixture.createPresident(savedClub, presidentUser); + + given(userRepository.getById(adminUserId)).willReturn(admin); + given(userRepository.getById(presidentUserId)).willReturn(presidentUser); + given(clubRepository.save(any(Club.class))).willReturn(savedClub); + given(clubMemberRepository.save(any(ClubMember.class))).willReturn(savedPresident); + given(clubRepository.getById(savedClub.getId())).willReturn(savedClub); + given(clubMemberRepository.findAllByClubId(savedClub.getId())).willReturn(List.of(savedPresident)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(savedClub.getId(), adminUserId)).willReturn(false); + + // when + ClubDetailResponse response = clubService.createClub(adminUserId, request); + + // then + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(clubMemberRepository).save(argThat(clubMember -> + clubMember.getClub().equals(savedClub) + && clubMember.getUser().equals(presidentUser) + && clubMember.getClubPosition() == ClubPosition.PRESIDENT + )); + verify(chatRoomMembershipService).addClubMember(savedPresident); + + ArgumentCaptor> questionCaptor = ArgumentCaptor.forClass(List.class); + verify(clubApplyQuestionRepository).saveAll(questionCaptor.capture()); + assertThat(questionCaptor.getValue()) + .extracting(ClubApplyQuestion::getQuestion, ClubApplyQuestion::getIsRequired, + ClubApplyQuestion::getDisplayOrder) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("본인의 전화번호를 입력해주세요.", true, 1), + org.assertj.core.groups.Tuple.tuple("지원 동기", false, 2) + ); + + assertThat(response.id()).isEqualTo(savedClub.getId()); + assertThat(response.presidentUserId()).isEqualTo(presidentUserId); + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isFalse(); + assertThat(response.recruitment().status()).isEqualTo(RecruitmentStatus.CLOSED); + } + + @Test + @DisplayName("createClub은 관리자가 아니면 접근을 거부한다") + void createClubRejectsNonAdminRequester() { + // given + Integer userId = 1; + User user = UserFixture.createUserWithId(userId, "일반 사용자", UserRole.USER); + ClubCreateRequest request = new ClubCreateRequest( + 2, + "KONECT", + "테스트 동아리", + "상세 소개", + "https://example.com/club.png", + "학생회관 101호", + ClubCategory.ACADEMIC + ); + given(userRepository.getById(userId)).willReturn(user); + + // when & then + assertErrorCode(() -> clubService.createClub(userId, request), FORBIDDEN_ROLE_ACCESS); + verify(clubRepository, never()).save(any(Club.class)); + } + + @Test + @DisplayName("getClubs는 지원 중이지만 아직 회원이 아닌 동아리만 pending 으로 표시한다") + void getClubsReturnsOnlyPendingAppliedNonMemberClubs() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo appliedOnlyClub = new ClubSummaryInfo( + 101, + "대기 동아리", + "https://example.com/club-1.png", + "학술", + "설명", + RecruitmentStatus.ONGOING, + false, + null + ); + ClubSummaryInfo joinedClub = new ClubSummaryInfo( + 102, + "가입 동아리", + "https://example.com/club-2.png", + "학술", + "설명", + RecruitmentStatus.ONGOING, + false, + null + ); + Page page = new PageImpl<>( + List.of(appliedOnlyClub, joinedClub), + PageRequest.of(0, 10), + 2 + ); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(102)); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.currentCount()).isEqualTo(2); + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::id, ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(101, true), + org.assertj.core.groups.Tuple.tuple(102, false) + ); + } + + @Test + @DisplayName("getClubs는 조회 결과가 비어 있으면 pending 집합도 비운다") + void getClubsReturnsEmptyPendingWhenNoClubExists() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, null, null); + Page emptyPage = Page.empty(PageRequest.of(0, 10)); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(emptyPage); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()).isEmpty(); + verify(clubApplyRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + verify(clubMemberRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + } + + @Test + @DisplayName("getClubDetail은 회원 여부와 pending 지원 여부를 함께 계산한다") + void getClubDetailCombinesMembershipAndPendingApplication() { + // given + Integer clubId = 1; + Integer userId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + User memberUser = UserFixture.createUserWithId(2, "회원", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president, member)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(clubId, userId)).willReturn(true); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.memberCount()).isEqualTo(2); + assertThat(response.presidentUserId()).isEqualTo(presidentUser.getId()); + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isTrue(); + } + + @Test + @DisplayName("getManagedClubs는 관리자는 전체 동아리를, 일반 사용자는 manager 이상 소속만 반환한다") + void getManagedClubsReturnsAllForAdminAndManagerOnlyForUser() { + // given + Integer adminId = 1; + Integer managerId = 2; + User admin = UserFixture.createUserWithId(adminId, "관리자", UserRole.ADMIN); + User manager = UserFixture.createUserWithId(managerId, "운영진", UserRole.USER); + Club allClub = ClubFixture.createWithId(UniversityFixture.createWithId(1), 100, "전체 동아리"); + Club managedClub = ClubFixture.createWithId(UniversityFixture.createWithId(1), 200, "운영 동아리"); + ClubMember managerMember = ClubMemberFixture.createManager(managedClub, manager); + + given(userRepository.getById(adminId)).willReturn(admin); + given(userRepository.getById(managerId)).willReturn(manager); + given(clubRepository.findAll()).willReturn(List.of(allClub)); + given(clubMemberRepository.findAllByUserIdAndClubPositions(managerId, ClubPosition.MANAGERS)) + .willReturn(List.of(managerMember)); + + // when + ClubMembershipsResponse adminResponse = clubService.getManagedClubs(adminId); + ClubMembershipsResponse managerResponse = clubService.getManagedClubs(managerId); + + // then + assertThat(adminResponse.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id) + .containsExactly(allClub.getId()); + assertThat(managerResponse.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id, + ClubMembershipsResponse.InnerJoinedClubResponse::position) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(managedClub.getId(), ClubPosition.MANAGER.getDescription()) + ); + } + + @Test + @DisplayName("getManagedClubDetail은 일반 사용자에게 manager 권한 검증 후 상세 정보를 반환한다") + void getManagedClubDetailValidatesManagerAccessForUser() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + ClubMember clubMember = ClubMemberFixture.createManager(club, user); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.getByClubIdAndUserId(clubId, userId)).willReturn(clubMember); + + // when + MyManagedClubResponse response = clubService.getManagedClubDetail(clubId, userId); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, user); + assertThat(response.clubId()).isEqualTo(clubId); + assertThat(response.name()).isEqualTo(user.getName()); + assertThat(response.position()).isEqualTo(ClubPosition.MANAGER.getDescription()); + } + + @Test + @DisplayName("getClubMembers는 admin과 manager에게는 학번을 그대로 보여준다") + void getClubMembersReturnsUnmaskedStudentNumbersForAdminAndManager() { + // given + Integer clubId = 1; + User admin = UserFixture.createUserWithId(1, "관리자", UserRole.ADMIN); + User managerUser = UserFixture.createUserWithId(2, "운영진", UserRole.USER); + User memberUser = UserFixture.createUserWithId(3, "회원", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + ClubMember managerMember = ClubMemberFixture.createManager(club, managerUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + ClubMemberCondition condition = new ClubMemberCondition(ClubPosition.MEMBER); + + given(userRepository.getById(admin.getId())).willReturn(admin); + given(userRepository.getById(managerUser.getId())).willReturn(managerUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, managerUser.getId())).willReturn( + java.util.Optional.of(managerMember)); + given(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER)).willReturn(List.of(member)); + + // when + ClubMembersResponse adminResponse = clubService.getClubMembers(clubId, admin.getId(), condition); + ClubMembersResponse managerResponse = clubService.getClubMembers(clubId, managerUser.getId(), condition); + + // then + assertThat(adminResponse.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(memberUser.getStudentNumber()); + assertThat(managerResponse.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(memberUser.getStudentNumber()); + } + + @Test + @DisplayName("getClubMembers는 일반 회원에게는 마스킹된 학번을 반환하고 비회원은 거부한다") + void getClubMembersMasksStudentNumberForMemberAndRejectsNonMember() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Integer outsiderUserId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "일반 회원", + "20241234", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "조회 대상", "20249876", + UserRole.USER); + User outsider = UserFixture.createUserWithId(outsiderUserId, "외부인", UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, memberUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(userRepository.getById(outsiderUserId)).willReturn(outsider); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)).willReturn( + java.util.Optional.of(requesterMember)); + given(clubMemberRepository.findByClubIdAndUserId(clubId, outsiderUserId)).willReturn( + java.util.Optional.empty()); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****876"); + + assertErrorCode( + () -> clubService.getClubMembers(clubId, outsiderUserId, null), + FORBIDDEN_CLUB_MEMBER_ACCESS + ); + } + + @Test + @DisplayName("getClubDetail은 회원이면 isMember=true이고 isApplied도 true이다") + void getClubDetailReturnsMemberAndAppliedWhenUserIsMember() { + // given + Integer clubId = 1; + Integer userId = 20; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + User memberUser = UserFixture.createUserWithId(userId, "회원", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president, member)); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.isMember()).isTrue(); + assertThat(response.isApplied()).isTrue(); + verify(clubApplyRepository, never()).existsPendingByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("getClubDetail은 회원도 아니고 지원하지도 않았으면 isMember=false, isApplied=false이다") + void getClubDetailReturnsFalseWhenUserIsNeitherMemberNorApplied() { + // given + Integer clubId = 1; + Integer userId = 99; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User presidentUser = UserFixture.createUserWithId(1, "회장", UserRole.USER); + ClubMember president = ClubMemberFixture.createPresident(club, presidentUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(president)); + given(clubApplyRepository.existsPendingByClubIdAndUserId(clubId, userId)).willReturn(false); + + // when + ClubDetailResponse response = clubService.getClubDetail(clubId, userId); + + // then + assertThat(response.isMember()).isFalse(); + assertThat(response.isApplied()).isFalse(); + } + + @Test + @DisplayName("getClubs는 지원한 동아리가 없으면 어떤 클럽도 pending으로 표시하지 않는다") + void getClubsReturnsNoPendingWhenUserHasNoApplications() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo club = new ClubSummaryInfo( + 101, "동아리", "https://example.com/club.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(club), PageRequest.of(0, 10), 1); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101))) + .willReturn(Collections.emptyList()); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly(false); + verify(clubMemberRepository, never()).findClubIdsByUserIdAndClubIdIn(any(), any()); + } + + @Test + @DisplayName("getClubMembers는 부회장에게도 마스킹 없이 학번을 반환한다") + void getClubMembersReturnsUnmaskedStudentNumbersForVicePresident() { + // given + Integer clubId = 1; + User vicePresidentUser = UserFixture.createUserWithId(2, "부회장", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 3, "회원", "20249876", + UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + ClubMember vicePresident = ClubMemberFixture.createVicePresident(club, vicePresidentUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + + given(userRepository.getById(vicePresidentUser.getId())).willReturn(vicePresidentUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, vicePresidentUser.getId())) + .willReturn(java.util.Optional.of(vicePresident)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, vicePresidentUser.getId(), null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly(targetUser.getStudentNumber()); + } + + @Test + @DisplayName("getClubMembers는 일반 회원이 position 필터를 사용하면 마스킹된 결과를 반환한다") + void getClubMembersReturnsMaskedResultsForMemberWithPositionFilter() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "일반 회원", + "20241234", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "조회 대상", "20249876", + UserRole.USER); + ClubMember requesterMember = ClubMemberFixture.createMember(club, memberUser); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + ClubMemberCondition condition = new ClubMemberCondition(ClubPosition.MEMBER); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requesterMember)); + given(clubMemberRepository.findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER)) + .willReturn(List.of(targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, condition); + + // then + verify(clubMemberRepository).findAllByClubIdAndPosition(clubId, ClubPosition.MEMBER); + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****876"); + } + + @Test + @DisplayName("updateInfo는 매니저가 동아리 정보를 수정하면 club.updateInfo를 호출한다") + void updateInfoCallsClubUpdateInfoForManager() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + ClubUpdateRequest request = new ClubUpdateRequest("새 소개", "https://new.png", "신공 201호", "새 상세 소개"); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn(club); + + // when + clubService.updateInfo(clubId, userId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, userId); + assertThat(club.getDescription()).isEqualTo("새 소개"); + assertThat(club.getImageUrl()).isEqualTo("https://new.png"); + assertThat(club.getLocation()).isEqualTo("신공 201호"); + assertThat(club.getIntroduce()).isEqualTo("새 상세 소개"); + } + + @Test + @DisplayName("updateInfo는 매니저 권한이 없으면 예외를 던진다") + void updateInfoRejectsNonManagerAccess() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + ClubUpdateRequest request = new ClubUpdateRequest("새 소개", "https://new.png", "신공 201호", "새 상세 소개"); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn( + ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD")); + willThrow(CustomException.of(FORBIDDEN_ROLE_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, userId); + + // when & then + assertErrorCode(() -> clubService.updateInfo(clubId, userId, request), FORBIDDEN_ROLE_ACCESS); + } + + @Test + @DisplayName("updateBasicInfo는 매니저가 동아리 이름과 분과를 수정한다") + void updateBasicInfoUpdatesNameAndCategoryForManager() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "매니저", UserRole.USER); + ClubBasicInfoUpdateRequest request = new ClubBasicInfoUpdateRequest("새 이름", ClubCategory.SPORTS); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn(club); + + // when + clubService.updateBasicInfo(clubId, userId, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, user); + assertThat(club.getName()).isEqualTo("새 이름"); + assertThat(club.getClubCategory()).isEqualTo(ClubCategory.SPORTS); + } + + @Test + @DisplayName("updateBasicInfo는 매니저 권한이 없으면 예외를 던진다") + void updateBasicInfoRejectsNonManagerAccess() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + ClubBasicInfoUpdateRequest request = new ClubBasicInfoUpdateRequest("새 이름", ClubCategory.SPORTS); + + given(userRepository.getById(userId)).willReturn(user); + given(clubRepository.getById(clubId)).willReturn( + ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD")); + willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, user); + + // when & then + assertErrorCode( + () -> clubService.updateBasicInfo(clubId, userId, request), + ApiResponseCode.FORBIDDEN_CLUB_MANAGER_ACCESS + ); + } + + @Test + @DisplayName("getJoinedClubs는 사용자가 가입한 동아리 목록을 반환한다") + void getJoinedClubsReturnsUsersClubMemberships() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "사용자", UserRole.USER); + Club club1 = ClubFixture.createWithId(UniversityFixture.createWithId(1), 100, "동아리1"); + Club club2 = ClubFixture.createWithId(UniversityFixture.createWithId(1), 200, "동아리2"); + ClubMember member1 = ClubMemberFixture.createMember(club1, user); + ClubMember member2 = ClubMemberFixture.createManager(club2, user); + + given(clubMemberRepository.findAllByUserId(userId)).willReturn(List.of(member1, member2)); + + // when + ClubMembershipsResponse response = clubService.getJoinedClubs(userId); + + // then + assertThat(response.joinedClubs()).hasSize(2); + assertThat(response.joinedClubs()) + .extracting(ClubMembershipsResponse.InnerJoinedClubResponse::id) + .containsExactly(100, 200); + } + + @Test + @DisplayName("getJoinedClubs는 가입한 동아리가 없으면 빈 목록을 반환한다") + void getJoinedClubsReturnsEmptyListWhenNoClubsJoined() { + // given + Integer userId = 10; + given(clubMemberRepository.findAllByUserId(userId)).willReturn(Collections.emptyList()); + + // when + ClubMembershipsResponse response = clubService.getJoinedClubs(userId); + + // then + assertThat(response.joinedClubs()).isEmpty(); + } + + @Test + @DisplayName("getManagedClubDetail은 관리자에게 forAdmin 응답을 반환한다") + void getManagedClubDetailReturnsForAdminResponseForAdmin() { + // given + Integer clubId = 1; + Integer adminId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User admin = UserFixture.createUserWithId(adminId, "관리자", UserRole.ADMIN); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(adminId)).willReturn(admin); + + // when + MyManagedClubResponse response = clubService.getManagedClubDetail(clubId, adminId); + + // then + verify(clubPermissionValidator, never()).validateManagerAccess(any(Integer.class), any(Integer.class)); + assertThat(response.clubId()).isEqualTo(clubId); + assertThat(response.position()).isEqualTo(ClubPosition.PRESIDENT.getDescription()); + assertThat(response.name()).isEqualTo(admin.getName()); + } + + @Test + @DisplayName("getManagedClubs는 매니저 권한이 없는 일반 회원에게 빈 목록을 반환한다") + void getManagedClubsReturnsEmptyForRegularMemberWithNoManagerPositions() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 회원", UserRole.USER); + + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.findAllByUserIdAndClubPositions(userId, ClubPosition.MANAGERS)) + .willReturn(Collections.emptyList()); + + // when + ClubMembershipsResponse response = clubService.getManagedClubs(userId); + + // then + assertThat(response.joinedClubs()).isEmpty(); + } + + @Test + @DisplayName("getClubDetail은 회장이 존재하지 않으면 NOT_FOUND_CLUB_PRESIDENT 예외를 던진다") + void getClubDetailThrowsWhenNoPresidentExists() { + // given + Integer clubId = 1; + Integer userId = 99; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(10, "일반 회원", UserRole.USER); + ClubMember memberOnly = ClubMemberFixture.createMember(club, memberUser); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(memberOnly)); + + // when & then + assertErrorCode( + () -> clubService.getClubDetail(clubId, userId), + ApiResponseCode.NOT_FOUND_CLUB_PRESIDENT + ); + } + + @Test + @DisplayName("getClubMembers는 condition이 있지만 position이 null이면 전체 회원을 조회한다") + void getClubMembersFetchesAllMembersWhenConditionHasNullPosition() { + // given + Integer clubId = 1; + Integer userId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User user = UserFixture.createUserWithId(userId, "운영진", UserRole.USER); + User targetUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 20, "회원", "20249876", + UserRole.USER); + ClubMember requester = ClubMemberFixture.createManager(club, user); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + ClubMemberCondition condition = new ClubMemberCondition(null); + + given(userRepository.getById(userId)).willReturn(user); + given(clubMemberRepository.findByClubIdAndUserId(clubId, userId)).willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, targetMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, userId, condition); + + // then + verify(clubMemberRepository).findAllByClubId(clubId); + verify(clubMemberRepository, never()).findAllByClubIdAndPosition(any(), any()); + assertThat(response.clubMembers()).hasSize(2); + } + + @Test + @DisplayName("getClubMembers는 회원이 없으면 빈 목록을 반환한다") + void getClubMembersReturnsEmptyListWhenNoMembers() { + // given + Integer clubId = 1; + Integer userId = 10; + User admin = UserFixture.createUserWithId(userId, "관리자", UserRole.ADMIN); + + given(userRepository.getById(userId)).willReturn(admin); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(Collections.emptyList()); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, userId, null); + + // then + assertThat(response.clubMembers()).isEmpty(); + } + + @Test + @DisplayName("getManagedClubDetail은 매니저가 아닌 일반 사용자의 접근을 거부한다") + void getManagedClubDetailRejectsNonManagerNonAdminUser() { + // given + Integer clubId = 1; + Integer userId = 10; + User user = UserFixture.createUserWithId(userId, "일반 사용자", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + + given(clubRepository.getById(clubId)).willReturn(club); + given(userRepository.getById(userId)).willReturn(user); + willThrow(CustomException.of(FORBIDDEN_ROLE_ACCESS)) + .given(clubPermissionValidator) + .validateManagerAccess(clubId, user); + + // when & then + assertErrorCode(() -> clubService.getManagedClubDetail(clubId, userId), FORBIDDEN_ROLE_ACCESS); + verify(clubMemberRepository, never()).getByClubIdAndUserId(any(), any()); + } + + @Test + @DisplayName("getClubs는 id가 null인 ClubSummaryInfo를 pending 계산에서 제외한다") + void getClubsSkipsNullIdClubSummariesInPendingCalculation() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo nullIdClub = new ClubSummaryInfo( + null, "null id 동아리", "https://example.com/null.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + ClubSummaryInfo validClub = new ClubSummaryInfo( + 200, "정상 동아리", "https://example.com/valid.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(nullIdClub, validClub), PageRequest.of(0, 10), 2); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + // null id는 필터링되므로 clubIds에는 200만 남는다 + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(200))) + .willReturn(List.of(200)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(200))) + .willReturn(Collections.emptyList()); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + // null id 클럽은 pending 계산에서 제외되었으므로 repository에는 [200]만 전달됨 + verify(clubApplyRepository).findClubIdsByUserIdAndClubIdIn(userId, List.of(200)); + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::id, ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(null, false), + org.assertj.core.groups.Tuple.tuple(200, true) + ); + } + + @Test + @DisplayName("getClubMembers는 학번이 3자 이하이면 마스킹 없이 그대로 반환한다") + void getClubMembersDoesNotMaskShortStudentNumber() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "회원", + "20241234", UserRole.USER); + User shortIdUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "짧은학번", "123", + UserRole.USER); + ClubMember requester = ClubMemberFixture.createMember(club, memberUser); + ClubMember shortIdMember = ClubMemberFixture.createMember(club, shortIdUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, shortIdMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****234", "123"); + } + + @Test + @DisplayName("getClubMembers는 학번이 null이면 null을 그대로 반환한다") + void getClubMembersReturnsNullWhenStudentNumberIsNull() { + // given + Integer clubId = 1; + Integer memberUserId = 10; + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId, "BCSD"); + User memberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), memberUserId, "회원", + "20241234", UserRole.USER); + User nullStudentNumberUser = UserFixture.createUserWithId(UniversityFixture.createWithId(1), 30, "학번없음", null, + UserRole.USER); + ClubMember requester = ClubMemberFixture.createMember(club, memberUser); + ClubMember nullStudentNumberMember = ClubMemberFixture.createMember(club, nullStudentNumberUser); + + given(userRepository.getById(memberUserId)).willReturn(memberUser); + given(clubMemberRepository.findByClubIdAndUserId(clubId, memberUserId)) + .willReturn(java.util.Optional.of(requester)); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(requester, nullStudentNumberMember)); + + // when + ClubMembersResponse response = clubService.getClubMembers(clubId, memberUserId, null); + + // then + assertThat(response.clubMembers()) + .extracting(ClubMembersResponse.InnerClubMember::studentNumber) + .containsExactly("*****234", null); + } + + @Test + @DisplayName("getClubs는 모든 지원 동아리가 가입 상태이면 pending 집합이 비어있다") + void getClubsReturnsEmptyPendingWhenAllAppliedClubsAreAlsoJoined() { + // given + Integer userId = 10; + User user = UserFixture.createUserWithId(UniversityFixture.createWithId(1), userId, "사용자", "20240010", + UserRole.USER); + ClubCondition condition = new ClubCondition(1, 10, "", false); + ClubSummaryInfo club1 = new ClubSummaryInfo( + 101, "동아리1", "https://example.com/1.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + ClubSummaryInfo club2 = new ClubSummaryInfo( + 102, "동아리2", "https://example.com/2.png", "학술", "설명", + RecruitmentStatus.ONGOING, false, null + ); + Page page = new PageImpl<>(List.of(club1, club2), PageRequest.of(0, 10), 2); + + given(userRepository.getById(userId)).willReturn(user); + given(clubQueryRepository.findAllByFilter(PageRequest.of(0, 10), "", false, user.getUniversity().getId())) + .willReturn(page); + // 두 클럽 모두에 지원했고, 두 클럽 모두 가입 상태 + given(clubApplyRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + given(clubMemberRepository.findClubIdsByUserIdAndClubIdIn(userId, List.of(101, 102))) + .willReturn(List.of(101, 102)); + + // when + ClubsResponse response = clubService.getClubs(condition, userId); + + // then + assertThat(response.clubs()) + .extracting(ClubsResponse.InnerClubResponse::isPendingApproval) + .containsExactly(false, false); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} From aa00849785f751c17b37a9126fba01f33f53a4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:34:24 +0900 Subject: [PATCH 30/50] =?UTF-8?q?test:=20ClubApplicationService=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: ClubApplicationService 단위 테스트 추가 동아리 가입 신청 관련 핵심 비즈니스 로직을 검증하는 단위 테스트를 작성한다. * test: ClubApplicationService 단위 테스트 엣지케이스 보강 - applyClub: 공백/null 답변 검증, 중복 질문 ID, 운영진 없을 때 이벤트 미발행, 다중 운영진 이벤트 포함 - approveClubApplication: 재승인 방지, 거절 후 승인, 승인 후 거절 상태 전이 - replaceApplyQuestions: isRequired 기본값 true, displayOrder만 변경 시 soft delete 방지 - 질문 가시성 경계 테스트: createdAt == appliedAt, deletedAt == appliedAt, 단일 지원서 - 미테스트 메서드 커버: getApprovedMemberApplicationAnswers, getClubApplications, getFeeInfo, replaceFeeInfo * fix: 이미 처리된 지원서의 상태 전이를 방지하는 가드 추가 - ALREADY_PROCESSED_CLUB_APPLY 에러 코드 추가 - approveClubApplication, rejectClubApplication에서 PENDING이 아닌 지원서 처리 시 예외 발생 - 승인→거절, 거절→승인 등 터미널 상태 전이 테스트를 가드 검증 테스트로 교체 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * fix: 동아리 지원서 처리 동시성 가드 추가 승인/거절 시 지원서를 비관적 락으로 조회해 중복 처리 경쟁 조건을 막는다. * test: align application answers fixture with pending query * docs: document processed application conflict responses * fix: narrow pessimistic lock scope for club apply lookup * chore: 코드 포맷팅 * refactor: reuse pending club apply fixture in tests --- .../club/controller/ClubApplicationApi.java | 16 +- .../club/repository/ClubApplyRepository.java | 20 + .../club/service/ClubApplicationService.java | 15 +- .../konect/global/code/ApiResponseCode.java | 1 + .../service/ClubApplicationServiceTest.java | 1268 +++++++++++++++++ 5 files changed, 1311 insertions(+), 9 deletions(-) create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java index 6ca48c78b..ade43b00e 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java @@ -34,7 +34,7 @@ public interface ClubApplicationApi { 동아리 가입 신청서를 제출합니다. 설문 질문이 없는 경우 answers는 빈 배열을 전달합니다. 모집 공고에서 회비 납부가 필요한 경우(isFeeRequired=true), feePaymentImageUrl은 필수입니다. - + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. @@ -82,7 +82,7 @@ ResponseEntity getClubApplications( - 정렬 기준: APPLIED_AT(신청 일시), STUDENT_NUMBER(학번), NAME(이름) - 정렬 방향: ASC(오름차순), DESC(내림차순) - 기본 정렬: 신청 일시 오래된 순 (APPLIED_AT ASC) - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -102,7 +102,7 @@ ResponseEntity getApprovedMemberApplications( @Operation(summary = "승인된 회원의 동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 승인된 회원 지원 답변을 조회할 수 있습니다. - 사용자 ID(userId) 기준으로 해당 사용자의 지원서를 찾아 문항/답변을 반환합니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -122,7 +122,7 @@ ResponseEntity getApprovedMemberApplicationAnswe - 정렬 기준: APPLIED_AT(신청 일시), STUDENT_NUMBER(학번), NAME(이름) - 정렬 방향: ASC(오름차순), DESC(내림차순) - 기본 정렬: 신청 일시 오래된 순 (APPLIED_AT ASC) - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -141,7 +141,7 @@ ResponseEntity getApprovedMemberApplicatio @Operation(summary = "동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 지원 답변을 조회할 수 있습니다. - + ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. @@ -157,9 +157,10 @@ ResponseEntity getClubApplicationAnswers( @Operation(summary = "동아리 가입 신청을 승인한다.", description = """ 동아리 운영진 권한부터 가입 신청을 승인할 수 있습니다. 승인 시 지원자는 일반회원으로 등록되며, 지원 내역은 보관됩니다. - + ## 에러 - ALREADY_CLUB_MEMBER (409): 이미 동아리 회원입니다. + - ALREADY_PROCESSED_CLUB_APPLY (409): 이미 처리된 동아리 가입 신청입니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_CLUB_APPLY (404): 동아리 지원 내역을 찾을 수 없습니다. @@ -176,6 +177,7 @@ ResponseEntity approveClubApplication( 거절 시 상태를 REJECTED로 변경합니다. ## 에러 + - ALREADY_PROCESSED_CLUB_APPLY (409): 이미 처리된 동아리 가입 신청입니다. - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_CLUB_APPLY (404): 동아리 지원 내역을 찾을 수 없습니다. @@ -216,7 +218,7 @@ ResponseEntity replaceApplyQuestions( @Operation(summary = "동아리 회비 정보를 조회한다.", description = """ 동아리의 회비 계좌 정보를 조회합니다. - + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. """) diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java index 24ef15c35..3f060de9a 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyRepository.java @@ -4,10 +4,13 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; + import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.enums.ClubApplyStatus; import gg.agit.konect.global.code.ApiResponseCode; @@ -58,6 +61,23 @@ default ClubApply getByIdAndClubId(Integer id, Integer clubId) { .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_APPLY)); } + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT clubApply + FROM ClubApply clubApply + WHERE clubApply.id = :id + AND clubApply.club.id = :clubId + """) + Optional findByIdAndClubIdForUpdate( + @Param("id") Integer id, + @Param("clubId") Integer clubId + ); + + default ClubApply getByIdAndClubIdForUpdate(Integer id, Integer clubId) { + return findByIdAndClubIdForUpdate(id, clubId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_APPLY)); + } + @Query(""" SELECT clubApply FROM ClubApply clubApply diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 44952ec19..f2947d4f1 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -2,6 +2,7 @@ import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; +import gg.agit.konect.domain.club.enums.ClubApplyStatus; import gg.agit.konect.domain.club.enums.ClubPosition; import static gg.agit.konect.global.code.ApiResponseCode.*; @@ -231,7 +232,12 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubPermissionValidator.validateManagerAccess(clubId, userId); - ClubApply clubApply = clubApplyRepository.getByIdAndClubId(applicationId, clubId); + ClubApply clubApply = clubApplyRepository.getByIdAndClubIdForUpdate(applicationId, clubId); + + if (clubApply.getStatus() != ClubApplyStatus.PENDING) { + throw CustomException.of(ALREADY_PROCESSED_CLUB_APPLY); + } + User applicant = clubApply.getUser(); if (clubMemberRepository.existsByClubIdAndUserId(clubId, applicant.getId())) { @@ -261,7 +267,12 @@ public void rejectClubApplication(Integer clubId, Integer applicationId, Integer clubPermissionValidator.validateManagerAccess(clubId, userId); - ClubApply clubApply = clubApplyRepository.getByIdAndClubId(applicationId, clubId); + ClubApply clubApply = clubApplyRepository.getByIdAndClubIdForUpdate(applicationId, clubId); + + if (clubApply.getStatus() != ClubApplyStatus.PENDING) { + throw CustomException.of(ALREADY_PROCESSED_CLUB_APPLY); + } + User applicant = clubApply.getUser(); clubApply.reject(); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index d2999a5f5..47c916f93 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -119,6 +119,7 @@ public enum ApiResponseCode { ALREADY_CLUB_MEMBER(HttpStatus.CONFLICT, "이미 동아리 회원입니다."), ALREADY_CLUB_PRE_MEMBER(HttpStatus.CONFLICT, "이미 동아리에 사전 등록된 회원입니다."), DUPLICATE_CLUB_APPLY_QUESTION(HttpStatus.CONFLICT, "중복된 가입 문항이 포함되어 있습니다."), + ALREADY_PROCESSED_CLUB_APPLY(HttpStatus.CONFLICT, "이미 처리된 동아리 가입 신청입니다."), VICE_PRESIDENT_ALREADY_EXISTS(HttpStatus.CONFLICT, "부회장은 이미 존재합니다."), ALREADY_RUNNING_STUDY_TIMER(HttpStatus.CONFLICT, "이미 실행 중인 스터디 타이머가 있습니다."), ALREADY_EXIST_CLUB_RECRUITMENT(HttpStatus.CONFLICT, "이미 동아리 모집 공고가 존재합니다."), diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java new file mode 100644 index 000000000..bef0918d2 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubApplicationServiceTest.java @@ -0,0 +1,1268 @@ +package gg.agit.konect.unit.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_APPLIED_CLUB; +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_CLUB_MEMBER; +import static gg.agit.konect.global.code.ApiResponseCode.DUPLICATE_CLUB_APPLY_QUESTION; +import static gg.agit.konect.global.code.ApiResponseCode.FEE_PAYMENT_IMAGE_REQUIRED; +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_PROCESSED_CLUB_APPLY; +import static gg.agit.konect.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_APPLY_QUESTION; +import static gg.agit.konect.global.code.ApiResponseCode.REQUIRED_CLUB_APPLY_ANSWER_MISSING; +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.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.bank.model.Bank; +import gg.agit.konect.domain.bank.repository.BankRepository; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.club.dto.ClubApplicationAnswersResponse; +import gg.agit.konect.domain.club.dto.ClubApplicationCondition; +import gg.agit.konect.domain.club.dto.ClubApplicationsResponse; +import gg.agit.konect.domain.club.dto.ClubAppliedClubsResponse; +import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; +import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; +import gg.agit.konect.domain.club.dto.ClubApplyRequest; +import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; +import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.domain.club.dto.ClubMemberApplicationAnswersResponse; +import gg.agit.konect.domain.club.enums.ClubApplyStatus; +import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationRejectedEvent; +import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubApply; +import gg.agit.konect.domain.club.model.ClubApplyAnswer; +import gg.agit.konect.domain.club.model.ClubApplyQuestion; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.club.repository.ClubApplyAnswerRepository; +import gg.agit.konect.domain.club.repository.ClubApplyQueryRepository; +import gg.agit.konect.domain.club.repository.ClubApplyQuestionRepository; +import gg.agit.konect.domain.club.repository.ClubApplyRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.club.service.ClubApplicationService; +import gg.agit.konect.domain.club.service.ClubPermissionValidator; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ClubApplicationServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubRecruitmentRepository clubRecruitmentRepository; + + @Mock + private ClubApplyRepository clubApplyRepository; + + @Mock + private ClubApplyQuestionRepository clubApplyQuestionRepository; + + @Mock + private ClubApplyAnswerRepository clubApplyAnswerRepository; + + @Mock + private ClubApplyQueryRepository clubApplyQueryRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private BankRepository bankRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @InjectMocks + private ClubApplicationService clubApplicationService; + + @Test + @DisplayName("applyClub은 이미 동아리 멤버인 사용자의 지원을 거부한다") + void applyClubRejectsAlreadyMember() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), ALREADY_CLUB_MEMBER); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 이미 pending 지원서가 있으면 중복 지원을 거부한다") + void applyClubRejectsAlreadyApplied() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), ALREADY_APPLIED_CLUB); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 회비 필수 동아리에서 납부 이미지가 없으면 실패한다") + void applyClubRequiresFeePaymentImage() { + // given + Club club = createFeeRequiredClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), FEE_PAYMENT_IMAGE_REQUIRED); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 답변을 저장하고 운영진이 있으면 제출 이벤트를 발행한다") + void applyClubSavesAnswersAndPublishesSubmittedEvent() { + // given + Club club = createClubWithFeeInfo(1, "국민은행"); + User applicant = createUser(10, "2021136001", "지원자"); + User managerUser = createUser(20, "2021136002", "운영진"); + ClubMember manager = ClubMemberFixture.createManager(club, managerUser); + ClubApplyQuestion question = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, "백엔드를 공부하고 싶습니다.")), + "https://example.com/payment.png" + ); + Bank bank = org.mockito.Mockito.mock(Bank.class); + ClubApply savedApply = ClubApply.of(club, applicant, request.feePaymentImageUrl()); + setId(savedApply, 300); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(applicant); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of(question)); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of(manager)); + given(bankRepository.getByName("국민은행")).willReturn(bank); + given(bank.getId()).willReturn(7); + + // when + ClubFeeInfoResponse response = clubApplicationService.applyClub(1, 10, request); + + // then + verify(clubApplyAnswerRepository).saveAll(argThat(answers -> { + List savedAnswers = (List)answers; + return savedAnswers.size() == 1 + && savedAnswers.get(0).getQuestion().getId().equals(100) + && savedAnswers.get(0).getAnswer().equals("백엔드를 공부하고 싶습니다."); + })); + verify(applicationEventPublisher).publishEvent(ClubApplicationSubmittedEvent.of( + List.of(20), + 300, + 1, + club.getName(), + applicant.getName() + )); + assertThat(response.bankId()).isEqualTo(7); + assertThat(response.bankName()).isEqualTo("국민은행"); + assertThat(response.accountNumber()).isEqualTo("123-456-7890"); + } + + @Test + @DisplayName("approveClubApplication은 멤버 저장, 채팅 멤버십 추가, 승인 이벤트 발행을 수행한다") + void approveClubApplicationAddsMembershipAndPublishesEvent() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + ClubMember savedMember = ClubMemberFixture.createMember(club, applicant); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubMemberRepository.save(any(ClubMember.class))).willReturn(savedMember); + + // when + clubApplicationService.approveClubApplication(1, 100, 99); + + // then + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.APPROVED); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(chatRoomMembershipService).addClubMember(savedMember); + verify(applicationEventPublisher).publishEvent(ClubApplicationApprovedEvent.of(10, 1, club.getName())); + } + + @Test + @DisplayName("approveClubApplication은 이미 멤버인 사용자를 다시 승인하지 않는다") + void approveClubApplicationRejectsExistingMember() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(true); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_CLUB_MEMBER); + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.PENDING); + verify(chatRoomMembershipService, never()).addClubMember(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("rejectClubApplication은 상태를 거절로 바꾸고 거절 이벤트를 발행한다") + void rejectClubApplicationChangesStatusAndPublishesEvent() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply clubApply = ClubApply.of(club, applicant, null); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(clubApply); + + // when + clubApplicationService.rejectClubApplication(1, 100, 99); + + // then + assertThat(clubApply.getStatus()).isEqualTo(ClubApplyStatus.REJECTED); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(applicationEventPublisher).publishEvent(ClubApplicationRejectedEvent.of(10, 1, club.getName())); + } + + @Test + @DisplayName("replaceApplyQuestions는 수정된 질문을 soft delete하고 새 질문을 생성하며 빠진 질문도 soft delete한다") + void replaceApplyQuestionsSoftDeletesAndCreatesQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion unchangedQuestion = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion changedQuestion = createQuestion(club, 2, "관심 분야", false, 2, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion removedQuestion = createQuestion(club, 3, "자기소개", true, 3, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion createdQuestion = createQuestion(club, 4, "새 질문", true, 2, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(2, "관심 기술", false), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문", true) + )); + List existingQuestions = List.of(unchangedQuestion, changedQuestion, removedQuestion); + ArgumentCaptor> questionsCaptor = ArgumentCaptor.forClass(List.class); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(existingQuestions) + .willReturn(List.of(unchangedQuestion, createdQuestion)); + + // when + var response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyQuestionRepository).saveAll(questionsCaptor.capture()); + List createdQuestions = questionsCaptor.getValue(); + assertThat(createdQuestions).hasSize(2); + assertThat(createdQuestions) + .extracting(ClubApplyQuestion::getQuestion) + .containsExactly("관심 기술", "새 질문"); + assertThat(unchangedQuestion.getDisplayOrder()).isEqualTo(1); + assertThat(changedQuestion.getDeletedAt()).isNotNull(); + assertThat(removedQuestion.getDeletedAt()).isNotNull(); + assertThat(response.questions()).hasSize(2); + } + + @Test + @DisplayName("replaceApplyQuestions는 중복 questionId를 거부한다") + void replaceApplyQuestionsRejectsDuplicateQuestionIds() { + // given + Club club = createClub(1); + ClubApplyQuestion existingQuestion = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기 수정", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(existingQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceApplyQuestions(1, 99, request), + DUPLICATE_CLUB_APPLY_QUESTION); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 승인 지원서가 없으면 빈 응답을 즉시 반환한다") + void getApprovedMemberApplicationAnswersListReturnsEmptyImmediately() { + // given + Page emptyPage = new PageImpl<>(List.of()); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + given(clubRepository.getById(1)).willReturn(createClub(1)); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(emptyPage); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).isEmpty(); + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyAnswerRepository, never()).findAllByApplyIdsWithQuestion(any()); + verify(clubApplyQuestionRepository, never()).findAllCandidatesVisibleBetweenApplyTimes(any(), any(), any()); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 지원 시점에 보이던 질문만 각 지원서에 포함한다") + void getApprovedMemberApplicationAnswersListUsesQuestionVisibilityAtApplyTime() { + // given + Club club = createClub(1); + User firstUser = createUser(10, "2021136001", "첫번째"); + User secondUser = createUser(20, "2021136002", "두번째"); + LocalDateTime firstAppliedAt = at(2026, 4, 2, 10, 0); + LocalDateTime secondAppliedAt = at(2026, 4, 4, 10, 0); + + ClubApply firstApply = createApprovedApply(club, firstUser, 100, firstAppliedAt); + ClubApply secondApply = createApprovedApply(club, secondUser, 200, secondAppliedAt); + Page page = new PageImpl<>(List.of(firstApply, secondApply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion oldQuestion = createQuestion(club, 1, "공통 질문", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyQuestion deletedQuestion = createQuestion(club, 2, "이전 질문", false, 2, at(2026, 4, 1, 9, 0)); + deletedQuestion.softDelete(at(2026, 4, 3, 0, 0)); + ClubApplyQuestion newQuestion = createQuestion(club, 3, "신규 질문", true, 3, at(2026, 4, 3, 12, 0)); + + ClubApplyAnswer firstAnswer1 = ClubApplyAnswer.of(firstApply, oldQuestion, "첫번째 공통 답변"); + ClubApplyAnswer firstAnswer2 = ClubApplyAnswer.of(firstApply, deletedQuestion, "첫번째 이전 답변"); + ClubApplyAnswer secondAnswer1 = ClubApplyAnswer.of(secondApply, oldQuestion, "두번째 공통 답변"); + ClubApplyAnswer secondAnswer2 = ClubApplyAnswer.of(secondApply, newQuestion, "두번째 신규 답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100, 200))) + .willReturn(List.of(firstAnswer1, firstAnswer2, secondAnswer1, secondAnswer2)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, firstAppliedAt, secondAppliedAt)) + .willReturn(List.of(oldQuestion, deletedQuestion, newQuestion)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).hasSize(2); + + ClubApplicationAnswersResponse firstResponse = response.applications().get(0); + assertThat(firstResponse.applicationId()).isEqualTo(100); + assertThat(firstResponse.answers()) + .extracting(ClubApplicationAnswersResponse.ClubApplicationAnswerResponse::question) + .containsExactly("공통 질문", "이전 질문"); + + ClubApplicationAnswersResponse secondResponse = response.applications().get(1); + assertThat(secondResponse.applicationId()).isEqualTo(200); + assertThat(secondResponse.answers()) + .extracting(ClubApplicationAnswersResponse.ClubApplicationAnswerResponse::question) + .containsExactly("공통 질문", "신규 질문"); + } + + @Test + @DisplayName("applyClub은 필수 질문에 답변이 누락되면 실패한다") + void applyClubRejectsMissingRequiredAnswer() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 존재하지 않는 질문 ID에 답변하면 실패한다") + void applyClubRejectsAnswerToNonExistentQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(999, "답변")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), NOT_FOUND_CLUB_APPLY_QUESTION); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 답변에 중복 질문 ID가 있으면 실패한다") + void applyClubRejectsDuplicateAnswerQuestionIds() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyRequest request = new ClubApplyRequest( + List.of( + new ClubApplyRequest.InnerClubQuestionAnswer(100, "첫 답변"), + new ClubApplyRequest.InnerClubQuestionAnswer(100, "중복 답변") + ), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), DUPLICATE_CLUB_APPLY_QUESTION); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 운영진이 없으면 제출 이벤트를 발행하지 않는다") + void applyClubDoesNotPublishEventWhenNoManagers() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(clubApplyAnswerRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("applyClub은 회비 불필요 동아리에서 이미지 없이도 성공한다") + void applyClubSucceedsWithoutFeeImage() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + ClubFeeInfoResponse response = clubApplicationService.applyClub(1, 10, request); + + // then + assertThat(response.bankId()).isNull(); + assertThat(response.bankName()).isNull(); + assertThat(response.amount()).isNull(); + } + + @Test + @DisplayName("applyClub은 선택 질문에 빈 답변을 허용한다") + void applyClubAllowsEmptyAnswerForOptionalQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion optionalQuestion = createQuestion(club, 100, "관심 분야", false, 1, at(2026, 4, 1, 12, 0)); + ClubApply savedApply = ClubApply.of(club, user, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, "")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(optionalQuestion)); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of()); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(clubApplyAnswerRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("replaceApplyQuestions는 존재하지 않는 질문 ID를 거부한다") + void replaceApplyQuestionsRejectsNonExistentQuestionId() { + // given + Club club = createClub(1); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(999, "수정된 질문", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceApplyQuestions(1, 99, request), + NOT_FOUND_CLUB_APPLY_QUESTION); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("replaceApplyQuestions는 모든 질문이 새로 생성되는 경우 정상 동작한다") + void replaceApplyQuestionsCreatesAllNewQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion newQuestion1 = createQuestion(club, 10, "새 질문 1", true, 1, at(2026, 4, 2, 10, 0)); + ClubApplyQuestion newQuestion2 = createQuestion(club, 11, "새 질문 2", false, 2, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문 1", true), + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문 2", false) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of()) + .willReturn(List.of(newQuestion1, newQuestion2)); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubApplyQuestionRepository).saveAll(argThat(list -> ((List)list).size() == 2)); + assertThat(response.questions()).hasSize(2); + } + + @Test + @DisplayName("replaceApplyQuestions는 빈 질문 목록이면 기존 질문을 모두 soft delete한다") + void replaceApplyQuestionsSoftDeletesAllWhenEmptyRequest() { + // given + Club club = createClub(1); + ClubApplyQuestion existingQ1 = createQuestion(club, 1, "질문 1", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion existingQ2 = createQuestion(club, 2, "질문 2", false, 2, at(2026, 4, 1, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of(existingQ1, existingQ2)) + .willReturn(List.of()); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + assertThat(existingQ1.getDeletedAt()).isNotNull(); + assertThat(existingQ2.getDeletedAt()).isNotNull(); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + assertThat(response.questions()).isEmpty(); + } + + @Test + @DisplayName("getClubApplicationAnswers는 특정 지원서의 답변을 질문과 함께 반환한다") + void getClubApplicationAnswersReturnsAnswersWithQuestions() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply clubApply = createPendingApply(club, applicant, 100, appliedAt); + ClubApplyQuestion question = createQuestion(club, 200, "지원 동기", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(clubApply, question, "성장하고 싶습니다."); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubId(100, 1)).willReturn(clubApply); + given(clubApplyAnswerRepository.findAllByApplyIdWithQuestion(100)).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllVisibleAtApplyTime(1, appliedAt)).willReturn(List.of(question)); + + // when + ClubApplicationAnswersResponse response = clubApplicationService.getClubApplicationAnswers(1, 100, 99); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applicationId()).isEqualTo(100); + assertThat(response.answers()).hasSize(1); + assertThat(response.answers().get(0).question()).isEqualTo("지원 동기"); + assertThat(response.answers().get(0).answer()).isEqualTo("성장하고 싶습니다."); + } + + @Test + @DisplayName("getApplyQuestions는 삭제되지 않은 질문 목록을 반환한다") + void getApplyQuestionsReturnsActiveQuestions() { + // given + Club club = createClub(1); + ClubApplyQuestion q1 = createQuestion(club, 1, "지원 동기", true, 1, at(2026, 4, 1, 10, 0)); + ClubApplyQuestion q2 = createQuestion(club, 2, "관심 분야", false, 2, at(2026, 4, 1, 10, 0)); + + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of(q1, q2)); + + // when + ClubApplyQuestionsResponse response = clubApplicationService.getApplyQuestions(1, 99); + + // then + assertThat(response.questions()).hasSize(2); + assertThat(response.questions().get(0).question()).isEqualTo("지원 동기"); + assertThat(response.questions().get(1).isRequired()).isFalse(); + } + + @Test + @DisplayName("getAppliedClubs는 사용자의 대기 중인 지원 목록을 반환한다") + void getAppliedClubsReturnsPendingApplications() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApply apply = ClubApply.of(club, user, null); + setId(apply, 100); + + given(clubApplyRepository.findAllPendingByUserIdWithClub(10)).willReturn(List.of(apply)); + + // when + ClubAppliedClubsResponse response = clubApplicationService.getAppliedClubs(10); + + // then + assertThat(response.appliedClubs()).hasSize(1); + } + + @Test + @DisplayName("getAppliedClubs는 대기 중인 지원이 없으면 빈 목록을 반환한다") + void getAppliedClubsReturnsEmptyWhenNoPendingApplications() { + // given + given(clubApplyRepository.findAllPendingByUserIdWithClub(10)).willReturn(List.of()); + + // when + ClubAppliedClubsResponse response = clubApplicationService.getAppliedClubs(10); + + // then + assertThat(response.appliedClubs()).isEmpty(); + } + + // ========== US-001: applyClub validation boundary cases ========== + + @Test + @DisplayName("applyClub은 필수 질문에 공백만 있는 답변을 거부한다") + void applyClubRejectsWhitespaceOnlyRequiredAnswer() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, " ")), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 필수 질문에 null 답변을 거부한다") + void applyClubRejectsNullAnswerForRequiredQuestion() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + ClubApplyQuestion requiredQuestion = createQuestion(club, 100, "지원 동기", true, 1, at(2026, 4, 1, 12, 0)); + ClubApplyRequest request = new ClubApplyRequest( + List.of(new ClubApplyRequest.InnerClubQuestionAnswer(100, null)), + null + ); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(user); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn( + List.of(requiredQuestion)); + + // when & then + assertErrorCode(() -> clubApplicationService.applyClub(1, 10, request), REQUIRED_CLUB_APPLY_ANSWER_MISSING); + verify(clubApplyRepository, never()).save(any()); + } + + @Test + @DisplayName("applyClub은 여러 운영진의 ID를 모두 제출 이벤트에 포함한다") + void applyClubPublishesEventWithMultipleManagers() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + User manager1 = createUser(20, "2021136002", "운영진1"); + User manager2 = createUser(21, "2021136003", "운영진2"); + ClubMember member1 = ClubMemberFixture.createManager(club, manager1); + ClubMember member2 = ClubMemberFixture.createManager(club, manager2); + ClubApply savedApply = ClubApply.of(club, applicant, null); + setId(savedApply, 300); + ClubApplyRequest request = new ClubApplyRequest(List.of(), null); + + given(clubRepository.getById(1)).willReturn(club); + given(userRepository.getById(10)).willReturn(applicant); + given(clubMemberRepository.existsByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyRepository.existsPendingByClubIdAndUserId(1, 10)).willReturn(false); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)).willReturn(List.of()); + given(clubApplyRepository.save(any(ClubApply.class))).willReturn(savedApply); + given(clubMemberRepository.findAllByClubIdAndPositionIn(1, + gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS)) + .willReturn(List.of(member1, member2)); + + // when + clubApplicationService.applyClub(1, 10, request); + + // then + verify(applicationEventPublisher).publishEvent(ClubApplicationSubmittedEvent.of( + List.of(20, 21), + 300, + 1, + club.getName(), + applicant.getName() + )); + } + + // ========== US-002: Approve/reject logical state transitions ========== + + @Test + @DisplayName("approveClubApplication은 이미 승인된 지원서를 재승인하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void approveClubApplicationRejectsAlreadyApproved() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply approvedApply = ClubApply.of(club, applicant, null); + approvedApply.approve(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(approvedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + verify(clubMemberRepository, never()).save(any()); + } + + @Test + @DisplayName("approveClubApplication은 이미 거절된 지원서를 승인하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void approveClubApplicationRejectsAlreadyRejected() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply rejectedApply = ClubApply.of(club, applicant, null); + rejectedApply.reject(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(rejectedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.approveClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + verify(clubMemberRepository, never()).save(any()); + } + + @Test + @DisplayName("rejectClubApplication은 이미 승인된 지원서를 거절하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void rejectClubApplicationRejectsAlreadyApproved() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply approvedApply = ClubApply.of(club, applicant, null); + approvedApply.approve(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(approvedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.rejectClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + assertThat(approvedApply.getStatus()).isEqualTo(ClubApplyStatus.APPROVED); + } + + @Test + @DisplayName("rejectClubApplication은 이미 거절된 지원서를 재거절하면 ALREADY_PROCESSED_CLUB_APPLY를 던진다") + void rejectClubApplicationRejectsAlreadyRejected() { + // given + Club club = createClub(1); + User applicant = createUser(10, "2021136001", "지원자"); + ClubApply rejectedApply = ClubApply.of(club, applicant, null); + rejectedApply.reject(); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyRepository.getByIdAndClubIdForUpdate(100, 1)).willReturn(rejectedApply); + + // when & then + assertErrorCode(() -> clubApplicationService.rejectClubApplication(1, 100, 99), ALREADY_PROCESSED_CLUB_APPLY); + } + + // ========== US-003: replaceApplyQuestions edge cases ========== + + @Test + @DisplayName("replaceApplyQuestions는 isRequired가 null이면 기본값 true로 질문을 생성한다") + void replaceApplyQuestionsDefaultsIsRequiredToTrue() { + // given + Club club = createClub(1); + ClubApplyQuestion createdQuestion = createQuestion(club, 10, "새 질문", true, 1, at(2026, 4, 2, 10, 0)); + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(null, "새 질문", null) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of()) + .willReturn(List.of(createdQuestion)); + + // when + clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + verify(clubApplyQuestionRepository).saveAll(argThat(list -> { + List questions = (List)list; + return questions.size() == 1 && questions.get(0).getIsRequired().equals(true); + })); + } + + @Test + @DisplayName("replaceApplyQuestions는 내용이 동일하면 displayOrder만 변경하고 soft delete하지 않는다") + void replaceApplyQuestionsOnlyChangesDisplayOrderWhenContentSame() { + // given + Club club = createClub(1); + ClubApplyQuestion question = createQuestion(club, 1, "지원 동기", true, 2, at(2026, 4, 1, 10, 0)); + // Same content, only repositioning to display order 1 + ClubApplyQuestionsReplaceRequest request = new ClubApplyQuestionsReplaceRequest(List.of( + new ClubApplyQuestionsReplaceRequest.ApplyQuestionRequest(1, "지원 동기", true) + )); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQuestionRepository.findAllByClubIdOrderByDisplayOrderAsc(1)) + .willReturn(List.of(question)) + .willReturn(List.of(question)); + + // when + clubApplicationService.replaceApplyQuestions(1, 99, request); + + // then + assertThat(question.getDisplayOrder()).isEqualTo(1); + assertThat(question.getDeletedAt()).isNull(); + verify(clubApplyQuestionRepository, never()).saveAll(any()); + } + + // ========== US-004: Question visibility boundary tests ========== + + @Test + @DisplayName("createdAt이 appliedAt과 정확히 같은 질문도 가입 시점에 보이는 것으로 처리된다") + void questionCreatedAtEqualToAppliedAtIsVisible() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "질문", true, 1, appliedAt); + ClubApplyAnswer answer = ClubApplyAnswer.of(apply, question, "답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).answers()).hasSize(1); + assertThat(response.applications().get(0).answers().get(0).question()).isEqualTo("질문"); + } + + @Test + @DisplayName("deletedAt이 appliedAt과 정확히 같은 질문은 가입 시점에 보이지 않는다") + void questionDeletedAtEqualToAppliedAtIsNotVisible() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "삭제된 질문", true, 1, at(2026, 4, 1, 9, 0)); + question.softDelete(appliedAt); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of()); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then - isAfter returns false for equal timestamps, so question is filtered out + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).answers()).isEmpty(); + } + + @Test + @DisplayName("getApprovedMemberApplicationAnswersList는 지원서가 하나뿐이어도 정상 동작한다") + void getApprovedMemberApplicationAnswersListWithSingleApplication() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "지원자"); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply apply = createApprovedApply(club, user, 100, appliedAt); + Page page = new PageImpl<>(List.of(apply)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + + ClubApplyQuestion question = createQuestion(club, 1, "질문", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(apply, question, "답변"); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + given(clubApplyAnswerRepository.findAllByApplyIdsWithQuestion(List.of(100))).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllCandidatesVisibleBetweenApplyTimes(1, appliedAt, appliedAt)) + .willReturn(List.of(question)); + + // when + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(1, 99, condition); + + // then - minAppliedAt == maxAppliedAt + assertThat(response.applications()).hasSize(1); + assertThat(response.applications().get(0).applicationId()).isEqualTo(100); + } + + // ========== US-005: Untested service method coverage ========== + + @Test + @DisplayName("getApprovedMemberApplicationAnswers는 특정 회원의 최신 승인 지원서를 반환한다") + void getApprovedMemberApplicationAnswersReturnsLatestApproved() { + // given + Club club = createClub(1); + User targetUser = createUser(10, "2021136001", "회원"); + ClubMember targetMember = ClubMemberFixture.createMember(club, targetUser); + LocalDateTime appliedAt = at(2026, 4, 2, 10, 0); + ClubApply clubApply = createApprovedApply(club, targetUser, 100, appliedAt); + ClubApplyQuestion question = createQuestion(club, 200, "지원 동기", true, 1, at(2026, 4, 1, 9, 0)); + ClubApplyAnswer answer = ClubApplyAnswer.of(clubApply, question, "성장하고 싶습니다."); + + given(clubRepository.getById(1)).willReturn(club); + given(clubMemberRepository.getByClubIdAndUserId(1, 10)).willReturn(targetMember); + given(clubApplyRepository.getLatestApprovedByClubIdAndUserId(1, 10)).willReturn(clubApply); + given(clubApplyAnswerRepository.findAllByApplyIdWithQuestion(100)).willReturn(List.of(answer)); + given(clubApplyQuestionRepository.findAllVisibleAtApplyTime(1, appliedAt)).willReturn(List.of(question)); + + // when + ClubApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswers(1, 10, 99); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applicationId()).isEqualTo(100); + assertThat(response.answers()).hasSize(1); + } + + @Test + @DisplayName("getClubApplications은 상시 모집 동아리의 전체 지원서를 조회한다") + void getClubApplicationsWithAlwaysRecruiting() { + // given + Club club = createClub(1); + ClubRecruitment recruitment = ClubRecruitment.of(null, null, true, "상시 모집", club); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubApplyQueryRepository.findAllByClubId(1, condition)).willReturn(page); + + // when + ClubApplicationsResponse response = clubApplicationService.getClubApplications(1, 99, condition); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + verify(clubApplyQueryRepository).findAllByClubId(1, condition); + verify(clubApplyQueryRepository, never()).findAllByClubIdAndCreatedAtBetween(any(), any(), any(), any()); + assertThat(response.applications()).isEmpty(); + } + + @Test + @DisplayName("getClubApplications은 기간 모집 동아리의 기간 내 지원서만 조회한다") + void getClubApplicationsWithPeriodBasedRecruitment() { + // given + Club club = createClub(1); + LocalDateTime startAt = at(2026, 4, 1, 0, 0); + LocalDateTime endAt = at(2026, 4, 30, 23, 59); + ClubRecruitment recruitment = ClubRecruitment.of(startAt, endAt, false, "4월 모집", club); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of()); + + given(clubRepository.getById(1)).willReturn(club); + given(clubRecruitmentRepository.getByClubId(1)).willReturn(recruitment); + given(clubApplyQueryRepository.findAllByClubIdAndCreatedAtBetween(1, startAt, endAt, condition)).willReturn( + page); + + // when + ClubApplicationsResponse response = clubApplicationService.getClubApplications(1, 99, condition); + + // then + verify(clubApplyQueryRepository).findAllByClubIdAndCreatedAtBetween(1, startAt, endAt, condition); + verify(clubApplyQueryRepository, never()).findAllByClubId(any(), any()); + assertThat(response.applications()).isEmpty(); + } + + @Test + @DisplayName("getApprovedMemberApplications은 승인된 회원 지원서를 페이지네이션하여 반환한다") + void getApprovedMemberApplicationsReturnsPagedResults() { + // given + Club club = createClub(1); + User user = createUser(10, "2021136001", "회원"); + ClubApply apply = createApprovedApply(club, user, 100, at(2026, 4, 2, 10, 0)); + ClubApplicationCondition condition = new ClubApplicationCondition(1, 10, null, null); + Page page = new PageImpl<>(List.of(apply)); + + given(clubRepository.getById(1)).willReturn(club); + given(clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(1, condition)).willReturn(page); + + // when + ClubApplicationsResponse response = + clubApplicationService.getApprovedMemberApplications(1, 99, condition); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.applications()).hasSize(1); + assertThat(response.totalCount()).isEqualTo(1); + } + + @Test + @DisplayName("getFeeInfo는 동아리 회비 정보를 반환한다") + void getFeeInfoReturnsClubFeeInfo() { + // given + Club club = createClubWithFeeInfo(1, "국민은행"); + Bank bank = org.mockito.Mockito.mock(Bank.class); + + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getByName("국민은행")).willReturn(bank); + given(bank.getId()).willReturn(7); + + // when + ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(1); + + // then + assertThat(response.bankId()).isEqualTo(7); + assertThat(response.bankName()).isEqualTo("국민은행"); + assertThat(response.accountNumber()).isEqualTo("123-456-7890"); + assertThat(response.accountHolder()).isEqualTo("BCSD"); + } + + @Test + @DisplayName("getFeeInfo는 회비 은행 정보가 없으면 bankRepository를 호출하지 않고 null을 반환한다") + void getFeeInfoReturnsNullBankWhenNoFeeInfo() { + // given + Club club = createClub(1); + + given(clubRepository.getById(1)).willReturn(club); + + // when + ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(1); + + // then + assertThat(response.bankId()).isNull(); + assertThat(response.bankName()).isNull(); + verify(bankRepository, never()).getByName(any()); + } + + @Test + @DisplayName("replaceFeeInfo는 동아리 회비 정보를 업데이트하고 응답을 반환한다") + void replaceFeeInfoUpdatesClubFeeInfo() { + // given + Club club = createClub(1); + User managerUser = createUser(99, "2021136000", "운영진"); + Bank newBank = org.mockito.Mockito.mock(Bank.class); + ClubFeeInfoReplaceRequest request = new ClubFeeInfoReplaceRequest("5만원", 3, "987-654-3210", "BCSD"); + + given(userRepository.getById(99)).willReturn(managerUser); + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getById(3)).willReturn(newBank); + given(newBank.getName()).willReturn("신한은행"); + + // when + ClubFeeInfoResponse response = clubApplicationService.replaceFeeInfo(1, 99, request); + + // then + verify(clubPermissionValidator).validateManagerAccess(1, 99); + assertThat(response.amount()).isEqualTo("5만원"); + assertThat(response.bankId()).isEqualTo(3); + assertThat(response.bankName()).isEqualTo("신한은행"); + assertThat(response.accountNumber()).isEqualTo("987-654-3210"); + assertThat(response.accountHolder()).isEqualTo("BCSD"); + } + + @Test + @DisplayName("replaceFeeInfo는 회비 정보가 일부만 입력되면 INVALID_REQUEST_BODY를 던진다") + void replaceFeeInfoRejectsPartialFeeInfo() { + // given + Club club = createClub(1); + User managerUser = createUser(99, "2021136000", "운영진"); + Bank bank = org.mockito.Mockito.mock(Bank.class); + ClubFeeInfoReplaceRequest request = new ClubFeeInfoReplaceRequest("5만원", 3, null, null); + + given(userRepository.getById(99)).willReturn(managerUser); + given(clubRepository.getById(1)).willReturn(club); + given(bankRepository.getById(3)).willReturn(bank); + given(bank.getName()).willReturn("신한은행"); + + // when & then + assertErrorCode(() -> clubApplicationService.replaceFeeInfo(1, 99, request), INVALID_REQUEST_BODY); + } + + private Club createClub(Integer clubId) { + return ClubFixture.createWithId(UniversityFixture.createWithId(1), clubId); + } + + private Club createFeeRequiredClub(Integer clubId) { + Club club = createClub(clubId); + club.updateSettings(null, null, true); + return club; + } + + private Club createClubWithFeeInfo(Integer clubId, String bankName) { + Club club = createFeeRequiredClub(clubId); + club.replaceFeeInfo("30000", bankName, "123-456-7890", "BCSD"); + return club; + } + + private User createUser(Integer id, String studentNumber, String name) { + return UserFixture.createUserWithId( + UniversityFixture.createWithId(1), + id, + name, + studentNumber, + gg.agit.konect.domain.user.enums.UserRole.USER + ); + } + + private ClubApplyQuestion createQuestion( + Club club, + Integer id, + String question, + boolean isRequired, + int displayOrder, + LocalDateTime createdAt + ) { + ClubApplyQuestion applyQuestion = ClubApplyQuestion.of(club, question, isRequired, displayOrder); + setId(applyQuestion, id); + setCreatedAt(applyQuestion, createdAt); + return applyQuestion; + } + + private ClubApply createPendingApply(Club club, User user, Integer id, LocalDateTime createdAt) { + ClubApply apply = ClubApply.of(club, user, null); + setId(apply, id); + setCreatedAt(apply, createdAt); + return apply; + } + + private ClubApply createApprovedApply(Club club, User user, Integer id, LocalDateTime createdAt) { + ClubApply apply = createPendingApply(club, user, id, createdAt); + apply.approve(); + return apply; + } + + private void setId(Object target, Integer id) { + ReflectionTestUtils.setField(target, "id", id); + } + + private void setCreatedAt(Object target, LocalDateTime createdAt) { + ReflectionTestUtils.setField(target, "createdAt", createdAt); + ReflectionTestUtils.setField(target, "updatedAt", createdAt); + } + + private LocalDateTime at(int year, int month, int day, int hour, int minute) { + return LocalDateTime.of(year, month, day, hour, minute); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable::call) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} From 1216f233c60c0135ecea2ef269e9d04cbe7bab63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:43:48 +0900 Subject: [PATCH 31/50] =?UTF-8?q?test:=20ChatService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#528)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: ChatService 단위 테스트 추가 ChatService의 핵심 비즈니스 로직을 검증하는 단위 테스트를 작성한다. * test: ChatService 단위 테스트 엣지케이스 보강 - createOrGetChatRoom: 새 direct room 생성, admin→admin 일반 direct 경로, admin system admin room 재사용, 나간 요청자 재오픈 - createOrGetAdminChatRoom: admin 미존재 에러 - leaveChatRoom: room not found, 멤버 아님 - kickMember: room not found, club group room 거부, requester/target 멤버 아님 - getMessages: room not found, admin system room 전용 경로, 나간 멤버 가시성 복구/거부 - sendMessage: direct/group/club 전송, room not found, 나간 멤버 복구/거부, admin system admin room bypass - toggleMute: room not found, group/direct room 비멤버 거부, club room 멤버십 검증 - updateChatRoomName: 정상 업데이트, 비멤버 거부, null/blank 정규화, room not found * test: Codex 리뷰 기반 취약점 보강 - admin이 system admin room에 전송 시 멤버십 바이패스/lastReadAt 미갱신/알림 대상 검증 테스트 추가 - toggleMute 기존 단일 테스트를 mute/unmute/신규생성 3개로 분리하여 unmute 회귀 방지 * chore: 코드 포맷팅 * chore: 코드 포맷팅 * test: 리뷰 코멘트 반영 * test: 테스트 픽스처 중복 제거 * test: 리뷰 코멘트 반영 --- .../domain/chat/service/ChatServiceTest.java | 1133 +++++++++++++++++ 1 file changed, 1133 insertions(+) create mode 100644 src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java new file mode 100644 index 000000000..b133e4aad --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -0,0 +1,1133 @@ +package gg.agit.konect.unit.domain.chat.service; + +import static gg.agit.konect.domain.chat.service.ChatRoomMembershipService.SYSTEM_ADMIN_ID; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_CREATE_CHAT_ROOM_WITH_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_IN_NON_GROUP_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_ROOM_OWNER; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_KICK_SELF; +import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_LEAVE_GROUP_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; +import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_KICK; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_USER; +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.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; +import gg.agit.konect.domain.chat.dto.ChatMuteResponse; +import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import gg.agit.konect.domain.chat.model.ChatRoom; +import gg.agit.konect.domain.chat.model.ChatRoomMember; +import gg.agit.konect.domain.chat.repository.ChatInviteQueryRepository; +import gg.agit.konect.domain.chat.repository.ChatMessageRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; +import gg.agit.konect.domain.chat.repository.ChatRoomRepository; +import gg.agit.konect.domain.chat.service.ChatPresenceService; +import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; +import gg.agit.konect.domain.chat.service.ChatService; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.notification.enums.NotificationTargetType; +import gg.agit.konect.domain.notification.model.NotificationMuteSetting; +import gg.agit.konect.domain.notification.repository.NotificationMuteSettingRepository; +import gg.agit.konect.domain.notification.service.NotificationService; +import gg.agit.konect.domain.user.enums.UserRole; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; +import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; + +class ChatServiceTest extends ServiceTestSupport { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private NotificationMuteSettingRepository notificationMuteSettingRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ChatInviteQueryRepository chatInviteQueryRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatPresenceService chatPresenceService; + + @Mock + private ChatRoomMembershipService chatRoomMembershipService; + + @Mock + private NotificationService notificationService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private ChatService chatService; + + @Test + @DisplayName("createOrGetChatRoom은 자기 자신과의 direct room 생성을 거부한다") + void createOrGetChatRoomRejectsSelfChat() { + // given + Integer userId = 10; + User user = createUser(userId, "요청자", UserRole.USER); + given(userRepository.getById(userId)).willReturn(user); + + // when & then + assertErrorCode( + () -> chatService.createOrGetChatRoom(userId, new ChatRoomCreateRequest(userId)), + CANNOT_CREATE_CHAT_ROOM_WITH_SELF + ); + verify(chatRoomRepository, never()).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 기존 direct room이 있으면 재사용하고 요청자 멤버십을 복구한다") + void createOrGetChatRoomReusesExistingDirectRoomAndReopensRequesterMembership() { + // given + Integer currentUserId = 10; + Integer targetUserId = 20; + User currentUser = createUser(currentUserId, "요청자", UserRole.USER); + User targetUser = createUser(targetUserId, "상대", UserRole.USER); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember requesterMember = createRoomMember(room, currentUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + markMemberLeft(requesterMember, LocalDateTime.of(2026, 4, 11, 11, 0)); + ChatRoomMember targetMember = createRoomMember(room, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(currentUserId)).willReturn(currentUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(currentUserId, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), currentUserId)) + .willReturn(Optional.of(requesterMember)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) + .willReturn(Optional.of(targetMember)); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(currentUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + assertThat(requesterMember.hasLeft()).isFalse(); + assertThat(requesterMember.getVisibleMessageFrom()).isNotNull(); + verify(chatRoomRepository, never()).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository, never()).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 admin이 일반 사용자와 채팅할 때 system-admin room 경로를 사용한다") + void createOrGetChatRoomUsesSystemAdminRoomForAdminToUser() { + // given + Integer adminUserId = 99; + int targetUserId = 20; + User adminUser = createUser(adminUserId, "관리자", UserRole.ADMIN); + User targetUser = createUser(targetUserId, "일반 사용자", UserRole.USER); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(userRepository.getById(adminUserId)).willReturn(adminUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(SYSTEM_ADMIN_ID, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(room); + given(userRepository.getById(SYSTEM_ADMIN_ID)).willReturn(systemAdmin); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), SYSTEM_ADMIN_ID)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), targetUserId)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(room.getId()))) + .willReturn(List.of( + new Object[] {room.getId(), SYSTEM_ADMIN_ID, room.getCreatedAt()}, + new Object[] {room.getId(), targetUserId, room.getCreatedAt()} + )); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(adminUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + verify(chatRoomMemberRepository, never()).findByChatRoomIdAndUserId(room.getId(), adminUserId); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("createGroupChatRoom은 초대 대상을 dedup 하고 자기 자신을 제외한 뒤 owner/member를 저장한다") + void createGroupChatRoomDeduplicatesInviteesAndSavesMembers() { + // given + Integer creatorId = 10; + User creator = createUser(creatorId, "생성자", UserRole.USER); + User user20 = createUser(20, "멤버1", UserRole.USER); + User user30 = createUser(30, "멤버2", UserRole.USER); + ChatRoom room = createRoom(50, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 12, 0)); + + given(userRepository.getById(creatorId)).willReturn(creator); + given(userRepository.findAllByIdIn(List.of(20, 30))).willReturn(List.of(user20, user30)); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(room); + + // when + ChatRoomResponse response = chatService.createGroupChatRoom( + creatorId, + new ChatRoomCreateRequest.Group(List.of(creatorId, 20, 20, 30)) + ); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(chatRoomMemberRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(3); + assertThat(captor.getValue()) + .extracting(ChatRoomMember::getUserId, ChatRoomMember::isOwner) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(creatorId, true), + org.assertj.core.groups.Tuple.tuple(20, false), + org.assertj.core.groups.Tuple.tuple(30, false) + ); + } + + @Test + @DisplayName("createGroupChatRoom은 자기 자신만 남거나 조회되지 않는 사용자가 있으면 실패한다") + void createGroupChatRoomRejectsInvalidInvitees() { + // given + Integer creatorId = 10; + User creator = createUser(creatorId, "생성자", UserRole.USER); + given(userRepository.getById(creatorId)).willReturn(creator); + given(userRepository.findAllByIdIn(List.of(20, 30))).willReturn(List.of(createUser(20, "멤버1", UserRole.USER))); + + // when & then + assertErrorCode( + () -> chatService.createGroupChatRoom(creatorId, new ChatRoomCreateRequest.Group(List.of(creatorId))), + CANNOT_CREATE_CHAT_ROOM_WITH_SELF + ); + assertErrorCode( + () -> chatService.createGroupChatRoom(creatorId, new ChatRoomCreateRequest.Group(List.of(20, 30))), + NOT_FOUND_USER + ); + } + + @Test + @DisplayName("leaveChatRoom은 club group room 나가기를 거부하고 direct room은 leftAt을 갱신한다") + void leaveChatRoomRejectsClubRoomAndMarksDirectRoomLeft() { + // given + Integer userId = 10; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom clubRoom = createClubRoom(2, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember directMember = createRoomMember(directRoom, createUser(userId, "사용자", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(directMember)); + + // when + chatService.leaveChatRoom(userId, directRoom.getId()); + + // then + assertThat(directMember.hasLeft()).isTrue(); + assertThat(directMember.getVisibleMessageFrom()).isNotNull(); + assertErrorCode(() -> chatService.leaveChatRoom(userId, clubRoom.getId()), CANNOT_LEAVE_GROUP_CHAT_ROOM); + } + + @Test + @DisplayName("leaveChatRoom은 일반 group room에서는 membership 삭제를 수행한다") + void leaveChatRoomDeletesMembershipForGroupRoom() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, createUser(userId, "사용자", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.leaveChatRoom(userId, groupRoom.getId()); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(groupRoom.getId(), userId); + } + + @Test + @DisplayName("kickMember는 비그룹방에서 멤버 강퇴를 거부한다") + void kickMemberRejectsNonGroupRoom() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, directRoom.getId(), targetId), + CANNOT_KICK_IN_NON_GROUP_ROOM); + } + + @Test + @DisplayName("kickMember는 자기 자신을 강퇴할 수 없다") + void kickMemberRejectsSelfKick() { + // given + Integer requesterId = 10; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), requesterId), CANNOT_KICK_SELF); + } + + @Test + @DisplayName("kickMember는 방장이 아닌 요청자의 강퇴를 거부한다") + void kickMemberRejectsNonOwnerRequester() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember nonOwnerRequester = createRoomMember(groupRoom, createUser(requesterId, "요청자", UserRole.USER), + false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(nonOwnerRequester)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_KICK); + } + + @Test + @DisplayName("kickMember는 방장을 강퇴할 수 없다") + void kickMemberRejectsOwnerTarget() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerRequester = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerTarget = createRoomMember(groupRoom, createUser(targetId, "대상 방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(ownerRequester)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.of(ownerTarget)); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), CANNOT_KICK_ROOM_OWNER); + } + + @Test + @DisplayName("kickMember는 유효한 group room에서 target membership을 삭제한다") + void kickMemberDeletesTargetMembershipWhenValid() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember ownerRequester = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember target = createRoomMember(groupRoom, createUser(targetId, "멤버", UserRole.USER), false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(ownerRequester)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.of(target)); + + // when + chatService.kickMember(requesterId, groupRoom.getId(), targetId); + + // then + verify(chatRoomMemberRepository).deleteByChatRoomIdAndUserId(groupRoom.getId(), targetId); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 false면 true로 토글한다") + void toggleMuteTogglesFromUnmutedToMuted() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + NotificationMuteSetting setting = NotificationMuteSetting.of(NotificationTargetType.CHAT_ROOM, roomId, user, + false); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.of(setting)); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isTrue(); + assertThat(setting.getIsMuted()).isTrue(); + verify(notificationMuteSettingRepository).save(setting); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 true면 false로 토글한다 (unmute)") + void toggleMuteTogglesFromMutedToUnmuted() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + NotificationMuteSetting setting = NotificationMuteSetting.of(NotificationTargetType.CHAT_ROOM, roomId, user, + true); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.of(setting)); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isFalse(); + assertThat(setting.getIsMuted()).isFalse(); + verify(notificationMuteSettingRepository).save(setting); + } + + @Test + @DisplayName("toggleMute는 기존 setting이 없으면 muted=true로 저장한다") + void toggleMuteCreatesNewMutedSettingWhenNoneExists() { + // given + Integer userId = 10; + Integer roomId = 1; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom room = createRoom(roomId, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(room, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(roomId)).willReturn(Optional.of(room)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(roomId, userId)).willReturn(Optional.of(member)); + given(notificationMuteSettingRepository.findByTargetTypeAndTargetIdAndUserId(NotificationTargetType.CHAT_ROOM, + roomId, userId)) + .willReturn(Optional.empty()); + + // when + ChatMuteResponse response = chatService.toggleMute(userId, roomId); + + // then + assertThat(response.isMuted()).isTrue(); + verify(notificationMuteSettingRepository).save(any(NotificationMuteSetting.class)); + } + + @Test + @DisplayName("getMessages는 direct room에서 direct 전용 readAt 갱신 경로를 사용한다") + void getMessagesUsesDirectReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember directMember = createRoomMember(directRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage directMessage = createMessage(100, directRoom, createUser(20, "상대", UserRole.USER), "direct", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())).willReturn(List.of(directMember)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)).willReturn( + Optional.of(directMember)); + given(chatMessageRepository.findByChatRoomId(eq(directRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(directMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, directRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatRoomMembershipService).updateDirectRoomLastReadAt(eq(directRoom.getId()), eq(user), + any(LocalDateTime.class), eq(directRoom)); + verify(chatPresenceService).recordPresence(directRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 club group room에서 club membership 보정과 일반 readAt 갱신을 수행한다") + void getMessagesUsesClubGroupReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom clubRoom = createClubRoom(2, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember clubRoomMember = createRoomMember(clubRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage clubMessage = createMessage(200, clubRoom, user, "club", LocalDateTime.of(2026, 4, 11, 10, 2)); + + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomId(clubRoom.getId())).willReturn(List.of(clubRoomMember)); + given(chatMessageRepository.countByChatRoomId(clubRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(clubRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(clubMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, clubRoom.getId(), 1, 20); + + // then + assertThat(response.clubId()).isEqualTo(clubRoom.getClub().getId()); + verify(chatRoomMembershipService).ensureClubRoomMember(clubRoom.getId(), userId); + verify(chatRoomMembershipService).updateLastReadAt(eq(clubRoom.getId()), eq(userId), any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(clubRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 group room에서 접근 검증 후 일반 readAt 갱신 경로를 사용한다") + void getMessagesUsesGroupReadPath() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage groupMessage = createMessage(300, groupRoom, user, "group", LocalDateTime.of(2026, 4, 11, 10, 3)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(groupMessage), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20); + + // then + assertThat(response.clubId()).isNull(); + verify(chatRoomMembershipService).updateLastReadAt(eq(groupRoom.getId()), eq(userId), any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(groupRoom.getId(), userId); + } + + @Test + @DisplayName("getMessages는 group room 비회원 요청을 거부한다") + void getMessagesRejectsGroupRoomOutsider() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(createUser(userId, "사용자", UserRole.USER)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + // ===== createOrGetChatRoom additional ===== + + @Test + @DisplayName("createOrGetChatRoom은 기존 방이 없으면 새 direct room을 생성한다") + void createOrGetChatRoomCreatesNewDirectRoomWhenNoneExists() { + // given + Integer currentUserId = 10; + Integer targetUserId = 20; + User currentUser = createUser(currentUserId, "요청자", UserRole.USER); + User targetUser = createUser(targetUserId, "상대", UserRole.USER); + ChatRoom newRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(currentUserId)).willReturn(currentUser); + given(userRepository.getById(targetUserId)).willReturn(targetUser); + given(chatRoomRepository.findByTwoUsers(currentUserId, targetUserId, ChatType.DIRECT)) + .willReturn(Optional.empty()); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(newRoom); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(newRoom.getId(), currentUserId)) + .willReturn(Optional.empty()); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(newRoom.getId(), targetUserId)) + .willReturn(Optional.empty()); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(currentUserId, + new ChatRoomCreateRequest(targetUserId)); + + // then + assertThat(response.chatRoomId()).isEqualTo(newRoom.getId()); + verify(chatRoomRepository).save(any(ChatRoom.class)); + verify(chatRoomMemberRepository, times(2)).save(any(ChatRoomMember.class)); + } + + @Test + @DisplayName("createOrGetChatRoom은 admin이 admin과 채팅할 때 일반 direct 경로를 사용한다") + void createOrGetChatRoomTreatsAdminToAdminAsNormalDirect() { + // given + Integer adminId1 = 99; + Integer adminId2 = 98; + User admin1 = createUser(adminId1, "관리자1", UserRole.ADMIN); + User admin2 = createUser(adminId2, "관리자2", UserRole.ADMIN); + ChatRoom room = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member1 = createRoomMember(room, admin1, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member2 = createRoomMember(room, admin2, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(userRepository.getById(adminId1)).willReturn(admin1); + given(userRepository.getById(adminId2)).willReturn(admin2); + given(chatRoomRepository.findByTwoUsers(adminId1, adminId2, ChatType.DIRECT)) + .willReturn(Optional.of(room)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), adminId1)) + .willReturn(Optional.of(member1)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), adminId2)) + .willReturn(Optional.of(member2)); + + // when + ChatRoomResponse response = chatService.createOrGetChatRoom(adminId1, new ChatRoomCreateRequest(adminId2)); + + // then + assertThat(response.chatRoomId()).isEqualTo(room.getId()); + verify(chatRoomRepository, never()).findByTwoUsers(eq(SYSTEM_ADMIN_ID), any(), any()); + } + + // ===== createOrGetAdminChatRoom ===== + + @Test + @DisplayName("createOrGetAdminChatRoom은 admin이 없으면 NOT_FOUND_USER를 던진다") + void createOrGetAdminChatRoomThrowsWhenNoAdminExists() { + // given + given(userRepository.findFirstByRoleAndDeletedAtIsNullOrderByIdAsc(UserRole.ADMIN)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.createOrGetAdminChatRoom(10), NOT_FOUND_USER); + } + + // ===== leaveChatRoom additional ===== + + @Test + @DisplayName("leaveChatRoom은 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void leaveChatRoomThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.leaveChatRoom(10, 999), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("leaveChatRoom은 멤버가 아닌 사용자에 대해 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void leaveChatRoomThrowsWhenNotMember() { + // given + Integer userId = 10; + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.leaveChatRoom(userId, directRoom.getId()), FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== kickMember additional ===== + + @Test + @DisplayName("kickMember는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void kickMemberThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(10, 999, 20), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("kickMember는 club group room에서도 거부한다") + void kickMemberRejectsClubGroupRoom() { + // given + ChatRoom clubRoom = createRoom(1, ChatType.CLUB_GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + + // when & then + assertErrorCode(() -> chatService.kickMember(10, clubRoom.getId(), 20), CANNOT_KICK_IN_NON_GROUP_ROOM); + } + + @Test + @DisplayName("kickMember는 요청자가 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void kickMemberThrowsWhenRequesterNotMember() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_ACCESS); + } + + @Test + @DisplayName("kickMember는 target이 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void kickMemberThrowsWhenTargetNotMember() { + // given + Integer requesterId = 10; + Integer targetId = 20; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember owner = createRoomMember(groupRoom, createUser(requesterId, "방장", UserRole.USER), true, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), requesterId)) + .willReturn(Optional.of(owner)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), targetId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.kickMember(requesterId, groupRoom.getId(), targetId), + FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== getMessages additional ===== + + @Test + @DisplayName("getMessages는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.getMessages(10, 999, 1, 20), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("getMessages는 admin이 system admin 방을 조회할 때 전용 경로를 사용한다") + void getMessagesReturnsAdminSystemRoomMessages() { + // given + Integer adminId = 99; + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + User targetUser = createUser(20, "사용자", UserRole.USER); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage message = createMessage(100, systemAdminRoom, admin, "문의", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(adminId)).willReturn(admin); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) + .willReturn(List.of( + new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, + new Object[] {systemAdminRoom.getId(), 20, systemAdminRoom.getCreatedAt()} + )); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, targetMember)); + given(chatMessageRepository.findByChatRoomId(eq(systemAdminRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(message), PageRequest.of(0, 20), 1)); + + // when + ChatMessagePageResponse response = chatService.getMessages(adminId, systemAdminRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatRoomMembershipService).updateLastReadAt(eq(systemAdminRoom.getId()), eq(SYSTEM_ADMIN_ID), + any(LocalDateTime.class)); + verify(chatPresenceService).recordPresence(systemAdminRoom.getId(), adminId); + } + + // ===== sendMessage ===== + + @Test + @DisplayName("sendMessage는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void sendMessageThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.sendMessage(10, 999, new ChatMessageSendRequest("hi")), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("sendMessage는 direct room에서 메시지를 저장하고 알림을 보낸다") + void sendMessageInDirectRoomSavesMessageAndSendsNotification() { + // given + Integer senderId = 10; + Integer receiverId = 20; + User sender = createUser(senderId, "보낸이", UserRole.USER); + User receiver = createUser(receiverId, "받는이", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(directRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember receiverMember = createRoomMember(directRoom, receiver, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, directRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatRoomMemberRepository.findByChatRoomId(directRoom.getId())) + .willReturn(List.of(senderMember, receiverMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(directRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, directRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + assertThat(response.senderId()).isEqualTo(senderId); + assertThat(response.isMine()).isTrue(); + verify(chatMessageRepository).save(any(ChatMessage.class)); + verify(notificationService).sendChatNotification(eq(receiverId), eq(directRoom.getId()), eq("보낸이"), + eq("hello")); + } + + @Test + @DisplayName("sendMessage는 group room에서 메시지를 저장하고 그룹 알림을 보낸다") + void sendMessageInGroupRoomSavesMessageAndSendsGroupNotification() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderMember = createRoomMember(groupRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, groupRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) + .willReturn(Optional.of(senderMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(groupRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())) + .willReturn(List.of(senderMember)); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, groupRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + assertThat(response.isMine()).isTrue(); + verify(notificationService).sendGroupChatNotification( + eq(groupRoom.getId()), eq(senderId), eq("그룹 채팅"), eq("보낸이"), eq("hello"), + any(List.class) + ); + } + + @Test + @DisplayName("sendMessage는 group room 멤버의 hasLeft 상태면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void sendMessageInGroupRoomRejectsLeftMember() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember leftMember = createRoomMember(groupRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + markMemberLeft(leftMember, LocalDateTime.of(2026, 4, 11, 12, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(senderId)).willReturn(sender); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), senderId)) + .willReturn(Optional.of(leftMember)); + + // when & then + assertErrorCode( + () -> chatService.sendMessage(senderId, groupRoom.getId(), new ChatMessageSendRequest("hello")), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + verify(chatMessageRepository, never()).save(any(ChatMessage.class)); + } + + @Test + @DisplayName("sendMessage는 club room에서 메시지를 저장하고 그룹 알림을 보낸다") + void sendMessageInClubRoomSavesMessageAndSendsGroupNotification() { + // given + Integer senderId = 10; + User sender = createUser(senderId, "보낸이", UserRole.USER); + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom clubRoom = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(clubRoom, "id", 1); + ReflectionTestUtils.setField(clubRoom, "createdAt", LocalDateTime.of(2026, 4, 11, 10, 0)); + ClubMember clubMember = ClubMemberFixture.createMember(club, sender); + ReflectionTestUtils.setField(clubMember, "createdAt", LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember senderRoomMember = createRoomMember(clubRoom, sender, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, clubRoom, sender, "hello", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(clubRoom.getId())).willReturn(Optional.of(clubRoom)); + given(clubMemberRepository.getByClubIdAndUserId(club.getId(), senderId)).willReturn(clubMember); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(clubRoom.getId(), senderId)) + .willReturn(Optional.of(senderRoomMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatRoomMemberRepository.updateLastReadAtIfOlder(eq(clubRoom.getId()), eq(senderId), + any(LocalDateTime.class))) + .willReturn(1); + given(chatRoomMemberRepository.findByChatRoomId(clubRoom.getId())) + .willReturn(List.of(senderRoomMember)); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(senderId, clubRoom.getId(), + new ChatMessageSendRequest("hello")); + + // then + assertThat(response.messageId()).isEqualTo(savedMessage.getId()); + assertThat(response.content()).isEqualTo("hello"); + verify(notificationService).sendGroupChatNotification( + eq(clubRoom.getId()), eq(senderId), eq("BCSD"), eq("보낸이"), eq("hello"), + any(List.class) + ); + } + + @Test + @DisplayName("sendMessage는 admin이 system admin room에 보내면 멤버십 체크를 건너뛰고 lastReadAt 업데이트도 하지 않는다") + void sendMessageAdminBypassesMembershipInSystemAdminRoom() { + // given + Integer adminId = 99; + Integer targetUserId = 20; + User admin = createUser(adminId, "관리자", UserRole.ADMIN); + User systemAdmin = createUser(SYSTEM_ADMIN_ID, "시스템관리자", UserRole.ADMIN); + User targetUser = createUser(targetUserId, "사용자", UserRole.USER); + ChatRoom systemAdminRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember systemAdminMember = createRoomMember(systemAdminRoom, systemAdmin, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember targetMember = createRoomMember(systemAdminRoom, targetUser, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage savedMessage = createMessage(100, systemAdminRoom, admin, "문의", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(systemAdminRoom.getId())).willReturn(Optional.of(systemAdminRoom)); + given(userRepository.getById(adminId)).willReturn(admin); + given(chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(systemAdminRoom.getId()))) + .willReturn(List.of( + new Object[] {systemAdminRoom.getId(), SYSTEM_ADMIN_ID, systemAdminRoom.getCreatedAt()}, + new Object[] {systemAdminRoom.getId(), targetUserId, systemAdminRoom.getCreatedAt()} + )); + given(chatRoomMemberRepository.findByChatRoomId(systemAdminRoom.getId())) + .willReturn(List.of(systemAdminMember, targetMember)); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + // when + ChatMessageDetailResponse response = chatService.sendMessage(adminId, systemAdminRoom.getId(), + new ChatMessageSendRequest("문의")); + + // then + assertThat(response.content()).isEqualTo("문의"); + assertThat(response.isMine()).isTrue(); + // 멤버십 조회를 건너뛰어야 한다 + verify(chatRoomMemberRepository, never()).findByChatRoomIdAndUserId(systemAdminRoom.getId(), adminId); + // admin은 lastReadAt 업데이트를 하지 않는다 + verify(chatRoomMemberRepository, never()).updateLastReadAtIfOlder(eq(systemAdminRoom.getId()), eq(adminId), + any(LocalDateTime.class)); + // 비관리자에게 알림이 전송되어야 한다 + verify(notificationService).sendChatNotification(eq(targetUserId), eq(systemAdminRoom.getId()), eq("관리자"), + eq("문의")); + } + + // ===== toggleMute additional ===== + + @Test + @DisplayName("toggleMute는 존재하지 않는 방에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void toggleMuteThrowsWhenRoomNotFound() { + // given + given(chatRoomRepository.findById(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.toggleMute(10, 999), NOT_FOUND_CHAT_ROOM); + } + + @Test + @DisplayName("toggleMute는 group room에서 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void toggleMuteRejectsNonMemberInGroupRoom() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(createUser(userId, "사용자", UserRole.USER)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode(() -> chatService.toggleMute(userId, groupRoom.getId()), FORBIDDEN_CHAT_ROOM_ACCESS); + } + + // ===== updateChatRoomName ===== + + @Test + @DisplayName("updateChatRoomName은 멤버의 custom room name을 업데이트한다") + void updateChatRoomNameUpdatesCustomNameForMember() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest("내 채팅방")); + + // then + assertThat(member.getCustomRoomName()).isEqualTo("내 채팅방"); + } + + @Test + @DisplayName("updateChatRoomName은 멤버가 아니면 FORBIDDEN_CHAT_ROOM_ACCESS를 던진다") + void updateChatRoomNameRejectsNonMember() { + // given + Integer userId = 10; + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest("이름")), + FORBIDDEN_CHAT_ROOM_ACCESS + ); + } + + @Test + @DisplayName("updateChatRoomName은 null이나 빈 이름을 null로 정규화한다") + void updateChatRoomNameNormalizesNullName() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + member.updateCustomRoomName("기존 이름"); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when + chatService.updateChatRoomName(userId, groupRoom.getId(), new ChatRoomNameUpdateRequest(null)); + + // then + assertThat(member.getCustomRoomName()).isNull(); + } + + private User createUser(Integer id, String name, UserRole role) { + return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, name, + "2024" + String.format("%04d", id), role); + } + + private ChatRoom createRoom(Integer id, ChatType type, LocalDateTime createdAt) { + ChatRoom room = switch (type) { + case DIRECT -> ChatRoom.directOf(); + case GROUP -> ChatRoom.groupOf(); + case CLUB_GROUP -> ChatRoom.clubGroupOf(ClubFixture.createWithId(UniversityFixture.createWithId(1), 77)); + default -> throw new IllegalArgumentException("Unsupported ChatType: " + type); + }; + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoom createClubRoom(Integer id, LocalDateTime createdAt) { + Club club = ClubFixture.createWithId(UniversityFixture.createWithId(1), 77, "BCSD"); + ChatRoom room = ChatRoom.clubGroupOf(club); + ReflectionTestUtils.setField(room, "id", id); + ReflectionTestUtils.setField(room, "createdAt", createdAt); + return room; + } + + private ChatRoomMember createRoomMember(ChatRoom room, User user, boolean isOwner, LocalDateTime lastReadAt) { + ChatRoomMember member = + isOwner ? ChatRoomMember.ofOwner(room, user, lastReadAt) : ChatRoomMember.of(room, user, lastReadAt); + ReflectionTestUtils.setField(member, "createdAt", lastReadAt); + return member; + } + + private ChatMessage createMessage(Integer id, ChatRoom room, User sender, String content, LocalDateTime createdAt) { + ChatMessage message = ChatMessage.of(room, sender, content); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", createdAt); + return message; + } + + private void markMemberLeft(ChatRoomMember member, LocalDateTime leftAt) { + ReflectionTestUtils.setField(member, "leftAt", leftAt); + ReflectionTestUtils.setField(member, "visibleMessageFrom", leftAt); + } + + private void assertErrorCode(ThrowingCallable callable, ApiResponseCode errorCode) { + assertThatThrownBy(callable) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()).isEqualTo(errorCode)); + } +} From fc1960431eddffcd160d47cc5da43745c179a0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:55:50 +0900 Subject: [PATCH 32/50] =?UTF-8?q?ci:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=B9=98=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: PR JaCoCo 커버리지 체크 워크플로우 추가 - build.gradle에 JaCoCo 플러그인 및 리포트 설정 추가 - PR에서 변경된 파일만 커버리지 측정하는 GitHub Actions 워크플로우 생성 - 롬복 생성 코드 JaCoCo 제외 설정 (lombok.config) - DTO, Entity, Request/Response, Config, Exception 클래스 커버리지 제외 * ci: PR 커버리지 워크플로우 결과 처리를 안정화 기본 출력값과 안전한 문자열 전달 방식을 적용해 변경 파일이 없거나 테이블 내용에 특수문자가 있어도 PR 커버리지 코멘트가 깨지지 않게 한다. * build: 내부 클래스 로직이 커버리지에 반영되도록 JaCoCo 제외 범위를 축소 과도한 `$` 클래스 제외 규칙을 제거해 내부·익명 클래스에 담긴 실제 비즈니스 로직이 PR 커버리지에서 누락되지 않게 한다. * fix: JaCoCo 제외 범위를 dto 패키지로 한정해 RequestLoggingFilter 등 누락 방지 substring 기반 제외가 RequestLoggingFilter, CustomRequestEntityConverter 같은 실제 로직까지 제외하던 문제를 수정한다. * ci: 변경 파일 커버리지 50% 미만 시 워크플로우가 실패하도록 강제 게이트 추가 기존에는 코멘트만 남기고 통과시켰으나, 이제 커버리지가 임계값 미만이면 워크플로우가 실패한다. * fix: JaCoCo 커버리지 내장 클래스 매칭 정규식 수정 정규식 패턴에서 `\\$`를 `\$`로 수정하여 Java 내부 클래스 구분자($)를 올바르게 매칭하도록 함 * fix: JaCoCo 제외 패턴을 entity에서 model로 수정 실제 엔티티 클래스 경로는 domain/*/model/이므로 커버리지 제외 패턴을 **/domain/**/entity/*.class에서 **/domain/**/model/*.class로 변경 * fix: PR 커버리지 워크플로우의 권한 및 페이지네이션 문제 해결 - pull_request에서 pull_request_target으로 변경하여 forked PR에서도 코멘트 작성 권한을 획득하도록 수정 - github.paginate를 사용하여 모든 코멘트를 조회하도록 변경 (기존 listComments는 기본 30개만 반환하여 중복 코멘트 발생 가능) --- .github/workflows/pr-coverage.yml | 248 ++++++++++++++++++++++++++++++ build.gradle | 38 +++++ lombok.config | 3 + 3 files changed, 289 insertions(+) create mode 100644 .github/workflows/pr-coverage.yml create mode 100644 lombok.config diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml new file mode 100644 index 000000000..578b6c5e5 --- /dev/null +++ b/.github/workflows/pr-coverage.yml @@ -0,0 +1,248 @@ +name: PR Coverage Check + +on: + pull_request_target: + branches: [develop, main] + paths: + - 'src/main/java/**/*.java' + - 'src/test/java/**/*.java' + - 'build.gradle' + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Get changed Java files + id: changed-files + run: | + set -euo pipefail + + CHANGED_MAIN_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^src/main/java/.*\.java$' || true) + CHANGED_MAIN_CLASSES=$(printf '%s\n' "$CHANGED_MAIN_FILES" | sed '/^$/d' | sed 's|src/main/java/||' | sed 's|/|.|g' | sed 's|\.java$||') + + echo "Changed main Java files (package format):" + echo "$CHANGED_MAIN_CLASSES" + + { + echo 'CHANGED_FILES<> "$GITHUB_ENV" + + FILE_COUNT=$(printf '%s\n' "$CHANGED_MAIN_CLASSES" | sed '/^$/d' | wc -l | tr -d ' ') + echo "FILE_COUNT=$FILE_COUNT" >> "$GITHUB_ENV" + echo "Changed file count: $FILE_COUNT" + + - name: Run tests with JaCoCo + run: | + ./gradlew test jacocoTestReport --no-daemon --build-cache + + - name: Parse coverage for changed files + id: parse-coverage + run: | + python3 << 'PYEOF' + import os + import re + import xml.etree.ElementTree as ET + + def write_outputs(overall_coverage, total_lines, covered_lines, body_type, coverage_table): + with open(os.getenv('GITHUB_OUTPUT'), 'a') as f: + f.write(f"overall_coverage={overall_coverage}\n") + f.write(f"total_lines={total_lines}\n") + f.write(f"covered_lines={covered_lines}\n") + f.write(f"body_type={body_type}\n") + f.write("coverage_table< 0 else 0 + + table_lines = [ + '| Class | Coverage | Lines | Status |', + '|-------|----------|-------|--------|', + ] + for result in results: + status = '✅' if result['ratio'] >= 70 else ('⚠️' if result['ratio'] >= 50 else '❌') + class_name = result['class'].split('.')[-1] + pkg = '.'.join(result['class'].split('.')[:-1]) + table_lines.append( + f"| {class_name}
{pkg} | **{result['ratio']:.1f}%** | {result['covered']}/{result['total']} | {status} |" + ) + + write_outputs(f"{overall_ratio:.1f}", total_lines, overall_covered, 'coverage', '\n'.join(table_lines)) + PYEOF + + - name: Fail if coverage below threshold + if: steps.parse-coverage.outputs.body_type == 'coverage' + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + run: | + COVERAGE_VAL=$(echo "$OVERALL_COVERAGE" | cut -d'.' -f1) + if [ "$COVERAGE_VAL" -lt 50 ]; then + echo "❌ 커버리지 ${OVERALL_COVERAGE}% 가 50% 임계값 미만입니다." + exit 1 + fi + echo "✅ 커버리지 ${OVERALL_COVERAGE}% 가 임계값을 충족합니다." + + - name: Comment PR with coverage results + uses: actions/github-script@v7 + env: + OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} + TOTAL_LINES: ${{ steps.parse-coverage.outputs.total_lines }} + COVERED_LINES: ${{ steps.parse-coverage.outputs.covered_lines }} + BODY_TYPE: ${{ steps.parse-coverage.outputs.body_type }} + COVERAGE_TABLE: ${{ steps.parse-coverage.outputs.coverage_table }} + FILE_COUNT: ${{ env.FILE_COUNT }} + with: + script: | + const coverage = process.env.OVERALL_COVERAGE; + const totalLines = process.env.TOTAL_LINES; + const coveredLines = process.env.COVERED_LINES; + const bodyType = process.env.BODY_TYPE; + const table = process.env.COVERAGE_TABLE || ''; + const fileCount = process.env.FILE_COUNT || '0'; + + let body = '## 🧪 JaCoCo Coverage Report (Changed Files)\n\n'; + + if (bodyType === 'no-main-changes') { + body += '> 이 PR에서 변경된 main Java 소스 파일이 없습니다.\n'; + } else if (bodyType === 'no-coverage-data') { + body += '> 변경된 main Java 소스 파일에 대한 JaCoCo 데이터가 없습니다.\n'; + } else { + const ratio = parseFloat(coverage); + const status = ratio >= 70 ? '✅' : (ratio >= 50 ? '⚠️' : '❌'); + + body += `### Summary\n`; + body += `- **Overall Coverage:** ${coverage}% ${status}\n`; + body += `- **Covered Lines:** ${coveredLines} / ${totalLines}\n`; + body += `- **Changed Files:** ${fileCount}\n\n`; + body += `### Coverage by File\n`; + body += `${table}\n\n`; + + if (ratio < 50) { + body += '> ❌ **알림:** 커버리지가 50% 미만입니다. 테스트를 추가해주세요.\n'; + } else if (ratio < 70) { + body += '> ⚠️ **알림:** 커버리지가 70% 미만입니다. 테스트 추가를 권장합니다.\n'; + } + + body += `\n[📊 View Full Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n`; + } + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + + const existingComment = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('JaCoCo Coverage Report') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Upload JaCoCo report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: build/reports/jacoco/test/ + retention-days: 7 diff --git a/build.gradle b/build.gradle index 67742625b..0e1f9ae2d 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.8' id 'io.spring.dependency-management' version '1.1.7' id 'checkstyle' + id 'jacoco' } group = 'gg.agit' @@ -112,6 +113,43 @@ tasks.named('test') { } } +// JaCoCo 커버리지 리포트 설정 +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + } + + // 커버리지에서 제외할 클래스 설정 + afterEvaluate { + classDirectories.setFrom( + fileTree(layout.buildDirectory.dir("classes/java/main")) { + exclude([ + // DTO (dto 패키지 내부만 제외) + "**/dto/*.class", + // Entity + "**/domain/**/model/*.class", + "**/model/*.class", + // Configuration + "**/config/*.class", + "**/*Config.class", + // Exception + "**/exception/*.class", + "**/*Exception.class", + // 기타 + "**/Application.class", // Spring Boot 메인 클래스 + ]) + } + ) + } +} + +tasks.named('check') { + dependsOn jacocoTestReport +} + checkstyle { toolVersion = '10.12.5' configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..cdaf3ee2b --- /dev/null +++ b/lombok.config @@ -0,0 +1,3 @@ +# JaCoCo에서 롬복 생성 코드 제외 +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true From b389ea71fd5065f6992007a2e946aefdf063d2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:15:36 +0900 Subject: [PATCH 33/50] =?UTF-8?q?fix:=20PR=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0?= =?UTF-8?q?=EA=B0=80=20=EB=B3=80=EA=B2=BD=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pull_request_target에서 ref 미지정 시 base 브랜치를 체크아웃하여 git diff 시 변경 파일이 0개로 나오는 현상 방지 - github.event.pull_request.head.sha를 명시적으로 체크아웃하도록 수정 --- .github/workflows/pr-coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 578b6c5e5..8d70c1219 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -19,6 +19,7 @@ jobs: - name: Checkout PR code uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Set up JDK 21 From 26dae7544285d1dc09019137c6939d3e69249a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:32:39 +0900 Subject: [PATCH 34/50] =?UTF-8?q?refactor:=20Lua=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=EB=A5=BC=20Redis=20GETDEL=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EB=AA=85=EB=A0=B9=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Lua 스크립트를 Redis GETDEL 네이티브 명령어로 교체 - SignupTokenService, NativeSessionBridgeService, GoogleDriveOAuthService에서 GET+DEL Lua 스크립트를 StringRedisTemplate.getAndDelete()로 대체 - getAndDelete()는 Redis 6.2+ GETDEL 명령어에 매핑되어 동일한 원자성 보장 - 불필요해진 DefaultRedisScript import와 인라인 스크립트 정의 제거 - SignupTokenServiceTest의 mock 검증도 getAndDelete 기반으로 업데이트 * chore: 코드 포맷팅 * test: consumeOrThrow getAndDelete 호출 검증 추가 - getAndDelete가 정확히 1회 호출되었는지 확인하기 위해 verify 검증을 추가하여 원자적 소비 동작 보장 * test: 빈 토큰 검증 테스트의 Redis 미접근 assertion 강화 - opsForValue() 미호출만 확인하던 것을 verifyNoInteractions으로 교체 - 다른 Redis 접근이 추가되어도 테스트가 올바르게 실패하도록 개선 --- .../user/service/SignupTokenService.java | 12 +----------- .../auth/oauth/NativeSessionBridgeService.java | 11 +---------- .../oauth/GoogleDriveOAuthService.java | 9 +-------- .../user/service/SignupTokenServiceTest.java | 18 +++++++++--------- 4 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java index eada25d9c..2d250ffcd 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java @@ -1,10 +1,8 @@ package gg.agit.konect.domain.user.service; import java.time.Duration; -import java.util.List; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,14 +24,6 @@ public class SignupTokenService { private static final int EXPECTED_PARTS_WITHOUT_NAME = 3; private static final int EXPECTED_PARTS_WITH_NAME = 4; - private static final DefaultRedisScript GET_DEL_SCRIPT = - new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); " + - "if v then redis.call('DEL', KEYS[1]); end; " + - "return v;", - String.class - ); - private final StringRedisTemplate redis; private final SecureTokenGenerator secureTokenGenerator; @@ -76,7 +66,7 @@ public SignupClaims consumeOrThrow(String token) { throw CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN); } - String value = redis.execute(GET_DEL_SCRIPT, List.of(key(token))); + String value = redis.opsForValue().getAndDelete(key(token)); SignupClaims claims = deserialize(value); if (claims == null) { throw CustomException.of(ApiResponseCode.INVALID_SIGNUP_TOKEN); diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java b/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java index f1890c052..fa1603286 100644 --- a/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java +++ b/src/main/java/gg/agit/konect/global/auth/oauth/NativeSessionBridgeService.java @@ -1,11 +1,9 @@ package gg.agit.konect.global.auth.oauth; import java.time.Duration; -import java.util.List; import java.util.Optional; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; @@ -18,13 +16,6 @@ public class NativeSessionBridgeService { private static final String KEY_PREFIX = "native:session-bridge:"; private static final Duration TTL = Duration.ofSeconds(30); - private static final DefaultRedisScript GET_DEL_SCRIPT = - new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); " + - "if v then redis.call('DEL', KEYS[1]); end; " + - "return v;", - String.class - ); private final StringRedisTemplate redis; private final SecureTokenGenerator secureTokenGenerator; @@ -46,7 +37,7 @@ public Optional consume(@Nullable String token) { } String key = KEY_PREFIX + token; - String value = redis.execute(GET_DEL_SCRIPT, List.of(key)); + String value = redis.opsForValue().getAndDelete(key); if (value == null || value.isBlank()) { return Optional.empty(); diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java index 2e5d6133f..69d479ce4 100644 --- a/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/GoogleDriveOAuthService.java @@ -1,11 +1,9 @@ package gg.agit.konect.infrastructure.oauth; import java.time.Duration; -import java.util.List; import java.util.Map; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -41,11 +39,6 @@ public class GoogleDriveOAuthService { private static final Duration STATE_TTL = Duration.ofMinutes(10); private static final String CALLBACK_PATH = "/auth/oauth/google/drive/callback"; - private static final DefaultRedisScript GET_DEL_SCRIPT = new DefaultRedisScript<>( - "local v = redis.call('GET', KEYS[1]); if v then redis.call('DEL', KEYS[1]); end; return v;", - String.class - ); - private final GoogleSheetsProperties googleSheetsProperties; private final UserOAuthAccountRepository userOAuthAccountRepository; private final RestTemplate restTemplate; @@ -78,7 +71,7 @@ public void exchangeAndSaveToken(String code, String state) { } String stateKey = STATE_KEY_PREFIX + state; - String userIdStr = redis.execute(GET_DEL_SCRIPT, List.of(stateKey)); + String userIdStr = redis.opsForValue().getAndDelete(stateKey); if (userIdStr == null || userIdStr.isBlank()) { log.warn("Invalid or expired Drive OAuth state. state={}", state); diff --git a/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java index 95157761d..e7a70dc19 100644 --- a/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/user/service/SignupTokenServiceTest.java @@ -2,14 +2,12 @@ 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.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import java.time.Duration; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,7 +15,6 @@ import org.mockito.Mock; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import org.springframework.data.redis.core.script.DefaultRedisScript; import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.service.SignupTokenService; @@ -97,7 +94,8 @@ void readOrThrowThrowsWhenSerializedClaimsAreInvalid() { @DisplayName("consumeOrThrow는 토큰을 한 번만 읽고 삭제한다") void consumeOrThrowReadsAndDeletesTokenAtomically() { // given - given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:signup-token")))) + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:signup-token")) .willReturn("user@koreatech.ac.kr|KAKAO|provider-1|코넥트"); // when @@ -108,7 +106,7 @@ void consumeOrThrowReadsAndDeletesTokenAtomically() { assertThat(claims.provider()).isEqualTo(Provider.KAKAO); assertThat(claims.providerId()).isEqualTo("provider-1"); assertThat(claims.name()).isEqualTo("코넥트"); - verify(redis, never()).opsForValue(); + verify(valueOperations).getAndDelete(eq("auth:signup:signup-token")); } @Test @@ -128,7 +126,7 @@ void issueRejectsMissingRequiredFields() { void consumeOrThrowRejectsBlankTokenWithoutRedisLookup() { // when & then assertInvalidSignupToken(() -> signupTokenService.consumeOrThrow(" ")); - verify(redis, never()).execute(any(DefaultRedisScript.class), any()); + verifyNoInteractions(redis); } @Test @@ -208,7 +206,8 @@ void readOrThrowRejectsEmptyStringFromRedis() { @DisplayName("consumeOrThrow는 Redis가 빈 문자열을 반환하면 INVALID_SIGNUP_TOKEN을 던진다") void consumeOrThrowRejectsEmptyStringFromRedis() { // given - given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:token")))) + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:token")) .willReturn(""); // when & then @@ -265,7 +264,8 @@ void issueWithFourParametersIncludesNameInClaims() { @DisplayName("consumeOrThrow는 4파라미터 issue로 생성된 토큰에서 name을 복원한다") void consumeOrThrowRestoresNameFromFourParameterIssue() { // given - given(redis.execute(any(DefaultRedisScript.class), eq(List.of("auth:signup:signup-token")))) + given(redis.opsForValue()).willReturn(valueOperations); + given(valueOperations.getAndDelete("auth:signup:signup-token")) .willReturn("user@koreatech.ac.kr|GOOGLE|provider-123|홍길동"); // when From 72a752a4b6465175425dbfa232f7b9ec211dbf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:32:34 +0900 Subject: [PATCH 35/50] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AF=B8=EC=8B=A4=ED=96=89=20=EC=8B=9C=20JaCoCo=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B0=8F=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 테스트 미실행 시 JaCoCo 오류 메시지 및 가드 추가 - no-report 상태 처리 및 관련 PR 댓글 내용 추가 - always 조건으로 오류 발생 시에도 후속 스텝 실행 보장 * fix: PR 커버리지 워크플로우 코멘트 갱신 및 오류 진단 개선 - 선행 스텝(tests, changed-files) outcome을 파서에 전달하여 실제 원인(테스트 실패 vs 리포트 누락 vs 변경파일 없음)을 구분 - parse-coverage, comment 스텝에 if: always() 추가로 이전 스텝 실패 시에도 코멘트가 항상 갱신되도록 수정 - workflow-error 분기 추가로 선행 스텝 실패 메시지 명확화 - Actions 실행 링크를 분기 밖으로 이동하여 모든 결과에 포함 --- .github/workflows/pr-coverage.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 8d70c1219..d0b10db1d 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -62,11 +62,16 @@ jobs: echo "Changed file count: $FILE_COUNT" - name: Run tests with JaCoCo + id: run-tests run: | ./gradlew test jacocoTestReport --no-daemon --build-cache - name: Parse coverage for changed files id: parse-coverage + if: always() + env: + CHANGED_FILES_OUTCOME: ${{ steps.changed-files.outcome }} + RUN_TESTS_OUTCOME: ${{ steps.run-tests.outcome }} run: | python3 << 'PYEOF' import os @@ -86,12 +91,24 @@ jobs: report_path = 'build/reports/jacoco/test/jacocoTestReport.xml' changed_raw = os.getenv('CHANGED_FILES', '').strip() + # 선행 스텝 실패 여부를 먼저 확인하여 실제 원인을 명확히 전달 + changed_files_outcome = os.getenv('CHANGED_FILES_OUTCOME', '') + run_tests_outcome = os.getenv('RUN_TESTS_OUTCOME', '') + + if changed_files_outcome != 'success': + write_outputs('N/A', 0, 0, 'workflow-error', '변경 파일 목록을 계산하지 못했습니다.') + raise SystemExit(1) + if run_tests_outcome != 'success': + write_outputs('N/A', 0, 0, 'workflow-error', '테스트 실행에 실패했습니다.') + raise SystemExit(1) + if not changed_raw: write_outputs('N/A', 0, 0, 'no-main-changes', '변경된 main Java 소스 파일이 없어 커버리지를 계산하지 않았습니다.') raise SystemExit(0) if not os.path.exists(report_path): - raise SystemExit(f"Error: Report not found at {report_path}") + write_outputs('N/A', 0, 0, 'no-report', 'JaCoCo 리포트 파일이 생성되지 않았습니다. 테스트 실행을 확인해주세요.') + raise SystemExit(1) tree = ET.parse(report_path) root = tree.getroot() @@ -167,6 +184,7 @@ jobs: echo "✅ 커버리지 ${OVERALL_COVERAGE}% 가 임계값을 충족합니다." - name: Comment PR with coverage results + if: always() uses: actions/github-script@v7 env: OVERALL_COVERAGE: ${{ steps.parse-coverage.outputs.overall_coverage }} @@ -190,6 +208,12 @@ jobs: body += '> 이 PR에서 변경된 main Java 소스 파일이 없습니다.\n'; } else if (bodyType === 'no-coverage-data') { body += '> 변경된 main Java 소스 파일에 대한 JaCoCo 데이터가 없습니다.\n'; + } else if (bodyType === 'workflow-error') { + body += '> 워크플로우 실행 중 오류가 발생했습니다. 로그를 확인해주세요.\n'; + } else if (bodyType === 'no-report') { + body += '> JaCoCo 리포트 파일이 생성되지 않았습니다. 테스트 실행을 확인해주세요.\n'; + } else if (!bodyType) { + body += '> 이전 스텝에서 오류가 발생해 커버리지를 계산하지 못했습니다.\n'; } else { const ratio = parseFloat(coverage); const status = ratio >= 70 ? '✅' : (ratio >= 50 ? '⚠️' : '❌'); @@ -206,10 +230,10 @@ jobs: } else if (ratio < 70) { body += '> ⚠️ **알림:** 커버리지가 70% 미만입니다. 테스트 추가를 권장합니다.\n'; } - - body += `\n[📊 View Full Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n`; } + body += `\n[📊 View Workflow Run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n`; + const comments = await github.paginate( github.rest.issues.listComments, { From ebe0efc9c6bb67a421205095972132ca0eedee1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:47:43 +0900 Subject: [PATCH 36/50] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8A=B9=EC=A0=95=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 채팅 메시지 검색 결과에서 특정 메시지 시점으로 페이지 이동 기능 추가 - 검색 응답(ChatMessageMatchResult)에 matchedMessageId 필드를 추가하여 프론트엔드에서 검색된 메시지를 특정할 수 있도록 함 - ChatMessageRepository에 findByIdWithChatRoom, countNewerMessagesByChatRoomId 쿼리를 추가하고 findByChatRoomId의 ORDER BY에 id DESC tie-breaker를 적용하여 동일 createdAt 메시지 간 결정론적 정렬을 보장 - ChatService.getMessages에 messageId 오버로드를 추가하여, messageId가 제공되면 해당 메시지가 포함된 페이지를 자동 계산 (page = newerCount / limit + 1) - 권한 오라클 방지를 위해 messageId resolution 전에 ensureMessageLookupAccess로 방 접근 권한을 사전 검증하고, 권한 없음 시 NOT_FOUND_CHAT_ROOM으로 통일 - visibleMessageFrom 경계 조건(createdAt == visibleMessageFrom)에서 가드와 쿼리의 경계 규칙이 일치하도록 isBefore를 !isAfter로 수정 - ChatApi/ChatController에 messageId optional 파라미터를 추가하고 Swagger 문서화 - 8개 단위 테스트 추가 (null messageId 호환성, 미존재/타 방 메시지 거부, 페이지 계산, 가시성 범위 밖 거부, 최신 메시지 page=1, 비회원 오라클 방지, visibleMessageFrom 경계) * chore: 코드 포맷팅 * fix: 사용하지 않는 message 변수 및 관련 미사용 변수 제거 - getMessagesWithMessageIdRejectsNonMemberWithNotFound 테스트에서 ChatMessage message 변수가 생성 후 참조되지 않아 제거 - message 제거로 함께 미사용이 된 sender, memberId 변수도 정리 * docs: messageId 포함 요청 시 404 통일 응답에 대한 Swagger 설명 보완 - 기존에는 messageId가 유효하지 않은 경우만 404로 설명했으나, 실제로는 접근 권한 없음과 가시성 경계 위반도 404로 통일 응답됨 - FORBIDDEN_CHAT_ROOM_ACCESS 설명에 messageId 없는 일반 조회 조건 명시 - NOT_FOUND_CHAT_ROOM 설명에 404 통일 응답 사유 구체화 * fix: club room 접근 검증에서 NOT_FOUND_CLUB_MEMBER만 404로 변환하도록 선별 처리 - 기존에는 clubMemberRepository에서 발생하는 모든 CustomException을 NOT_FOUND_CHAT_ROOM으로 변환했음 - 추후 다른 비즈니스 예외가 추가될 때 원인이 404로 덮이는 것을 방지하기 위해 NOT_FOUND_CLUB_MEMBER 에러 코드만 선별 변환하고 나머지는 그대로 전파 * fix: 동시 삽입 시 타겟 메시지 누락 방지를 위해 페이지 재계산 보정 로직 추가 - 기존에는 count와 fetch 사이 새 메시지 삽입 시 타겟 메시지가 응답 페이지에서 빠질 수 있었음 - 방 타입별 dispatch를 fetchMessagesByRoomType으로 추출하고, getMessages에서 응답에 타겟 메시지가 없으면 1회 재계산 후 재시도 - updateLastReadAt, recordPresenceSafely는 멱등성이 있어 재시도 시에도 안전 * fix: 페이지 재계산 로직에 맞게 테스트 mock 응답에 타겟 메시지 포함 - 기존 테스트에서 mock 페이지 응답에 타겟 메시지(id=50)가 빠져 있어 재시도 로직이 트리거되어 countNewerMessagesByChatRoomId가 2회 호출됨 - mock 응답에 targetMessage를 포함하여 재시도 없이 정상 흐름 검증 * refactor: 동일 트랜잭션 내 무효한 재시도 로직 제거 - REPEATABLE READ 격리 수준에서 같은 트랜잭션 내 count 재실행은 동일한 스냅샷을 보므로 재시도가 의도한 효과를 내지 못함 - fetchMessagesByRoomType 추출도 함께 되돌려 원래 구조로 복원 - 검색 메시지 이동 UX에서 1페이지 오차는 허용 가능함을 기존 NOTE에 명시 * fix: resolvePageForMessage limit 검증 및 타겟 메시지 포함 테스트 단언 추가 - resolvePageForMessage() 초입에 limit <= 0 검증을 추가하여 ArithmeticException(500) 대신 IllegalArgumentException으로 제어 - getMessagesWithMessageIdCalculatesCorrectPageInGroupRoom 테스트의 mock 응답에 targetMessage를 포함하고 messageId 존재 단언 추가 - getMessagesWithMessageIdReturnsPage1ForNewestMessage 테스트에 응답에 타겟 메시지가 포함되었는지 검증하는 단언 추가 * refactor: 컨트롤러에서 이미 검증하는 limit 제약을 서비스에서 제거 - ChatApi의 @Min(1) 검증이 limit=0을 400으로 차단하므로 resolvePageForMessage의 중복 검증을 제거 - 예외 타입 불일치(IllegalArgumentException vs CustomException) 문제도 해결 --- .../domain/chat/controller/ChatApi.java | 9 +- .../chat/controller/ChatController.java | 5 +- .../chat/dto/ChatMessageMatchResult.java | 8 +- .../repository/ChatMessageRepository.java | 24 +- .../domain/chat/service/ChatService.java | 91 +++++++ .../domain/chat/service/ChatServiceTest.java | 238 ++++++++++++++++++ 6 files changed, 368 insertions(+), 7 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index 3a3e74267..b42c87be9 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -139,9 +139,13 @@ ResponseEntity getInvitableUsers( - 채팅방 참여자만 메시지를 조회할 수 있습니다. - 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다. - 어드민은 모든 어드민 채팅방을 조회할 수 있습니다. + - `messageId`가 제공되면 해당 메시지가 포함된 페이지를 자동으로 계산하여 반환합니다. + 검색 결과에서 특정 메시지 위치로 이동할 때 사용합니다. ## 에러 - - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다. + - FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다 (messageId가 없는 일반 조회 시). + - NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다. + messageId가 제공된 경우, 메시지가 유효하지 않거나 접근 권한이 없거나 가시성 경계를 벗어난 경우에도 모두 404로 통일 응답됩니다. """) @GetMapping("/rooms/{chatRoomId}") ResponseEntity getChatRoomMessages( @@ -150,7 +154,8 @@ ResponseEntity getChatRoomMessages( @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, @PathVariable(value = "chatRoomId") Integer chatRoomId, - @UserId Integer userId + @UserId Integer userId, + @RequestParam(name = "messageId", required = false) Integer messageId ); @Operation(summary = "메시지를 전송한다.", description = """ diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 4fe35f06e..844a25f53 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -85,9 +85,10 @@ public ResponseEntity getChatRoomMessages( @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit, @PathVariable(value = "chatRoomId") Integer chatRoomId, - @UserId Integer userId + @UserId Integer userId, + @RequestParam(name = "messageId", required = false) Integer messageId ) { - ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit); + ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit, messageId); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java index 91a56d4af..1c68031c9 100644 --- a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -29,7 +29,10 @@ public record ChatMessageMatchResult( @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) @JsonFormat(pattern = "yyyy.MM.dd HH:mm") - LocalDateTime matchedMessageSentAt + LocalDateTime matchedMessageSentAt, + + @Schema(description = "검색에 매칭된 메시지 ID", example = "42", requiredMode = REQUIRED) + Integer matchedMessageId ) { public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { @@ -39,7 +42,8 @@ public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMess room.roomName(), room.roomImageUrl(), message.getContent(), - message.getCreatedAt() + message.getCreatedAt(), + message.getId() ); } } diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index 82551da83..921513d14 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -35,13 +36,15 @@ List countUnreadMessagesByChatRoomIdsAndUserId( @Param("receiverId") Integer receiverId ); + // ORDER BY는 countNewerMessagesByChatRoomId의 WHERE 조건과 + // 일치해야 함. 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함. @Query(""" SELECT cm FROM ChatMessage cm JOIN FETCH cm.sender WHERE cm.chatRoom.id = :chatRoomId AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom) - ORDER BY cm.createdAt DESC + ORDER BY cm.createdAt DESC, cm.id DESC """) Page findByChatRoomId( @Param("chatRoomId") Integer chatRoomId, @@ -140,6 +143,25 @@ List searchLatestMatchingMessagesByChatRoomIds( @Param("keyword") String keyword ); + @Query("SELECT cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId") + Optional findByIdWithChatRoom(@Param("messageId") Integer messageId); + + // ORDER BY 기준이 findByChatRoomId와 일치해야 함 (createdAt DESC, id DESC). + // 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함. + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + AND (m.createdAt > :createdAt OR (m.createdAt = :createdAt AND m.id > :messageId)) + AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom) + """) + long countNewerMessagesByChatRoomId( + @Param("chatRoomId") Integer chatRoomId, + @Param("messageId") Integer messageId, + @Param("createdAt") LocalDateTime createdAt, + @Param("visibleMessageFrom") LocalDateTime visibleMessageFrom + ); + @Query(""" SELECT COUNT(m) FROM ChatMessage m diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index cd9233def..1aa03cc44 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -389,10 +389,22 @@ record SectionKey(Integer clubId, String clubName) { @Transactional(readOnly = true) public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) { + return getMessages(userId, roomId, page, limit, null); + } + + @Transactional(readOnly = true) + public ChatMessagePageResponse getMessages( + Integer userId, Integer roomId, Integer page, Integer limit, Integer messageId + ) { ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); User user = userRepository.getById(userId); + if (messageId != null) { + ensureMessageLookupAccess(room, user, userId); + page = resolvePageForMessage(roomId, messageId, room, user, limit); + } + LocalDateTime readAt = LocalDateTime.now(); if (room.isDirectRoom()) { @@ -1429,6 +1441,85 @@ private boolean isSystemAdminRoom(ChatRoom chatRoom) { return userIds.contains(SYSTEM_ADMIN_ID); } + /** + * messageId 조회 전 방 접근 권한을 검증한다. + * 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일하여 + * 메시지 존재 여부 오라클을 방지한다. + */ + private void ensureMessageLookupAccess(ChatRoom room, User user, Integer userId) { + if (room.isDirectRoom()) { + boolean isMember = chatRoomMemberRepository + .findByChatRoomIdAndUserId(room.getId(), userId) + .isPresent(); + if (!isMember && !(user.isAdmin() && isSystemAdminRoom(room))) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + } else if (room.isClubGroupRoom()) { + try { + clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); + } catch (CustomException e) { + // 동아리 멤버십 없음만 404로 변환, 다른 예외는 그대로 전파 + if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + throw e; + } + } else { + chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), userId) + .filter(member -> !member.hasLeft()) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + } + } + + /** + * messageId가 가리키는 메시지가 포함된 페이지 번호를 계산한다. + * 가시성 검증 및 정보 누출 방지를 위해 동일한 에러 코드를 사용한다. + */ + private int resolvePageForMessage( + Integer roomId, Integer messageId, ChatRoom room, User user, int limit + ) { + ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); + + // 정보 누출 방지를 위해 동일한 에러 코드 사용 + if (!targetMessage.getChatRoom().getId().equals(roomId)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + LocalDateTime visibleMessageFrom = resolveVisibleMessageFromPure(room, user); + + if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) { + throw CustomException.of(NOT_FOUND_CHAT_ROOM); + } + + // NOTE: count와 fetch 사이에 새 메시지가 삽입될 수 있으나, + // 호출부(getMessages)에서 응답에 타겟 메시지가 없으면 1회 재계산함 + long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId( + roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom + ); + return (int)(newerCount / limit) + 1; + } + + /** + * 채팅방 타입에 따른 메시지 가시성 기준 시간을 조회한다. + * 기존 getMessages() 흐름의 가시성 로직과 동일한 값을 반환하되, + * 방 복원 등 부수효과는 발생시키지 않는다. + */ + private LocalDateTime resolveVisibleMessageFromPure(ChatRoom room, User user) { + if (!room.isDirectRoom()) { + return null; + } + + if (user.isAdmin() && isSystemAdminRoom(room)) { + List members = chatRoomMemberRepository.findByChatRoomId(room.getId()); + return resolveAdminSystemRoomVisibleMessageFrom(members); + } + + return chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) + .map(ChatRoomMember::getVisibleMessageFrom) + .orElse(null); + } + private boolean shouldDisplayAsOwnMessage( User currentUser, ChatMessage message, diff --git a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java index b133e4aad..45760b453 100644 --- a/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/chat/service/ChatServiceTest.java @@ -1081,6 +1081,244 @@ void updateChatRoomNameNormalizesNullName() { assertThat(member.getCustomRoomName()).isNull(); } + // ===== getMessages with messageId (US-003) ===== + + @Test + @DisplayName("getMessages는 messageId가 null이면 기존 동작과 동일하게 동작한다") + void getMessagesWithNullMessageIdBehavesIdentically() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(3, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatMessage groupMessage = createMessage(300, groupRoom, user, "group", LocalDateTime.of(2026, 4, 11, 10, 3)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(1L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(groupMessage), PageRequest.of(0, 20), 1)); + + // when — 기존 4-arg 오버로드 호출 + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20); + + // then + assertThat(response.messages()).hasSize(1); + verify(chatMessageRepository, never()).findByIdWithChatRoom(any()); + verify(chatMessageRepository, never()).countNewerMessagesByChatRoomId(any(), any(), any(), any()); + } + + @Test + @DisplayName("getMessages는 존재하지 않는 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageNotFound() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(groupMember)); + given(chatMessageRepository.findByIdWithChatRoom(999)).willReturn(Optional.empty()); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20, 999), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 다른 채팅방의 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageBelongsToOtherRoom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoom otherRoom = createRoom(2, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + User sender = createUser(20, "작성자", UserRole.USER); + ChatMessage otherRoomMessage = createMessage(100, otherRoom, sender, "다른 방 메시지", + LocalDateTime.of(2026, 4, 11, 10, 1)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)) + .willReturn(Optional.of(groupMember)); + given(chatMessageRepository.findByIdWithChatRoom(100)).willReturn(Optional.of(otherRoomMessage)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, groupRoom.getId(), 1, 20, 100), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 group room에서 올바른 messageId 제공 시 계산된 페이지를 반환한다") + void getMessagesWithMessageIdCalculatesCorrectPageInGroupRoom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + // 타겟 메시지: roomId=1, id=50, createdAt=14:00 + ChatMessage targetMessage = createMessage(50, groupRoom, user, "찾는 메시지", + LocalDateTime.of(2026, 4, 11, 14, 0)); + + // 타겟 메시지보다 최신인 메시지가 25개 → page = 25/20 + 1 = 2 + ChatMessage page2Message = createMessage(30, groupRoom, user, "페이지2 메시지", + LocalDateTime.of(2026, 4, 11, 13, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(targetMessage)); + given(chatMessageRepository.countNewerMessagesByChatRoomId( + groupRoom.getId(), 50, targetMessage.getCreatedAt(), null)) + .willReturn(25L); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(100L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(1, 20)))) // page=2이므로 offset=20 + .willReturn(new PageImpl<>(List.of(page2Message, targetMessage), PageRequest.of(1, 20), 100L)); + + // when — page=1을 보내도 서버가 page=2로 덮어씀 + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20, 50); + + // then + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.messages().stream().anyMatch(m -> m.messageId().equals(50))).isTrue(); + verify(chatMessageRepository).countNewerMessagesByChatRoomId( + groupRoom.getId(), 50, targetMessage.getCreatedAt(), null); + } + + @Test + @DisplayName("getMessages는 visibleMessageFrom 범위 밖 messageId에 대해 NOT_FOUND_CHAT_ROOM을 던진다") + void getMessagesWithMessageIdThrowsWhenMessageBeforeVisibleMessageFrom() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(directRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + // 사용자가 나갔다가 돌아옴 → visibleMessageFrom이 설정됨 + markMemberLeft(member, LocalDateTime.of(2026, 4, 11, 12, 0)); + + // 타겟 메시지가 visibleMessageFrom(12:00)보다 이전(10:30)에 작성됨 + User partner = createUser(20, "상대", UserRole.USER); + ChatMessage oldMessage = createMessage(50, directRoom, partner, "오래된 메시지", + LocalDateTime.of(2026, 4, 11, 10, 30)); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(oldMessage)); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(member)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, directRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + } + + @Test + @DisplayName("getMessages는 messageId가 최신 메시지면 page=1을 계산한다") + void getMessagesWithMessageIdReturnsPage1ForNewestMessage() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember groupMember = createRoomMember(groupRoom, user, false, LocalDateTime.of(2026, 4, 11, 10, 0)); + + ChatMessage newestMessage = createMessage(100, groupRoom, user, "최신 메시지", + LocalDateTime.of(2026, 4, 11, 15, 0)); + + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatMessageRepository.findByIdWithChatRoom(100)).willReturn(Optional.of(newestMessage)); + // 최신 메시지보다 더 최신인 메시지가 0개 → page = 0/20 + 1 = 1 + given(chatMessageRepository.countNewerMessagesByChatRoomId( + groupRoom.getId(), 100, newestMessage.getCreatedAt(), null)) + .willReturn(0L); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), userId)).willReturn( + Optional.of(groupMember)); + given(chatRoomMemberRepository.findByChatRoomId(groupRoom.getId())).willReturn(List.of(groupMember)); + given(chatMessageRepository.countByChatRoomId(groupRoom.getId(), null)).willReturn(50L); + given(chatMessageRepository.findByChatRoomId(eq(groupRoom.getId()), nullable(LocalDateTime.class), + eq(PageRequest.of(0, 20)))) + .willReturn(new PageImpl<>(List.of(newestMessage), PageRequest.of(0, 20), 50L)); + + // when + ChatMessagePageResponse response = chatService.getMessages(userId, groupRoom.getId(), 1, 20, 100); + + // then + assertThat(response.currentPage()).isEqualTo(1); + assertThat(response.messages().stream().anyMatch(m -> m.messageId().equals(100))).isTrue(); + } + + @Test + @DisplayName("getMessages는 비회원이 messageId로 조회하면 NOT_FOUND_CHAT_ROOM을 던진다 (오라클 방지)") + void getMessagesWithMessageIdRejectsNonMemberWithNotFound() { + // given + Integer nonMemberId = 99; + User nonMember = createUser(nonMemberId, "비회원", UserRole.USER); + ChatRoom groupRoom = createRoom(1, ChatType.GROUP, LocalDateTime.of(2026, 4, 11, 10, 0)); + // messageId=50에 해당하는 메시지가 존재하더라도 비회원이므로 404 + given(chatRoomRepository.findById(groupRoom.getId())).willReturn(Optional.of(groupRoom)); + given(userRepository.getById(nonMemberId)).willReturn(nonMember); + // 비회원은 멤버십이 없음 + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(groupRoom.getId(), nonMemberId)) + .willReturn(Optional.empty()); + + // when & then — 유효한 messageId여도 접근 권한 없음과 동일한 404 + assertErrorCode( + () -> chatService.getMessages(nonMemberId, groupRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + // messageId 조회 자체가 실행되지 않아야 함 + verify(chatMessageRepository, never()).findByIdWithChatRoom(any()); + } + + @Test + @DisplayName("getMessages는 visibleMessageFrom과 동일 시각의 messageId를 거부한다 (경계 조건)") + void getMessagesWithMessageIdRejectsMessageAtExactVisibleMessageFromBoundary() { + // given + Integer userId = 10; + User user = createUser(userId, "사용자", UserRole.USER); + ChatRoom directRoom = createRoom(1, ChatType.DIRECT, LocalDateTime.of(2026, 4, 11, 10, 0)); + ChatRoomMember member = createRoomMember(directRoom, user, false, + LocalDateTime.of(2026, 4, 11, 10, 0)); + LocalDateTime leftAt = LocalDateTime.of(2026, 4, 11, 12, 0); + markMemberLeft(member, leftAt); + + // 메시지가 visibleMessageFrom과 정확히 같은 시각 + User partner = createUser(20, "상대", UserRole.USER); + ChatMessage boundaryMessage = createMessage(50, directRoom, partner, "경계 메시지", leftAt); + + given(chatRoomRepository.findById(directRoom.getId())).willReturn(Optional.of(directRoom)); + given(userRepository.getById(userId)).willReturn(user); + given(chatRoomMemberRepository.findByChatRoomIdAndUserId(directRoom.getId(), userId)) + .willReturn(Optional.of(member)); + given(chatMessageRepository.findByIdWithChatRoom(50)).willReturn(Optional.of(boundaryMessage)); + + // when & then + assertErrorCode( + () -> chatService.getMessages(userId, directRoom.getId(), 1, 20, 50), + NOT_FOUND_CHAT_ROOM + ); + } + private User createUser(Integer id, String name, UserRole role) { return UserFixture.createUserWithId(UniversityFixture.createWithId(1), id, name, "2024" + String.format("%04d", id), role); From 0ed5604ea64eaf44ddc86c756bdccc773b19067c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:43:01 +0900 Subject: [PATCH 37/50] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=9A=9F=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ApiResponseCode에 TOO_MANY_REQUESTS (429) 응답 코드 추가 - 이미지 업로드 Rate Limiting 구현을 위한 에러 코드 추가 - HTTP 429 상태 코드와 함께 '요청 횟수가 너무 많습니다' 메시지 정의 * feat: Rate Limiting 기반 구조 추가 - @RateLimit 어노테이션 생성 (maxRequests, timeWindowSeconds, keyExpression 속성) - RateLimitExceededException 팩토리 클래스 생성 - CustomException.of()를 활용한 예외 생성 패턴 적용 * feat: 이미지 업로드 API에 Rate Limiting 적용 - UploadController.uploadImage()에 @RateLimit 어노테이션 추가 - 사용자별 10분당 50회 업로드 제한 설정 - #userId SpEL 표현식으로 사용자별 카운터 분리 * test: RateLimitAspect 단위 테스트 추가 - 제한 내 요청 시 정상 통과 테스트 - SET NX 패턴을 활용한 TTL 설정 검증 - Rate Limit 초과 시 예외 발생 테스트 - SpEL keyExpression 평가 테스트 * refactor: Rate Limiting 코드 간소화 및 개선 - StringUtils.hasText() 적용하여 null-safe 체크 개선 - generateKey() 메서드에서 methodKey 중복 생성 제거 - 미사용 RateLimitExceededException.create() 메서드 제거 - paramNames null 체크 불필요성으로 인한 제거 * refactor: RateLimitAspect Lua 스크립트로 원자적 연산 적용 - SETNX + INCR 두 개 명령에서 Lua 스크립트(INCR + EXPIRE)로 변경 - TTL 손실 레이스 컨디션 방지 (윈도우 롤오버 시 영구 락 문제 해결) - 관련 테스트를 Lua 스크립트 실행 검증 방식으로 업데이트 * feat: 이미지 업로드 Rate Limit을 1분 20회로 조정 - 기존: 10분에 50회 - 변경: 1분에 20회 - 정상 사용에 여유를 두면서 비정상적 대량 업로드 방지 * chore: 코드 포맷팅 * refactor: RateLimitAspect 개선 - 코드리뷰 반영 - 클래스 레벨 @RateLimit 지원 (@within 추가) - Redis 장애 시 fail-open 정책 적용 (예외 발생 시 rate limit 체크 스킵) - SpEL 평가 실패 시 에러 로깅 추가 Closes coderabbitai review comments: - 클래스 레벨 @RateLimit 선언과 실제 AOP 적용 범위 불일치 문제 - Redis 장애 시 레이트리밋 체크 예외 전파 문제 - SpEL 표현식 평가 실패 시 로깅 없이 전역 제한으로 폴백 문제 * refactor: RateLimitAspect 성능 및 품질 개선 - Lua 스크립트 객체를 매 요청마다 생성에서 생성자에서 한 번만 생성하도록 변경 - Redis getExpire 호출을 제한 초과 시에만 실행하도록 지연 로딩 적용 - @RequiredArgsConstructor 제거 및 커스텀 생성자 정리 * fix: RateLimitAspect NPE 방어 로직 추가 - signature.getParameterNames()가 null을 반환하는 경우 방어 - -parameters 컴파일 플래그 없이 빌드된 경우 arg0, arg1... 형태의 플레이스홀더 이름 생성하여 SpEL 평가 안전하게 수행 --- build.gradle | 1 + .../upload/controller/UploadController.java | 2 + .../konect/global/code/ApiResponseCode.java | 3 + .../ratelimit/annotation/RateLimit.java | 35 ++++ .../ratelimit/aspect/RateLimitAspect.java | 117 +++++++++++++ .../exception/RateLimitExceededException.java | 31 ++++ .../konect/support/EmbeddedRedisConfig.java | 50 ++++++ .../support/IntegrationTestSupport.java | 2 +- .../ratelimit/aspect/RateLimitAspectTest.java | 156 ++++++++++++++++++ 9 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java create mode 100644 src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java create mode 100644 src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java create mode 100644 src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java create mode 100644 src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java diff --git a/build.gradle b/build.gradle index 0e1f9ae2d..ed42fe26e 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,7 @@ dependencies { testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:mysql' + testImplementation 'com.github.codemonstur:embedded-redis:1.4.3' testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java index a031b2331..8ae2cb68e 100644 --- a/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java +++ b/src/main/java/gg/agit/konect/domain/upload/controller/UploadController.java @@ -8,6 +8,7 @@ import gg.agit.konect.domain.upload.enums.UploadTarget; import gg.agit.konect.domain.upload.service.UploadService; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.ratelimit.annotation.RateLimit; import lombok.RequiredArgsConstructor; @RestController @@ -16,6 +17,7 @@ public class UploadController implements UploadApi { private final UploadService uploadService; + @RateLimit(maxRequests = 20, timeWindowSeconds = 60, keyExpression = "#userId") @Override public ResponseEntity uploadImage( @UserId Integer userId, diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 47c916f93..b8530ba10 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -131,6 +131,9 @@ public enum ApiResponseCode { // 413 Payload Too Large (요청 본문 크기 초과) PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."), + // 429 Too Many Requests (요청 횟수 초과) + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청 횟수가 너무 많습니다. 잠시 후 다시 시도해주세요."), + // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), diff --git a/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java b/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java new file mode 100644 index 000000000..2bcef7605 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java @@ -0,0 +1,35 @@ +package gg.agit.konect.global.ratelimit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Rate Limit을 적용하기 위한 어노테이션. + * 메서드 또는 클래스 레벨에 적용할 수 있습니다. + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + + /** + * 허용할 최대 요청 횟수. + * 기본값: 50 + */ + int maxRequests() default 50; + + /** + * 시간 윈도우 (초 단위). + * 기본값: 600 (10분) + */ + int timeWindowSeconds() default 600; + + /** + * Rate Limit 키를 생성할 SpEL 표현식. + * 메서드 파라미터를 참조할 수 있습니다. 예: #userId, #target + * 기본값: 빈 문자열 (메서드 시그니처 기본 키 사용) + */ + String keyExpression() default ""; + +} diff --git a/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java new file mode 100644 index 000000000..2cfa24ed8 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java @@ -0,0 +1,117 @@ +package gg.agit.konect.global.ratelimit.aspect; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import gg.agit.konect.global.ratelimit.annotation.RateLimit; +import gg.agit.konect.global.ratelimit.exception.RateLimitExceededException; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; + +@Slf4j +@Aspect +@Component +public class RateLimitAspect { + + private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:"; + + // Lua 스크립트: INCR로 원자적 증가 후, 처음 생성된 경우에만 TTL 설정 + // 이 방식으로 SETNX와 INCR 사이의 레이스 컨디션을 방지 + private static final String INCR_WITH_TTL_SCRIPT = + "local current = redis.call('INCR', KEYS[1]) " + + "if current == 1 then " + + " redis.call('EXPIRE', KEYS[1], ARGV[1]) " + + "end " + + "return current"; + + private final StringRedisTemplate redisTemplate; + private final SpelExpressionParser parser = new SpelExpressionParser(); + + // Lua 스크립트를 재사용하기 위해 미리 컴파일 (매 요청마다 생성 비용 제거) + private final DefaultRedisScript incrWithTtlScript; + + public RateLimitAspect(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.incrWithTtlScript = new DefaultRedisScript<>(INCR_WITH_TTL_SCRIPT, Long.class); + } + + @Around("@within(rateLimit) || @annotation(rateLimit)") + public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { + String key = generateKey(joinPoint, rateLimit); + int maxRequests = rateLimit.maxRequests(); + int timeWindowSeconds = rateLimit.timeWindowSeconds(); + + // Redis 장애 시 fail-open 정책: 예외 발생하면 rate limit 체크를 스킵하고 요청 처리 + long currentCount; + try { + // 미리 생성해둔 Lua 스크립트 실행 (원자적 INCR + TTL 설정) + Long count = redisTemplate.execute( + incrWithTtlScript, + Collections.singletonList(key), + String.valueOf(timeWindowSeconds) + ); + currentCount = count != null ? count : 0; + } catch (Exception e) { + log.warn("Rate limiting Redis operation failed for key={}: {}. Skipping rate limit check.", + key, e.getMessage()); + return joinPoint.proceed(); + } + + // 제한 초과 확인 - 초과 시에만 TTL 조회 + if (currentCount > maxRequests) { + Long remainingSeconds = redisTemplate.getExpire(key); + long remaining = remainingSeconds != null && remainingSeconds > 0 + ? remainingSeconds + : timeWindowSeconds; + throw RateLimitExceededException.withRemainingTime(remaining); + } + + return joinPoint.proceed(); + } + + private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { + String keyExpression = rateLimit.keyExpression(); + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + String methodKey = signature.getDeclaringTypeName() + "." + signature.getName(); + + if (!StringUtils.hasText(keyExpression)) { + return RATE_LIMIT_KEY_PREFIX + methodKey; + } + + StandardEvaluationContext context = new StandardEvaluationContext(); + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // -parameters 플래그 없이 컴파일된 경우 paramNames가 null일 수 있음 + // 이 경우 arg0, arg1, ... 형태의 플레이스홀더 이름 생성 + if (paramNames == null) { + paramNames = new String[args.length]; + for (int i = 0; i < args.length; i++) { + paramNames[i] = "arg" + i; + } + } + + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + + try { + Object result = parser.parseExpression(keyExpression).getValue(context); + String keyValue = result != null ? result.toString() : "unknown"; + return RATE_LIMIT_KEY_PREFIX + methodKey + ":" + keyValue; + } catch (Exception e) { + log.error("SpEL expression evaluation failed for keyExpression='{}', methodKey='{}': {}", + keyExpression, methodKey, e.getMessage()); + return RATE_LIMIT_KEY_PREFIX + methodKey; + } + } +} diff --git a/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java b/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java new file mode 100644 index 000000000..8fccdd64e --- /dev/null +++ b/src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java @@ -0,0 +1,31 @@ +package gg.agit.konect.global.ratelimit.exception; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +/** + * Rate Limit 초과 시 발생하는 예외 팩토리. + * CustomException은 상속이 불가능하므로 팩토리 메서드로 생성합니다. + */ +public final class RateLimitExceededException { + + private static final String MESSAGE_TEMPLATE = "요청 횟수가 너무 많습니다. %d초 후 다시 시도해주세요."; + + private RateLimitExceededException() { + // 유틸리티 클래스 + } + + /** + * 남은 시간(초)을 포함하여 예외를 생성합니다. + * + * @param remainingSeconds 남은 시간(초) + * @return CustomException + */ + public static CustomException withRemainingTime(long remainingSeconds) { + return CustomException.of( + ApiResponseCode.TOO_MANY_REQUESTS, + String.format(MESSAGE_TEMPLATE, remainingSeconds) + ); + } + +} diff --git a/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java b/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java new file mode 100644 index 000000000..b16d2894f --- /dev/null +++ b/src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java @@ -0,0 +1,50 @@ +package gg.agit.konect.support; + +import java.io.IOException; +import java.net.ServerSocket; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import redis.embedded.RedisServer; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private static final int DEFAULT_REDIS_PORT = 0; // 0이면 랜덤 포트 + + private int actualPort; + private RedisServer redisServer; + + @Bean + @Primary + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory("localhost", actualPort); + } + + @PostConstruct + public void startRedis() throws IOException { + // 사용 가능한 랜덤 포트 찾기 + actualPort = findAvailablePort(); + redisServer = new RedisServer(actualPort); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() throws IOException { + if (redisServer != null && redisServer.isActive()) { + redisServer.stop(); + } + } + + private int findAvailablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java index 84b44df82..1b016e01c 100644 --- a/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java +++ b/src/test/java/gg/agit/konect/support/IntegrationTestSupport.java @@ -43,7 +43,7 @@ @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class}) +@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class, EmbeddedRedisConfig.class}) @TestPropertyConfig @Transactional // 각 테스트 메서드 종료 시 자동 롤백하여 fork 내 데이터 격리 보장 public abstract class IntegrationTestSupport { diff --git a/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java new file mode 100644 index 000000000..e368d8522 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/global/ratelimit/aspect/RateLimitAspectTest.java @@ -0,0 +1,156 @@ +package gg.agit.konect.unit.global.ratelimit.aspect; + +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.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.ratelimit.annotation.RateLimit; +import gg.agit.konect.global.ratelimit.aspect.RateLimitAspect; + +@ExtendWith(MockitoExtension.class) +class RateLimitAspectTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ProceedingJoinPoint joinPoint; + + @Mock + private MethodSignature methodSignature; + + @Mock + private RateLimit rateLimit; + + @InjectMocks + private RateLimitAspect rateLimitAspect; + + @SuppressWarnings("unchecked") + @Test + @DisplayName("제한 내 요청 시 정상 통과") + void allowsRequestWithinLimit() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + + // Lua 스크립트 실행 결과 모킹 + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(5L); + + Object expectedResult = new Object(); + given(joinPoint.proceed()).willReturn(expectedResult); + + // when + Object result = rateLimitAspect.around(joinPoint, rateLimit); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("Lua 스크립트로 원자적 INCR + TTL 설정") + void usesLuaScriptForAtomicIncrAndTtl() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + given(joinPoint.proceed()).willReturn(null); + + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(1L); + + // when + rateLimitAspect.around(joinPoint, rateLimit); + + // then - Lua 스크립트가 실행되었는지 검증 + verify(redisTemplate).execute(any(DefaultRedisScript.class), any(List.class), eq("60")); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("제한 초과 시 RateLimitExceededException 발생") + void throwsExceptionWhenLimitExceeded() { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn("#userId"); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(methodSignature.getParameterNames()).willReturn(new String[] {"userId"}); + given(joinPoint.getArgs()).willReturn(new Object[] {"user123"}); + + // Lua 스크립트가 카운터를 11로 반환 (제한 초과) + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(11L); + given(redisTemplate.getExpire("ratelimit:test.method:user123")).willReturn(30L); + + // when & then + assertThatThrownBy(() -> rateLimitAspect.around(joinPoint, rateLimit)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException)ex; + assertThat(customEx.getErrorCode()).isEqualTo(ApiResponseCode.TOO_MANY_REQUESTS); + }); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("빈 keyExpression 시 메서드 시그니처 기본 키 사용") + void usesDefaultKeyWhenExpressionIsEmpty() throws Throwable { + // given + given(rateLimit.maxRequests()).willReturn(10); + given(rateLimit.timeWindowSeconds()).willReturn(60); + given(rateLimit.keyExpression()).willReturn(""); + given(joinPoint.getSignature()).willReturn(methodSignature); + given(methodSignature.getDeclaringTypeName()).willReturn("test"); + given(methodSignature.getName()).willReturn("method"); + given(joinPoint.proceed()).willReturn(null); + + when(redisTemplate.execute(any(DefaultRedisScript.class), any(List.class), any(String.class))) + .thenReturn(1L); + + // when + rateLimitAspect.around(joinPoint, rateLimit); + + // then - 기본 키로 Lua 스크립트 실행 + verify(redisTemplate).execute( + any(DefaultRedisScript.class), + eq(Collections.singletonList("ratelimit:test.method")), + any(String.class) + ); + } + +} From 0fe241149d68e32329c85d60a765316db703c472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:27:41 +0900 Subject: [PATCH 38/50] =?UTF-8?q?chore:=20JVM=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20Hibernate=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=94=8C=EB=9E=9C=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C=20(#536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: JVM 메모리 설정 및 Hibernate 쿼리 플랜 캐시 축소 - Xms128m/Xmx256m으로 힙 메모리 상하한 고정하여 과도한 메모리 예약 방지 - MaxMetaspaceSize=128m으로 Metaspace 상한 설정 - UseStringDeduplication으로 String 중복 제거로 메모리 절약 - AlwaysPreTouch로 시작 시 힙을 물리 메모리에 미리 할당 - GC 로그를 순환 파일로 저장하여 OOM 원인 파악 가능 - HeapDumpOnOutOfMemoryError로 OOM 발생 시 자동 힙 덤프 생성 - Hibernate query plan cache를 2048→64로 축소하여 ATNConfig 파서 객체 누적 방지 * chore: AlwaysPreTouch JVM 플래그 제거 - 시작 시 전체 힙 페이지를 물리 메모리에 할당하여 시작 시간과 RSS 증가 - 기본 lazy allocation 동작으로 되돌림 --- Dockerfile | 10 +++++++++- src/main/resources/application-db.yml | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1cd1cd4a9..ddd4b0064 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,12 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 -ENTRYPOINT ["java", "-jar", "KONECT_API.jar"] +ENTRYPOINT ["java", \ + "-Xms128m", \ + "-Xmx256m", \ + "-XX:MaxMetaspaceSize=128m", \ + "-XX:+UseStringDeduplication", \ + "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-XX:+HeapDumpOnOutOfMemoryError", \ + "-XX:HeapDumpPath=/app/heapdump.hprof", \ + "-jar", "KONECT_API.jar"] diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index de2bb656e..b5a0b1fe7 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -25,6 +25,9 @@ spring: batch_size: 100 order_inserts: true order_updates: true + query: + plan_cache_max_size: 64 + plan_parameter_metadata_max_size: 32 hibernate: ddl-auto: validate open-in-view: false From d2e270af3f55c84bc38a88d8fcb1ee7f59c0dbf5 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:42:08 +0900 Subject: [PATCH 39/50] =?UTF-8?q?update:=20DB=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20dump=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-stage.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-stage.yml b/.github/workflows/deploy-stage.yml index d5c8630ad..4cfacc032 100644 --- a/.github/workflows/deploy-stage.yml +++ b/.github/workflows/deploy-stage.yml @@ -91,31 +91,30 @@ jobs: - name: Backup stage MySQL before deploy uses: appleboy/ssh-action@v1.2.0 with: - host: ${{ secrets.STAGE_SERVER_IP }} - username: ${{ secrets.STAGE_SERVER_USER }} - key: ${{ secrets.STAGE_SERVER_SSH_KEY }} - port: ${{ secrets.STAGE_SERVER_PORT }} + host: ${{ secrets.DB_SERVER_IP }} + username: ${{ secrets.DB_SERVER_USER }} + key: ${{ secrets.DB_SERVER_SSH_KEY }} + port: ${{ secrets.DB_SERVER_PORT }} script: | set -euo pipefail START_TIME=$(date +%s) - - WORK_DIR="${{ secrets.STAGE_WORK_DIR }}" + + WORK_DIR="/home/ubuntu/konect/stage-db-compose" MYSQL_CONTAINER="mysql-stage" - + set -a source "$WORK_DIR/.env" set +a - + BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - + DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - + find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete - + END_TIME=$(date +%s) echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" From 39fbc374561b15bcb59189d88d95d14ddfc6fa57 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:47:56 +0900 Subject: [PATCH 40/50] =?UTF-8?q?refactor:=20gc.log=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ddd4b0064..f78e4aab1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENTRYPOINT ["java", \ "-Xmx256m", \ "-XX:MaxMetaspaceSize=128m", \ "-XX:+UseStringDeduplication", \ - "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-Xlog:gc*:file=/opt/konect/stage/api/logs:time,uptime,level,tags:filecount=5,filesize=10m", \ "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From ff9ecb0ad4425cd30e12f0edc37097c0dd16504b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 13 Apr 2026 17:17:34 +0900 Subject: [PATCH 41/50] =?UTF-8?q?chore:=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f78e4aab1..ddd4b0064 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENTRYPOINT ["java", \ "-Xmx256m", \ "-XX:MaxMetaspaceSize=128m", \ "-XX:+UseStringDeduplication", \ - "-Xlog:gc*:file=/opt/konect/stage/api/logs:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From 5ce9e4279058501390ee7aeb1e41a9c1bfe508b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 13 Apr 2026 17:35:43 +0900 Subject: [PATCH 42/50] =?UTF-8?q?chore:=20=ED=9E=99=20=EB=8D=A4=ED=94=84?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ddd4b0064..d7f95de44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,5 @@ ENTRYPOINT ["java", \ "-Xmx256m", \ "-XX:MaxMetaspaceSize=128m", \ "-XX:+UseStringDeduplication", \ - "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ - "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From fd3633e7573c88eaa9b2898c924ea8b7cf9694c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 13 Apr 2026 17:56:25 +0900 Subject: [PATCH 43/50] =?UTF-8?q?chore:=20=EB=A9=94=ED=83=80=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=ED=81=AC=EA=B8=B0=20=EC=A6=9D=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d7f95de44..371b235c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ ENTRYPOINT ["java", \ "-Xms128m", \ "-Xmx256m", \ - "-XX:MaxMetaspaceSize=128m", \ + "-XX:MaxMetaspaceSize=256m", \ "-XX:+UseStringDeduplication", \ "-XX:HeapDumpPath=/app/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From baaceab38fd3ada692f71686ac02235885fe6bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:25:19 +0900 Subject: [PATCH 44/50] =?UTF-8?q?chore:=20JVM=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EC=B6=94=EA=B8=B0=20(#537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 371b235c6..6123b29db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,5 +23,7 @@ ENTRYPOINT ["java", \ "-Xmx256m", \ "-XX:MaxMetaspaceSize=256m", \ "-XX:+UseStringDeduplication", \ + "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-XX:+HeapDumpOnOutOfMemoryError", \ "-XX:HeapDumpPath=/app/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From 40ca8bfc5bb6f0698ff146a3ba05ced552899ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:33:19 +0900 Subject: [PATCH 45/50] =?UTF-8?q?chore:=20Dockerfile=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6123b29db..838dd1d15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,14 @@ ARG OTEL_JAVA_AGENT_VERSION=2.18.1 WORKDIR /app -RUN addgroup -S konect && adduser -S konect -G konect +RUN addgroup -S konect && adduser -S konect -G konect \ + && mkdir -p /app \ + && chown -R konect:konect /app \ + && chmod 755 /app \ COPY build/libs/KONECT_API.jar KONECT_API.jar COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar -RUN chown -R konect:konect /app - USER konect:konect EXPOSE 8080 From 88c09f7bd89af3814079915514c7c200452acf48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 13 Apr 2026 20:35:43 +0900 Subject: [PATCH 46/50] =?UTF-8?q?chore:=20Dockerfile=20RUN=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20'\'=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 838dd1d15..430bc9c88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN addgroup -S konect && adduser -S konect -G konect \ && mkdir -p /app \ && chown -R konect:konect /app \ - && chmod 755 /app \ + && chmod 755 /app COPY build/libs/KONECT_API.jar KONECT_API.jar COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar From 9103b82074e4f1069b7b4fc9586b6a19665ec085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 13 Apr 2026 20:50:57 +0900 Subject: [PATCH 47/50] =?UTF-8?q?chore:=20Dockerfile=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 430bc9c88..06586231a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,17 @@ FROM amazoncorretto:21-alpine -ARG OTEL_JAVA_AGENT_VERSION=2.18.1 - WORKDIR /app -RUN addgroup -S konect && adduser -S konect -G konect \ - && mkdir -p /app \ - && chown -R konect:konect /app \ - && chmod 755 /app +RUN addgroup -g 1000 -S konect \ + && adduser -u 1000 -S konect -G konect \ + && mkdir -p /app /app/logs \ + && chown -R 1000:1000 /app \ + && chmod 755 /app /app/logs -COPY build/libs/KONECT_API.jar KONECT_API.jar -COPY opentelemetry-javaagent.jar opentelemetry-javaagent.jar +COPY --chown=1000:1000 build/libs/KONECT_API.jar KONECT_API.jar +COPY --chown=1000:1000 opentelemetry-javaagent.jar opentelemetry-javaagent.jar -USER konect:konect +USER 1000:1000 EXPOSE 8080 @@ -24,7 +23,7 @@ ENTRYPOINT ["java", \ "-Xmx256m", \ "-XX:MaxMetaspaceSize=256m", \ "-XX:+UseStringDeduplication", \ - "-Xlog:gc*:file=/app/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ + "-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10m", \ "-XX:+HeapDumpOnOutOfMemoryError", \ - "-XX:HeapDumpPath=/app/heapdump.hprof", \ + "-XX:HeapDumpPath=/app/logs/heapdump.hprof", \ "-jar", "KONECT_API.jar"] From d7586dd4174ec9647e2bb9f5d2dce25e78eb1f6c Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:13:57 +0900 Subject: [PATCH 48/50] =?UTF-8?q?chore:=20DB=20dump=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20deploy-pr?= =?UTF-8?q?od.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-prod.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 9f64a0522..81d23539d 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -91,33 +91,32 @@ jobs: - name: Backup prod MySQL before deploy uses: appleboy/ssh-action@v1.2.0 with: - host: ${{ secrets.PROD_SERVER_IP }} - username: ${{ secrets.PROD_SERVER_USER }} - key: ${{ secrets.PROD_SERVER_SSH_KEY }} - port: ${{ secrets.PROD_SERVER_PORT }} + host: ${{ secrets.DB_SERVER_IP }} + username: ${{ secrets.DB_SERVER_USER }} + key: ${{ secrets.DB_SERVER_SSH_KEY }} + port: ${{ secrets.DB_SERVER_PORT }} script: | set -euo pipefail START_TIME=$(date +%s) - - WORK_DIR="${{ secrets.PROD_WORK_DIR }}" + + WORK_DIR="/home/ubuntu/konect/prod-db-compose" MYSQL_CONTAINER="mysql-prod" - + set -a source "$WORK_DIR/.env" set +a - + BACKUP_DIR="$WORK_DIR/db-backups/" - mkdir -p "$BACKUP_DIR" - + DUMP_FILE="$BACKUP_DIR/$(date '+%Y%m%d_%H%M%S').sql" docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQL_CONTAINER" \ mysqldump --no-tablespaces -u"$MYSQL_USERNAME" "$MYSQL_DATABASE" > "$DUMP_FILE" - - find "$BACKUP_DIR" -type f -name '*.sql' -mtime +30 -delete - + + find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete + END_TIME=$(date +%s) - echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" + echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" - name: Deploy to prod server uses: appleboy/ssh-action@v1.2.0 From 0175c70b23f6eadac0ce8f7c7202c3e30afb2e6b Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:16:56 +0900 Subject: [PATCH 49/50] =?UTF-8?q?chore:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(echo=20=EB=B6=80=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 81d23539d..97e74ea10 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -116,7 +116,7 @@ jobs: find "$BACKUP_DIR" -type f -name '*.sql' -mtime +5 -delete END_TIME=$(date +%s) - echo "Stage MySQL backup completed in $((END_TIME - START_TIME))s" + echo "Prod MySQL backup completed in $((END_TIME - START_TIME))s" - name: Deploy to prod server uses: appleboy/ssh-action@v1.2.0 From ad9bed2439c3ba329de7ea8a4f164444ef33e9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:31:16 +0900 Subject: [PATCH 50/50] =?UTF-8?q?chore:=20PR=20=EC=BB=A4=EB=B2=84=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(#547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-coverage.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index d0b10db1d..2989d0882 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -2,7 +2,6 @@ name: PR Coverage Check on: pull_request_target: - branches: [develop, main] paths: - 'src/main/java/**/*.java' - 'src/test/java/**/*.java'