From d6cb1061a84f888ceeb5f9c1a73f466caa0a2ceb Mon Sep 17 00:00:00 2001 From: ChanWook Lee Date: Sat, 6 Jun 2026 09:05:59 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B0=B8?= =?UTF-8?q?=EA=B0=80=EC=9E=90=20=EB=8C=80=EA=B8=B0=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExamParticipantDisplayStatusResolver.java | 4 + ...mParticipantDisplayStatusResolverTest.java | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolver.java b/src/main/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolver.java index 4e37d21..2a97955 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolver.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolver.java @@ -62,6 +62,10 @@ public static ParticipantAttendanceStatus resolveAttendanceStatus( return ParticipantAttendanceStatus.ENDED; } + if (exam.getState() == ExamState.WAITING) { + return ParticipantAttendanceStatus.WAITING; + } + if (submitted && resolveSubmissionDisplayStatus(submitted, submission, score) != ParticipantSubmissionDisplayStatus.NOT_SUBMITTED) { return ParticipantAttendanceStatus.SUBMITTED; diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolverTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolverTest.java index 2f52160..6bd174b 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolverTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolverTest.java @@ -76,4 +76,120 @@ void attendance_endedWhenSessionEnded() { exam, ep, false, null, null, LocalDateTime.now())) .isEqualTo(ParticipantAttendanceStatus.ENDED); } + + @Test + @DisplayName("WAITING 시험 + joinedAt 있음 + 미제출 → 대기중") + void attendance_waitingExamWithJoinedAt_notSubmitted() { + Exam exam = waitingExam(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .joinedAt(LocalDateTime.now()) + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, false, null, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.WAITING); + } + + @Test + @DisplayName("WAITING 시험 + tokenUsed > 0 + 미제출 → 대기중") + void attendance_waitingExamWithTokenUsed_notSubmitted() { + Exam exam = waitingExam(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .tokenUsed(100) + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, false, null, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.WAITING); + } + + @Test + @DisplayName("WAITING 시험 + 제출 데이터 있음 → 대기중 우선") + void attendance_waitingExamWithSubmission_stillWaiting() { + Exam exam = waitingExam(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .joinedAt(LocalDateTime.now()) + .build(); + Submission sub = Submission.builder() + .examId(1L) + .participantId(1L) + .specId(1L) + .status(SubmissionStatus.QUEUED) + .lang("python") + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, true, sub, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.WAITING); + } + + @Test + @DisplayName("RUNNING 시험 + joinedAt 있음 + 미제출 → 응시중") + void attendance_runningExamWithJoinedAt_notSubmitted() { + Exam exam = runningExam(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .joinedAt(LocalDateTime.now()) + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, false, null, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.IN_EXAM); + } + + @Test + @DisplayName("RUNNING 시험 + 제출 완료 → 응시완료") + void attendance_runningExam_submitted() { + Exam exam = runningExam(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .joinedAt(LocalDateTime.now()) + .build(); + Submission sub = Submission.builder() + .examId(1L) + .participantId(1L) + .specId(1L) + .status(SubmissionStatus.QUEUED) + .lang("python") + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, true, sub, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.SUBMITTED); + } + + private static Exam waitingExam() { + return Exam.builder() + .title("t") + .state(ExamState.WAITING) + .startsAt(LocalDateTime.now().plusHours(1)) + .endsAt(LocalDateTime.now().plusHours(2)) + .version(1) + .createdBy(1L) + .build(); + } + + private static Exam runningExam() { + return Exam.builder() + .title("t") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now().minusMinutes(10)) + .endsAt(LocalDateTime.now().plusHours(1)) + .version(1) + .createdBy(1L) + .build(); + } } From 6808c5a3772fa644373477c754e377899a7ec0eb Mon Sep 17 00:00:00 2001 From: ChanWook Lee Date: Sat, 6 Jun 2026 10:25:39 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=85=80?= =?UTF-8?q?=ED=94=84=20=EC=82=AD=EC=A0=9C=20=EB=A7=88=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/DeleteOwnAdminAccountUseCase.java | 5 + .../DeleteOwnAdminAccountUseCaseTest.java | 110 ++++++++++++++++++ .../service/MasterActivityLogServiceTest.java | 15 +++ 3 files changed, 130 insertions(+) create mode 100644 src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCaseTest.java diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCase.java index a0372fe..ca341aa 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCase.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.application.usecase.AdminLogoutUseCase; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.service.AdminAccountDeletionService; @@ -20,13 +21,17 @@ public class DeleteOwnAdminAccountUseCase { private final AdminService adminService; private final AdminAccountDeletionService adminAccountDeletionService; + private final MasterActivityLogService masterActivityLogService; private final AdminLogoutUseCase adminLogoutUseCase; private final CookieUtils cookieUtils; @Transactional public void execute(Long adminId, String accessToken, HttpServletResponse httpResponse) { Admin admin = adminService.findById(adminId); + Long targetAdminId = admin.getId(); + String targetDisplayName = admin.getDisplayName(); adminAccountDeletionService.softDelete(admin, adminId, AUDIT_ACTION); + masterActivityLogService.logAdminAccountDeleted(null, targetAdminId, targetDisplayName); adminLogoutUseCase.execute(accessToken); cookieUtils.clearAccessTokenCookie(httpResponse); diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCaseTest.java new file mode 100644 index 0000000..f0c3424 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteOwnAdminAccountUseCaseTest.java @@ -0,0 +1,110 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +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.test.util.ReflectionTestUtils; + +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; +import com.yd.vibecode.domain.auth.application.usecase.AdminLogoutUseCase; +import com.yd.vibecode.domain.auth.domain.entity.Admin; +import com.yd.vibecode.domain.auth.domain.entity.AdminRole; +import com.yd.vibecode.domain.auth.domain.service.AdminAccountDeletionService; +import com.yd.vibecode.domain.auth.domain.service.AdminService; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.AuthErrorStatus; +import com.yd.vibecode.global.util.CookieUtils; + +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class DeleteOwnAdminAccountUseCaseTest { + + private static final Long ADMIN_ID = 10L; + private static final String ACCESS_TOKEN = "access-token"; + + @InjectMocks + private DeleteOwnAdminAccountUseCase deleteOwnAdminAccountUseCase; + + @Mock + private AdminService adminService; + + @Mock + private AdminAccountDeletionService adminAccountDeletionService; + + @Mock + private MasterActivityLogService masterActivityLogService; + + @Mock + private AdminLogoutUseCase adminLogoutUseCase; + + @Mock + private CookieUtils cookieUtils; + + @Mock + private HttpServletResponse httpResponse; + + @Test + @DisplayName("일반 ADMIN 셀프 삭제 시 master_activity_logs에 ADMIN_ACCOUNT_DELETED를 기록한다") + void execute_success_logsMasterActivity() { + Admin admin = Admin.builder() + .adminNumber("ADM-123456") + .displayName("이관리") + .role(AdminRole.ADMIN) + .build(); + ReflectionTestUtils.setField(admin, "id", ADMIN_ID); + + given(adminService.findById(ADMIN_ID)).willReturn(admin); + + deleteOwnAdminAccountUseCase.execute(ADMIN_ID, ACCESS_TOKEN, httpResponse); + + verify(adminAccountDeletionService).softDelete( + eq(admin), + eq(ADMIN_ID), + eq("DELETE_OWN_ACCOUNT")); + verify(masterActivityLogService).logAdminAccountDeleted(isNull(), eq(ADMIN_ID), eq("이관리")); + verify(adminLogoutUseCase).execute(ACCESS_TOKEN); + verify(cookieUtils).clearAccessTokenCookie(httpResponse); + verify(cookieUtils).clearRefreshTokenCookie(httpResponse); + } + + @Test + @DisplayName("MASTER 셀프 삭제 차단 시 master_activity_logs와 logout은 수행하지 않는다") + void execute_masterAccount_throwsWithoutLogging() { + Admin master = Admin.builder() + .adminNumber("MASTER-0001") + .displayName("마스터") + .role(AdminRole.MASTER) + .build(); + ReflectionTestUtils.setField(master, "id", ADMIN_ID); + + given(adminService.findById(ADMIN_ID)).willReturn(master); + doThrow(new RestApiException(AuthErrorStatus.MASTER_ACCOUNT_CANNOT_BE_DEACTIVATED)) + .when(adminAccountDeletionService) + .softDelete(eq(master), eq(ADMIN_ID), eq("DELETE_OWN_ACCOUNT")); + + assertThatThrownBy(() -> deleteOwnAdminAccountUseCase.execute(ADMIN_ID, ACCESS_TOKEN, httpResponse)) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode()) + .isEqualTo(AuthErrorStatus.MASTER_ACCOUNT_CANNOT_BE_DEACTIVATED.getCode()); + + verify(masterActivityLogService, never()).logAdminAccountDeleted( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + verify(adminLogoutUseCase, never()).execute(org.mockito.ArgumentMatchers.anyString()); + verify(cookieUtils, never()).clearAccessTokenCookie(httpResponse); + verify(cookieUtils, never()).clearRefreshTokenCookie(httpResponse); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogServiceTest.java b/src/test/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogServiceTest.java index ce0d53f..82d5a68 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogServiceTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogServiceTest.java @@ -147,4 +147,19 @@ void logAdminAccountDeleted() { assertThat(captor.getValue().getType()).isEqualTo(MasterActivityLogType.ADMIN_ACCOUNT_DELETED); assertThat(captor.getValue().getMessage()).isEqualTo("'이관리' 관리자 계정이 삭제되었습니다."); } + + @Test + @DisplayName("ADMIN_ACCOUNT_DELETED 로그는 masterId가 null이어도 기록된다") + void logAdminAccountDeleted_withNullMasterId() { + masterActivityLogService.logAdminAccountDeleted(null, 5L, "이관리"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getMasterId()).isNull(); + assertThat(saved.getTargetAdminId()).isEqualTo(5L); + assertThat(saved.getType()).isEqualTo(MasterActivityLogType.ADMIN_ACCOUNT_DELETED); + assertThat(saved.getMessage()).isEqualTo("'이관리' 관리자 계정이 삭제되었습니다."); + } } From 7fdbdc6bbce5106b0be87f379bfd02133439cfc4 Mon Sep 17 00:00:00 2001 From: ChanWook Lee Date: Sat, 6 Jun 2026 10:26:18 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=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 --- .../domain/repository/ExamRepository.java | 5 + .../infrastructure/ExamAutoEndScheduler.java | 100 +++++++++++++ .../ExamRepositoryAutoEndQueryTest.java | 79 ++++++++++ .../ExamAutoEndSchedulerTest.java | 141 ++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndScheduler.java create mode 100644 src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoEndQueryTest.java create mode 100644 src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndSchedulerTest.java diff --git a/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepository.java b/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepository.java index ddefdf5..1fe122f 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepository.java +++ b/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepository.java @@ -18,4 +18,9 @@ public interface ExamRepository extends JpaRepository { * 자동 시작 대상: WAITING 상태이며 시작 시각이 현재 이하인 시험. */ List findByStateAndStartsAtLessThanEqual(ExamState state, LocalDateTime startsAtThreshold); + + /** + * 자동 종료 대상: RUNNING 상태이며 종료 시각이 설정되어 있고 현재 이하인 시험. + */ + List findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(ExamState state, LocalDateTime endsAtThreshold); } diff --git a/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndScheduler.java b/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndScheduler.java new file mode 100644 index 0000000..7142d18 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndScheduler.java @@ -0,0 +1,100 @@ +package com.yd.vibecode.domain.exam.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.yd.vibecode.domain.exam.application.usecase.EndExamUseCase; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ExamErrorStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 시험 종료 시각({@code endsAt}) 도래 시 RUNNING 상태 시험을 자동으로 종료한다. + *

+ * 서버 내부 스케줄러 전용이며, 공개 API를 제공하지 않는다. + * 상태 전환은 {@link EndExamUseCase}에 위임한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExamAutoEndScheduler { + + private static final Set BENIGN_SKIP_ERROR_CODES = Set.of( + ExamErrorStatus.INVALID_EXAM_STATE.getCode().getCode(), + ExamErrorStatus.EXAM_ALREADY_ENDED.getCode().getCode() + ); + + private final ExamRepository examRepository; + private final EndExamUseCase endExamUseCase; + + @Scheduled(fixedDelay = 1_000) + public void pollAndAutoEndExams() { + LocalDateTime now = LocalDateTime.now(); + List candidates = examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual( + ExamState.RUNNING, + now + ); + + if (candidates.isEmpty()) { + return; + } + + log.info("[ExamAutoEnd] {} exam(s) eligible (RUNNING, endsAt <= {})", candidates.size(), now); + + int successCount = 0; + int skipCount = 0; + int failCount = 0; + + for (Exam exam : candidates) { + Long examId = exam.getId(); + try { + endExamUseCase.execute(examId); + successCount++; + log.info("[ExamAutoEnd] Success examId={}", examId); + } catch (RestApiException e) { + if (isBenignSkip(e)) { + skipCount++; + log.info( + "[ExamAutoEnd] Skipped examId={}, code={}, message={}", + examId, + e.getErrorCode().getCode(), + e.getErrorCode().getMessage() + ); + } else { + failCount++; + log.warn( + "[ExamAutoEnd] Failed examId={}, code={}, message={}", + examId, + e.getErrorCode().getCode(), + e.getErrorCode().getMessage() + ); + } + } catch (Exception e) { + failCount++; + log.error("[ExamAutoEnd] Failed examId={}", examId, e); + } + } + + log.info( + "[ExamAutoEnd] Batch finished: candidates={}, success={}, skipped={}, failed={}", + candidates.size(), + successCount, + skipCount, + failCount + ); + } + + private static boolean isBenignSkip(RestApiException e) { + String code = e.getErrorCode().getCode(); + return code != null && BENIGN_SKIP_ERROR_CODES.contains(code); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoEndQueryTest.java b/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoEndQueryTest.java new file mode 100644 index 0000000..d93b00d --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoEndQueryTest.java @@ -0,0 +1,79 @@ +package com.yd.vibecode.domain.exam.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.global.config.JpaConfig; + +@DataJpaTest +@Import(JpaConfig.class) +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.jpa.properties.hibernate.default_schema=", + "spring.config.import=" +}) +class ExamRepositoryAutoEndQueryTest { + + @Autowired + private ExamRepository examRepository; + + @Test + @DisplayName("RUNNING·endsAt<=now 만 자동 종료 대상으로 조회된다") + void findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual_onlyEligibleRunningExams() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime past = now.minusMinutes(10); + LocalDateTime future = now.plusHours(1); + + examRepository.save(exam("past-running", ExamState.RUNNING, past, now.minusMinutes(1))); + examRepository.save(exam("future-running", ExamState.RUNNING, past, future)); + examRepository.save(exam("past-waiting", ExamState.WAITING, past, now.minusMinutes(1))); + examRepository.save(exam("past-ended", ExamState.ENDED, past, now.minusMinutes(1))); + + List candidates = examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual( + ExamState.RUNNING, + now + ); + + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).getTitle()).isEqualTo("past-running"); + } + + @Test + @DisplayName("endsAt이 미래인 RUNNING 시험은 조회되지 않는다") + void findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual_excludesFutureRunning() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime future = now.plusHours(2); + + examRepository.save(exam("future-only", ExamState.RUNNING, now.minusHours(1), future)); + + List candidates = examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual( + ExamState.RUNNING, + now + ); + + assertThat(candidates).isEmpty(); + } + + private static Exam exam(String title, ExamState state, LocalDateTime startsAt, LocalDateTime endsAt) { + return Exam.builder() + .title(title) + .state(state) + .startsAt(startsAt) + .endsAt(endsAt) + .version(0) + .createdBy(1L) + .build(); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndSchedulerTest.java b/src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndSchedulerTest.java new file mode 100644 index 0000000..f42c6cb --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoEndSchedulerTest.java @@ -0,0 +1,141 @@ +package com.yd.vibecode.domain.exam.infrastructure; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +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 org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.yd.vibecode.domain.exam.application.usecase.EndExamUseCase; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ExamErrorStatus; + +@ExtendWith(MockitoExtension.class) +class ExamAutoEndSchedulerTest { + + @InjectMocks + private ExamAutoEndScheduler scheduler; + + @Mock + private ExamRepository examRepository; + + @Mock + private EndExamUseCase endExamUseCase; + + @Test + @DisplayName("대상 없으면 EndExamUseCase를 호출하지 않는다") + void pollAndAutoEndExams_noCandidates() { + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of()); + + scheduler.pollAndAutoEndExams(); + + verify(endExamUseCase, never()).execute(any()); + } + + @Test + @DisplayName("RUNNING·endsAt<=now 대상마다 EndExamUseCase를 호출한다") + void pollAndAutoEndExams_endsEligibleExams() { + Exam pastRunning = runningExam(1L, LocalDateTime.now().minusMinutes(5)); + Exam justNow = runningExam(2L, LocalDateTime.now()); + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of(pastRunning, justNow)); + + scheduler.pollAndAutoEndExams(); + + verify(endExamUseCase).execute(1L); + verify(endExamUseCase).execute(2L); + } + + @Test + @DisplayName("조회는 RUNNING·endsAt<=now 조건으로 수행한다") + void pollAndAutoEndExams_queriesWithRunningAndNowThreshold() { + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of()); + + scheduler.pollAndAutoEndExams(); + + ArgumentCaptor thresholdCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(examRepository).findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual( + eq(ExamState.RUNNING), + thresholdCaptor.capture() + ); + LocalDateTime threshold = thresholdCaptor.getValue(); + Assertions.assertThat(threshold).isBeforeOrEqualTo(LocalDateTime.now().plusSeconds(2)); + Assertions.assertThat(threshold).isAfterOrEqualTo(LocalDateTime.now().minusSeconds(2)); + } + + @Test + @DisplayName("한 시험 실패 시 다른 시험 자동 종료는 계속된다") + void pollAndAutoEndExams_continuesAfterSingleFailure() { + Exam exam1 = runningExam(10L, LocalDateTime.now().minusMinutes(1)); + Exam exam2 = runningExam(20L, LocalDateTime.now().minusMinutes(1)); + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of(exam1, exam2)); + willThrow(new RuntimeException("db error")) + .given(endExamUseCase).execute(10L); + + scheduler.pollAndAutoEndExams(); + + verify(endExamUseCase).execute(10L); + verify(endExamUseCase).execute(20L); + } + + @Test + @DisplayName("이미 종료된 시험(INVALID_EXAM_STATE)은 스킵하고 다음 시험을 처리한다") + void pollAndAutoEndExams_skipsInvalidStateAndContinues() { + Exam exam1 = runningExam(30L, LocalDateTime.now().minusMinutes(1)); + Exam exam2 = runningExam(40L, LocalDateTime.now().minusMinutes(1)); + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of(exam1, exam2)); + willThrow(new RestApiException(ExamErrorStatus.INVALID_EXAM_STATE)) + .given(endExamUseCase).execute(30L); + + scheduler.pollAndAutoEndExams(); + + verify(endExamUseCase, times(1)).execute(30L); + verify(endExamUseCase, times(1)).execute(40L); + } + + @Test + @DisplayName("RUNNING·endsAt>now 시험은 조회 결과에 없으면 EndExamUseCase를 호출하지 않는다") + void pollAndAutoEndExams_doesNotEndFutureExams() { + given(examRepository.findByStateAndEndsAtIsNotNullAndEndsAtLessThanEqual(eq(ExamState.RUNNING), any())) + .willReturn(List.of()); + + scheduler.pollAndAutoEndExams(); + + verify(endExamUseCase, never()).execute(any()); + } + + private static Exam runningExam(Long id, LocalDateTime endsAt) { + Exam exam = Exam.builder() + .title("auto-end") + .state(ExamState.RUNNING) + .startsAt(endsAt.minusHours(2)) + .endsAt(endsAt) + .version(1) + .createdBy(1L) + .build(); + ReflectionTestUtils.setField(exam, "id", id); + return exam; + } +} From fbd13c5d6933647e9e2a1184ffe286f9764ddc29 Mon Sep 17 00:00:00 2001 From: ChanWook Lee Date: Sat, 6 Jun 2026 10:26:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/exam/domain/entity/Exam.java | 2 ++ .../domain/exam/domain/entity/ExamTest.java | 14 ++++++++++-- .../exam/domain/service/ExamServiceTest.java | 22 ++++++++++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/yd/vibecode/domain/exam/domain/entity/Exam.java b/src/main/java/com/yd/vibecode/domain/exam/domain/entity/Exam.java index ccfc3d4..b48b18c 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/domain/entity/Exam.java +++ b/src/main/java/com/yd/vibecode/domain/exam/domain/entity/Exam.java @@ -64,6 +64,7 @@ public void start() { if (this.state != ExamState.WAITING) { throw new RestApiException(ExamErrorStatus.INVALID_EXAM_STATE); } + this.startsAt = LocalDateTime.now(); this.state = ExamState.RUNNING; this.version++; } @@ -73,6 +74,7 @@ public void end() { if (this.state != ExamState.RUNNING) { throw new RestApiException(ExamErrorStatus.INVALID_EXAM_STATE); } + this.endsAt = LocalDateTime.now(); this.state = ExamState.ENDED; this.version++; } diff --git a/src/test/java/com/yd/vibecode/domain/exam/domain/entity/ExamTest.java b/src/test/java/com/yd/vibecode/domain/exam/domain/entity/ExamTest.java index 3489076..9bc3b92 100644 --- a/src/test/java/com/yd/vibecode/domain/exam/domain/entity/ExamTest.java +++ b/src/test/java/com/yd/vibecode/domain/exam/domain/entity/ExamTest.java @@ -35,21 +35,26 @@ void createExam() { @DisplayName("시험 시작: WAITING -> RUNNING 상태 전환 성공") void startExam_Success() { // given + LocalDateTime scheduledStart = LocalDateTime.now().plusHours(1); Exam exam = Exam.builder() .title("Test Exam") .state(ExamState.WAITING) - .startsAt(LocalDateTime.now().plusHours(1)) + .startsAt(scheduledStart) .endsAt(LocalDateTime.now().plusHours(2)) .createdBy(1L) .build(); int initialVersion = exam.getVersion(); + LocalDateTime beforeStart = LocalDateTime.now(); // when exam.start(); + LocalDateTime afterStart = LocalDateTime.now(); // then assertThat(exam.getState()).isEqualTo(ExamState.RUNNING); assertThat(exam.getVersion()).isEqualTo(initialVersion + 1); + assertThat(exam.getStartsAt()).isBetween(beforeStart, afterStart); + assertThat(exam.getStartsAt()).isBefore(scheduledStart); } @Test @@ -74,21 +79,26 @@ void startExam_Fail_AlreadyRunning() { @DisplayName("시험 종료: RUNNING -> ENDED 상태 전환 성공") void endExam_Success() { // given + LocalDateTime scheduledEnd = LocalDateTime.now().plusHours(1); Exam exam = Exam.builder() .title("Test Exam") .state(ExamState.RUNNING) .startsAt(LocalDateTime.now()) - .endsAt(LocalDateTime.now().plusHours(1)) + .endsAt(scheduledEnd) .createdBy(1L) .build(); int initialVersion = exam.getVersion(); + LocalDateTime beforeEnd = LocalDateTime.now(); // when exam.end(); + LocalDateTime afterEnd = LocalDateTime.now(); // then assertThat(exam.getState()).isEqualTo(ExamState.ENDED); assertThat(exam.getVersion()).isEqualTo(initialVersion + 1); + assertThat(exam.getEndsAt()).isBetween(beforeEnd, afterEnd); + assertThat(exam.getEndsAt()).isBefore(scheduledEnd); } @Test diff --git a/src/test/java/com/yd/vibecode/domain/exam/domain/service/ExamServiceTest.java b/src/test/java/com/yd/vibecode/domain/exam/domain/service/ExamServiceTest.java index c6159d0..1ba4e35 100644 --- a/src/test/java/com/yd/vibecode/domain/exam/domain/service/ExamServiceTest.java +++ b/src/test/java/com/yd/vibecode/domain/exam/domain/service/ExamServiceTest.java @@ -70,22 +70,27 @@ void findById_Fail_NotFound() { void startExam_Success() { // given Long examId = 1L; + LocalDateTime scheduledStart = LocalDateTime.now().plusHours(2); Exam exam = Exam.builder() .title("Test Exam") .state(ExamState.WAITING) - .startsAt(LocalDateTime.now()) - .endsAt(LocalDateTime.now().plusHours(1)) + .startsAt(scheduledStart) + .endsAt(LocalDateTime.now().plusHours(4)) .createdBy(1L) .build(); - + LocalDateTime beforeStart = LocalDateTime.now(); + given(examRepository.findById(examId)).willReturn(Optional.of(exam)); given(examRepository.save(any(Exam.class))).willReturn(exam); // when examService.startExam(examId); + LocalDateTime afterStart = LocalDateTime.now(); // then assertThat(exam.getState()).isEqualTo(ExamState.RUNNING); + assertThat(exam.getStartsAt()).isBetween(beforeStart, afterStart); + assertThat(exam.getStartsAt()).isBefore(scheduledStart); verify(examRepository).save(exam); } @@ -94,22 +99,27 @@ void startExam_Success() { void endExam_Success() { // given Long examId = 1L; + LocalDateTime scheduledEnd = LocalDateTime.now().plusHours(2); Exam exam = Exam.builder() .title("Test Exam") .state(ExamState.RUNNING) - .startsAt(LocalDateTime.now()) - .endsAt(LocalDateTime.now().plusHours(1)) + .startsAt(LocalDateTime.now().minusHours(1)) + .endsAt(scheduledEnd) .createdBy(1L) .build(); - + LocalDateTime beforeEnd = LocalDateTime.now(); + given(examRepository.findById(examId)).willReturn(Optional.of(exam)); given(examRepository.save(any(Exam.class))).willReturn(exam); // when examService.endExam(examId); + LocalDateTime afterEnd = LocalDateTime.now(); // then assertThat(exam.getState()).isEqualTo(ExamState.ENDED); + assertThat(exam.getEndsAt()).isBetween(beforeEnd, afterEnd); + assertThat(exam.getEndsAt()).isBefore(scheduledEnd); verify(examRepository).save(exam); } From 65e95113614ce87718b0ba617910800a92f32f3d Mon Sep 17 00:00:00 2001 From: ChanWook Lee Date: Sat, 6 Jun 2026 12:58:56 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B3=B4=EA=B4=80=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=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 --- .../PurgeExpiredPlatformDataUseCase.java | 125 ++++++++++ .../AdminActivityLogRepository.java | 7 + .../MasterActivityLogRepository.java | 7 + .../DataRetentionScheduler.java | 41 ++++ .../PromptEvaluationRepository.java | 8 + .../repository/PromptMessageRepository.java | 12 +- .../repository/PromptSessionRepository.java | 20 ++ .../repository/OutboxEventRepository.java | 17 +- .../domain/repository/ScoreRepository.java | 7 + .../repository/SubmissionRepository.java | 18 ++ .../repository/SubmissionRunRepository.java | 8 + .../PurgeExpiredPlatformDataUseCaseTest.java | 228 ++++++++++++++++++ .../DataRetentionSchedulerTest.java | 80 ++++++ ...essionRepositoryExpiredPurgeQueryTest.java | 87 +++++++ ...issionRepositoryExpiredPurgeQueryTest.java | 93 +++++++ 15 files changed, 753 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCase.java create mode 100644 src/main/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionScheduler.java create mode 100644 src/test/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCaseTest.java create mode 100644 src/test/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionSchedulerTest.java create mode 100644 src/test/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepositoryExpiredPurgeQueryTest.java create mode 100644 src/test/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepositoryExpiredPurgeQueryTest.java diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCase.java new file mode 100644 index 0000000..9427de4 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCase.java @@ -0,0 +1,125 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptEvaluationRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptMessageRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptSessionRepository; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.submission.domain.repository.OutboxEventRepository; +import com.yd.vibecode.domain.submission.domain.repository.ScoreRepository; +import com.yd.vibecode.domain.submission.domain.repository.SubmissionRepository; +import com.yd.vibecode.domain.submission.domain.repository.SubmissionRunRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 플랫폼 전역 보관 정책에 따라 만료된 로그·제출·AI 산출물을 물리 삭제한다. + * {@code autoDeleteExpiredData=false}이면 스케줄러가 이 UseCase를 호출하지 않는다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PurgeExpiredPlatformDataUseCase { + + static final String SUBMISSION_AGGREGATE_TYPE = "SUBMISSION"; + + private final AdminActivityLogRepository adminActivityLogRepository; + private final MasterActivityLogRepository masterActivityLogRepository; + private final SubmissionRepository submissionRepository; + private final SubmissionRunRepository submissionRunRepository; + private final ScoreRepository scoreRepository; + private final OutboxEventRepository outboxEventRepository; + private final PromptSessionRepository promptSessionRepository; + private final PromptMessageRepository promptMessageRepository; + private final PromptEvaluationRepository promptEvaluationRepository; + + @Transactional + public void execute(PlatformSettings settings) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime logCutoff = now.minusDays(settings.getLogRetentionDays()); + LocalDateTime submissionCutoff = now.minusDays(settings.getSubmissionRetentionDays()); + + purgeExpiredLogs(logCutoff); + purgeExpiredSubmissionArtifacts(submissionCutoff); + purgeExpiredPromptArtifacts(submissionCutoff); + } + + private void purgeExpiredLogs(LocalDateTime logCutoff) { + int adminLogsDeleted = adminActivityLogRepository.deleteByCreatedAtBefore(logCutoff); + int masterLogsDeleted = masterActivityLogRepository.deleteByCreatedAtBefore(logCutoff); + log.info( + "[DataRetention] Purged expired logs (cutoff={}): adminActivityLogs={}, masterActivityLogs={}", + logCutoff, + adminLogsDeleted, + masterLogsDeleted + ); + } + + private void purgeExpiredSubmissionArtifacts(LocalDateTime submissionCutoff) { + List submissionIds = submissionRepository.findIdsByExamStateAndCreatedAtBefore( + ExamState.ENDED, + submissionCutoff + ); + if (submissionIds.isEmpty()) { + log.info( + "[DataRetention] No expired submission artifacts to purge (cutoff={}, examState=ENDED)", + submissionCutoff + ); + return; + } + + int outboxDeleted = outboxEventRepository.deleteByAggregateTypeAndAggregateIdIn( + SUBMISSION_AGGREGATE_TYPE, + submissionIds + ); + int runsDeleted = submissionRunRepository.deleteBySubmissionIdIn(submissionIds); + int scoresDeleted = scoreRepository.deleteBySubmissionIdIn(submissionIds); + int submissionsDeleted = submissionRepository.deleteByIdIn(submissionIds); + + log.info( + "[DataRetention] Purged expired submission artifacts (cutoff={}, examState=ENDED): " + + "outboxEvents={}, submissionRuns={}, scores={}, submissions={}", + submissionCutoff, + outboxDeleted, + runsDeleted, + scoresDeleted, + submissionsDeleted + ); + } + + private void purgeExpiredPromptArtifacts(LocalDateTime submissionCutoff) { + List sessionIds = promptSessionRepository.findIdsByExamStateAndCreatedAtBefore( + ExamState.ENDED, + submissionCutoff + ); + if (sessionIds.isEmpty()) { + log.info( + "[DataRetention] No expired prompt artifacts to purge (cutoff={}, examState=ENDED)", + submissionCutoff + ); + return; + } + + int messagesDeleted = promptMessageRepository.deleteBySessionIdIn(sessionIds); + int evaluationsDeleted = promptEvaluationRepository.deleteBySessionIdIn(sessionIds); + int sessionsDeleted = promptSessionRepository.deleteByIdIn(sessionIds); + + log.info( + "[DataRetention] Purged expired prompt artifacts (cutoff={}, examState=ENDED): " + + "promptMessages={}, promptEvaluations={}, promptSessions={}", + submissionCutoff, + messagesDeleted, + evaluationsDeleted, + sessionsDeleted + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepository.java b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepository.java index aa63c4e..ad0828f 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepository.java +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepository.java @@ -1,8 +1,11 @@ package com.yd.vibecode.domain.admin.domain.repository; +import java.time.LocalDateTime; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -26,4 +29,8 @@ Page searchByAdmin( @Param("type") AdminActivityLogType type, @Param("keyword") String keyword, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM AdminActivityLog l WHERE l.createdAt < :cutoff") + int deleteByCreatedAtBefore(@Param("cutoff") LocalDateTime cutoff); } diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepository.java b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepository.java index b323a8b..42fb170 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepository.java +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepository.java @@ -1,8 +1,11 @@ package com.yd.vibecode.domain.admin.domain.repository; +import java.time.LocalDateTime; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -24,4 +27,8 @@ Page search( @Param("type") MasterActivityLogType type, @Param("keyword") String keyword, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MasterActivityLog l WHERE l.createdAt < :cutoff") + int deleteByCreatedAtBefore(@Param("cutoff") LocalDateTime cutoff); } diff --git a/src/main/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionScheduler.java b/src/main/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionScheduler.java new file mode 100644 index 0000000..7edeae6 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionScheduler.java @@ -0,0 +1,41 @@ +package com.yd.vibecode.domain.admin.infrastructure; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.yd.vibecode.domain.admin.application.usecase.PurgeExpiredPlatformDataUseCase; +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 플랫폼 전역 데이터 보관 정책에 따라 만료 데이터를 주기적으로 삭제한다. + * {@code autoDeleteExpiredData=false}이면 아무 데이터도 삭제하지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataRetentionScheduler { + + private static final long PURGE_INTERVAL_MS = 86_400_000L; + + private final PlatformSettingsService platformSettingsService; + private final PurgeExpiredPlatformDataUseCase purgeExpiredPlatformDataUseCase; + + @Scheduled(fixedDelay = PURGE_INTERVAL_MS) + public void purgeExpiredData() { + PlatformSettings settings = platformSettingsService.getOrCreate(); + if (!Boolean.TRUE.equals(settings.getAutoDeleteExpiredData())) { + log.debug("[DataRetention] autoDeleteExpiredData=false; skipping purge"); + return; + } + + try { + purgeExpiredPlatformDataUseCase.execute(settings); + } catch (Exception e) { + log.error("[DataRetention] Failed to purge expired platform data", e); + } + } +} diff --git a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptEvaluationRepository.java b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptEvaluationRepository.java index 605ff80..f34f10d 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptEvaluationRepository.java +++ b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptEvaluationRepository.java @@ -1,9 +1,13 @@ package com.yd.vibecode.domain.chat.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.chat.domain.entity.PromptEvaluation; import com.yd.vibecode.domain.chat.domain.entity.PromptSession; +import java.util.Collection; import java.util.Optional; public interface PromptEvaluationRepository extends JpaRepository { @@ -12,4 +16,8 @@ Optional findBySessionAndTurnAndEvaluationType( Integer turn, PromptEvaluation.EvaluationType evaluationType ); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM PromptEvaluation pe WHERE pe.session.id IN :sessionIds") + int deleteBySessionIdIn(@Param("sessionIds") Collection sessionIds); } diff --git a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptMessageRepository.java b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptMessageRepository.java index 05fdf95..42cbcad 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptMessageRepository.java +++ b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptMessageRepository.java @@ -1,14 +1,16 @@ package com.yd.vibecode.domain.chat.domain.repository; -import java.util.List; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.chat.domain.entity.PromptMessage; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + public interface PromptMessageRepository extends JpaRepository { List findBySessionIdOrderByTurnAsc(Long sessionId); @@ -22,4 +24,8 @@ public interface PromptMessageRepository extends JpaRepository findMaxTurnBySessionId(@Param("sessionId") Long sessionId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM PromptMessage pm WHERE pm.sessionId IN :sessionIds") + int deleteBySessionIdIn(@Param("sessionIds") Collection sessionIds); } diff --git a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepository.java b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepository.java index fe016c2..d58661b 100644 --- a/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepository.java +++ b/src/main/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepository.java @@ -1,9 +1,15 @@ package com.yd.vibecode.domain.chat.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.chat.domain.entity.PromptSession; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -14,4 +20,18 @@ public interface PromptSessionRepository extends JpaRepository findByExamId(Long examId); List findByParticipantId(Long participantId); + + @Query(""" + SELECT ps.id FROM PromptSession ps, Exam e + WHERE ps.examId = e.id + AND e.state = :examState + AND ps.createdAt < :cutoff + """) + List findIdsByExamStateAndCreatedAtBefore( + @Param("examState") ExamState examState, + @Param("cutoff") LocalDateTime cutoff); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM PromptSession ps WHERE ps.id IN :ids") + int deleteByIdIn(@Param("ids") Collection ids); } diff --git a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/OutboxEventRepository.java b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/OutboxEventRepository.java index d567ffc..99314c3 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/OutboxEventRepository.java +++ b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/OutboxEventRepository.java @@ -4,15 +4,18 @@ import com.yd.vibecode.domain.submission.domain.entity.OutboxStatus; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.QueryHints; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; public interface OutboxEventRepository extends JpaRepository { @@ -37,4 +40,14 @@ List findStaleProcessingEvents( @Param("status") OutboxStatus status, @Param("staleThreshold") LocalDateTime staleThreshold ); + + @Modifying(clearAutomatically = true) + @Query(""" + DELETE FROM OutboxEvent e + WHERE e.aggregateType = :aggregateType + AND e.aggregateId IN :aggregateIds + """) + int deleteByAggregateTypeAndAggregateIdIn( + @Param("aggregateType") String aggregateType, + @Param("aggregateIds") Collection aggregateIds); } diff --git a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/ScoreRepository.java b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/ScoreRepository.java index bad15dd..3908d3d 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/ScoreRepository.java +++ b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/ScoreRepository.java @@ -1,6 +1,9 @@ package com.yd.vibecode.domain.submission.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.submission.domain.entity.Score; @@ -13,4 +16,8 @@ public interface ScoreRepository extends JpaRepository { Optional findBySubmissionId(Long submissionId); List findBySubmissionIdIn(Collection submissionIds); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Score s WHERE s.submissionId IN :submissionIds") + int deleteBySubmissionIdIn(@Param("submissionIds") Collection submissionIds); } diff --git a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepository.java b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepository.java index daf83bd..9037fe1 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepository.java +++ b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepository.java @@ -1,11 +1,15 @@ package com.yd.vibecode.domain.submission.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; import com.yd.vibecode.domain.submission.domain.entity.Submission; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -23,4 +27,18 @@ public interface SubmissionRepository extends JpaRepository { @Query("SELECT s.examId, COUNT(s) FROM Submission s WHERE s.examId IN :examIds GROUP BY s.examId") List countGroupByExamIdIn(@Param("examIds") List examIds); + + @Query(""" + SELECT s.id FROM Submission s, Exam e + WHERE s.examId = e.id + AND e.state = :examState + AND s.createdAt < :cutoff + """) + List findIdsByExamStateAndCreatedAtBefore( + @Param("examState") ExamState examState, + @Param("cutoff") LocalDateTime cutoff); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Submission s WHERE s.id IN :ids") + int deleteByIdIn(@Param("ids") Collection ids); } diff --git a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRunRepository.java b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRunRepository.java index 6aa4f13..24d39d3 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRunRepository.java +++ b/src/main/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRunRepository.java @@ -1,9 +1,13 @@ package com.yd.vibecode.domain.submission.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.submission.domain.entity.SubmissionRun; +import java.util.Collection; import java.util.List; public interface SubmissionRunRepository extends JpaRepository { @@ -13,4 +17,8 @@ public interface SubmissionRunRepository extends JpaRepository findBySubmissionIdOrderByCaseIndexAsc(Long submissionId); void deleteBySubmissionId(Long submissionId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM SubmissionRun r WHERE r.submissionId IN :submissionIds") + int deleteBySubmissionIdIn(@Param("submissionIds") Collection submissionIds); } diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCaseTest.java new file mode 100644 index 0000000..1ca5245 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/PurgeExpiredPlatformDataUseCaseTest.java @@ -0,0 +1,228 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptEvaluationRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptMessageRepository; +import com.yd.vibecode.domain.chat.domain.repository.PromptSessionRepository; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.submission.domain.repository.OutboxEventRepository; +import com.yd.vibecode.domain.submission.domain.repository.ScoreRepository; +import com.yd.vibecode.domain.submission.domain.repository.SubmissionRepository; +import com.yd.vibecode.domain.submission.domain.repository.SubmissionRunRepository; + +@ExtendWith(MockitoExtension.class) +class PurgeExpiredPlatformDataUseCaseTest { + + @InjectMocks + private PurgeExpiredPlatformDataUseCase useCase; + + @Mock + private AdminActivityLogRepository adminActivityLogRepository; + + @Mock + private MasterActivityLogRepository masterActivityLogRepository; + + @Mock + private SubmissionRepository submissionRepository; + + @Mock + private SubmissionRunRepository submissionRunRepository; + + @Mock + private ScoreRepository scoreRepository; + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private PromptSessionRepository promptSessionRepository; + + @Mock + private PromptMessageRepository promptMessageRepository; + + @Mock + private PromptEvaluationRepository promptEvaluationRepository; + + @Test + @DisplayName("logRetentionDays 기준으로 admin/master 로그 삭제 repository를 호출한다") + void execute_deletesLogsByLogRetentionDays() { + PlatformSettings settings = settings(30, 90); + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(2); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(3); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + ArgumentCaptor logCutoffCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(adminActivityLogRepository).deleteByCreatedAtBefore(logCutoffCaptor.capture()); + verify(masterActivityLogRepository).deleteByCreatedAtBefore(logCutoffCaptor.getValue()); + } + + @Test + @DisplayName("ENDED 시험 제출 산출물을 submissionRetentionDays 기준으로 삭제한다") + void execute_deletesExpiredEndedExamSubmissionArtifacts() { + PlatformSettings settings = settings(90, 60); + List submissionIds = List.of(10L, 20L); + + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(submissionIds); + given(outboxEventRepository.deleteByAggregateTypeAndAggregateIdIn( + PurgeExpiredPlatformDataUseCase.SUBMISSION_AGGREGATE_TYPE, submissionIds)).willReturn(2); + given(submissionRunRepository.deleteBySubmissionIdIn(submissionIds)).willReturn(4); + given(scoreRepository.deleteBySubmissionIdIn(submissionIds)).willReturn(2); + given(submissionRepository.deleteByIdIn(submissionIds)).willReturn(2); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + var inOrder = inOrder( + outboxEventRepository, + submissionRunRepository, + scoreRepository, + submissionRepository + ); + inOrder.verify(outboxEventRepository).deleteByAggregateTypeAndAggregateIdIn( + PurgeExpiredPlatformDataUseCase.SUBMISSION_AGGREGATE_TYPE, + submissionIds + ); + inOrder.verify(submissionRunRepository).deleteBySubmissionIdIn(submissionIds); + inOrder.verify(scoreRepository).deleteBySubmissionIdIn(submissionIds); + inOrder.verify(submissionRepository).deleteByIdIn(submissionIds); + } + + @Test + @DisplayName("제출 id 목록이 비어 있으면 submission 관련 delete query를 호출하지 않는다") + void execute_skipsSubmissionDeletesWhenNoExpiredSubmissions() { + PlatformSettings settings = settings(90, 90); + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + verify(submissionRepository).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any()); + verifyNoInteractions(outboxEventRepository, submissionRunRepository, scoreRepository); + verify(submissionRepository, never()).deleteByIdIn(any()); + } + + @Test + @DisplayName("ENDED 시험만 조회하므로 RUNNING/WAITING 시험 제출은 repository 조회 단계에서 제외된다") + void execute_queriesOnlyEndedExamSubmissions() { + PlatformSettings settings = settings(90, 90); + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + verify(submissionRepository).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any()); + verify(submissionRepository, never()).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.RUNNING), any()); + verify(submissionRepository, never()).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.WAITING), any()); + } + + @Test + @DisplayName("ENDED 시험 prompt 산출물을 submissionRetentionDays 기준으로 삭제한다") + void execute_deletesExpiredEndedExamPromptArtifacts() { + PlatformSettings settings = settings(90, 45); + List sessionIds = List.of(100L, 200L); + + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(sessionIds); + given(promptMessageRepository.deleteBySessionIdIn(sessionIds)).willReturn(5); + given(promptEvaluationRepository.deleteBySessionIdIn(sessionIds)).willReturn(2); + given(promptSessionRepository.deleteByIdIn(sessionIds)).willReturn(2); + + useCase.execute(settings); + + var inOrder = inOrder( + promptMessageRepository, + promptEvaluationRepository, + promptSessionRepository + ); + inOrder.verify(promptMessageRepository).deleteBySessionIdIn(sessionIds); + inOrder.verify(promptEvaluationRepository).deleteBySessionIdIn(sessionIds); + inOrder.verify(promptSessionRepository).deleteByIdIn(sessionIds); + } + + @Test + @DisplayName("prompt session id 목록이 비어 있으면 prompt 관련 delete query를 호출하지 않는다") + void execute_skipsPromptDeletesWhenNoExpiredSessions() { + PlatformSettings settings = settings(90, 90); + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + verify(promptSessionRepository).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any()); + verifyNoInteractions(promptMessageRepository, promptEvaluationRepository); + verify(promptSessionRepository, never()).deleteByIdIn(any()); + } + + @Test + @DisplayName("ENDED 시험만 조회하므로 RUNNING/WAITING 시험 prompt는 repository 조회 단계에서 제외된다") + void execute_queriesOnlyEndedExamPromptSessions() { + PlatformSettings settings = settings(90, 90); + given(adminActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(masterActivityLogRepository.deleteByCreatedAtBefore(any())).willReturn(0); + given(submissionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + given(promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any())) + .willReturn(List.of()); + + useCase.execute(settings); + + verify(promptSessionRepository).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.ENDED), any()); + verify(promptSessionRepository, never()).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.RUNNING), any()); + verify(promptSessionRepository, never()).findIdsByExamStateAndCreatedAtBefore(eq(ExamState.WAITING), any()); + } + + private static PlatformSettings settings(int logRetentionDays, int submissionRetentionDays) { + return PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(logRetentionDays) + .submissionRetentionDays(submissionRetentionDays) + .autoDeleteExpiredData(true) + .build(); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionSchedulerTest.java b/src/test/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionSchedulerTest.java new file mode 100644 index 0000000..f1c0fc7 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/infrastructure/DataRetentionSchedulerTest.java @@ -0,0 +1,80 @@ +package com.yd.vibecode.domain.admin.infrastructure; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +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 com.yd.vibecode.domain.admin.application.usecase.PurgeExpiredPlatformDataUseCase; +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; + +@ExtendWith(MockitoExtension.class) +class DataRetentionSchedulerTest { + + @InjectMocks + private DataRetentionScheduler scheduler; + + @Mock + private PlatformSettingsService platformSettingsService; + + @Mock + private PurgeExpiredPlatformDataUseCase purgeExpiredPlatformDataUseCase; + + @Test + @DisplayName("autoDeleteExpiredData=false이면 PurgeExpiredPlatformDataUseCase를 호출하지 않는다") + void purgeExpiredData_skipsWhenAutoDeleteDisabled() { + PlatformSettings settings = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(false) + .build(); + given(platformSettingsService.getOrCreate()).willReturn(settings); + + scheduler.purgeExpiredData(); + + verifyNoInteractions(purgeExpiredPlatformDataUseCase); + } + + @Test + @DisplayName("autoDeleteExpiredData=true이면 PurgeExpiredPlatformDataUseCase를 호출한다") + void purgeExpiredData_invokesUseCaseWhenAutoDeleteEnabled() { + PlatformSettings settings = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(30) + .submissionRetentionDays(60) + .autoDeleteExpiredData(true) + .build(); + given(platformSettingsService.getOrCreate()).willReturn(settings); + + scheduler.purgeExpiredData(); + + verify(purgeExpiredPlatformDataUseCase).execute(settings); + } + + @Test + @DisplayName("UseCase 예외가 발생해도 scheduler 메서드는 예외를 던지지 않는다") + void purgeExpiredData_swallowsUseCaseException() { + PlatformSettings settings = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(true) + .build(); + given(platformSettingsService.getOrCreate()).willReturn(settings); + org.mockito.BDDMockito.willThrow(new RuntimeException("purge failed")) + .given(purgeExpiredPlatformDataUseCase) + .execute(settings); + + scheduler.purgeExpiredData(); + + verify(purgeExpiredPlatformDataUseCase).execute(settings); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepositoryExpiredPurgeQueryTest.java b/src/test/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepositoryExpiredPurgeQueryTest.java new file mode 100644 index 0000000..6a1023b --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/chat/domain/repository/PromptSessionRepositoryExpiredPurgeQueryTest.java @@ -0,0 +1,87 @@ +package com.yd.vibecode.domain.chat.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.yd.vibecode.domain.chat.domain.entity.PromptSession; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.global.config.JpaConfig; + +@DataJpaTest +@Import(JpaConfig.class) +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.jpa.properties.hibernate.default_schema=", + "spring.config.import=" +}) +class PromptSessionRepositoryExpiredPurgeQueryTest { + + @Autowired + private ExamRepository examRepository; + + @Autowired + private PromptSessionRepository promptSessionRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + @DisplayName("ENDED 시험·cutoff 이전 prompt session id만 조회된다") + void findIdsByExamStateAndCreatedAtBefore_onlyOldEndedExamSessions() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime cutoff = now.minusDays(30); + LocalDateTime old = cutoff.minusDays(1); + LocalDateTime recent = cutoff.plusDays(1); + + Exam endedExam = saveExam("ended", ExamState.ENDED); + Exam runningExam = saveExam("running", ExamState.RUNNING); + + PromptSession oldEnded = saveSession(endedExam.getId(), 1L, old); + saveSession(endedExam.getId(), 2L, recent); + saveSession(runningExam.getId(), 3L, old); + + var ids = promptSessionRepository.findIdsByExamStateAndCreatedAtBefore(ExamState.ENDED, cutoff); + + assertThat(ids).containsExactly(oldEnded.getId()); + } + + private Exam saveExam(String title, ExamState state) { + LocalDateTime now = LocalDateTime.now(); + return examRepository.save(Exam.builder() + .title(title) + .state(state) + .startsAt(now.minusHours(2)) + .endsAt(now.minusHours(1)) + .version(0) + .createdBy(1L) + .build()); + } + + private PromptSession saveSession(Long examId, Long participantId, LocalDateTime createdAt) { + PromptSession session = promptSessionRepository.save(PromptSession.builder() + .examId(examId) + .participantId(participantId) + .specId(1L) + .build()); + entityManager.getEntityManager().createNativeQuery( + "UPDATE prompt_sessions SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", session.getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + return promptSessionRepository.findById(session.getId()).orElseThrow(); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepositoryExpiredPurgeQueryTest.java b/src/test/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepositoryExpiredPurgeQueryTest.java new file mode 100644 index 0000000..b779703 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/submission/domain/repository/SubmissionRepositoryExpiredPurgeQueryTest.java @@ -0,0 +1,93 @@ +package com.yd.vibecode.domain.submission.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.domain.submission.domain.entity.Submission; +import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; +import com.yd.vibecode.global.config.JpaConfig; + +@DataJpaTest +@Import(JpaConfig.class) +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.jpa.properties.hibernate.default_schema=", + "spring.config.import=" +}) +class SubmissionRepositoryExpiredPurgeQueryTest { + + @Autowired + private ExamRepository examRepository; + + @Autowired + private SubmissionRepository submissionRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + @DisplayName("ENDED 시험·cutoff 이전 제출 id만 조회된다") + void findIdsByExamStateAndCreatedAtBefore_onlyOldEndedExamSubmissions() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime cutoff = now.minusDays(30); + LocalDateTime old = cutoff.minusDays(1); + LocalDateTime recent = cutoff.plusDays(1); + + Exam endedExam = saveExam("ended", ExamState.ENDED); + Exam runningExam = saveExam("running", ExamState.RUNNING); + Exam waitingExam = saveExam("waiting", ExamState.WAITING); + + Submission oldEnded = saveSubmission(endedExam.getId(), 1L, old); + saveSubmission(endedExam.getId(), 2L, recent); + saveSubmission(runningExam.getId(), 3L, old); + saveSubmission(waitingExam.getId(), 4L, old); + + var ids = submissionRepository.findIdsByExamStateAndCreatedAtBefore(ExamState.ENDED, cutoff); + + assertThat(ids).containsExactly(oldEnded.getId()); + } + + private Exam saveExam(String title, ExamState state) { + LocalDateTime now = LocalDateTime.now(); + return examRepository.save(Exam.builder() + .title(title) + .state(state) + .startsAt(now.minusHours(2)) + .endsAt(now.minusHours(1)) + .version(0) + .createdBy(1L) + .build()); + } + + private Submission saveSubmission(Long examId, Long participantId, LocalDateTime createdAt) { + Submission submission = submissionRepository.save(Submission.builder() + .examId(examId) + .participantId(participantId) + .specId(1L) + .lang("python3.11") + .status(SubmissionStatus.DONE) + .codeInline("print(1)") + .build()); + entityManager.getEntityManager().createNativeQuery( + "UPDATE submissions SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", submission.getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + return submissionRepository.findById(submission.getId()).orElseThrow(); + } +}