From 72d310977df4991a82e68d21fda17b666f682312 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:22:19 +0900 Subject: [PATCH 1/8] test: uniform distribution policy test --- .../policy/UniformDistributionPolicyTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java diff --git a/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java b/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java new file mode 100644 index 0000000..1dd5041 --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java @@ -0,0 +1,70 @@ +package me.thinkcat.opic.practice.service.question.policy; + +import me.thinkcat.opic.practice.entity.Question; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class UniformDistributionPolicyTest { + + private final UniformDistributionPolicy policy = new UniformDistributionPolicy(); + + @Test + void 카테고리3개_질문4개씩_5개_요청시_총5개_반환() { + Map> byCategory = new LinkedHashMap<>(); + byCategory.put(1L, makeQuestions(4)); + byCategory.put(2L, makeQuestions(4)); + byCategory.put(3L, makeQuestions(4)); + + List result = policy.select(byCategory, 5); + + assertThat(result).hasSize(5); + } + + @Test + void 카테고리2개_나머지없을때_카테고리당_균등_분배() { + Map> byCategory = new LinkedHashMap<>(); + byCategory.put(1L, makeQuestions(6)); + byCategory.put(2L, makeQuestions(6)); + + List result = policy.select(byCategory, 4); + + assertThat(result).hasSize(4); + } + + @Test + void 질문수보다_많이_요청하면_있는만큼만_반환() { + Map> byCategory = new LinkedHashMap<>(); + byCategory.put(1L, makeQuestions(2)); + + List result = policy.select(byCategory, 5); + + assertThat(result).hasSize(2); + } + + /** + * 자연어: 빈 카테고리 맵으로 요청하면 빈 리스트가 반환된다. + */ + @Test + void 빈_카테고리맵은_빈_리스트_반환() { + List result = policy.select(Map.of(), 5); + + assertThat(result).isEmpty(); + } + + private List makeQuestions(int count) { + return IntStream.range(0, count) + .mapToObj(i -> Question.builder() + .id((long) i) + .categoryId(1L) + .questionTypeId(1L) + .question("Q" + i) + .build()) + .toList(); + } +} From 3ff954cd783c40cdc66fe6722a7f4b55d3a2375d Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:28:12 +0900 Subject: [PATCH 2/8] fix: validate the uniform distribution of category. --- .../policy/UniformDistributionPolicyTest.java | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java b/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java index 1dd5041..6325251 100644 --- a/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java +++ b/src/test/java/me/thinkcat/opic/practice/service/question/policy/UniformDistributionPolicyTest.java @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; @@ -17,9 +18,9 @@ class UniformDistributionPolicyTest { @Test void 카테고리3개_질문4개씩_5개_요청시_총5개_반환() { Map> byCategory = new LinkedHashMap<>(); - byCategory.put(1L, makeQuestions(4)); - byCategory.put(2L, makeQuestions(4)); - byCategory.put(3L, makeQuestions(4)); + byCategory.put(1L, makeQuestions(1L, 4)); + byCategory.put(2L, makeQuestions(2L, 4)); + byCategory.put(3L, makeQuestions(3L, 4)); List result = policy.select(byCategory, 5); @@ -27,29 +28,30 @@ class UniformDistributionPolicyTest { } @Test - void 카테고리2개_나머지없을때_카테고리당_균등_분배() { + void 카테고리2개_나머지없을때_카테고리당_정확히_균등_분배() { Map> byCategory = new LinkedHashMap<>(); - byCategory.put(1L, makeQuestions(6)); - byCategory.put(2L, makeQuestions(6)); + byCategory.put(1L, makeQuestions(1L, 6)); + byCategory.put(2L, makeQuestions(2L, 6)); List result = policy.select(byCategory, 4); - assertThat(result).hasSize(4); + Map countByCategory = result.stream() + .collect(Collectors.groupingBy(Question::getCategoryId, Collectors.counting())); + + assertThat(countByCategory.get(1L)).isEqualTo(2L); + assertThat(countByCategory.get(2L)).isEqualTo(2L); } @Test void 질문수보다_많이_요청하면_있는만큼만_반환() { Map> byCategory = new LinkedHashMap<>(); - byCategory.put(1L, makeQuestions(2)); + byCategory.put(1L, makeQuestions(1L, 2)); List result = policy.select(byCategory, 5); assertThat(result).hasSize(2); } - /** - * 자연어: 빈 카테고리 맵으로 요청하면 빈 리스트가 반환된다. - */ @Test void 빈_카테고리맵은_빈_리스트_반환() { List result = policy.select(Map.of(), 5); @@ -57,13 +59,13 @@ class UniformDistributionPolicyTest { assertThat(result).isEmpty(); } - private List makeQuestions(int count) { + private List makeQuestions(long categoryId, int count) { return IntStream.range(0, count) .mapToObj(i -> Question.builder() - .id((long) i) - .categoryId(1L) + .id(categoryId * 100 + i) + .categoryId(categoryId) .questionTypeId(1L) - .question("Q" + i) + .question("Q-cat" + categoryId + "-" + i) .build()) .toList(); } From fcf27b440bcd24a418c89a6179d579cab9b797ce Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:29:38 +0900 Subject: [PATCH 3/8] test: answer service unit tests with mocking --- .../practice/service/AnswerServiceTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java diff --git a/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java new file mode 100644 index 0000000..b8fc11f --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceTest.java @@ -0,0 +1,96 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse; +import me.thinkcat.opic.practice.entity.Answer; +import me.thinkcat.opic.practice.entity.FeedbackStatus; +import me.thinkcat.opic.practice.entity.Session; +import me.thinkcat.opic.practice.entity.SessionStatus; +import me.thinkcat.opic.practice.entity.StorageType; +import me.thinkcat.opic.practice.entity.UserRole; +import me.thinkcat.opic.practice.repository.AnswerRepository; +import me.thinkcat.opic.practice.repository.SessionRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AnswerServiceTest { + + @Mock private AnswerRepository answerRepository; + @Mock private SessionRepository sessionRepository; + @Mock private FeatureFlagService featureFlagService; + @Mock private FeedbackLambdaService feedbackLambdaService; + @Mock private PresignedUrlService presignedUrlService; + @InjectMocks private AnswerService answerService; + + private Answer pendingAnswer; + private final Long answerId = 1L; + private final Long userId = 10L; + + @BeforeEach + void setUp() { + pendingAnswer = Answer.builder() + .questionId(1L) + .sessionId(1L) + .audioUrl("uploads/sessions/1/questions/1/uuid.m4a") + .storageType(StorageType.S3) + .mimeType("audio/m4a") + .durationMs(0) + .build(); + + Session session = Session.builder() + .id(1L).userId(userId).title("test").mode("EXAM") + .statusCode(SessionStatus.IN_PROGRESS.getCode()) + .build(); + + given(answerRepository.findById(answerId)).willReturn(Optional.of(pendingAnswer)); + given(sessionRepository.findByIdAndUserId(1L, userId)).willReturn(Optional.of(session)); + given(answerRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(presignedUrlService.generateDownloadUrl(anyString())) + .willReturn(mock(PresignedUrlResponse.class)); + } + + @Test + void FREE유저_aiForFree_플래그ON_피드백요청되고_Lambda_1회호출() { + given(featureFlagService.isEnabled("ai-for-free")).willReturn(true); + + answerService.completeAnswerUpload(userId, UserRole.FREE, answerId, 5000); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeSessionFeedbackAsync(pendingAnswer.getAudioUrl()); + } + + @Test + void FREE유저_aiForFree_플래그OFF_피드백없고_Lambda_미호출() { + given(featureFlagService.isEnabled("ai-for-free")).willReturn(false); + + answerService.completeAnswerUpload(userId, UserRole.FREE, answerId, 5000); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.NONE); + verify(feedbackLambdaService, never()).invokeSessionFeedbackAsync(any()); + } + + @Test + void PAID유저_플래그무관하게_피드백요청되고_Lambda_1회호출() { + answerService.completeAnswerUpload(userId, UserRole.PAID, answerId, 5000); + + assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED); + verify(feedbackLambdaService, times(1)) + .invokeSessionFeedbackAsync(pendingAnswer.getAudioUrl()); + } +} From 3c6c1a8eaaccb637a69a46c5876b6a73e36ba9a1 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:57:46 +0900 Subject: [PATCH 4/8] test: quesstion service with mocking --- .../practice/service/QuestionServiceTest.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/test/java/me/thinkcat/opic/practice/service/QuestionServiceTest.java diff --git a/src/test/java/me/thinkcat/opic/practice/service/QuestionServiceTest.java b/src/test/java/me/thinkcat/opic/practice/service/QuestionServiceTest.java new file mode 100644 index 0000000..78b4493 --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/QuestionServiceTest.java @@ -0,0 +1,113 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.dto.response.QuestionResponse; +import me.thinkcat.opic.practice.entity.Question; +import me.thinkcat.opic.practice.entity.UploadStatus; +import me.thinkcat.opic.practice.repository.AnswerRepository; +import me.thinkcat.opic.practice.repository.DrillAnswerRepository; +import me.thinkcat.opic.practice.repository.QuestionPracticeCountProjection; +import me.thinkcat.opic.practice.repository.QuestionRepository; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class QuestionServiceTest { + + @Mock private QuestionRepository questionRepository; + @Mock private AnswerRepository answerRepository; + @Mock private DrillAnswerRepository drillAnswerRepository; + @InjectMocks private QuestionService questionService; + + private final Long userId = 10L; + private final String successCode = UploadStatus.SUCCESS.getCode(); + + @Test + void 세션2회_드릴3회인_질문의_practiceCount는_5() { + Long questionId = 1L; + Question question = makeQuestion(questionId); + + given(questionRepository.findAll()).willReturn(List.of(question)); + given(answerRepository.countByQuestionIdsAndUserId(List.of(questionId), userId, successCode)) + .willReturn(List.of(projection(questionId, 2L))); + given(drillAnswerRepository.countByQuestionIdsAndUserId(List.of(questionId), userId, successCode)) + .willReturn(List.of(projection(questionId, 3L))); + + List result = questionService.getAllQuestions(userId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getPracticeCount()).isEqualTo(5); + } + + @Test + void 연습이력이_없는_질문의_practiceCount는_0() { + Long questionId = 2L; + Question question = makeQuestion(questionId); + + given(questionRepository.findAll()).willReturn(List.of(question)); + given(answerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of()); + given(drillAnswerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of()); + + List result = questionService.getAllQuestions(userId); + + assertThat(result.get(0).getPracticeCount()).isEqualTo(0); + } + + @Test + void 드릴이력만_있는_질문은_드릴횟수만_집계() { + Long questionId = 4L; + Question question = makeQuestion(questionId); + + given(questionRepository.findAll()).willReturn(List.of(question)); + given(answerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of()); + given(drillAnswerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of(projection(questionId, 5L))); + + List result = questionService.getAllQuestions(userId); + + assertThat(result.get(0).getPracticeCount()).isEqualTo(5); + } + + @Test + void 세션이력만_있는_질문은_세션횟수만_집계() { + Long questionId = 3L; + Question question = makeQuestion(questionId); + + given(questionRepository.findAll()).willReturn(List.of(question)); + given(answerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of(projection(questionId, 4L))); + given(drillAnswerRepository.countByQuestionIdsAndUserId(any(), any(), any())) + .willReturn(List.of()); + + List result = questionService.getAllQuestions(userId); + + assertThat(result.get(0).getPracticeCount()).isEqualTo(4); + } + + private Question makeQuestion(Long id) { + return Question.builder() + .id(id) + .categoryId(1L) + .questionTypeId(1L) + .question("Q" + id) + .build(); + } + + private QuestionPracticeCountProjection projection(Long questionId, Long count) { + return new QuestionPracticeCountProjection() { + @Override public Long getQuestionId() { return questionId; } + @Override public Long getCount() { return count; } + }; + } +} From 7fb915bcf88f4be1e26b73e9a7cac3e8686571c4 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:58:33 +0900 Subject: [PATCH 5/8] test: user service with mocking --- .../practice/service/UserServiceTest.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/test/java/me/thinkcat/opic/practice/service/UserServiceTest.java diff --git a/src/test/java/me/thinkcat/opic/practice/service/UserServiceTest.java b/src/test/java/me/thinkcat/opic/practice/service/UserServiceTest.java new file mode 100644 index 0000000..137ef2d --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/UserServiceTest.java @@ -0,0 +1,77 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.config.security.JwtTokenProvider; +import me.thinkcat.opic.practice.dto.request.UserRegisterRequest; +import me.thinkcat.opic.practice.dto.response.UserResponse; +import me.thinkcat.opic.practice.entity.User; +import me.thinkcat.opic.practice.exception.ValidationException; +import me.thinkcat.opic.practice.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.crypto.password.PasswordEncoder; + +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.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock private UserRepository userRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private AuthenticationManager authenticationManager; + @Mock private RefreshTokenService refreshTokenService; + @InjectMocks private UserService userService; + + @ParameterizedTest + @ValueSource(strings = { + "password1@", // 대문자 없음 + "PASSWORD1@", // 소문자 없음 + "Password@", // 숫자 없음 + "Password1", // 특수문자 없음 + "Pa1@" // 8자 미만 + }) + void 유효하지않은_비밀번호로_회원가입시_ValidationException(String password) { + given(userRepository.existsByUsername(any())).willReturn(false); + + assertThatThrownBy(() -> + userService.register(new UserRegisterRequest("user", password, "a@b.com"))) + .isInstanceOf(ValidationException.class); + } + + @Test + void 유효한_비밀번호와_이메일로_회원가입시_성공() { + User saved = User.builder().id(1L).username("user").build(); + given(userRepository.existsByUsername("user")).willReturn(false); + given(passwordEncoder.encode(any())).willReturn("encoded"); + given(userRepository.save(any(User.class))).willReturn(saved); + + UserResponse response = userService.register( + new UserRegisterRequest("user", "Password1@", "user@example.com")); + + assertThat(response.getUsername()).isEqualTo("user"); + } + + @ParameterizedTest + @ValueSource(strings = { + "notanemail", + "@nodomain.com", + "missing@", + "missing@dot" + }) + void 유효하지않은_이메일로_회원가입시_ValidationException(String email) { + given(userRepository.existsByUsername(any())).willReturn(false); + + assertThatThrownBy(() -> + userService.register(new UserRegisterRequest("user", "Password1@", email))) + .isInstanceOf(ValidationException.class); + } +} From 9a7602146343ee0a563dc28bf48059a856b32e33 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 13:59:45 +0900 Subject: [PATCH 6/8] test: answer service file key with mocking --- .../service/AnswerServiceFileKeyTest.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/test/java/me/thinkcat/opic/practice/service/AnswerServiceFileKeyTest.java diff --git a/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceFileKeyTest.java b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceFileKeyTest.java new file mode 100644 index 0000000..c5efa37 --- /dev/null +++ b/src/test/java/me/thinkcat/opic/practice/service/AnswerServiceFileKeyTest.java @@ -0,0 +1,82 @@ +package me.thinkcat.opic.practice.service; + +import me.thinkcat.opic.practice.dto.response.PrepareAnswerUploadResponse; +import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse; +import me.thinkcat.opic.practice.entity.Answer; +import me.thinkcat.opic.practice.entity.Session; +import me.thinkcat.opic.practice.entity.SessionStatus; +import me.thinkcat.opic.practice.repository.AnswerRepository; +import me.thinkcat.opic.practice.repository.SessionRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class AnswerServiceFileKeyTest { + + @Mock private AnswerRepository answerRepository; + @Mock private SessionRepository sessionRepository; + @Mock private FeatureFlagService featureFlagService; + @Mock private FeedbackLambdaService feedbackLambdaService; + @Mock private PresignedUrlService presignedUrlService; + @InjectMocks private AnswerService answerService; + + private final Long userId = 10L; + private final Long sessionId = 1L; + private final Long questionId = 2L; + + @BeforeEach + void setUp() { + Session session = Session.builder() + .id(sessionId).userId(userId).title("t").mode("EXAM") + .statusCode(SessionStatus.IN_PROGRESS.getCode()) + .build(); + given(sessionRepository.findByIdAndUserId(sessionId, userId)) + .willReturn(Optional.of(session)); + + given(presignedUrlService.generateUploadUrl(any())) + .willReturn(PresignedUrlResponse.builder() + .uploadUrl("https://presigned.url") + .build()); + + given(answerRepository.save(any())).willAnswer(inv -> { + Answer a = inv.getArgument(0); + return Answer.builder() + .id(100L) + .questionId(a.getQuestionId()) + .sessionId(a.getSessionId()) + .audioUrl(a.getAudioUrl()) + .storageType(a.getStorageType()) + .mimeType(a.getMimeType()) + .durationMs(a.getDurationMs()) + .build(); + }); + } + + @Test + void fileKey가_올바른_S3_경로_패턴을_따른다() { + PrepareAnswerUploadResponse response = answerService.prepareAnswerUpload( + userId, sessionId, questionId, "recording.m4a", "audio/m4a", 1024L); + + assertThat(response.getFileKey()) + .matches("uploads/sessions/1/questions/2/[0-9a-f-]{36}\\.m4a"); + } + + @Test + void 확장자_없는_파일명은_확장자_없이_키_생성() { + PrepareAnswerUploadResponse response = answerService.prepareAnswerUpload( + userId, sessionId, questionId, "recording", "audio/m4a", 1024L); + + assertThat(response.getFileKey()) + .matches("uploads/sessions/1/questions/2/[0-9a-f-]{36}"); + } +} From 63d2f1ec4dbb2cd36e1601ea8b152593b2b1ca08 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Tue, 24 Feb 2026 15:00:29 +0900 Subject: [PATCH 7/8] =?UTF-8?q?ci:=20dev=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20Discord=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-workflow.yml | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/dev-workflow.yml diff --git a/.github/workflows/dev-workflow.yml b/.github/workflows/dev-workflow.yml new file mode 100644 index 0000000..7144d42 --- /dev/null +++ b/.github/workflows/dev-workflow.yml @@ -0,0 +1,41 @@ +name: Dev Branch Test & Notify + +on: + push: + branches: [ dev ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test + + notify: + runs-on: ubuntu-latest + needs: test + if: always() + steps: + - name: Send Discord Webhook + run: | + STATUS="${{ needs.test.result }}" + if [ "$STATUS" = "success" ]; then + ICON="✅" + else + ICON="❌" + fi + curl -H "Content-Type: application/json" \ + -d "{\"content\": \"${ICON} 테스트 결과: ${STATUS}\n저장소: ${{ github.repository }}\n브랜치: ${{ github.ref_name }}\n커밋: ${{ github.sha }}\"}" \ + ${{ secrets.DISCORD_WEBHOOK_URL }} From d61f2998a4cdb091e1d0a2b332469402f375ad84 Mon Sep 17 00:00:00 2001 From: ThinkKat Date: Wed, 25 Feb 2026 14:03:03 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C(=ED=83=88=ED=87=B4)=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20App=20Store=20=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /auth/withdrawal 엔드포인트 추가 - UserService.withdraw(): soft delete + refresh token 즉시 삭제 - UserRepository.findSoftDeletedBefore(): 30일 경과 계정 조회 native query - UserCleanupScheduler: 매일 04:00 soft delete 30일 경과 계정 하드 삭제 - @EnableScheduling 활성화 --- .../practice/OpicPracticeApplication.java | 2 + .../practice/controller/AuthController.java | 12 +++++ .../practice/repository/UserRepository.java | 4 ++ .../scheduler/UserCleanupScheduler.java | 46 +++++++++++++++++++ .../practice/service/RefreshTokenService.java | 5 ++ .../opic/practice/service/UserService.java | 9 ++++ 6 files changed, 78 insertions(+) create mode 100644 src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java diff --git a/src/main/java/me/thinkcat/opic/practice/OpicPracticeApplication.java b/src/main/java/me/thinkcat/opic/practice/OpicPracticeApplication.java index ea8465b..92ac1ec 100644 --- a/src/main/java/me/thinkcat/opic/practice/OpicPracticeApplication.java +++ b/src/main/java/me/thinkcat/opic/practice/OpicPracticeApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class OpicPracticeApplication { public static void main(String[] args) { diff --git a/src/main/java/me/thinkcat/opic/practice/controller/AuthController.java b/src/main/java/me/thinkcat/opic/practice/controller/AuthController.java index 9d599e4..9cd4542 100644 --- a/src/main/java/me/thinkcat/opic/practice/controller/AuthController.java +++ b/src/main/java/me/thinkcat/opic/practice/controller/AuthController.java @@ -87,4 +87,16 @@ public ResponseEntity> logout(@Valid @RequestBody LogoutReq return ResponseEntity.ok(response); } + + @DeleteMapping("/withdrawal") + public ResponseEntity> withdrawal(Authentication authentication) { + userService.withdraw(authentication.getName()); + + CommonResponse response = CommonResponse.builder() + .success(true) + .message("Account deleted successfully") + .build(); + + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/me/thinkcat/opic/practice/repository/UserRepository.java b/src/main/java/me/thinkcat/opic/practice/repository/UserRepository.java index 7073025..8a96412 100644 --- a/src/main/java/me/thinkcat/opic/practice/repository/UserRepository.java +++ b/src/main/java/me/thinkcat/opic/practice/repository/UserRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -19,4 +20,7 @@ public interface UserRepository extends JpaRepository { @Query(value = "SELECT * FROM users", nativeQuery = true) List findAllIncludingDeleted(); + + @Query(value = "SELECT * FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?1", nativeQuery = true) + List findSoftDeletedBefore(LocalDateTime threshold); } diff --git a/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java b/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java new file mode 100644 index 0000000..0dc7860 --- /dev/null +++ b/src/main/java/me/thinkcat/opic/practice/scheduler/UserCleanupScheduler.java @@ -0,0 +1,46 @@ +package me.thinkcat.opic.practice.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.thinkcat.opic.practice.entity.User; +import me.thinkcat.opic.practice.repository.RefreshTokenRepository; +import me.thinkcat.opic.practice.repository.UserRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 탈퇴 계정 하드 삭제 Scheduler + * soft delete 후 30일 경과한 계정을 매일 새벽 4시에 일괄 삭제 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class UserCleanupScheduler { + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + + @Scheduled(cron = "0 0 4 * * *") + @Transactional + public void hardDeleteWithdrawnUsers() { + LocalDateTime threshold = LocalDateTime.now().minusDays(30); + List targets = userRepository.findSoftDeletedBefore(threshold); + + if (targets.isEmpty()) { + log.info("[UserCleanup] No withdrawn users to delete."); + return; + } + + for (User user : targets) { + refreshTokenRepository.deleteByUserId(user.getId()); + userRepository.delete(user); + log.info("[UserCleanup] Hard deleted user id={}, deletedAt={}", user.getId(), user.getDeletedAt()); + } + + log.info("[UserCleanup] Hard deleted {} withdrawn users.", targets.size()); + } +} diff --git a/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java b/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java index bba6aae..1c821c2 100644 --- a/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java +++ b/src/main/java/me/thinkcat/opic/practice/service/RefreshTokenService.java @@ -65,4 +65,9 @@ public void revokeRefreshToken(String refreshTokenValue) { refreshTokenRepository.findByToken(refreshTokenValue) .ifPresent(refreshTokenRepository::delete); } + + @Transactional + public void revokeAllByUser(User user) { + refreshTokenRepository.deleteByUserId(user.getId()); + } } diff --git a/src/main/java/me/thinkcat/opic/practice/service/UserService.java b/src/main/java/me/thinkcat/opic/practice/service/UserService.java index f43190f..27a249e 100644 --- a/src/main/java/me/thinkcat/opic/practice/service/UserService.java +++ b/src/main/java/me/thinkcat/opic/practice/service/UserService.java @@ -88,6 +88,15 @@ public MeResponse getMe(String username) { .build(); } + @Transactional + public void withdraw(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + refreshTokenService.revokeAllByUser(user); + user.softDelete(); + userRepository.save(user); + } + private void validatePassword(String password) { String pattern = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"; if (!password.matches(pattern)) {