diff --git a/scripts/migration/add_exam_participants_code_snapshot.sql b/scripts/migration/add_exam_participants_code_snapshot.sql new file mode 100644 index 0000000..c48dce4 --- /dev/null +++ b/scripts/migration/add_exam_participants_code_snapshot.sql @@ -0,0 +1,4 @@ +-- exam_participants: 시험 종료 시 자동 제출용 코드 스냅샷 (ddl-auto=update 미사용 환경용) +ALTER TABLE exam_participants + ADD COLUMN IF NOT EXISTS last_code_lang VARCHAR(50), + ADD COLUMN IF NOT EXISTS last_code_inline TEXT; diff --git a/scripts/migration/alter_platform_settings_add_auto_delete_expired_data.sql b/scripts/migration/alter_platform_settings_add_auto_delete_expired_data.sql new file mode 100644 index 0000000..c9b764d --- /dev/null +++ b/scripts/migration/alter_platform_settings_add_auto_delete_expired_data.sql @@ -0,0 +1,12 @@ +-- platform_settings.auto_delete_expired_data: 기존 테이블에 컬럼 추가 +-- (테이블은 있으나 컬럼만 없는 로컬/운영 DB용, Hibernate ddl-auto=update 미반영 환경) +-- 적용: psql ... -v ON_ERROR_STOP=1 -f alter_platform_settings_add_auto_delete_expired_data.sql +-- 스키마 사용 시: SET search_path TO ai_vibe_coding_test; 후 실행하거나 동일 스키마에서 실행 + +ALTER TABLE platform_settings + ADD COLUMN IF NOT EXISTS auto_delete_expired_data BOOLEAN NOT NULL DEFAULT TRUE; + +-- 컬럼이 nullable로만 추가된 구버전 대비 (재실행 안전) +UPDATE platform_settings +SET auto_delete_expired_data = TRUE +WHERE auto_delete_expired_data IS NULL; diff --git a/scripts/migration/alter_platform_settings_add_retention_columns.sql b/scripts/migration/alter_platform_settings_add_retention_columns.sql new file mode 100644 index 0000000..ebcd720 --- /dev/null +++ b/scripts/migration/alter_platform_settings_add_retention_columns.sql @@ -0,0 +1,14 @@ +-- platform_settings 보관 기간 컬럼: Hibernate ddl-auto로 생성된 불완전 테이블 보완 +ALTER TABLE platform_settings + ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 90; + +ALTER TABLE platform_settings + ADD COLUMN IF NOT EXISTS submission_retention_days INTEGER NOT NULL DEFAULT 90; + +UPDATE platform_settings +SET log_retention_days = 90 +WHERE log_retention_days IS NULL; + +UPDATE platform_settings +SET submission_retention_days = 90 +WHERE submission_retention_days IS NULL; diff --git a/scripts/migration/create_admin_activity_logs.sql b/scripts/migration/create_admin_activity_logs.sql new file mode 100644 index 0000000..446b56a --- /dev/null +++ b/scripts/migration/create_admin_activity_logs.sql @@ -0,0 +1,22 @@ +-- admin_activity_logs: 관리자 활동 로그 (Hibernate ddl-auto=update 미사용 환경용) +CREATE TABLE IF NOT EXISTS admin_activity_logs ( + id BIGSERIAL PRIMARY KEY, + admin_id BIGINT NOT NULL, + exam_id BIGINT NOT NULL, + participant_id BIGINT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + message VARCHAR(500) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_activity_logs_admin_created + ON admin_activity_logs (admin_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_admin_activity_logs_type + ON admin_activity_logs (type); + +CREATE INDEX IF NOT EXISTS idx_admin_activity_logs_exam_id + ON admin_activity_logs (exam_id); diff --git a/scripts/migration/create_master_activity_logs.sql b/scripts/migration/create_master_activity_logs.sql new file mode 100644 index 0000000..1d7a88c --- /dev/null +++ b/scripts/migration/create_master_activity_logs.sql @@ -0,0 +1,21 @@ +-- master_activity_logs: 마스터 활동 로그 (Hibernate ddl-auto=update 미사용 환경용) +CREATE TABLE IF NOT EXISTS master_activity_logs ( + id BIGSERIAL PRIMARY KEY, + master_id BIGINT NULL, + target_admin_id BIGINT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + message VARCHAR(500) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL +); + +CREATE INDEX IF NOT EXISTS idx_master_activity_logs_master_created + ON master_activity_logs (master_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_master_activity_logs_type + ON master_activity_logs (type); + +CREATE INDEX IF NOT EXISTS idx_master_activity_logs_created + ON master_activity_logs (created_at DESC); diff --git a/scripts/migration/create_platform_settings.sql b/scripts/migration/create_platform_settings.sql new file mode 100644 index 0000000..10f8589 --- /dev/null +++ b/scripts/migration/create_platform_settings.sql @@ -0,0 +1,22 @@ +-- platform_settings: 마스터 전역 플랫폼 설정 (단일 row, id=1) +CREATE TABLE IF NOT EXISTS platform_settings ( + id BIGINT PRIMARY KEY DEFAULT 1, + default_token_limit INTEGER NOT NULL, + log_retention_days INTEGER NOT NULL, + submission_retention_days INTEGER NOT NULL, + auto_delete_expired_data BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + CONSTRAINT chk_platform_settings_singleton CHECK (id = 1) +); + +INSERT INTO platform_settings ( + id, + default_token_limit, + log_retention_days, + submission_retention_days, + auto_delete_expired_data +) +VALUES (1, 10000, 90, 90, TRUE) +ON CONFLICT (id) DO NOTHING; diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateMasterPlatformSettingsRequest.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateMasterPlatformSettingsRequest.java new file mode 100644 index 0000000..7b34384 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateMasterPlatformSettingsRequest.java @@ -0,0 +1,25 @@ +package com.yd.vibecode.domain.admin.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record UpdateMasterPlatformSettingsRequest( + @NotNull(message = "로그 보관 기간은 필수입니다.") + @Min(value = 1, message = "로그 보관 기간은 1일 이상이어야 합니다.") + @Max(value = 3650, message = "로그 보관 기간이 너무 깁니다.") + @Schema(description = "로그 보관 기간(일)", example = "90") + Integer logRetentionDays, + + @NotNull(message = "제출물 보관 기간은 필수입니다.") + @Min(value = 1, message = "제출물 보관 기간은 1일 이상이어야 합니다.") + @Max(value = 3650, message = "제출물 보관 기간이 너무 깁니다.") + @Schema(description = "제출물 보관 기간(일)", example = "90") + Integer submissionRetentionDays, + + @NotNull(message = "만료 데이터 자동 삭제 여부는 필수입니다.") + @Schema(description = "만료 데이터 자동 삭제 활성화 여부 (false면 향후 삭제 스케줄러 미실행 정책)", example = "true") + Boolean autoDeleteExpiredData +) { +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateProblemAvailabilityRequest.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateProblemAvailabilityRequest.java new file mode 100644 index 0000000..ab13663 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/request/UpdateProblemAvailabilityRequest.java @@ -0,0 +1,11 @@ +package com.yd.vibecode.domain.admin.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "문제 사용 가능 여부 변경 요청") +public record UpdateProblemAvailabilityRequest( + @Schema(description = "사용 가능 여부 (true=PUBLISHED, false=ARCHIVED)", example = "true") + @NotNull(message = "available 값은 필수입니다.") + Boolean available +) {} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogPageResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogPageResponse.java new file mode 100644 index 0000000..ae516e1 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogPageResponse.java @@ -0,0 +1,25 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; + +public record AdminActivityLogPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static AdminActivityLogPageResponse from(Page page) { + return new AdminActivityLogPageResponse( + page.getContent().stream().map(AdminActivityLogResponse::from).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogResponse.java new file mode 100644 index 0000000..080fb19 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/AdminActivityLogResponse.java @@ -0,0 +1,28 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +import java.time.LocalDateTime; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; + +public record AdminActivityLogResponse( + Long id, + AdminActivityLogType type, + String title, + String message, + LocalDateTime createdAt, + Long examId, + Long participantId +) { + public static AdminActivityLogResponse from(AdminActivityLog log) { + return new AdminActivityLogResponse( + log.getId(), + log.getType(), + log.getTitle(), + log.getMessage(), + log.getCreatedAt(), + log.getExamId(), + log.getParticipantId() + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ExamineeBoardResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ExamineeBoardResponse.java index 9a25e94..602754e 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ExamineeBoardResponse.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ExamineeBoardResponse.java @@ -21,7 +21,9 @@ public record ExamineeBoardResponse( BigDecimal correctnessScore, BigDecimal totalScore, LocalDateTime submittedAt, - LocalDateTime evaluatedAt + LocalDateTime evaluatedAt, + ParticipantAttendanceStatus attendanceStatus, + ParticipantSubmissionDisplayStatus submissionDisplayStatus ) { public static ExamineeBoardResponse of( ExamParticipant ep, @@ -34,7 +36,9 @@ public static ExamineeBoardResponse of( BigDecimal correctnessScore, BigDecimal totalScore, LocalDateTime submittedAt, - LocalDateTime evaluatedAt) { + LocalDateTime evaluatedAt, + ParticipantAttendanceStatus attendanceStatus, + ParticipantSubmissionDisplayStatus submissionDisplayStatus) { return new ExamineeBoardResponse( ep.getId(), p != null ? p.getName() : "", @@ -50,13 +54,14 @@ public static ExamineeBoardResponse of( correctnessScore, totalScore, submittedAt, - evaluatedAt + evaluatedAt, + attendanceStatus, + submissionDisplayStatus ); } private static String maskPhone(String phone) { if (phone == null || phone.length() < 9) return phone; - // 010-1234-5678 -> 010-****-5678 String[] parts = phone.split("-"); if (parts.length == 3) { return parts[0] + "-****-" + parts[2]; diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogPageResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogPageResponse.java new file mode 100644 index 0000000..92e5c0d --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogPageResponse.java @@ -0,0 +1,25 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; + +public record MasterActivityLogPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static MasterActivityLogPageResponse from(Page page) { + return new MasterActivityLogPageResponse( + page.getContent().stream().map(MasterActivityLogResponse::from).toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogResponse.java new file mode 100644 index 0000000..1b70626 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterActivityLogResponse.java @@ -0,0 +1,26 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +import java.time.LocalDateTime; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; + +public record MasterActivityLogResponse( + Long id, + MasterActivityLogType type, + String title, + String message, + LocalDateTime createdAt, + Long targetAdminId +) { + public static MasterActivityLogResponse from(MasterActivityLog log) { + return new MasterActivityLogResponse( + log.getId(), + log.getType(), + log.getTitle(), + log.getMessage(), + log.getCreatedAt(), + log.getTargetAdminId() + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterPlatformSettingsResponse.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterPlatformSettingsResponse.java new file mode 100644 index 0000000..a7d921f --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/MasterPlatformSettingsResponse.java @@ -0,0 +1,21 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +import java.time.LocalDateTime; + +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; + +public record MasterPlatformSettingsResponse( + int logRetentionDays, + int submissionRetentionDays, + boolean autoDeleteExpiredData, + LocalDateTime updatedAt +) { + public static MasterPlatformSettingsResponse from(PlatformSettings settings) { + return new MasterPlatformSettingsResponse( + settings.getLogRetentionDays(), + settings.getSubmissionRetentionDays(), + Boolean.TRUE.equals(settings.getAutoDeleteExpiredData()), + settings.getUpdatedAt() != null ? settings.getUpdatedAt() : settings.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantAttendanceStatus.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantAttendanceStatus.java new file mode 100644 index 0000000..586c507 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantAttendanceStatus.java @@ -0,0 +1,11 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +/** + * 관리자 참가자 목록 — 응시상태 (4값) + */ +public enum ParticipantAttendanceStatus { + WAITING, + IN_EXAM, + SUBMITTED, + ENDED +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantSubmissionDisplayStatus.java b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantSubmissionDisplayStatus.java new file mode 100644 index 0000000..3e8fccf --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/dto/response/ParticipantSubmissionDisplayStatus.java @@ -0,0 +1,10 @@ +package com.yd.vibecode.domain.admin.application.dto.response; + +/** + * 관리자 참가자 목록 — 제출상태 (3값) + */ +public enum ParticipantSubmissionDisplayStatus { + NOT_SUBMITTED, + GRADING, + GRADED +} 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 new file mode 100644 index 0000000..4e37d21 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolver.java @@ -0,0 +1,105 @@ +package com.yd.vibecode.domain.admin.application.service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import com.yd.vibecode.domain.admin.application.dto.response.ParticipantAttendanceStatus; +import com.yd.vibecode.domain.admin.application.dto.response.ParticipantSubmissionDisplayStatus; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.submission.domain.entity.Score; +import com.yd.vibecode.domain.submission.domain.entity.Submission; +import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; + +/** + * 시험/참가자/제출 정보로 관리자 화면용 응시·제출 표시 상태를 계산한다. + */ +public final class ExamParticipantDisplayStatusResolver { + + private static final java.util.Set WAITING_STATES = java.util.Set.of( + "WAITING", "PENDING", "IDLE"); + + private ExamParticipantDisplayStatusResolver() {} + + public static boolean isExamSessionEnded(Exam exam, LocalDateTime now) { + if (exam.getState() == ExamState.ENDED) { + return true; + } + return exam.getEndsAt() != null && !exam.getEndsAt().isAfter(now); + } + + public static ExamState displayExamState(Exam exam, LocalDateTime now) { + if (isExamSessionEnded(exam, now) && exam.getState() != ExamState.ENDED) { + return ExamState.ENDED; + } + return exam.getState(); + } + + public static ParticipantSubmissionDisplayStatus resolveSubmissionDisplayStatus( + boolean submitted, + Submission submission, + Score score) { + if (!submitted || submission == null) { + return ParticipantSubmissionDisplayStatus.NOT_SUBMITTED; + } + if (isGradingComplete(submission, score)) { + return ParticipantSubmissionDisplayStatus.GRADED; + } + return ParticipantSubmissionDisplayStatus.GRADING; + } + + public static ParticipantAttendanceStatus resolveAttendanceStatus( + Exam exam, + ExamParticipant participant, + boolean submitted, + Submission submission, + Score score, + LocalDateTime now) { + boolean sessionEnded = isExamSessionEnded(exam, now); + + if (sessionEnded) { + return ParticipantAttendanceStatus.ENDED; + } + + if (submitted && resolveSubmissionDisplayStatus(submitted, submission, score) + != ParticipantSubmissionDisplayStatus.NOT_SUBMITTED) { + return ParticipantAttendanceStatus.SUBMITTED; + } + + String state = participant.getState() != null ? participant.getState().trim().toUpperCase() : ""; + if (WAITING_STATES.contains(state)) { + return ParticipantAttendanceStatus.WAITING; + } + if ((participant.getTokenUsed() != null && participant.getTokenUsed() > 0) + || participant.getJoinedAt() != null) { + return ParticipantAttendanceStatus.IN_EXAM; + } + if (!state.isEmpty() && !"ENDED".equals(state)) { + return ParticipantAttendanceStatus.IN_EXAM; + } + + return ParticipantAttendanceStatus.WAITING; + } + + private static boolean isGradingComplete(Submission submission, Score score) { + if (score != null) { + if (score.getTotalScore() != null) { + return true; + } + if (score.getUpdatedAt() != null || score.getCreatedAt() != null) { + return true; + } + } + SubmissionStatus status = submission.getStatus(); + return status == SubmissionStatus.DONE || status == SubmissionStatus.FAILED; + } + + public static boolean hasScore(Score score) { + if (score == null) { + return false; + } + BigDecimal total = score.getTotalScore(); + return total != null; + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCase.java index fc866c0..b3019ae 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCase.java @@ -21,6 +21,7 @@ import com.yd.vibecode.domain.problem.infrastructure.entity.ProblemSetItem; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetItemRepository; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import com.yd.vibecode.global.exception.RestApiException; import com.yd.vibecode.global.exception.code.status.ExamErrorStatus; import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; @@ -37,6 +38,7 @@ public class CreateExamUseCase { private final ProblemSetRepository problemSetRepository; private final ProblemSetItemRepository problemSetItemRepository; private final EntryCodeRepository entryCodeRepository; + private final AdminActivityLogService adminActivityLogService; @Transactional public ExamResponse execute(Long adminId, CreateExamRequest request) { @@ -95,7 +97,9 @@ public ExamResponse execute(Long adminId, CreateExamRequest request) { .build(); entryCodeRepository.save(entryCode); - + + adminActivityLogService.logRoomCreated(adminId, savedExam.getId(), savedExam.getTitle()); + return ExamResponse.from(savedExam); } } diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCase.java index af79a7f..86859a2 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCase.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.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.service.AdminAccountDeletionService; import com.yd.vibecode.domain.auth.domain.service.AdminService; @@ -19,6 +20,7 @@ public class DeleteAdminByMasterUseCase { private final AdminService adminService; private final AdminAccountDeletionService adminAccountDeletionService; + private final MasterActivityLogService masterActivityLogService; @Transactional public void execute(Long requesterId, String targetAdminNumber) { @@ -29,5 +31,9 @@ public void execute(Long requesterId, String targetAdminNumber) { Admin target = adminService.findByAdminNumber(targetAdminNumber); adminAccountDeletionService.softDelete(target, requesterId, AUDIT_ACTION); + masterActivityLogService.logAdminAccountDeleted( + requesterId, + target.getId(), + target.getDisplayName()); } } diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCase.java new file mode 100644 index 0000000..a232373 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCase.java @@ -0,0 +1,51 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogResponse; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetAdminActivityLogsUseCase { + + private static final int MAX_PAGE_SIZE = 100; + + private final AdminActivityLogRepository adminActivityLogRepository; + + public AdminActivityLogPageResponse execute( + Long adminId, + String keyword, + AdminActivityLogType type, + int page, + int size) { + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + String normalizedKeyword = keyword != null ? keyword.trim() : null; + + Pageable pageable = PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = adminActivityLogRepository.searchByAdmin( + adminId, + type, + normalizedKeyword, + pageable); + + return new AdminActivityLogPageResponse( + result.getContent().stream().map(AdminActivityLogResponse::from).toList(), + result.getNumber(), + safeSize, + result.getTotalElements(), + result.getTotalPages()); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCase.java index 8d5a033..fb173fa 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCase.java @@ -1,9 +1,12 @@ package com.yd.vibecode.domain.admin.application.usecase; import com.yd.vibecode.domain.admin.application.dto.response.ExamineeBoardResponse; +import com.yd.vibecode.domain.admin.application.service.ExamParticipantDisplayStatusResolver; import com.yd.vibecode.domain.auth.domain.entity.User; +import com.yd.vibecode.domain.exam.domain.entity.Exam; import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; import com.yd.vibecode.domain.exam.domain.repository.ExamParticipantRepository; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; import com.yd.vibecode.domain.auth.domain.repository.UserRepository; import com.yd.vibecode.domain.submission.domain.entity.Score; import com.yd.vibecode.domain.submission.domain.entity.Submission; @@ -26,6 +29,7 @@ @RequiredArgsConstructor public class GetExamineeBoardUseCase { + private final ExamRepository examRepository; private final ExamParticipantRepository examParticipantRepository; private final UserRepository userRepository; private final SubmissionRepository submissionRepository; @@ -33,6 +37,12 @@ public class GetExamineeBoardUseCase { @Transactional(readOnly = true) public List execute(Long examId) { + Exam exam = examRepository.findById(examId) + .orElseThrow(() -> new com.yd.vibecode.global.exception.RestApiException( + com.yd.vibecode.global.exception.code.status.ExamErrorStatus.EXAM_NOT_FOUND)); + + LocalDateTime now = LocalDateTime.now(); + List participants = examParticipantRepository.findAllByExamId(examId); List participantIds = participants.stream() @@ -68,8 +78,9 @@ public List execute(Long examId) { BigDecimal correctnessScore = null; BigDecimal totalScore = null; LocalDateTime evaluatedAt = null; + Score score = null; if (submitted) { - Score score = scoreBySubmissionId.get(sub.getId()); + score = scoreBySubmissionId.get(sub.getId()); if (score != null) { promptScore = score.getPromptScore(); perfScore = score.getPerfScore(); @@ -78,6 +89,11 @@ public List execute(Long examId) { evaluatedAt = score.getUpdatedAt() != null ? score.getUpdatedAt() : score.getCreatedAt(); } } + var submissionDisplayStatus = ExamParticipantDisplayStatusResolver.resolveSubmissionDisplayStatus( + submitted, sub, score); + var attendanceStatus = ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, submitted, sub, score, now); + return ExamineeBoardResponse.of( ep, p, @@ -89,7 +105,9 @@ public List execute(Long examId) { correctnessScore, totalScore, submittedAt, - evaluatedAt + evaluatedAt, + attendanceStatus, + submissionDisplayStatus ); }) .toList(); diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCase.java new file mode 100644 index 0000000..ebbd4b3 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCase.java @@ -0,0 +1,60 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogResponse; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; +import com.yd.vibecode.domain.auth.domain.entity.Admin; +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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetMasterActivityLogsUseCase { + + private static final int MAX_PAGE_SIZE = 100; + + private final AdminService adminService; + private final MasterActivityLogRepository masterActivityLogRepository; + + public MasterActivityLogPageResponse execute( + Long requesterId, + String keyword, + MasterActivityLogType type, + int page, + int size) { + Admin requester = adminService.findById(requesterId); + if (!requester.isMaster()) { + throw new RestApiException(AuthErrorStatus.MASTER_ONLY); + } + + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + String normalizedKeyword = keyword != null ? keyword.trim() : null; + + Pageable pageable = PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = masterActivityLogRepository.search( + type, + normalizedKeyword, + pageable); + + return new MasterActivityLogPageResponse( + result.getContent().stream().map(MasterActivityLogResponse::from).toList(), + result.getNumber(), + safeSize, + result.getTotalElements(), + result.getTotalPages()); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCase.java new file mode 100644 index 0000000..17b6633 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCase.java @@ -0,0 +1,31 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; +import com.yd.vibecode.domain.auth.domain.entity.Admin; +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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class GetMasterPlatformSettingsUseCase { + + private final AdminService adminService; + private final PlatformSettingsService platformSettingsService; + + @Transactional + public MasterPlatformSettingsResponse execute(Long requesterId) { + Admin requester = adminService.findById(requesterId); + if (!requester.isMaster()) { + throw new RestApiException(AuthErrorStatus.MASTER_ONLY); + } + + return MasterPlatformSettingsResponse.from(platformSettingsService.getOrCreate()); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCase.java index 7f2dfb1..44309b6 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCase.java @@ -2,6 +2,7 @@ import com.yd.vibecode.domain.admin.application.dto.request.AdminNumberIssueRequest; import com.yd.vibecode.domain.admin.application.dto.response.AdminNumberResponse; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; import com.yd.vibecode.domain.auth.domain.service.AdminNumberService; @@ -18,6 +19,7 @@ public class IssueAdminNumberUseCase { private final AdminService adminService; private final AdminNumberService adminNumberService; + private final MasterActivityLogService masterActivityLogService; @Transactional public AdminNumberResponse execute(Long requesterId, AdminNumberIssueRequest request) { @@ -27,6 +29,7 @@ public AdminNumberResponse execute(Long requesterId, AdminNumberIssueRequest req } AdminNumber issued = adminNumberService.issue(admin.getId(), request.label(), request.expiresAt()); + masterActivityLogService.logSignupCodeIssued(admin.getId()); return AdminNumberResponse.from(issued); } } diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCase.java index ef09b5f..fcb6bc8 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCase.java @@ -2,6 +2,7 @@ import com.yd.vibecode.domain.admin.application.dto.response.ResetAdminPasswordByMasterResponse; import com.yd.vibecode.domain.admin.domain.service.AdminAuditLogService; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.service.AdminService; import com.yd.vibecode.global.exception.RestApiException; @@ -18,6 +19,7 @@ public class ResetAdminPasswordByMasterUseCase { private final AdminService adminService; private final AdminAuditLogService adminAuditLogService; + private final MasterActivityLogService masterActivityLogService; private final TemporaryPasswordGenerator temporaryPasswordGenerator; @Transactional @@ -41,6 +43,11 @@ public ResetAdminPasswordByMasterResponse execute(Long requesterId, String admin "targetAdminNumber", target.getAdminNumber() )); + masterActivityLogService.logAdminPasswordReset( + requesterId, + target.getId(), + target.getDisplayName()); + return new ResetAdminPasswordByMasterResponse(temporaryPassword); } } diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCase.java index c263596..215a069 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCase.java @@ -5,6 +5,7 @@ import com.yd.vibecode.domain.admin.application.dto.request.AdminNumberUpdateRequest; import com.yd.vibecode.domain.admin.application.dto.response.AdminNumberResponse; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; import com.yd.vibecode.domain.auth.domain.service.AdminNumberService; @@ -20,6 +21,7 @@ public class UpdateAdminNumberUseCase { private final AdminService adminService; private final AdminNumberService adminNumberService; + private final MasterActivityLogService masterActivityLogService; @Transactional public AdminNumberResponse execute(Long requesterId, String adminNumber, AdminNumberUpdateRequest request) { @@ -28,22 +30,64 @@ public AdminNumberResponse execute(Long requesterId, String adminNumber, AdminNu throw new RestApiException(AuthErrorStatus.MASTER_ONLY); } - AdminNumber updated = adminNumberService.update(adminNumber, request.label(), request.active(), request.expiresAt()); - - // 관리자 번호 상태 변경 시 해당 관리자 번호로 생성된 Admin 계정 상태도 동기화 - if (request.active() != null && updated.getAssignedAdminId() != null) { - Admin assignedAdmin = adminService.findById(updated.getAssignedAdminId()); - - // 마스터 계정은 비활성화할 수 없음 - if (!request.active() && assignedAdmin.isMaster()) { - throw new RestApiException(AuthErrorStatus.MASTER_ACCOUNT_CANNOT_BE_DEACTIVATED); + AdminNumber adminNumberBeforeUpdate = adminNumberService.getByAdminNumber(adminNumber); + Long assignedAdminIdBeforeUpdate = adminNumberBeforeUpdate.getAssignedAdminId(); + boolean adminNumberWasActive = resolveActiveState(adminNumberBeforeUpdate.getIsActive()); + + AdminNumber updatedAdminNumber = adminNumberService.update( + adminNumber, request.label(), request.active(), request.expiresAt()); + + if (request.active() != null) { + if (assignedAdminIdBeforeUpdate != null) { + syncAssignedAdminAndLog( + requesterId, + assignedAdminIdBeforeUpdate, + request.active()); + } else { + boolean isActive = resolveActiveState(updatedAdminNumber.getIsActive()); + recordActiveStateChange(requesterId, null, null, adminNumberWasActive, isActive); } - - assignedAdmin.updateActive(request.active()); } - - return AdminNumberResponse.from(updated); + + return AdminNumberResponse.from(updatedAdminNumber); } -} + private void syncAssignedAdminAndLog(Long requesterId, Long assignedAdminId, boolean requestedActive) { + Admin assignedAdmin = adminService.findById(assignedAdminId); + + if (!requestedActive && assignedAdmin.isMaster()) { + throw new RestApiException(AuthErrorStatus.MASTER_ACCOUNT_CANNOT_BE_DEACTIVATED); + } + + boolean wasActive = resolveActiveState(assignedAdmin.getIsActive()); + assignedAdmin.updateActive(requestedActive); + boolean isActive = resolveActiveState(requestedActive); + recordActiveStateChange( + requesterId, + assignedAdminId, + assignedAdmin.getDisplayName(), + wasActive, + isActive); + } + + private void recordActiveStateChange( + Long requesterId, + Long assignedAdminId, + String displayName, + boolean wasActive, + boolean isActive) { + if (wasActive && !isActive) { + masterActivityLogService.logSignupCodeDeactivated(requesterId, assignedAdminId, displayName); + } else if (!wasActive && isActive) { + masterActivityLogService.logSignupCodeReactivated(requesterId, assignedAdminId, displayName); + } + } + + /** + * 가입 번호 기본 상태는 활성화. null은 활성(true)으로 간주한다. + */ + static boolean resolveActiveState(Boolean active) { + return !Boolean.FALSE.equals(active); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCase.java new file mode 100644 index 0000000..91e9813 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCase.java @@ -0,0 +1,56 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.application.dto.request.UpdateMasterPlatformSettingsRequest; +import com.yd.vibecode.domain.admin.application.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; +import com.yd.vibecode.domain.auth.domain.entity.Admin; +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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UpdateMasterPlatformSettingsUseCase { + + private final AdminService adminService; + private final PlatformSettingsService platformSettingsService; + private final MasterActivityLogService masterActivityLogService; + + @Transactional + public MasterPlatformSettingsResponse execute(Long requesterId, UpdateMasterPlatformSettingsRequest request) { + Admin requester = adminService.findById(requesterId); + if (!requester.isMaster()) { + throw new RestApiException(AuthErrorStatus.MASTER_ONLY); + } + + PlatformSettings before = platformSettingsService.getOrCreate(); + boolean changed = hasChanges(before, request); + + PlatformSettings updated = platformSettingsService.updateRetention( + request.logRetentionDays(), + request.submissionRetentionDays(), + request.autoDeleteExpiredData()); + + if (changed) { + masterActivityLogService.logPlatformSettingsUpdated(requesterId); + } + + return MasterPlatformSettingsResponse.from(updated); + } + + private static boolean hasChanges(PlatformSettings before, UpdateMasterPlatformSettingsRequest request) { + boolean beforeAutoDelete = Boolean.TRUE.equals(before.getAutoDeleteExpiredData()); + return !Objects.equals(before.getLogRetentionDays(), request.logRetentionDays()) + || !Objects.equals(before.getSubmissionRetentionDays(), request.submissionRetentionDays()) + || beforeAutoDelete != request.autoDeleteExpiredData(); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCase.java b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCase.java new file mode 100644 index 0000000..369abe4 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCase.java @@ -0,0 +1,53 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.application.dto.request.UpdateProblemAvailabilityRequest; +import com.yd.vibecode.domain.admin.application.dto.response.ProblemResponse; +import com.yd.vibecode.domain.problem.domain.entity.Problem; +import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; +import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UpdateProblemAvailabilityUseCase { + + private final ProblemRepository problemRepository; + + @Transactional + public ProblemResponse execute(Long problemId, UpdateProblemAvailabilityRequest request) { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new RestApiException(ProblemErrorStatus.PROBLEM_NOT_FOUND)); + + if (Boolean.TRUE.equals(request.available())) { + if (problem.getStatus() != ProblemStatus.PUBLISHED) { + problem.publish(); + } + } else { + if (problem.getStatus() == ProblemStatus.PUBLISHED) { + List publishedProblems = + problemRepository.findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + if (publishedProblems.size() <= 1) { + throw new RestApiException(ProblemErrorStatus.LAST_AVAILABLE_PROBLEM_REQUIRED); + } + Problem publishedTarget = publishedProblems.stream() + .filter(p -> p.getId().equals(problemId)) + .findFirst() + .orElseThrow(() -> new RestApiException(ProblemErrorStatus.PROBLEM_NOT_FOUND)); + publishedTarget.archive(); + return ProblemResponse.from(publishedTarget); + } else if (problem.getStatus() != ProblemStatus.ARCHIVED) { + problem.archive(); + } + } + + return ProblemResponse.from(problem); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLog.java b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLog.java new file mode 100644 index 0000000..3a1df8f --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLog.java @@ -0,0 +1,62 @@ +package com.yd.vibecode.domain.admin.domain.entity; + +import com.yd.vibecode.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "admin_activity_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdminActivityLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, name = "admin_id") + private Long adminId; + + @Column(nullable = false, name = "exam_id") + private Long examId; + + @Column(name = "participant_id") + private Long participantId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private AdminActivityLogType type; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, length = 500) + private String message; + + @Builder + public AdminActivityLog( + Long adminId, + Long examId, + Long participantId, + AdminActivityLogType type, + String title, + String message) { + this.adminId = adminId; + this.examId = examId; + this.participantId = participantId; + this.type = type; + this.title = title; + this.message = message; + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLogType.java b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLogType.java new file mode 100644 index 0000000..5c14766 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/AdminActivityLogType.java @@ -0,0 +1,8 @@ +package com.yd.vibecode.domain.admin.domain.entity; + +public enum AdminActivityLogType { + ROOM_CREATED, + EXAM_STARTED, + EVALUATION_COMPLETED, + EXAM_ENDED +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLog.java b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLog.java new file mode 100644 index 0000000..a8923d4 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLog.java @@ -0,0 +1,57 @@ +package com.yd.vibecode.domain.admin.domain.entity; + +import com.yd.vibecode.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "master_activity_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MasterActivityLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "master_id") + private Long masterId; + + @Column(name = "target_admin_id") + private Long targetAdminId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private MasterActivityLogType type; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, length = 500) + private String message; + + @Builder + public MasterActivityLog( + Long masterId, + Long targetAdminId, + MasterActivityLogType type, + String title, + String message) { + this.masterId = masterId; + this.targetAdminId = targetAdminId; + this.type = type; + this.title = title; + this.message = message; + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLogType.java b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLogType.java new file mode 100644 index 0000000..accf753 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/MasterActivityLogType.java @@ -0,0 +1,11 @@ +package com.yd.vibecode.domain.admin.domain.entity; + +public enum MasterActivityLogType { + ADMIN_SIGNUP_CODE_ISSUED, + ADMIN_SIGNUP_CODE_DEACTIVATED, + ADMIN_SIGNUP_CODE_REACTIVATED, + ADMIN_SIGNED_UP, + ADMIN_ACCOUNT_DELETED, + ADMIN_PASSWORD_RESET, + PLATFORM_SETTINGS_UPDATED +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/entity/PlatformSettings.java b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/PlatformSettings.java new file mode 100644 index 0000000..7eb0632 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/entity/PlatformSettings.java @@ -0,0 +1,67 @@ +package com.yd.vibecode.domain.admin.domain.entity; + +import com.yd.vibecode.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 플랫폼 전역 설정 (단일 row, id=1). + * {@code autoDeleteExpiredData=false}이면 향후 만료 데이터 자동 삭제 스케줄러는 실행되지 않아야 한다. + */ +@Entity +@Table(name = "platform_settings") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlatformSettings extends BaseEntity { + + public static final long SINGLETON_ID = 1L; + + @Id + private Long id = SINGLETON_ID; + + @Column(name = "default_token_limit", nullable = false) + private Integer defaultTokenLimit; + + @Column(name = "log_retention_days", nullable = false) + private Integer logRetentionDays; + + @Column(name = "submission_retention_days", nullable = false) + private Integer submissionRetentionDays; + + /** + * false이면 만료 데이터 자동 삭제 스케줄러(향후 구현)가 실행되지 않는 정책 값이다. + */ + @Column(name = "auto_delete_expired_data", nullable = false) + private Boolean autoDeleteExpiredData; + + @Builder + public PlatformSettings( + Long id, + Integer defaultTokenLimit, + Integer logRetentionDays, + Integer submissionRetentionDays, + Boolean autoDeleteExpiredData) { + this.id = id != null ? id : SINGLETON_ID; + this.defaultTokenLimit = defaultTokenLimit; + this.logRetentionDays = logRetentionDays; + this.submissionRetentionDays = submissionRetentionDays; + this.autoDeleteExpiredData = autoDeleteExpiredData != null ? autoDeleteExpiredData : true; + } + + /** 데이터 보관 정책만 갱신한다. default_token_limit(DB 컬럼)은 변경하지 않는다. */ + public void updateRetention( + int logRetentionDays, + int submissionRetentionDays, + boolean autoDeleteExpiredData) { + this.logRetentionDays = logRetentionDays; + this.submissionRetentionDays = submissionRetentionDays; + this.autoDeleteExpiredData = autoDeleteExpiredData; + } +} 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 new file mode 100644 index 0000000..aa63c4e --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepository.java @@ -0,0 +1,29 @@ +package com.yd.vibecode.domain.admin.domain.repository; + +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.Query; +import org.springframework.data.repository.query.Param; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; + +public interface AdminActivityLogRepository extends JpaRepository { + + @Query(""" + SELECT l FROM AdminActivityLog l + WHERE l.adminId = :adminId + AND (:type IS NULL OR l.type = :type) + AND ( + :keyword IS NULL OR :keyword = '' + OR LOWER(l.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(l.message) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + """) + Page searchByAdmin( + @Param("adminId") Long adminId, + @Param("type") AdminActivityLogType type, + @Param("keyword") String keyword, + Pageable pageable); +} 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 new file mode 100644 index 0000000..b323a8b --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepository.java @@ -0,0 +1,27 @@ +package com.yd.vibecode.domain.admin.domain.repository; + +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.Query; +import org.springframework.data.repository.query.Param; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; + +public interface MasterActivityLogRepository extends JpaRepository { + + @Query(""" + SELECT l FROM MasterActivityLog l + WHERE (:type IS NULL OR l.type = :type) + AND ( + :keyword IS NULL OR :keyword = '' + OR LOWER(l.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(l.message) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + """) + Page search( + @Param("type") MasterActivityLogType type, + @Param("keyword") String keyword, + Pageable pageable); +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/repository/PlatformSettingsRepository.java b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/PlatformSettingsRepository.java new file mode 100644 index 0000000..9cc9ce8 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/repository/PlatformSettingsRepository.java @@ -0,0 +1,8 @@ +package com.yd.vibecode.domain.admin.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; + +public interface PlatformSettingsRepository extends JpaRepository { +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogService.java b/src/main/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogService.java new file mode 100644 index 0000000..7169c14 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogService.java @@ -0,0 +1,93 @@ +package com.yd.vibecode.domain.admin.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminActivityLogService { + + private static final String ROOM_CREATED_TITLE = "시험 방 생성됨"; + private static final String EXAM_STARTED_TITLE = "시험 세션이 시작되었습니다."; + private static final String EVALUATION_COMPLETED_TITLE = "채점 과정이 성공적으로 완료되었습니다."; + private static final String EXAM_ENDED_TITLE = "시험이 종료되었습니다."; + + private final AdminActivityLogRepository adminActivityLogRepository; + + @Transactional + public AdminActivityLog logRoomCreated(Long adminId, Long examId, String examTitle) { + return save(adminId, examId, null, AdminActivityLogType.ROOM_CREATED, + ROOM_CREATED_TITLE, + formatMessage("'%s' 시험 방이 생성되었습니다.", examTitle, examId)); + } + + @Transactional + public AdminActivityLog logExamStarted(Long adminId, Long examId, String examTitle) { + return save(adminId, examId, null, AdminActivityLogType.EXAM_STARTED, + EXAM_STARTED_TITLE, + formatMessage("'%s' 시험이 시작되었습니다.", examTitle, examId)); + } + + @Transactional + public AdminActivityLog logEvaluationCompleted( + Long adminId, + Long examId, + Long participantId, + String examTitle, + String participantDisplayName) { + String message = buildEvaluationCompletedMessage(examTitle, examId, participantDisplayName); + return save(adminId, examId, participantId, AdminActivityLogType.EVALUATION_COMPLETED, + EVALUATION_COMPLETED_TITLE, message); + } + + @Transactional + public AdminActivityLog logExamEnded(Long adminId, Long examId, String examTitle) { + return save(adminId, examId, null, AdminActivityLogType.EXAM_ENDED, + EXAM_ENDED_TITLE, + formatMessage("'%s' 시험이 종료되었습니다.", examTitle, examId)); + } + + static String resolveExamLabel(String examTitle, Long examId) { + if (examTitle != null && !examTitle.isBlank()) { + return examTitle.trim(); + } + return "시험 ID " + examId; + } + + static String formatMessage(String template, String examTitle, Long examId) { + return String.format(template, resolveExamLabel(examTitle, examId)); + } + + static String buildEvaluationCompletedMessage(String examTitle, Long examId, String participantDisplayName) { + String examLabel = resolveExamLabel(examTitle, examId); + if (participantDisplayName != null && !participantDisplayName.isBlank()) { + return String.format("참가자 %s님의 '%s' 채점이 완료되었습니다.", + participantDisplayName.trim(), examLabel); + } + return String.format("'%s' 시험의 참가자 채점이 완료되었습니다.", examLabel); + } + + private AdminActivityLog save( + Long adminId, + Long examId, + Long participantId, + AdminActivityLogType type, + String title, + String message) { + AdminActivityLog log = AdminActivityLog.builder() + .adminId(adminId) + .examId(examId) + .participantId(participantId) + .type(type) + .title(title) + .message(message) + .build(); + return adminActivityLogRepository.save(log); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogService.java b/src/main/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogService.java new file mode 100644 index 0000000..a228d76 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogService.java @@ -0,0 +1,119 @@ +package com.yd.vibecode.domain.admin.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MasterActivityLogService { + + private static final String SIGNUP_CODE_ISSUED_TITLE = "관리자 가입 번호가 발급되었습니다"; + private static final String SIGNUP_CODE_ISSUED_MESSAGE = "새 관리자가 가입할 수 있는 가입 번호가 발급되었습니다."; + + private static final String SIGNUP_CODE_DEACTIVATED_TITLE = "관리자 가입 번호가 비활성화되었습니다"; + private static final String SIGNUP_CODE_DEACTIVATED_MESSAGE = "관리자 가입 번호가 비활성화되었습니다."; + + private static final String SIGNUP_CODE_REACTIVATED_TITLE = "관리자 가입 번호가 재활성화되었습니다"; + private static final String SIGNUP_CODE_REACTIVATED_MESSAGE = "관리자 가입 번호가 다시 사용 가능 상태로 변경되었습니다."; + + private static final String ADMIN_ACCOUNT_DEACTIVATED_TITLE = "관리자 계정이 비활성화되었습니다"; + private static final String ADMIN_ACCOUNT_REACTIVATED_TITLE = "관리자 계정이 재활성화되었습니다"; + + private static final String ADMIN_SIGNED_UP_TITLE = "관리자 가입이 완료되었습니다"; + private static final String ADMIN_ACCOUNT_DELETED_TITLE = "관리자 계정이 삭제되었습니다"; + private static final String ADMIN_PASSWORD_RESET_TITLE = "관리자 비밀번호가 재설정되었습니다"; + + private static final String PLATFORM_SETTINGS_UPDATED_TITLE = "플랫폼 전역 설정이 변경되었습니다"; + private static final String PLATFORM_SETTINGS_UPDATED_MESSAGE = "플랫폼 전역 설정이 변경되었습니다."; + + private final MasterActivityLogRepository masterActivityLogRepository; + + @Transactional + public MasterActivityLog logSignupCodeIssued(Long masterId) { + return save(masterId, null, MasterActivityLogType.ADMIN_SIGNUP_CODE_ISSUED, + SIGNUP_CODE_ISSUED_TITLE, SIGNUP_CODE_ISSUED_MESSAGE); + } + + @Transactional + public MasterActivityLog logSignupCodeDeactivated(Long masterId, Long targetAdminId, String displayName) { + if (targetAdminId != null) { + String label = resolveTargetDisplayName(displayName, targetAdminId); + return save(masterId, targetAdminId, MasterActivityLogType.ADMIN_SIGNUP_CODE_DEACTIVATED, + ADMIN_ACCOUNT_DEACTIVATED_TITLE, + String.format("'%s' 관리자 계정이 비활성화되었습니다.", label)); + } + return save(masterId, null, MasterActivityLogType.ADMIN_SIGNUP_CODE_DEACTIVATED, + SIGNUP_CODE_DEACTIVATED_TITLE, SIGNUP_CODE_DEACTIVATED_MESSAGE); + } + + @Transactional + public MasterActivityLog logSignupCodeReactivated(Long masterId, Long targetAdminId, String displayName) { + if (targetAdminId != null) { + String label = resolveTargetDisplayName(displayName, targetAdminId); + return save(masterId, targetAdminId, MasterActivityLogType.ADMIN_SIGNUP_CODE_REACTIVATED, + ADMIN_ACCOUNT_REACTIVATED_TITLE, + String.format("'%s' 관리자 계정이 재활성화되었습니다.", label)); + } + return save(masterId, null, MasterActivityLogType.ADMIN_SIGNUP_CODE_REACTIVATED, + SIGNUP_CODE_REACTIVATED_TITLE, SIGNUP_CODE_REACTIVATED_MESSAGE); + } + + @Transactional + public MasterActivityLog logAdminSignedUp(Long masterId, Long targetAdminId, String displayName) { + String label = resolveTargetDisplayName(displayName, targetAdminId); + return save(masterId, targetAdminId, MasterActivityLogType.ADMIN_SIGNED_UP, + ADMIN_SIGNED_UP_TITLE, + String.format("'%s' 관리자가 가입을 완료했습니다.", label)); + } + + @Transactional + public MasterActivityLog logAdminAccountDeleted(Long masterId, Long targetAdminId, String displayName) { + String label = resolveTargetDisplayName(displayName, targetAdminId); + return save(masterId, targetAdminId, MasterActivityLogType.ADMIN_ACCOUNT_DELETED, + ADMIN_ACCOUNT_DELETED_TITLE, + String.format("'%s' 관리자 계정이 삭제되었습니다.", label)); + } + + @Transactional + public MasterActivityLog logPlatformSettingsUpdated(Long masterId) { + return save(masterId, null, MasterActivityLogType.PLATFORM_SETTINGS_UPDATED, + PLATFORM_SETTINGS_UPDATED_TITLE, PLATFORM_SETTINGS_UPDATED_MESSAGE); + } + + @Transactional + public MasterActivityLog logAdminPasswordReset(Long masterId, Long targetAdminId, String displayName) { + String label = resolveTargetDisplayName(displayName, targetAdminId); + return save(masterId, targetAdminId, MasterActivityLogType.ADMIN_PASSWORD_RESET, + ADMIN_PASSWORD_RESET_TITLE, + String.format("'%s' 관리자 비밀번호가 재설정되었습니다.", label)); + } + + static String resolveTargetDisplayName(String displayName, Long adminId) { + if (displayName != null && !displayName.isBlank()) { + return displayName.trim(); + } + return "관리자 ID " + adminId; + } + + private MasterActivityLog save( + Long masterId, + Long targetAdminId, + MasterActivityLogType type, + String title, + String message) { + MasterActivityLog log = MasterActivityLog.builder() + .masterId(masterId) + .targetAdminId(targetAdminId) + .type(type) + .title(title) + .message(message) + .build(); + return masterActivityLogRepository.save(log); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsService.java b/src/main/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsService.java new file mode 100644 index 0000000..fad916f --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsService.java @@ -0,0 +1,49 @@ +package com.yd.vibecode.domain.admin.domain.service; + +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.PlatformSettingsRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PlatformSettingsService { + + public static final int DEFAULT_TOKEN_LIMIT = 10_000; + public static final int DEFAULT_LOG_RETENTION_DAYS = 90; + public static final int DEFAULT_SUBMISSION_RETENTION_DAYS = 90; + public static final boolean DEFAULT_AUTO_DELETE_EXPIRED_DATA = true; + + private final PlatformSettingsRepository platformSettingsRepository; + + @Transactional + public PlatformSettings getOrCreate() { + return platformSettingsRepository.findById(PlatformSettings.SINGLETON_ID) + .orElseGet(this::createAndSaveDefaults); + } + + @Transactional + public PlatformSettings updateRetention( + int logRetentionDays, + int submissionRetentionDays, + boolean autoDeleteExpiredData) { + PlatformSettings settings = getOrCreate(); + settings.updateRetention(logRetentionDays, submissionRetentionDays, autoDeleteExpiredData); + return platformSettingsRepository.save(settings); + } + + @Transactional + public PlatformSettings createAndSaveDefaults() { + PlatformSettings defaults = PlatformSettings.builder() + .id(PlatformSettings.SINGLETON_ID) + .defaultTokenLimit(DEFAULT_TOKEN_LIMIT) + .logRetentionDays(DEFAULT_LOG_RETENTION_DAYS) + .submissionRetentionDays(DEFAULT_SUBMISSION_RETENTION_DAYS) + .autoDeleteExpiredData(DEFAULT_AUTO_DELETE_EXPIRED_DATA) + .build(); + return platformSettingsRepository.save(defaults); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/infrastructure/PlatformSettingsInitializer.java b/src/main/java/com/yd/vibecode/domain/admin/infrastructure/PlatformSettingsInitializer.java new file mode 100644 index 0000000..288c5dc --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/infrastructure/PlatformSettingsInitializer.java @@ -0,0 +1,52 @@ +package com.yd.vibecode.domain.admin.infrastructure; + +import java.sql.SQLException; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Component; + +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@Order(3) +public class PlatformSettingsInitializer implements ApplicationRunner { + + /** PostgreSQL: undefined_table */ + private static final String SQLSTATE_UNDEFINED_TABLE = "42P01"; + + private final PlatformSettingsService platformSettingsService; + + @Override + public void run(ApplicationArguments args) { + try { + platformSettingsService.getOrCreate(); + log.debug("[PlatformSettingsInitializer] Platform settings ready"); + } catch (DataAccessException e) { + if (isPlatformSettingsTableMissing(e)) { + log.warn("[PlatformSettingsInitializer] platform_settings table not yet created. Skipping init."); + } else { + log.error("[PlatformSettingsInitializer] Failed to initialize platform settings: {}", + e.getMessage(), e); + } + } catch (Exception e) { + log.error("[PlatformSettingsInitializer] Unexpected error: {}", e.getMessage(), e); + } + } + + private static boolean isPlatformSettingsTableMissing(DataAccessException e) { + Throwable cause = NestedExceptionUtils.getMostSpecificCause(e); + if (cause instanceof SQLException sqlException) { + return SQLSTATE_UNDEFINED_TABLE.equals(sqlException.getSQLState()); + } + return false; + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogController.java b/src/main/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogController.java new file mode 100644 index 0000000..b97c96c --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogController.java @@ -0,0 +1,36 @@ +package com.yd.vibecode.domain.admin.ui; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetAdminActivityLogsUseCase; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.global.annotation.CurrentUser; +import com.yd.vibecode.global.common.BaseResponse; +import com.yd.vibecode.global.swagger.AdminActivityLogApi; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/logs") +public class AdminActivityLogController implements AdminActivityLogApi { + + private final GetAdminActivityLogsUseCase getAdminActivityLogsUseCase; + + @GetMapping + @Override + public BaseResponse getLogs( + @CurrentUser Long adminId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) AdminActivityLogType type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + AdminActivityLogPageResponse response = getAdminActivityLogsUseCase.execute( + adminId, keyword, type, page, size); + return BaseResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/ui/AdminProblemController.java b/src/main/java/com/yd/vibecode/domain/admin/ui/AdminProblemController.java index fa8a50e..88f5253 100644 --- a/src/main/java/com/yd/vibecode/domain/admin/ui/AdminProblemController.java +++ b/src/main/java/com/yd/vibecode/domain/admin/ui/AdminProblemController.java @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import com.yd.vibecode.domain.admin.application.dto.request.CreateProblemRequest; +import com.yd.vibecode.domain.admin.application.dto.request.UpdateProblemAvailabilityRequest; import com.yd.vibecode.domain.admin.application.dto.response.ProblemDetailResponse; import com.yd.vibecode.domain.admin.application.dto.response.ProblemResponse; import com.yd.vibecode.domain.admin.application.dto.response.ProblemSpecResponse; @@ -21,6 +23,7 @@ import com.yd.vibecode.domain.admin.application.usecase.GetProblemDetailUseCase; import com.yd.vibecode.domain.admin.application.usecase.GetProblemSpecsUseCase; import com.yd.vibecode.domain.admin.application.usecase.GetProblemsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.UpdateProblemAvailabilityUseCase; import com.yd.vibecode.global.common.BaseResponse; import com.yd.vibecode.global.swagger.AdminProblemApi; @@ -37,6 +40,7 @@ public class AdminProblemController implements AdminProblemApi { private final DeleteProblemUseCase deleteProblemUseCase; private final GetProblemSpecsUseCase getProblemSpecsUseCase; private final GetProblemDetailUseCase getProblemDetailUseCase; + private final UpdateProblemAvailabilityUseCase updateProblemAvailabilityUseCase; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -53,6 +57,15 @@ public BaseResponse> getProblems() { return BaseResponse.onSuccess(response); } + @PatchMapping("/{problemId}/availability") + @Override + public BaseResponse updateProblemAvailability( + @PathVariable Long problemId, + @Valid @RequestBody UpdateProblemAvailabilityRequest request) { + ProblemResponse response = updateProblemAvailabilityUseCase.execute(problemId, request); + return BaseResponse.onSuccess(response); + } + @DeleteMapping("/{problemId}") @Override public BaseResponse deleteProblem(@PathVariable Long problemId) { diff --git a/src/main/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogController.java b/src/main/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogController.java new file mode 100644 index 0000000..2db96c7 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogController.java @@ -0,0 +1,36 @@ +package com.yd.vibecode.domain.admin.ui; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetMasterActivityLogsUseCase; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.global.annotation.CurrentUser; +import com.yd.vibecode.global.common.BaseResponse; +import com.yd.vibecode.global.swagger.MasterActivityLogApi; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/master/logs") +public class MasterActivityLogController implements MasterActivityLogApi { + + private final GetMasterActivityLogsUseCase getMasterActivityLogsUseCase; + + @GetMapping + @Override + public BaseResponse getLogs( + @CurrentUser Long adminId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) MasterActivityLogType type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + MasterActivityLogPageResponse response = getMasterActivityLogsUseCase.execute( + adminId, keyword, type, page, size); + return BaseResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsController.java b/src/main/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsController.java new file mode 100644 index 0000000..d2b5cee --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsController.java @@ -0,0 +1,41 @@ +package com.yd.vibecode.domain.admin.ui; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.yd.vibecode.domain.admin.application.dto.request.UpdateMasterPlatformSettingsRequest; +import com.yd.vibecode.domain.admin.application.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetMasterPlatformSettingsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.UpdateMasterPlatformSettingsUseCase; +import com.yd.vibecode.global.annotation.CurrentUser; +import com.yd.vibecode.global.common.BaseResponse; +import com.yd.vibecode.global.swagger.MasterPlatformSettingsApi; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/master/settings") +public class MasterPlatformSettingsController implements MasterPlatformSettingsApi { + + private final GetMasterPlatformSettingsUseCase getMasterPlatformSettingsUseCase; + private final UpdateMasterPlatformSettingsUseCase updateMasterPlatformSettingsUseCase; + + @GetMapping + @Override + public BaseResponse getSettings(@CurrentUser Long adminId) { + return BaseResponse.onSuccess(getMasterPlatformSettingsUseCase.execute(adminId)); + } + + @PutMapping + @Override + public BaseResponse updateSettings( + @CurrentUser Long adminId, + @Valid @RequestBody UpdateMasterPlatformSettingsRequest request) { + return BaseResponse.onSuccess(updateMasterPlatformSettingsUseCase.execute(adminId, request)); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCase.java b/src/main/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCase.java index eac0f9f..c0f9fc3 100644 --- a/src/main/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCase.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.dto.request.AdminSignupRequest; import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; import com.yd.vibecode.domain.auth.domain.service.AdminNumberService; @@ -18,6 +19,7 @@ public class AdminSignupUseCase { private final AdminService adminService; private final AdminNumberService adminNumberService; + private final MasterActivityLogService masterActivityLogService; @Transactional public void execute(AdminSignupRequest request) { @@ -44,6 +46,11 @@ public void execute(AdminSignupRequest request) { // 5. 관리자 번호 사용 처리 adminNumberService.assign(adminNumber, admin.getId()); + + masterActivityLogService.logAdminSignedUp( + adminNumber.getIssuedBy(), + admin.getId(), + admin.getDisplayName()); } } diff --git a/src/main/java/com/yd/vibecode/domain/auth/domain/service/AdminNumberService.java b/src/main/java/com/yd/vibecode/domain/auth/domain/service/AdminNumberService.java index 063a416..9b26e90 100644 --- a/src/main/java/com/yd/vibecode/domain/auth/domain/service/AdminNumberService.java +++ b/src/main/java/com/yd/vibecode/domain/auth/domain/service/AdminNumberService.java @@ -30,15 +30,20 @@ public AdminNumber issue(Long issuedBy, String label, LocalDateTime expiresAt) { return adminNumberRepository.save(entity); } + @Transactional(readOnly = true) + public AdminNumber getByAdminNumber(String adminNumber) { + return findByAdminNumber(adminNumber); + } + public AdminNumber update(String adminNumber, String label, Boolean active, LocalDateTime expiresAt) { - AdminNumber target = getByAdminNumber(adminNumber); + AdminNumber target = findByAdminNumber(adminNumber); target.update(label, active, expiresAt); return target; } @Transactional(readOnly = true) public AdminNumber validateUsable(String adminNumber) { - AdminNumber found = getByAdminNumber(adminNumber); + AdminNumber found = findByAdminNumber(adminNumber); if (!found.isUsable()) { throw new RestApiException(AuthErrorStatus.ADMIN_NUMBER_INACTIVE); } @@ -49,7 +54,7 @@ public void assign(AdminNumber adminNumber, Long adminId) { adminNumber.assign(adminId, LocalDateTime.now()); } - private AdminNumber getByAdminNumber(String adminNumber) { + private AdminNumber findByAdminNumber(String adminNumber) { return adminNumberRepository.findByAdminNumber(adminNumber) .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ADMIN_NUMBER)); } diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/dto/request/SaveParticipantCodeDraftRequest.java b/src/main/java/com/yd/vibecode/domain/exam/application/dto/request/SaveParticipantCodeDraftRequest.java new file mode 100644 index 0000000..5ac2306 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/application/dto/request/SaveParticipantCodeDraftRequest.java @@ -0,0 +1,11 @@ +package com.yd.vibecode.domain.exam.application.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record SaveParticipantCodeDraftRequest( + @NotBlank(message = "언어는 필수입니다") + String lang, + + @NotBlank(message = "코드는 필수입니다") + String code +) {} diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/CodeDraftResponse.java b/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/CodeDraftResponse.java new file mode 100644 index 0000000..17d6f16 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/CodeDraftResponse.java @@ -0,0 +1,9 @@ +package com.yd.vibecode.domain.exam.application.dto.response; + +import java.time.LocalDateTime; + +public record CodeDraftResponse( + String language, + String codeInline, + LocalDateTime savedAt +) {} diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/ExamResponse.java b/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/ExamResponse.java index e1f1de6..06ed22e 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/ExamResponse.java +++ b/src/main/java/com/yd/vibecode/domain/exam/application/dto/response/ExamResponse.java @@ -1,5 +1,6 @@ package com.yd.vibecode.domain.exam.application.dto.response; +import com.yd.vibecode.domain.admin.application.service.ExamParticipantDisplayStatusResolver; import com.yd.vibecode.domain.exam.domain.entity.Exam; import com.yd.vibecode.domain.exam.domain.entity.ExamState; @@ -9,6 +10,8 @@ public record ExamResponse( Long id, String title, ExamState state, + /** endsAt 경과 등 화면 표시용 상태 (DB state와 다를 수 있음) */ + ExamState displayState, LocalDateTime startsAt, LocalDateTime endsAt, Integer version, @@ -33,10 +36,13 @@ public static ExamResponse from( long completedCount, String creatorName ) { + LocalDateTime now = LocalDateTime.now(); + ExamState displayState = ExamParticipantDisplayStatusResolver.displayExamState(exam, now); return new ExamResponse( exam.getId(), exam.getTitle(), exam.getState(), + displayState, exam.getStartsAt(), exam.getEndsAt(), exam.getVersion(), diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCase.java b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCase.java new file mode 100644 index 0000000..95d8d6f --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCase.java @@ -0,0 +1,85 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.repository.ExamParticipantRepository; +import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; +import com.yd.vibecode.domain.submission.domain.service.ParticipantSubmitOrchestrationService; +import com.yd.vibecode.global.exception.RestApiException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 시험 종료 시 미제출 참가자 자동 제출. + * 저장된 코드 스냅샷(lastCodeLang/lastCodeInline)이 있을 때만 제출한다. + * + *

TODO: 접속자·관리자가 없을 때 endsAt에 맞춰 자동 종료/제출하려면 스케줄러가 필요하다. + * 조회 API에서 매 요청마다 이 UseCase를 호출하는 것은 부작용이 크므로 하지 않는다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AutoSubmitParticipantsOnExamEndUseCase { + + private final ExamParticipantRepository examParticipantRepository; + private final ParticipantSubmitOrchestrationService participantSubmitOrchestrationService; + + @Transactional + public AutoSubmitResult execute(Long examId) { + List participants = examParticipantRepository.findAllByExamId(examId); + int submitted = 0; + int skippedAlreadySubmitted = 0; + int skippedNoCodeSnapshot = 0; + int failed = 0; + + for (ExamParticipant participant : participants) { + Long participantId = participant.getParticipantId(); + if (!participant.hasCodeSnapshot()) { + skippedNoCodeSnapshot++; + log.info( + "Auto-submit skipped (no code snapshot): examId={}, participantId={}, examParticipantId={}", + examId, participantId, participant.getId()); + continue; + } + try { + SubmitRequest request = new SubmitRequest( + participant.getLastCodeLang(), + participant.getLastCodeInline()); + boolean created = participantSubmitOrchestrationService + .submitIfAbsent(examId, participantId, request) + .isPresent(); + if (created) { + submitted++; + } else { + skippedAlreadySubmitted++; + } + } catch (RestApiException ex) { + failed++; + log.warn("Auto-submit failed for participantId={} examId={}: {}", + participantId, examId, ex.getErrorCode().getMessage()); + } catch (Exception ex) { + failed++; + log.error("Auto-submit unexpected error for participantId={} examId={}", + participantId, examId, ex); + } + } + + log.info( + "Auto-submit on exam end: examId={}, submitted={}, skippedAlready={}, skippedNoCode={}, failed={}", + examId, submitted, skippedAlreadySubmitted, skippedNoCodeSnapshot, failed); + + return new AutoSubmitResult(submitted, skippedAlreadySubmitted, skippedNoCodeSnapshot, failed); + } + + public record AutoSubmitResult( + int submittedCount, + int skippedAlreadySubmitted, + int skippedNoCodeSnapshot, + int failedCount + ) {} +} diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCase.java b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCase.java index 3478371..8a848a1 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCase.java @@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Transactional; import com.yd.vibecode.domain.exam.application.dto.event.ExamStateEvent; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import com.yd.vibecode.domain.exam.domain.entity.Exam; import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; import com.yd.vibecode.domain.exam.domain.service.ExamService; @@ -26,15 +27,23 @@ public class EndExamUseCase { private final ExamService examService; private final ExamParticipantService examParticipantService; + private final AutoSubmitParticipantsOnExamEndUseCase autoSubmitParticipantsOnExamEndUseCase; private final SimpMessagingTemplate messagingTemplate; + private final AdminActivityLogService adminActivityLogService; @Transactional public void execute(Long examId) { + // 시험 상태 전환 전(RUNNING) 미제출 참가자 자동 제출 — 멱등 + autoSubmitParticipantsOnExamEndUseCase.execute(examId); + Exam exam = examService.endExam(examId); // 모든 참가자의 상태를 ENDED로 변경 examParticipantService.endAllParticipants(examId); + // 관리자 활동 로그 (DB — 트랜잭션 커밋 전 외부 브로드캐스트보다 먼저) + adminActivityLogService.logExamEnded(exam.getCreatedBy(), examId, exam.getTitle()); + ExamStateEvent event = ExamStateEvent.from(exam); messagingTemplate.convertAndSend("/topic/exam/" + examId, event); log.info("Exam ended, WS broadcast sent: examId={}, version={}", examId, exam.getVersion()); diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCase.java b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCase.java new file mode 100644 index 0000000..b4ddfb4 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCase.java @@ -0,0 +1,42 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.exam.application.dto.response.CodeDraftResponse; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.GlobalErrorStatus; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class GetParticipantCodeDraftUseCase { + + private final ExamParticipantService examParticipantService; + private final SubmissionService submissionService; + + @Transactional(readOnly = true) + public CodeDraftResponse execute(Long examId, Long participantId) { + ExamParticipant participant = examParticipantService.findByExamIdAndParticipantId(examId, participantId); + if (participant == null) { + throw new RestApiException(GlobalErrorStatus._NOT_FOUND); + } + + if (submissionService.existsByExamIdAndParticipantId(examId, participantId)) { + return null; + } + + if (!participant.hasCodeSnapshot()) { + return null; + } + + return new CodeDraftResponse( + participant.getLastCodeLang(), + participant.getLastCodeInline(), + participant.getUpdatedAt()); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCase.java b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCase.java new file mode 100644 index 0000000..9c1b89b --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCase.java @@ -0,0 +1,36 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.exam.application.dto.request.SaveParticipantCodeDraftRequest; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.GlobalErrorStatus; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SaveParticipantCodeDraftUseCase { + + private final ExamParticipantService examParticipantService; + private final SubmissionService submissionService; + + @Transactional + public void execute(Long examId, Long participantId, SaveParticipantCodeDraftRequest request) { + ExamParticipant participant = + examParticipantService.findByExamIdAndParticipantIdForUpdate(examId, participantId); + if (participant == null) { + throw new RestApiException(GlobalErrorStatus._NOT_FOUND); + } + + if (submissionService.existsByExamIdAndParticipantId(examId, participantId)) { + return; + } + + participant.updateCodeSnapshot(request.lang(), request.code()); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCase.java b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCase.java index 292b605..4fbfdc9 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCase.java @@ -13,6 +13,7 @@ import com.yd.vibecode.domain.auth.domain.entity.EntryCode; import com.yd.vibecode.domain.auth.domain.repository.EntryCodeRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import com.yd.vibecode.domain.exam.application.dto.event.ExamStateEvent; import com.yd.vibecode.domain.exam.domain.entity.Exam; import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; @@ -44,6 +45,7 @@ public class StartExamUseCase { private final ProblemRepository problemRepository; private final EntryCodeRepository entryCodeRepository; private final ProblemSetItemRepository problemSetItemRepository; + private final AdminActivityLogService adminActivityLogService; @Transactional public void execute(Long examId) { @@ -53,7 +55,10 @@ public void execute(Long examId) { // 2. 시험 상태 변경 WAITING -> RUNNING Exam exam = examService.startExam(examId); - // 3. WebSocket 브로드캐스트 + // 3. 관리자 활동 로그 (DB — 트랜잭션 커밋 전 외부 브로드캐스트보다 먼저) + adminActivityLogService.logExamStarted(exam.getCreatedBy(), examId, exam.getTitle()); + + // 4. WebSocket 브로드캐스트 ExamStateEvent event = ExamStateEvent.from(exam); messagingTemplate.convertAndSend("/topic/exam/" + examId, event); log.info("Exam started, WS broadcast sent: examId={}, version={}", examId, exam.getVersion()); diff --git a/src/main/java/com/yd/vibecode/domain/exam/domain/entity/ExamParticipant.java b/src/main/java/com/yd/vibecode/domain/exam/domain/entity/ExamParticipant.java index c1bd30b..9f9f549 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/domain/entity/ExamParticipant.java +++ b/src/main/java/com/yd/vibecode/domain/exam/domain/entity/ExamParticipant.java @@ -54,10 +54,18 @@ public class ExamParticipant extends BaseEntity { @Column private LocalDateTime joinedAt; + /** 자동 제출용 최신 코드 스냅샷 (FE 주기 저장) */ + @Column(length = 50) + private String lastCodeLang; + + @Column(columnDefinition = "TEXT") + private String lastCodeInline; + @Builder public ExamParticipant(Long examId, Long participantId, Long specId, String state, Integer tokenLimit, Integer tokenUsed, Integer assignedSpecVersion, - Long assignedProblemId, LocalDateTime joinedAt) { + Long assignedProblemId, LocalDateTime joinedAt, + String lastCodeLang, String lastCodeInline) { this.examId = examId; this.participantId = participantId; this.specId = specId; @@ -67,6 +75,27 @@ public ExamParticipant(Long examId, Long participantId, Long specId, String stat this.assignedSpecVersion = assignedSpecVersion; this.assignedProblemId = assignedProblemId; this.joinedAt = joinedAt != null ? joinedAt : LocalDateTime.now(); + this.lastCodeLang = lastCodeLang; + this.lastCodeInline = lastCodeInline; + } + + public boolean hasCodeSnapshot() { + return lastCodeLang != null && !lastCodeLang.isBlank() + && lastCodeInline != null && !lastCodeInline.isBlank(); + } + + public void updateCodeSnapshot(String lang, String code) { + if (lang != null && !lang.isBlank()) { + this.lastCodeLang = lang.trim(); + } + if (code != null && !code.isBlank()) { + this.lastCodeInline = code; + } + } + + public void clearCodeSnapshot() { + this.lastCodeLang = null; + this.lastCodeInline = null; } public void updateSpecId(Long specId) { diff --git a/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamParticipantRepository.java b/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamParticipantRepository.java index 23371c1..d2b856d 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamParticipantRepository.java +++ b/src/main/java/com/yd/vibecode/domain/exam/domain/repository/ExamParticipantRepository.java @@ -4,15 +4,24 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import jakarta.persistence.LockModeType; + public interface ExamParticipantRepository extends JpaRepository { Optional findByExamIdAndParticipantId(Long examId, Long participantId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ep FROM ExamParticipant ep WHERE ep.examId = :examId AND ep.participantId = :participantId") + Optional findByExamIdAndParticipantIdForUpdate( + @Param("examId") Long examId, + @Param("participantId") Long participantId); + List findByExamId(Long examId); List findAllByExamId(Long examId); 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 27f5364..ddefdf5 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 @@ -3,7 +3,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import java.time.LocalDateTime; import java.util.List; public interface ExamRepository extends JpaRepository { @@ -11,4 +13,9 @@ public interface ExamRepository extends JpaRepository { List findAll(); List findByCreatedBy(Long createdBy); + + /** + * 자동 시작 대상: WAITING 상태이며 시작 시각이 현재 이하인 시험. + */ + List findByStateAndStartsAtLessThanEqual(ExamState state, LocalDateTime startsAtThreshold); } diff --git a/src/main/java/com/yd/vibecode/domain/exam/domain/service/ExamParticipantService.java b/src/main/java/com/yd/vibecode/domain/exam/domain/service/ExamParticipantService.java index cd0bb89..7787bea 100644 --- a/src/main/java/com/yd/vibecode/domain/exam/domain/service/ExamParticipantService.java +++ b/src/main/java/com/yd/vibecode/domain/exam/domain/service/ExamParticipantService.java @@ -22,6 +22,11 @@ public ExamParticipant findByExamIdAndParticipantId(Long examId, Long participan .orElse(null); } + public ExamParticipant findByExamIdAndParticipantIdForUpdate(Long examId, Long participantId) { + return examParticipantRepository.findByExamIdAndParticipantIdForUpdate(examId, participantId) + .orElse(null); + } + public ExamParticipant findLatestByParticipantId(Long participantId) { return examParticipantRepository.findByParticipantIdOrderByJoinedAtDesc(participantId) .stream() diff --git a/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoStartScheduler.java b/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoStartScheduler.java new file mode 100644 index 0000000..4fd1d78 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoStartScheduler.java @@ -0,0 +1,98 @@ +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.StartExamUseCase; +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 startsAt}) 도래 시 WAITING 상태 시험을 자동으로 시작한다. + *

+ * 서버 내부 스케줄러 전용이며, 공개 API를 제공하지 않는다. + * 상태 전환은 {@link StartExamUseCase}에 위임한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExamAutoStartScheduler { + + private static final Set BENIGN_SKIP_ERROR_CODES = Set.of( + ExamErrorStatus.INVALID_EXAM_STATE.getCode().getCode(), + ExamErrorStatus.EXAM_ALREADY_STARTED.getCode().getCode(), + ExamErrorStatus.EXAM_ALREADY_ENDED.getCode().getCode() + ); + + private final ExamRepository examRepository; + private final StartExamUseCase startExamUseCase; + + @Scheduled(fixedDelay = 10_000) + public void pollAndAutoStartExams() { + LocalDateTime now = LocalDateTime.now(); + List candidates = examRepository.findByStateAndStartsAtLessThanEqual(ExamState.WAITING, now); + + if (candidates.isEmpty()) { + return; + } + + log.info("[ExamAutoStart] {} exam(s) eligible (WAITING, startsAt <= {})", candidates.size(), now); + + int successCount = 0; + int skipCount = 0; + int failCount = 0; + + for (Exam exam : candidates) { + Long examId = exam.getId(); + try { + startExamUseCase.execute(examId); + successCount++; + log.info("[ExamAutoStart] Success examId={}", examId); + } catch (RestApiException e) { + if (isBenignSkip(e)) { + skipCount++; + log.info( + "[ExamAutoStart] Skipped examId={}, code={}, message={}", + examId, + e.getErrorCode().getCode(), + e.getErrorCode().getMessage() + ); + } else { + failCount++; + log.warn( + "[ExamAutoStart] Failed examId={}, code={}, message={}", + examId, + e.getErrorCode().getCode(), + e.getErrorCode().getMessage() + ); + } + } catch (Exception e) { + failCount++; + log.error("[ExamAutoStart] Failed examId={}", examId, e); + } + } + + log.info( + "[ExamAutoStart] 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/main/java/com/yd/vibecode/domain/problem/domain/repository/ProblemRepository.java b/src/main/java/com/yd/vibecode/domain/problem/domain/repository/ProblemRepository.java index 948b49a..bd869cf 100644 --- a/src/main/java/com/yd/vibecode/domain/problem/domain/repository/ProblemRepository.java +++ b/src/main/java/com/yd/vibecode/domain/problem/domain/repository/ProblemRepository.java @@ -1,17 +1,28 @@ package com.yd.vibecode.domain.problem.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.yd.vibecode.domain.problem.domain.entity.Problem; import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; import com.yd.vibecode.domain.problem.domain.entity.Difficulty; +import jakarta.persistence.LockModeType; + import java.util.List; import java.util.Optional; public interface ProblemRepository extends JpaRepository { List findByStatus(ProblemStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Problem p WHERE p.status = :status") + List findAllByStatusForUpdate(@Param("status") ProblemStatus status); + + long countByStatus(ProblemStatus status); List findByDifficulty(Difficulty difficulty); diff --git a/src/main/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCase.java b/src/main/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCase.java index ef24e72..ea396b6 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCase.java @@ -17,6 +17,9 @@ import com.yd.vibecode.domain.submission.domain.repository.ScoreRepository; import com.yd.vibecode.domain.submission.domain.repository.SubmissionRunRepository; import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; +import com.yd.vibecode.domain.auth.domain.repository.UserRepository; import com.yd.vibecode.global.exception.RestApiException; import com.yd.vibecode.global.exception.code.status.GlobalErrorStatus; @@ -39,12 +42,16 @@ public class ReceiveScoringResultUseCase { private final SubmissionRunRepository submissionRunRepository; private final ScoreRepository scoreRepository; private final ApplicationEventPublisher eventPublisher; + private final ExamRepository examRepository; + private final AdminActivityLogService adminActivityLogService; + private final UserRepository userRepository; @Transactional public void execute(Long submissionId, ScoringResultRequest request) { validateTestCases(request); Submission submission = submissionService.findById(submissionId); + SubmissionStatus previousStatus = submission.getStatus(); submission.updateStatus(request.status()); submissionRunRepository.deleteBySubmissionId(submissionId); @@ -102,6 +109,20 @@ public void execute(Long submissionId, ScoringResultRequest request) { score.getTotalScore()); } + if (request.status() == SubmissionStatus.DONE && previousStatus != SubmissionStatus.DONE) { + examRepository.findById(submission.getExamId()).ifPresent(exam -> { + String participantName = userRepository.findById(submission.getParticipantId()) + .map(user -> user.getName()) + .orElse(null); + adminActivityLogService.logEvaluationCompleted( + exam.getCreatedBy(), + submission.getExamId(), + submission.getParticipantId(), + exam.getTitle(), + participantName); + }); + } + ScoringResultSseEvent.CompletionPayload completion = new ScoringResultSseEvent.CompletionPayload( casePayloads.size(), passedCount); diff --git a/src/main/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCase.java b/src/main/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCase.java index 217b676..3024cf0 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCase.java +++ b/src/main/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCase.java @@ -1,96 +1,25 @@ package com.yd.vibecode.domain.submission.application.usecase; -import com.yd.vibecode.domain.submission.domain.entity.Submission; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.yd.vibecode.domain.chat.domain.service.PromptSessionService; -import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; -import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; -import com.yd.vibecode.domain.problem.domain.entity.ProblemSpec; -import com.yd.vibecode.domain.problem.domain.service.ProblemSpecService; -import com.yd.vibecode.domain.submission.application.dto.request.AISubmitEvaluationRequest; import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; -import com.yd.vibecode.domain.submission.domain.service.OutboxEventService; -import com.yd.vibecode.domain.submission.domain.service.SubmissionService; -import com.yd.vibecode.global.exception.RestApiException; -import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; -import com.yd.vibecode.global.exception.code.status.SubmissionErrorStatus; +import com.yd.vibecode.domain.submission.domain.service.ParticipantSubmitOrchestrationService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; /** - * 제출 UseCase - * - 코드 검증 및 해시 계산 - * - DB 저장 - * - Redis Queue에 enqueue (채점 워커가 dequeue하여 처리) + * 제출 UseCase — 사용자 직접 제출 (공통 오케스트레이션 위임) */ -@Slf4j @Service @RequiredArgsConstructor public class SubmitUseCase { - private final ExamParticipantService examParticipantService; - private final SubmissionService submissionService; - private final ProblemSpecService problemSpecService; - private final PromptSessionService promptSessionService; - private final OutboxEventService outboxEventService; + private final ParticipantSubmitOrchestrationService participantSubmitOrchestrationService; @Transactional public SubmitResponse execute(Long examId, Long userId, SubmitRequest request) { - // 1. ExamParticipant 조회 및 specId 확인 - ExamParticipant examParticipant = examParticipantService.findByExamIdAndParticipantId(examId, userId); - - if (examParticipant == null || examParticipant.getSpecId() == null) { - throw new RestApiException(ProblemErrorStatus.NO_ASSIGNED_PROBLEM); - } - - if (submissionService.existsByExamIdAndParticipantId(examId, userId)) { - throw new RestApiException(SubmissionErrorStatus.ALREADY_SUBMITTED); - } - - Long problemId = examParticipant.getAssignedProblemId(); - if (problemId == null) { - ProblemSpec spec = problemSpecService.findBySpecId(examParticipant.getSpecId()); - problemId = spec.getProblemId(); - } - - // 2. 제출 생성 및 Redis Queue enqueue - Submission submission = submissionService.createAndEnqueue( - examId, - userId, - examParticipant.getSpecId(), - request.lang(), - request.code() - ); - - // 3. 세션 생성 (트랜잭션 내에서 먼저 생성하여 커밋 보장) - promptSessionService.getOrCreateSession( - examId, userId, examParticipant.getSpecId()); - - // 4. AI 평가 요청을 Outbox에 저장 (같은 트랜잭션 내에서 처리되어 유실 방지) - AISubmitEvaluationRequest aiRequest = new AISubmitEvaluationRequest( - examId, - userId, // participantId - problemId, - examParticipant.getSpecId(), - request.code(), - request.lang(), - submission.getId() - ); - - outboxEventService.saveEvent( - "SUBMISSION", - submission.getId(), - "AI_EVAL_REQUEST", - aiRequest - ); - - log.info("AI evaluation request saved to outbox: submissionId={}", submission.getId()); - - // 5. 응답 반환 (202 Accepted) - return new SubmitResponse(submission.getId(), submission.getStatus()); + return participantSubmitOrchestrationService.submitOrThrow(examId, userId, request); } } diff --git a/src/main/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationService.java b/src/main/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationService.java new file mode 100644 index 0000000..b55e2b6 --- /dev/null +++ b/src/main/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationService.java @@ -0,0 +1,99 @@ +package com.yd.vibecode.domain.submission.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.yd.vibecode.domain.chat.domain.service.PromptSessionService; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.problem.domain.entity.ProblemSpec; +import com.yd.vibecode.domain.problem.domain.service.ProblemSpecService; +import com.yd.vibecode.domain.submission.application.dto.request.AISubmitEvaluationRequest; +import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; +import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; +import com.yd.vibecode.domain.submission.domain.entity.Submission; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; +import com.yd.vibecode.global.exception.code.status.SubmissionErrorStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 참가자 제출 생성 + 채점/AI 평가 요청 공통 오케스트레이션. + * 사용자 직접 제출과 시험 종료 시 자동 제출이 동일 경로를 사용한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ParticipantSubmitOrchestrationService { + + private final ExamParticipantService examParticipantService; + private final SubmissionService submissionService; + private final ProblemSpecService problemSpecService; + private final PromptSessionService promptSessionService; + private final OutboxEventService outboxEventService; + + /** + * @return 제출이 새로 생성되면 SubmitResponse, 이미 제출된 경우 empty + */ + @Transactional + public java.util.Optional submitIfAbsent(Long examId, Long participantId, SubmitRequest request) { + ExamParticipant examParticipant = + examParticipantService.findByExamIdAndParticipantIdForUpdate(examId, participantId); + + if (examParticipant == null || examParticipant.getSpecId() == null) { + throw new RestApiException(ProblemErrorStatus.NO_ASSIGNED_PROBLEM); + } + + if (submissionService.existsByExamIdAndParticipantId(examId, participantId)) { + return java.util.Optional.empty(); + } + + Long problemId = examParticipant.getAssignedProblemId(); + if (problemId == null) { + ProblemSpec spec = problemSpecService.findBySpecId(examParticipant.getSpecId()); + problemId = spec.getProblemId(); + } + + Submission submission = submissionService.createAndEnqueue( + examId, + participantId, + examParticipant.getSpecId(), + request.lang(), + request.code() + ); + + promptSessionService.getOrCreateSession(examId, participantId, examParticipant.getSpecId()); + + AISubmitEvaluationRequest aiRequest = new AISubmitEvaluationRequest( + examId, + participantId, + problemId, + examParticipant.getSpecId(), + request.code(), + request.lang(), + submission.getId() + ); + + outboxEventService.saveEvent( + "SUBMISSION", + submission.getId(), + "AI_EVAL_REQUEST", + aiRequest + ); + + examParticipant.clearCodeSnapshot(); + + log.info("Participant submission created: examId={}, participantId={}, submissionId={}", + examId, participantId, submission.getId()); + + return java.util.Optional.of(new SubmitResponse(submission.getId(), submission.getStatus())); + } + + @Transactional + public SubmitResponse submitOrThrow(Long examId, Long participantId, SubmitRequest request) { + return submitIfAbsent(examId, participantId, request) + .orElseThrow(() -> new RestApiException(SubmissionErrorStatus.ALREADY_SUBMITTED)); + } +} diff --git a/src/main/java/com/yd/vibecode/domain/submission/ui/SubmissionController.java b/src/main/java/com/yd/vibecode/domain/submission/ui/SubmissionController.java index 0d61911..4e45f65 100644 --- a/src/main/java/com/yd/vibecode/domain/submission/ui/SubmissionController.java +++ b/src/main/java/com/yd/vibecode/domain/submission/ui/SubmissionController.java @@ -4,11 +4,16 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.yd.vibecode.domain.exam.application.dto.request.SaveParticipantCodeDraftRequest; +import com.yd.vibecode.domain.exam.application.dto.response.CodeDraftResponse; +import com.yd.vibecode.domain.exam.application.usecase.GetParticipantCodeDraftUseCase; +import com.yd.vibecode.domain.exam.application.usecase.SaveParticipantCodeDraftUseCase; import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; import com.yd.vibecode.domain.submission.application.dto.response.SubmissionDetailResponse; import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; @@ -35,6 +40,8 @@ public class SubmissionController implements SubmissionApi { private final SubmitUseCase submitUseCase; private final GetSubmissionDetailUseCase getSubmissionDetailUseCase; + private final SaveParticipantCodeDraftUseCase saveParticipantCodeDraftUseCase; + private final GetParticipantCodeDraftUseCase getParticipantCodeDraftUseCase; @PostMapping("/exams/{examId}/submissions") @ResponseStatus(HttpStatus.ACCEPTED) // 202 Accepted @@ -46,6 +53,24 @@ public BaseResponse submit( return BaseResponse.onSuccess(response); } + @GetMapping("/exams/{examId}/code-draft") + public BaseResponse getCodeDraft( + @PathVariable Long examId, + @CurrentUser Long userId) { + CodeDraftResponse response = getParticipantCodeDraftUseCase.execute(examId, userId); + return BaseResponse.onSuccess(response); + } + + @PutMapping("/exams/{examId}/code-draft") + @ResponseStatus(HttpStatus.OK) + public BaseResponse saveCodeDraft( + @PathVariable Long examId, + @CurrentUser Long userId, + @Valid @RequestBody SaveParticipantCodeDraftRequest request) { + saveParticipantCodeDraftUseCase.execute(examId, userId, request); + return BaseResponse.onSuccess(); + } + @GetMapping("/submissions/{submissionId}") public BaseResponse getSubmissionDetail( @PathVariable Long submissionId, diff --git a/src/main/java/com/yd/vibecode/global/exception/code/status/ProblemErrorStatus.java b/src/main/java/com/yd/vibecode/global/exception/code/status/ProblemErrorStatus.java index d020ff4..b9bd4ea 100644 --- a/src/main/java/com/yd/vibecode/global/exception/code/status/ProblemErrorStatus.java +++ b/src/main/java/com/yd/vibecode/global/exception/code/status/ProblemErrorStatus.java @@ -17,7 +17,8 @@ public enum ProblemErrorStatus implements BaseCodeInterface { PROBLEM_NOT_PUBLISHED(HttpStatus.BAD_REQUEST, "PROBLEM003", "게시되지 않은 문제입니다."), SPEC_VERSION_CONFLICT(HttpStatus.CONFLICT, "PROBLEM004", "이미 해당 버전의 스펙이 존재합니다."), NO_ASSIGNED_PROBLEM(HttpStatus.BAD_REQUEST, "PROBLEM005", "배정된 문제가 없습니다."), - NO_PUBLISHED_PROBLEMS(HttpStatus.BAD_REQUEST, "PROBLEM006", "게시된 문제가 없습니다.") + NO_PUBLISHED_PROBLEMS(HttpStatus.BAD_REQUEST, "PROBLEM006", "게시된 문제가 없습니다."), + LAST_AVAILABLE_PROBLEM_REQUIRED(HttpStatus.BAD_REQUEST, "PROBLEM007", "최소 1개 이상의 문제는 사용 가능해야 합니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/yd/vibecode/global/swagger/AdminActivityLogApi.java b/src/main/java/com/yd/vibecode/global/swagger/AdminActivityLogApi.java new file mode 100644 index 0000000..e440bf6 --- /dev/null +++ b/src/main/java/com/yd/vibecode/global/swagger/AdminActivityLogApi.java @@ -0,0 +1,23 @@ +package com.yd.vibecode.global.swagger; + +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogPageResponse; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "관리자 활동 로그", description = "관리자 본인 활동 로그 조회 API") +public interface AdminActivityLogApi extends BaseApi { + + @Operation( + summary = "관리자 활동 로그 조회", + description = "현재 로그인한 관리자 본인의 활동 로그를 최신순으로 조회합니다. " + + "대시보드 최근 활동은 size=5 등으로 동일 API를 사용할 수 있습니다.") + BaseResponse getLogs( + Long adminId, + String keyword, + AdminActivityLogType type, + int page, + int size); +} diff --git a/src/main/java/com/yd/vibecode/global/swagger/AdminProblemApi.java b/src/main/java/com/yd/vibecode/global/swagger/AdminProblemApi.java index 7bcc1f3..01eeebe 100644 --- a/src/main/java/com/yd/vibecode/global/swagger/AdminProblemApi.java +++ b/src/main/java/com/yd/vibecode/global/swagger/AdminProblemApi.java @@ -3,6 +3,7 @@ import java.util.List; import com.yd.vibecode.domain.admin.application.dto.request.CreateProblemRequest; +import com.yd.vibecode.domain.admin.application.dto.request.UpdateProblemAvailabilityRequest; import com.yd.vibecode.domain.admin.application.dto.response.ProblemDetailResponse; import com.yd.vibecode.domain.admin.application.dto.response.ProblemResponse; import com.yd.vibecode.domain.admin.application.dto.response.ProblemSpecResponse; @@ -25,6 +26,16 @@ BaseResponse createProblem( @ApiResponse(responseCode = "200", description = "조회 성공") BaseResponse> getProblems(); + @Operation( + summary = "문제 사용 가능 여부 변경", + description = "available=true이면 PUBLISHED, false이면 ARCHIVED로 변경합니다. " + + "시험 생성 시 PUBLISHED 문제만 랜덤 배정 후보가 됩니다." + ) + @ApiResponse(responseCode = "200", description = "변경 성공") + BaseResponse updateProblemAvailability( + Long problemId, + @RequestBody(description = "사용 가능 여부", required = true) UpdateProblemAvailabilityRequest request); + @Operation(summary = "문제 삭제", description = "문제를 삭제합니다.") @ApiResponse(responseCode = "200", description = "삭제 성공") BaseResponse deleteProblem(Long problemId); diff --git a/src/main/java/com/yd/vibecode/global/swagger/MasterActivityLogApi.java b/src/main/java/com/yd/vibecode/global/swagger/MasterActivityLogApi.java new file mode 100644 index 0000000..86e8f07 --- /dev/null +++ b/src/main/java/com/yd/vibecode/global/swagger/MasterActivityLogApi.java @@ -0,0 +1,26 @@ +package com.yd.vibecode.global.swagger; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogPageResponse; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "마스터 활동 로그", description = "MASTER 전용 플랫폼 활동 로그 조회 API") +public interface MasterActivityLogApi extends BaseApi { + + @Operation( + summary = "마스터 활동 로그 조회 (MASTER 전용)", + description = "MASTER 권한 사용자만 조회할 수 있습니다. " + + "관리자 가입 번호 발급·비활성화·재활성화, 관리자 가입 완료, 계정 삭제, 비밀번호 재설정 이력을 최신순으로 조회합니다. " + + "type: ADMIN_SIGNUP_CODE_ISSUED, ADMIN_SIGNUP_CODE_DEACTIVATED, ADMIN_SIGNUP_CODE_REACTIVATED, " + + "ADMIN_SIGNED_UP, ADMIN_ACCOUNT_DELETED, ADMIN_PASSWORD_RESET. " + + "응답에는 가입 번호 원문, 비밀번호, 토큰이 포함되지 않습니다.") + BaseResponse getLogs( + Long adminId, + String keyword, + MasterActivityLogType type, + int page, + int size); +} diff --git a/src/main/java/com/yd/vibecode/global/swagger/MasterPlatformSettingsApi.java b/src/main/java/com/yd/vibecode/global/swagger/MasterPlatformSettingsApi.java new file mode 100644 index 0000000..6bb3775 --- /dev/null +++ b/src/main/java/com/yd/vibecode/global/swagger/MasterPlatformSettingsApi.java @@ -0,0 +1,27 @@ +package com.yd.vibecode.global.swagger; + +import com.yd.vibecode.domain.admin.application.dto.request.UpdateMasterPlatformSettingsRequest; +import com.yd.vibecode.domain.admin.application.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.global.common.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "마스터 플랫폼 설정", description = "MASTER 전용 플랫폼 전역 설정 API") +public interface MasterPlatformSettingsApi extends BaseApi { + + @Operation( + summary = "플랫폼 전역 설정 조회 (MASTER 전용)", + description = "로그/제출물 보관 기간, 만료 데이터 자동 삭제 정책을 조회합니다. " + + "설정 row가 없으면 기본값으로 생성 후 반환합니다.") + BaseResponse getSettings(Long adminId); + + @Operation( + summary = "플랫폼 전역 설정 수정 (MASTER 전용)", + description = "플랫폼 전역 설정을 수정합니다. autoDeleteExpiredData=false이면 " + + "향후 만료 데이터 자동 삭제 스케줄러는 실행되지 않아야 하는 정책 값입니다. " + + "이번 단계에서는 실제 삭제 스케줄러는 구현하지 않습니다.") + BaseResponse updateSettings( + Long adminId, + UpdateMasterPlatformSettingsRequest request); +} diff --git a/src/main/resources/problems/problem2.json b/src/main/resources/problems/problem2.json index 3096ac1..be7a326 100644 --- a/src/main/resources/problems/problem2.json +++ b/src/main/resources/problems/problem2.json @@ -12,7 +12,7 @@ "", "한 이론물리 연구소에서 특정 양자 시스템을 모델링하고 있습니다. 이 모델에서 시스템의 기본 단위는 '큐빗'이며, 각 큐빗은 `N x N` 크기의 이산적인 위상 공간 내에 위치합니다. 공간의 좌표는 `(p, q)` (`0 ≤ p, q < N`)로 표현되며, 주기적 경계 조건을 가집니다. 즉, 공간의 한쪽 경계를 벗어나는 움직임은 반대쪽 경계에서 이어집니다.", "", - "또한 이 위상 공간 내에는 `W`개의 **'웜홀(Wormhole)'**이 고정된 좌표에 존재하여, 큐빗의 이동 경로를 비선형적으로 왜곡합니다.", + "또한 이 위상 공간 내에는 `W`개의 '**웜홀(Wormhole)**'이 고정된 좌표에 존재하여, 큐빗의 이동 경로를 비선형적으로 왜곡합니다.", "", "각 큐빗은 현재 위치 좌표 `(p, q)`, 직전 위치 좌표 `(p_prev, q_prev)`, 에너지 `E`, 전파 계수 `v`, 그리고 스핀 방향 `θ`를 속성으로 가집니다. 스핀 방향 `θ`는 0부터 7까지의 정수이며, 8개의 기본 운동 방향을 나타냅니다.", "", 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 new file mode 100644 index 0000000..2f52160 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/service/ExamParticipantDisplayStatusResolverTest.java @@ -0,0 +1,79 @@ +package com.yd.vibecode.domain.admin.application.service; + +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 com.yd.vibecode.domain.admin.application.dto.response.ParticipantAttendanceStatus; +import com.yd.vibecode.domain.admin.application.dto.response.ParticipantSubmissionDisplayStatus; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; +import com.yd.vibecode.domain.submission.domain.entity.Submission; +import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; + +class ExamParticipantDisplayStatusResolverTest { + + @Test + @DisplayName("endsAt 경과 시 displayExamState는 ENDED") + void displayExamState_whenEndsAtPassed() { + Exam exam = Exam.builder() + .title("t") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now().minusHours(2)) + .endsAt(LocalDateTime.now().minusMinutes(1)) + .version(1) + .createdBy(1L) + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.displayExamState(exam, LocalDateTime.now())) + .isEqualTo(ExamState.ENDED); + } + + @Test + @DisplayName("submission 없음 → 미제출") + void submissionDisplay_notSubmitted() { + assertThat(ExamParticipantDisplayStatusResolver.resolveSubmissionDisplayStatus(false, null, null)) + .isEqualTo(ParticipantSubmissionDisplayStatus.NOT_SUBMITTED); + } + + @Test + @DisplayName("submission 있음 + score 없음 → 채점중") + void submissionDisplay_grading() { + Submission sub = Submission.builder() + .examId(1L) + .participantId(1L) + .specId(1L) + .status(SubmissionStatus.QUEUED) + .lang("python") + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveSubmissionDisplayStatus(true, sub, null)) + .isEqualTo(ParticipantSubmissionDisplayStatus.GRADING); + } + + @Test + @DisplayName("시험 종료 후 응시상태 종료됨") + void attendance_endedWhenSessionEnded() { + Exam exam = Exam.builder() + .title("t") + .state(ExamState.ENDED) + .startsAt(LocalDateTime.now().minusHours(2)) + .endsAt(LocalDateTime.now().minusHours(1)) + .version(2) + .createdBy(1L) + .build(); + ExamParticipant ep = ExamParticipant.builder() + .examId(1L) + .participantId(1L) + .state("RUNNING") + .build(); + + assertThat(ExamParticipantDisplayStatusResolver.resolveAttendanceStatus( + exam, ep, false, null, null, LocalDateTime.now())) + .isEqualTo(ParticipantAttendanceStatus.ENDED); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCasePublishedGuardTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCasePublishedGuardTest.java new file mode 100644 index 0000000..a1e7824 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCasePublishedGuardTest.java @@ -0,0 +1,67 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.Collections; + +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.auth.domain.repository.EntryCodeRepository; +import com.yd.vibecode.domain.exam.application.dto.request.CreateExamRequest; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; +import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; +import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; +import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetItemRepository; +import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetRepository; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; + +@ExtendWith(MockitoExtension.class) +class CreateExamUseCasePublishedGuardTest { + + @InjectMocks + private CreateExamUseCase createExamUseCase; + + @Mock + private ExamRepository examRepository; + + @Mock + private ProblemRepository problemRepository; + + @Mock + private ProblemSetRepository problemSetRepository; + + @Mock + private ProblemSetItemRepository problemSetItemRepository; + + @Mock + private EntryCodeRepository entryCodeRepository; + + @Test + @DisplayName("PUBLISHED 문제가 없으면 NO_PUBLISHED_PROBLEMS로 시험 생성 실패") + void execute_noPublishedProblems_throws() { + CreateExamRequest request = new CreateExamRequest( + "테스트 시험", + LocalDateTime.now().plusHours(1), + LocalDateTime.now().plusHours(3) + ); + given(problemRepository.findByStatus(ProblemStatus.PUBLISHED)).willReturn(Collections.emptyList()); + + assertThatThrownBy(() -> createExamUseCase.execute(1L, request)) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode().getCode()) + .isEqualTo(ProblemErrorStatus.NO_PUBLISHED_PROBLEMS.getCode().getCode()); + + verify(problemSetRepository, never()).save(org.mockito.ArgumentMatchers.any()); + verify(entryCodeRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCaseTest.java index acc84e4..809f7ce 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/CreateExamUseCaseTest.java @@ -20,6 +20,7 @@ import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetItemRepository; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import com.yd.vibecode.global.exception.RestApiException; import com.yd.vibecode.global.exception.code.status.ExamErrorStatus; @@ -44,6 +45,9 @@ class CreateExamUseCaseTest { @Mock private EntryCodeRepository entryCodeRepository; + @Mock + private AdminActivityLogService adminActivityLogService; + @Test @DisplayName("종료 시각이 과거이면 시험 생성 실패 (DB 저장 없음)") void execute_pastEndsAt_throwsBeforeSave() { diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCaseTest.java index 2d3732c..1024934 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/DeleteAdminByMasterUseCaseTest.java @@ -8,10 +8,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; 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; @@ -31,6 +33,9 @@ class DeleteAdminByMasterUseCaseTest { @Mock private AdminAccountDeletionService adminAccountDeletionService; + @Mock + private MasterActivityLogService masterActivityLogService; + @Test @DisplayName("MASTER가 일반 관리자 계정을 삭제할 수 있다") void execute_success() { @@ -39,6 +44,7 @@ void execute_success() { Admin requester = Admin.builder().role(AdminRole.MASTER).build(); Admin target = Admin.builder().adminNumber(targetAdminNumber).role(AdminRole.ADMIN).build(); + ReflectionTestUtils.setField(target, "id", 10L); given(adminService.findById(requesterId)).willReturn(requester); given(adminService.findByAdminNumber(targetAdminNumber)).willReturn(target); @@ -50,6 +56,7 @@ void execute_success() { eq(requesterId), eq("DELETE_ADMIN_BY_MASTER") ); + verify(masterActivityLogService).logAdminAccountDeleted(requesterId, 10L, target.getDisplayName()); } @Test diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCaseTest.java new file mode 100644 index 0000000..dc832de --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetAdminActivityLogsUseCaseTest.java @@ -0,0 +1,73 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogPageResponse; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; + +@ExtendWith(MockitoExtension.class) +class GetAdminActivityLogsUseCaseTest { + + @InjectMocks + private GetAdminActivityLogsUseCase getAdminActivityLogsUseCase; + + @Mock + private AdminActivityLogRepository adminActivityLogRepository; + + @Test + @DisplayName("현재 관리자 adminId 기준으로 로그를 조회한다") + void execute_filtersByAdminId() { + Long adminId = 5L; + AdminActivityLog log = AdminActivityLog.builder() + .adminId(adminId) + .examId(1L) + .type(AdminActivityLogType.EXAM_STARTED) + .title("시험 세션이 시작되었습니다.") + .message("시험이 시작되었습니다.") + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(log, "createdAt", LocalDateTime.now()); + + Page page = new PageImpl<>(List.of(log)); + given(adminActivityLogRepository.searchByAdmin( + eq(adminId), isNull(), isNull(), org.mockito.ArgumentMatchers.any(Pageable.class))) + .willReturn(page); + + AdminActivityLogPageResponse response = getAdminActivityLogsUseCase.execute(adminId, null, null, 0, 20); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).type()).isEqualTo(AdminActivityLogType.EXAM_STARTED); + assertThat(response.page()).isZero(); + assertThat(response.size()).isEqualTo(20); + } + + @Test + @DisplayName("size는 최대 100으로 제한된다") + void execute_capsPageSize() { + Page page = new PageImpl<>(List.of()); + given(adminActivityLogRepository.searchByAdmin( + eq(1L), isNull(), isNull(), org.mockito.ArgumentMatchers.argThat(p -> p.getPageSize() == 100))) + .willReturn(page); + + AdminActivityLogPageResponse response = getAdminActivityLogsUseCase.execute(1L, null, null, 0, 500); + + assertThat(response.size()).isEqualTo(100); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCaseTest.java index d26110d..bd4ffab 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetExamineeBoardUseCaseTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -18,8 +19,13 @@ import com.yd.vibecode.domain.admin.application.dto.response.ExamineeBoardResponse; import com.yd.vibecode.domain.auth.domain.entity.User; import com.yd.vibecode.domain.auth.domain.repository.UserRepository; +import com.yd.vibecode.domain.admin.application.dto.response.ParticipantAttendanceStatus; +import com.yd.vibecode.domain.admin.application.dto.response.ParticipantSubmissionDisplayStatus; +import com.yd.vibecode.domain.exam.domain.entity.Exam; import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; import com.yd.vibecode.domain.exam.domain.repository.ExamParticipantRepository; +import com.yd.vibecode.domain.exam.domain.repository.ExamRepository; import com.yd.vibecode.domain.submission.domain.entity.Score; import com.yd.vibecode.domain.submission.domain.entity.Submission; import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; @@ -31,6 +37,9 @@ class GetExamineeBoardUseCaseTest { private static final Long EXAM_ID = 1L; + @Mock + private ExamRepository examRepository; + @Mock private ExamParticipantRepository examParticipantRepository; @@ -46,6 +55,19 @@ class GetExamineeBoardUseCaseTest { @InjectMocks private GetExamineeBoardUseCase getExamineeBoardUseCase; + private Exam runningExam() { + Exam exam = Exam.builder() + .title("테스트") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now().minusHours(1)) + .endsAt(LocalDateTime.now().plusHours(1)) + .version(1) + .createdBy(1L) + .build(); + ReflectionTestUtils.setField(exam, "id", EXAM_ID); + return exam; + } + @Test @DisplayName("participantId(users.id) 일치 시 제출 상태·점수 매핑") void execute_mapsSubmissionByParticipantId() { @@ -77,6 +99,7 @@ void execute_mapsSubmissionByParticipantId() { .build(); score.calculateTotalScore(); + given(examRepository.findById(EXAM_ID)).willReturn(Optional.of(runningExam())); given(examParticipantRepository.findAllByExamId(EXAM_ID)).willReturn(List.of(ep)); given(userRepository.findAllById(List.of(userId))).willReturn(List.of(user)); given(submissionRepository.findByExamId(EXAM_ID)).willReturn(List.of(submission)); @@ -87,6 +110,8 @@ void execute_mapsSubmissionByParticipantId() { assertThat(result).hasSize(1); ExamineeBoardResponse row = result.get(0); assertThat(row.examParticipantId()).isEqualTo(12L); + assertThat(row.attendanceStatus()).isEqualTo(ParticipantAttendanceStatus.SUBMITTED); + assertThat(row.submissionDisplayStatus()).isEqualTo(ParticipantSubmissionDisplayStatus.GRADED); assertThat(row.submitted()).isTrue(); assertThat(row.submissionId()).isEqualTo(50L); assertThat(row.submissionStatus()).isEqualTo("DONE"); @@ -121,6 +146,7 @@ void execute_doesNotFallbackToExamParticipantPk() { .build(); ReflectionTestUtils.setField(otherUserSubmission, "id", 99L); + given(examRepository.findById(EXAM_ID)).willReturn(Optional.of(runningExam())); given(examParticipantRepository.findAllByExamId(EXAM_ID)).willReturn(List.of(ep)); given(userRepository.findAllById(List.of(userId))).willReturn(List.of(user)); given(submissionRepository.findByExamId(EXAM_ID)).willReturn(List.of(otherUserSubmission)); @@ -130,6 +156,8 @@ void execute_doesNotFallbackToExamParticipantPk() { assertThat(result).hasSize(1); ExamineeBoardResponse row = result.get(0); + assertThat(row.attendanceStatus()).isEqualTo(ParticipantAttendanceStatus.IN_EXAM); + assertThat(row.submissionDisplayStatus()).isEqualTo(ParticipantSubmissionDisplayStatus.NOT_SUBMITTED); assertThat(row.submitted()).isFalse(); assertThat(row.submissionId()).isNull(); assertThat(row.submissionStatus()).isNull(); diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCaseTest.java new file mode 100644 index 0000000..e4ca70e --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterActivityLogsUseCaseTest.java @@ -0,0 +1,112 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogPageResponse; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; +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.AdminService; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.AuthErrorStatus; + +@ExtendWith(MockitoExtension.class) +class GetMasterActivityLogsUseCaseTest { + + @InjectMocks + private GetMasterActivityLogsUseCase getMasterActivityLogsUseCase; + + @Mock + private AdminService adminService; + + @Mock + private MasterActivityLogRepository masterActivityLogRepository; + + @Test + @DisplayName("MASTER는 활동 로그를 조회할 수 있다") + void execute_master_success() { + Long masterId = 1L; + Admin master = Admin.builder().role(AdminRole.MASTER).build(); + + MasterActivityLog log = MasterActivityLog.builder() + .masterId(masterId) + .type(MasterActivityLogType.ADMIN_SIGNUP_CODE_ISSUED) + .title("관리자 가입 번호가 발급되었습니다") + .message("새 관리자가 가입할 수 있는 가입 번호가 발급되었습니다.") + .build(); + ReflectionTestUtils.setField(log, "createdAt", LocalDateTime.now()); + + Page page = new PageImpl<>(List.of(log)); + given(adminService.findById(masterId)).willReturn(master); + given(masterActivityLogRepository.search(isNull(), isNull(), any(Pageable.class))).willReturn(page); + + MasterActivityLogPageResponse response = getMasterActivityLogsUseCase.execute(masterId, null, null, 0, 20); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).type()).isEqualTo(MasterActivityLogType.ADMIN_SIGNUP_CODE_ISSUED); + } + + @Test + @DisplayName("일반 ADMIN은 MASTER_ONLY 예외가 발생한다") + void execute_admin_throwsMasterOnly() { + Long adminId = 2L; + Admin admin = Admin.builder().role(AdminRole.ADMIN).build(); + given(adminService.findById(adminId)).willReturn(admin); + + assertThatThrownBy(() -> getMasterActivityLogsUseCase.execute(adminId, null, null, 0, 20)) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode().getCode()) + .isEqualTo(AuthErrorStatus.MASTER_ONLY.getCode().getCode()); + } + + @Test + @DisplayName("size는 최대 100으로 제한된다") + void execute_capsPageSize() { + Admin master = Admin.builder().role(AdminRole.MASTER).build(); + given(adminService.findById(1L)).willReturn(master); + given(masterActivityLogRepository.search(isNull(), isNull(), org.mockito.ArgumentMatchers.argThat(p -> p.getPageSize() == 100))) + .willReturn(new PageImpl<>(List.of())); + + MasterActivityLogPageResponse response = getMasterActivityLogsUseCase.execute(1L, null, null, 0, 500); + + assertThat(response.size()).isEqualTo(100); + } + + @Test + @DisplayName("type 필터를 repository에 전달한다") + void execute_passesTypeFilter() { + Admin master = Admin.builder().role(AdminRole.MASTER).build(); + given(adminService.findById(1L)).willReturn(master); + given(masterActivityLogRepository.search( + eq(MasterActivityLogType.ADMIN_ACCOUNT_DELETED), + isNull(), + any(Pageable.class))) + .willReturn(new PageImpl<>(List.of())); + + MasterActivityLogPageResponse response = getMasterActivityLogsUseCase.execute( + 1L, null, MasterActivityLogType.ADMIN_ACCOUNT_DELETED, 0, 20); + + assertThat(response.content()).isEmpty(); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCaseTest.java new file mode 100644 index 0000000..8b62335 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/GetMasterPlatformSettingsUseCaseTest.java @@ -0,0 +1,63 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +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.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; +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.AdminService; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.AuthErrorStatus; + +@ExtendWith(MockitoExtension.class) +class GetMasterPlatformSettingsUseCaseTest { + + @InjectMocks + private GetMasterPlatformSettingsUseCase getMasterPlatformSettingsUseCase; + + @Mock + private AdminService adminService; + + @Mock + private PlatformSettingsService platformSettingsService; + + @Test + @DisplayName("MASTER는 플랫폼 설정을 조회할 수 있다") + void execute_master_success() { + Long masterId = 1L; + given(adminService.findById(masterId)).willReturn(Admin.builder().role(AdminRole.MASTER).build()); + given(platformSettingsService.getOrCreate()).willReturn(PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(true) + .build()); + + MasterPlatformSettingsResponse response = getMasterPlatformSettingsUseCase.execute(masterId); + + assertThat(response.logRetentionDays()).isEqualTo(90); + assertThat(response.autoDeleteExpiredData()).isTrue(); + } + + @Test + @DisplayName("일반 ADMIN은 MASTER_ONLY 예외") + void execute_admin_forbidden() { + given(adminService.findById(2L)).willReturn(Admin.builder().role(AdminRole.ADMIN).build()); + + assertThatThrownBy(() -> getMasterPlatformSettingsUseCase.execute(2L)) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode().getCode()) + .isEqualTo(AuthErrorStatus.MASTER_ONLY.getCode().getCode()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCaseTest.java new file mode 100644 index 0000000..ecea6da --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/IssueAdminNumberUseCaseTest.java @@ -0,0 +1,75 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; + +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.dto.request.AdminNumberIssueRequest; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; +import com.yd.vibecode.domain.auth.domain.entity.Admin; +import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; +import com.yd.vibecode.domain.auth.domain.entity.AdminRole; +import com.yd.vibecode.domain.auth.domain.service.AdminNumberService; +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 org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class IssueAdminNumberUseCaseTest { + + @InjectMocks + private IssueAdminNumberUseCase issueAdminNumberUseCase; + + @Mock + private AdminService adminService; + + @Mock + private AdminNumberService adminNumberService; + + @Mock + private MasterActivityLogService masterActivityLogService; + + @Test + @DisplayName("MASTER가 관리자 가입 번호 발급 시 활동 로그를 기록한다") + void execute_logsSignupCodeIssued() { + Long masterId = 1L; + Admin master = Admin.builder().role(AdminRole.MASTER).build(); + ReflectionTestUtils.setField(master, "id", masterId); + + AdminNumberIssueRequest request = new AdminNumberIssueRequest("운영팀", LocalDateTime.now().plusDays(7)); + AdminNumber issued = AdminNumber.builder() + .adminNumber("ADM-SECRET-123") + .issuedBy(masterId) + .build(); + + given(adminService.findById(masterId)).willReturn(master); + given(adminNumberService.issue(masterId, request.label(), request.expiresAt())).willReturn(issued); + + issueAdminNumberUseCase.execute(masterId, request); + + verify(masterActivityLogService).logSignupCodeIssued(masterId); + } + + @Test + @DisplayName("MASTER가 아니면 발급할 수 없다") + void execute_notMaster_throws() { + Admin admin = Admin.builder().role(AdminRole.ADMIN).build(); + given(adminService.findById(2L)).willReturn(admin); + + assertThatThrownBy(() -> issueAdminNumberUseCase.execute( + 2L, new AdminNumberIssueRequest(null, null))) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode().getCode()) + .isEqualTo(AuthErrorStatus.MASTER_ONLY.getCode().getCode()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCaseTest.java index 9254bcd..8e85bf7 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/ResetAdminPasswordByMasterUseCaseTest.java @@ -16,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.yd.vibecode.domain.admin.domain.service.AdminAuditLogService; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; 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.AdminService; @@ -35,6 +36,9 @@ class ResetAdminPasswordByMasterUseCaseTest { @Mock private AdminAuditLogService adminAuditLogService; + @Mock + private MasterActivityLogService masterActivityLogService; + @Mock private TemporaryPasswordGenerator temporaryPasswordGenerator; @@ -66,6 +70,7 @@ void execute_success() { assertThat(target.getPasswordHash()).isEqualTo("encodedNew"); verify(adminService).encodePassword(temporaryPassword); verify(adminAuditLogService).log(eq(requesterId), eq("RESET_PASSWORD_BY_MASTER"), any()); + verify(masterActivityLogService).logAdminPasswordReset(requesterId, 2L, target.getDisplayName()); } @Test diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCaseTest.java index 1e9dea7..844eb35 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateAdminNumberUseCaseTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import java.time.LocalDateTime; @@ -14,9 +16,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; import com.yd.vibecode.domain.admin.application.dto.request.AdminNumberUpdateRequest; import com.yd.vibecode.domain.admin.application.dto.response.AdminNumberResponse; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; import com.yd.vibecode.domain.auth.domain.entity.AdminRole; @@ -37,6 +41,9 @@ class UpdateAdminNumberUseCaseTest { @Mock private AdminNumberService adminNumberService; + @Mock + private MasterActivityLogService masterActivityLogService; + @Test @DisplayName("관리자 번호 활성화 시 Admin 계정도 활성화되어야 한다") void execute_activate_success() { @@ -55,6 +62,12 @@ void execute_activate_success() { LocalDateTime.now().plusDays(30) ); + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(false) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() .adminNumber(adminNumberStr) .assignedAdminId(assignedAdminId) @@ -62,11 +75,13 @@ void execute_activate_success() { .build(); Admin assignedAdmin = Admin.builder() + .displayName("김관리") .isActive(false) // 현재 비활성 상태 .role(AdminRole.ADMIN) .build(); given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); given(adminService.findById(assignedAdminId)).willReturn(assignedAdmin); @@ -76,6 +91,7 @@ void execute_activate_success() { // then assertThat(response.active()).isTrue(); assertThat(assignedAdmin.getIsActive()).isTrue(); // 계정도 활성화되었는지 확인 + verify(masterActivityLogService).logSignupCodeReactivated(requesterId, assignedAdminId, "김관리"); } @Test @@ -96,6 +112,12 @@ void execute_deactivate_success() { LocalDateTime.now().plusDays(30) ); + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(true) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() .adminNumber(adminNumberStr) .assignedAdminId(assignedAdminId) @@ -103,11 +125,13 @@ void execute_deactivate_success() { .build(); Admin assignedAdmin = Admin.builder() + .displayName("이관리") .isActive(true) // 현재 활성 상태 .role(AdminRole.ADMIN) .build(); given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); given(adminService.findById(assignedAdminId)).willReturn(assignedAdmin); @@ -116,6 +140,7 @@ void execute_deactivate_success() { // then assertThat(assignedAdmin.getIsActive()).isFalse(); // 계정이 비활성화되었는지 확인 + verify(masterActivityLogService).logSignupCodeDeactivated(requesterId, assignedAdminId, "이관리"); } @Test @@ -148,6 +173,11 @@ void execute_deactivate_master_fail() { .build(); given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(true) + .build()); given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); given(adminService.findById(assignedAdminId)).willReturn(assignedAdmin); @@ -157,5 +187,238 @@ void execute_deactivate_master_fail() { RestApiException exception = (RestApiException) e; assertThat(exception.getErrorCode().getCode()).isEqualTo(AuthErrorStatus.MASTER_ACCOUNT_CANNOT_BE_DEACTIVATED.getCode().getCode()); }); + verify(masterActivityLogService, never()).logSignupCodeDeactivated(any(), any(), any()); + } + + @Test + @DisplayName("active 값이 변하지 않으면 활동 로그를 기록하지 않는다") + void execute_noActiveChange_doesNotLog() { + Long requesterId = 1L; + String adminNumberStr = "ADM-123456"; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest( + "Updated Label", + true, + LocalDateTime.now().plusDays(30) + ); + + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(true) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(true) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); + given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService, never()).logSignupCodeDeactivated(any(), any(), any()); + verify(masterActivityLogService, never()).logSignupCodeReactivated(any(), any(), any()); + } + + @Test + @DisplayName("연결된 관리자 없는 가입 번호 비활성화 시 가입 번호 기준 로그를 기록한다") + void execute_deactivate_unassignedSignupCode_logsSignupCodeMessage() { + Long requesterId = 1L; + String adminNumberStr = "ADM-UNASSIGNED"; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest( + null, + false, + LocalDateTime.now().plusDays(30) + ); + + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(true) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(false) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); + given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService).logSignupCodeDeactivated(requesterId, null, null); + } + + @Test + @DisplayName("연결된 관리자 없는 가입 번호 재활성화 시 가입 번호 기준 로그를 기록한다") + void execute_reactivate_unassignedSignupCode_logsSignupCodeMessage() { + Long requesterId = 1L; + String adminNumberStr = "ADM-UNASSIGNED"; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest( + null, + true, + LocalDateTime.now().plusDays(30) + ); + + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(false) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(true) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); + given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService).logSignupCodeReactivated(requesterId, null, null); + } + + @Test + @DisplayName("기본 active=true(null) 상태에서 최초 true → false 변경 시 DEACTIVATED 로그를 기록한다") + void execute_firstDeactivate_withNullIsActive_logsDeactivated() { + Long requesterId = 1L; + String adminNumberStr = "ADM-NEW"; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest(null, false, null); + + AdminNumber sharedEntity = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(null) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(sharedEntity); + given(adminNumberService.update(eq(adminNumberStr), eq(null), eq(false), eq(null))) + .willAnswer((Answer) invocation -> { + sharedEntity.update(null, false, null); + return sharedEntity; + }); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService).logSignupCodeDeactivated(requesterId, null, null); + } + + @Test + @DisplayName("동일 JPA 엔티티 참조에서도 최초 true → false 변경 시 DEACTIVATED 로그를 기록한다") + void execute_firstDeactivate_sameEntityReference_logsDeactivated() { + Long requesterId = 1L; + String adminNumberStr = "ADM-SHARED"; + Long assignedAdminId = 2L; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest(null, false, null); + + AdminNumber sharedEntity = AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(true) + .build(); + + Admin assignedAdmin = Admin.builder() + .displayName("박관리") + .isActive(true) + .role(AdminRole.ADMIN) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(sharedEntity); + given(adminNumberService.update(eq(adminNumberStr), eq(null), eq(false), eq(null))) + .willAnswer((Answer) invocation -> { + sharedEntity.update(null, false, null); + return sharedEntity; + }); + given(adminService.findById(assignedAdminId)).willReturn(assignedAdmin); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService).logSignupCodeDeactivated(requesterId, assignedAdminId, "박관리"); + assertThat(sharedEntity.getIsActive()).isFalse(); + } + + @Test + @DisplayName("false → false 요청 시 로그를 기록하지 않는다") + void execute_falseToFalse_doesNotLog() { + Long requesterId = 1L; + String adminNumberStr = "ADM-INACTIVE"; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest(null, false, null); + + AdminNumber sharedEntity = AdminNumber.builder() + .adminNumber(adminNumberStr) + .isActive(false) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(sharedEntity); + given(adminNumberService.update(eq(adminNumberStr), eq(null), eq(false), eq(null))) + .willAnswer((Answer) invocation -> { + sharedEntity.update(null, false, null); + return sharedEntity; + }); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + verify(masterActivityLogService, never()).logSignupCodeDeactivated(any(), any(), any()); + verify(masterActivityLogService, never()).logSignupCodeReactivated(any(), any(), any()); + } + + @Test + @DisplayName("가입 완료 후 AdminNumber=false·Admin=true 상태에서 최초 비활성화 시 DEACTIVATED 로그를 기록한다") + void execute_deactivate_afterSignup_adminActiveAdminNumberInactive_logsDeactivated() { + Long requesterId = 1L; + String adminNumberStr = "ADM-SIGNED-UP"; + Long assignedAdminId = 2L; + + Admin requester = Admin.builder().role(AdminRole.MASTER).build(); + AdminNumberUpdateRequest request = new AdminNumberUpdateRequest(null, false, null); + + // assign() 직후: 가입 번호는 false, 관리자 계정은 true + AdminNumber beforeUpdate = AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(false) + .build(); + AdminNumber updatedAdminNumber = AdminNumber.builder() + .adminNumber(adminNumberStr) + .assignedAdminId(assignedAdminId) + .isActive(false) + .build(); + Admin assignedAdmin = Admin.builder() + .displayName("최관리") + .isActive(true) + .role(AdminRole.ADMIN) + .build(); + + given(adminService.findById(requesterId)).willReturn(requester); + given(adminNumberService.getByAdminNumber(adminNumberStr)).willReturn(beforeUpdate); + given(adminNumberService.update(any(), any(), any(), any())).willReturn(updatedAdminNumber); + given(adminService.findById(assignedAdminId)).willReturn(assignedAdmin); + + updateAdminNumberUseCase.execute(requesterId, adminNumberStr, request); + + assertThat(assignedAdmin.getIsActive()).isFalse(); + verify(masterActivityLogService).logSignupCodeDeactivated(requesterId, assignedAdminId, "최관리"); + } + + @Test + @DisplayName("resolveActiveState는 null을 활성(true)으로 간주한다") + void resolveActiveState_nullMeansActive() { + assertThat(UpdateAdminNumberUseCase.resolveActiveState(null)).isTrue(); + assertThat(UpdateAdminNumberUseCase.resolveActiveState(true)).isTrue(); + assertThat(UpdateAdminNumberUseCase.resolveActiveState(false)).isFalse(); } } diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCaseTest.java new file mode 100644 index 0000000..0be4cba --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateMasterPlatformSettingsUseCaseTest.java @@ -0,0 +1,90 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +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 com.yd.vibecode.domain.admin.application.dto.request.UpdateMasterPlatformSettingsRequest; +import com.yd.vibecode.domain.admin.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; +import com.yd.vibecode.domain.admin.domain.service.PlatformSettingsService; +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.AdminService; + +@ExtendWith(MockitoExtension.class) +class UpdateMasterPlatformSettingsUseCaseTest { + + @InjectMocks + private UpdateMasterPlatformSettingsUseCase updateMasterPlatformSettingsUseCase; + + @Mock + private AdminService adminService; + + @Mock + private PlatformSettingsService platformSettingsService; + + @Mock + private MasterActivityLogService masterActivityLogService; + + @Test + @DisplayName("설정 변경 시 PLATFORM_SETTINGS_UPDATED 로그를 기록한다") + void execute_logsWhenChanged() { + Long masterId = 1L; + UpdateMasterPlatformSettingsRequest request = new UpdateMasterPlatformSettingsRequest( + 30, 180, false); + + PlatformSettings before = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(true) + .build(); + PlatformSettings after = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(30) + .submissionRetentionDays(180) + .autoDeleteExpiredData(false) + .build(); + + given(adminService.findById(masterId)).willReturn(Admin.builder().role(AdminRole.MASTER).build()); + given(platformSettingsService.getOrCreate()).willReturn(before); + given(platformSettingsService.updateRetention(30, 180, false)).willReturn(after); + + var response = updateMasterPlatformSettingsUseCase.execute(masterId, request); + + assertThat(response.logRetentionDays()).isEqualTo(30); + verify(masterActivityLogService).logPlatformSettingsUpdated(masterId); + } + + @Test + @DisplayName("설정 값이 동일하면 로그를 기록하지 않는다") + void execute_noLogWhenUnchanged() { + Long masterId = 1L; + UpdateMasterPlatformSettingsRequest request = new UpdateMasterPlatformSettingsRequest( + 90, 90, true); + + PlatformSettings same = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(true) + .build(); + + given(adminService.findById(masterId)).willReturn(Admin.builder().role(AdminRole.MASTER).build()); + given(platformSettingsService.getOrCreate()).willReturn(same); + given(platformSettingsService.updateRetention(90, 90, true)).willReturn(same); + + updateMasterPlatformSettingsUseCase.execute(masterId, request); + + verify(masterActivityLogService, never()).logPlatformSettingsUpdated(masterId); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCaseTest.java new file mode 100644 index 0000000..fdb621a --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/application/usecase/UpdateProblemAvailabilityUseCaseTest.java @@ -0,0 +1,164 @@ +package com.yd.vibecode.domain.admin.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.yd.vibecode.domain.admin.application.dto.request.UpdateProblemAvailabilityRequest; +import com.yd.vibecode.domain.problem.domain.entity.Difficulty; +import com.yd.vibecode.domain.problem.domain.entity.Problem; +import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; +import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; + +@ExtendWith(MockitoExtension.class) +class UpdateProblemAvailabilityUseCaseTest { + + @InjectMocks + private UpdateProblemAvailabilityUseCase updateProblemAvailabilityUseCase; + + @Mock + private ProblemRepository problemRepository; + + @Test + @DisplayName("PUBLISHED 문제를 available=false로 변경하면 ARCHIVED가 된다") + void execute_publishToArchived_success() { + Problem problem = publishedProblem(1L); + Problem otherPublished = publishedProblem(2L); + given(problemRepository.findById(1L)).willReturn(Optional.of(problem)); + given(problemRepository.findAllByStatusForUpdate(ProblemStatus.PUBLISHED)) + .willReturn(List.of(problem, otherPublished)); + + var response = updateProblemAvailabilityUseCase.execute( + 1L, new UpdateProblemAvailabilityRequest(false)); + + assertThat(response.status()).isEqualTo(ProblemStatus.ARCHIVED); + assertThat(problem.getStatus()).isEqualTo(ProblemStatus.ARCHIVED); + verify(problemRepository).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + } + + @Test + @DisplayName("ARCHIVED 문제를 available=true로 변경하면 PUBLISHED가 된다") + void execute_archivedToPublished_success() { + Problem problem = Problem.builder() + .title("문제 B") + .difficulty(Difficulty.EASY) + .status(ProblemStatus.ARCHIVED) + .build(); + given(problemRepository.findById(2L)).willReturn(Optional.of(problem)); + + var response = updateProblemAvailabilityUseCase.execute( + 2L, new UpdateProblemAvailabilityRequest(true)); + + assertThat(response.status()).isEqualTo(ProblemStatus.PUBLISHED); + assertThat(problem.getStatus()).isEqualTo(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + } + + @Test + @DisplayName("마지막 PUBLISHED 문제를 available=false로 변경하려 하면 400") + void execute_lastPublishedCannotBeArchived() { + Problem problem = publishedProblem(3L); + given(problemRepository.findById(3L)).willReturn(Optional.of(problem)); + given(problemRepository.findAllByStatusForUpdate(ProblemStatus.PUBLISHED)) + .willReturn(List.of(problem)); + + assertThatThrownBy(() -> updateProblemAvailabilityUseCase.execute( + 3L, new UpdateProblemAvailabilityRequest(false))) + .isInstanceOf(RestApiException.class) + .satisfies(ex -> assertThat(((RestApiException) ex).getErrorCode().getMessage()) + .isEqualTo("최소 1개 이상의 문제는 사용 가능해야 합니다.")); + + assertThat(problem.getStatus()).isEqualTo(ProblemStatus.PUBLISHED); + verify(problemRepository).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + } + + @Test + @DisplayName("PUBLISHED archive 시 lock 기반 목록 조회 후 개수 검증") + void execute_usesLockedPublishedListBeforeArchive() { + Problem problem = publishedProblem(10L); + Problem otherPublished = publishedProblem(11L); + given(problemRepository.findById(10L)).willReturn(Optional.of(problem)); + given(problemRepository.findAllByStatusForUpdate(ProblemStatus.PUBLISHED)) + .willReturn(List.of(problem, otherPublished)); + + updateProblemAvailabilityUseCase.execute(10L, new UpdateProblemAvailabilityRequest(false)); + + InOrder inOrder = inOrder(problemRepository); + inOrder.verify(problemRepository).findById(10L); + inOrder.verify(problemRepository).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + } + + @Test + @DisplayName("존재하지 않는 problemId는 PROBLEM_NOT_FOUND") + void execute_notFound() { + given(problemRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> updateProblemAvailabilityUseCase.execute( + 999L, new UpdateProblemAvailabilityRequest(false))) + .isInstanceOf(RestApiException.class) + .extracting(ex -> ((RestApiException) ex).getErrorCode().getCode()) + .isEqualTo(ProblemErrorStatus.PROBLEM_NOT_FOUND.getCode().getCode()); + } + + @Test + @DisplayName("이미 PUBLISHED인 문제에 available=true는 no-op") + void execute_alreadyPublished_noOp() { + Problem problem = publishedProblem(4L); + given(problemRepository.findById(4L)).willReturn(Optional.of(problem)); + + var response = updateProblemAvailabilityUseCase.execute( + 4L, new UpdateProblemAvailabilityRequest(true)); + + assertThat(response.status()).isEqualTo(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + } + + @Test + @DisplayName("이미 ARCHIVED인 문제에 available=false는 no-op") + void execute_alreadyArchived_noOp() { + Problem problem = Problem.builder() + .title("문제 C") + .difficulty(Difficulty.MEDIUM) + .status(ProblemStatus.ARCHIVED) + .build(); + given(problemRepository.findById(5L)).willReturn(Optional.of(problem)); + + var response = updateProblemAvailabilityUseCase.execute( + 5L, new UpdateProblemAvailabilityRequest(false)); + + assertThat(response.status()).isEqualTo(ProblemStatus.ARCHIVED); + verify(problemRepository, never()).countByStatus(ProblemStatus.PUBLISHED); + verify(problemRepository, never()).findAllByStatusForUpdate(ProblemStatus.PUBLISHED); + } + + private static Problem publishedProblem(Long id) { + Problem problem = Problem.builder() + .title("문제 A") + .difficulty(Difficulty.EASY) + .status(ProblemStatus.PUBLISHED) + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(problem, "id", id); + return problem; + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepositoryTest.java b/src/test/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepositoryTest.java new file mode 100644 index 0000000..e63f110 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/repository/AdminActivityLogRepositoryTest.java @@ -0,0 +1,76 @@ +package com.yd.vibecode.domain.admin.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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +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 AdminActivityLogRepositoryTest { + + @Autowired + private AdminActivityLogRepository adminActivityLogRepository; + + @Test + @DisplayName("adminId·type·keyword로 본인 로그만 조회된다") + void searchByAdmin_filtersByAdminTypeKeyword() { + saveLog(1L, 10L, AdminActivityLogType.ROOM_CREATED, "시험 방 생성됨", "새 입장 코드가 생성되었습니다."); + saveLog(1L, 11L, AdminActivityLogType.EXAM_STARTED, "시험 세션이 시작되었습니다.", "시험이 시작되었습니다."); + saveLog(2L, 12L, AdminActivityLogType.EXAM_ENDED, "시험이 종료되었습니다.", "시험이 종료되었습니다."); + + Page page = adminActivityLogRepository.searchByAdmin( + 1L, + AdminActivityLogType.EXAM_STARTED, + "시작", + PageRequest.of(0, 10)); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().get(0).getType()).isEqualTo(AdminActivityLogType.EXAM_STARTED); + } + + @Test + @DisplayName("다른 adminId 로그는 조회되지 않는다") + void searchByAdmin_excludesOtherAdmins() { + saveLog(99L, 10L, AdminActivityLogType.ROOM_CREATED, "시험 방 생성됨", "새 입장 코드가 생성되었습니다."); + + Page page = adminActivityLogRepository.searchByAdmin( + 1L, null, null, PageRequest.of(0, 10)); + + assertThat(page.getTotalElements()).isZero(); + } + + private void saveLog( + Long adminId, + Long examId, + AdminActivityLogType type, + String title, + String message) { + adminActivityLogRepository.save(AdminActivityLog.builder() + .adminId(adminId) + .examId(examId) + .type(type) + .title(title) + .message(message) + .build()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepositoryTest.java b/src/test/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepositoryTest.java new file mode 100644 index 0000000..69aeef2 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/repository/MasterActivityLogRepositoryTest.java @@ -0,0 +1,70 @@ +package com.yd.vibecode.domain.admin.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +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 MasterActivityLogRepositoryTest { + + @Autowired + private MasterActivityLogRepository masterActivityLogRepository; + + @Test + @DisplayName("type·keyword로 로그를 조회한다") + void search_filtersByTypeAndKeyword() { + saveLog(MasterActivityLogType.ADMIN_SIGNUP_CODE_ISSUED, + "관리자 가입 번호가 발급되었습니다", + "새 관리자가 가입할 수 있는 가입 번호가 발급되었습니다."); + saveLog(MasterActivityLogType.ADMIN_ACCOUNT_DELETED, + "관리자 계정이 삭제되었습니다", + "'홍길동' 관리자 계정이 삭제되었습니다."); + + Page page = masterActivityLogRepository.search( + MasterActivityLogType.ADMIN_ACCOUNT_DELETED, + "삭제", + PageRequest.of(0, 10)); + + assertThat(page.getTotalElements()).isEqualTo(1); + assertThat(page.getContent().get(0).getType()).isEqualTo(MasterActivityLogType.ADMIN_ACCOUNT_DELETED); + } + + @Test + @DisplayName("keyword 없이 전체 로그를 조회한다") + void search_withoutKeyword() { + saveLog(MasterActivityLogType.ADMIN_PASSWORD_RESET, + "관리자 비밀번호가 재설정되었습니다", + "'김관리' 관리자 비밀번호가 재설정되었습니다."); + + Page page = masterActivityLogRepository.search(null, null, PageRequest.of(0, 10)); + + assertThat(page.getTotalElements()).isEqualTo(1); + } + + private void saveLog(MasterActivityLogType type, String title, String message) { + masterActivityLogRepository.save(MasterActivityLog.builder() + .masterId(1L) + .type(type) + .title(title) + .message(message) + .build()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogServiceTest.java b/src/test/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogServiceTest.java new file mode 100644 index 0000000..f87f2ab --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/service/AdminActivityLogServiceTest.java @@ -0,0 +1,114 @@ +package com.yd.vibecode.domain.admin.domain.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.AdminActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.AdminActivityLogRepository; + +@ExtendWith(MockitoExtension.class) +class AdminActivityLogServiceTest { + + @InjectMocks + private AdminActivityLogService adminActivityLogService; + + @Mock + private AdminActivityLogRepository adminActivityLogRepository; + + @Test + @DisplayName("ROOM_CREATED 로그에 시험 제목을 포함한다") + void logRoomCreated_includesExamTitle() { + adminActivityLogService.logRoomCreated(10L, 100L, "2026 AI 코딩 테스트"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminActivityLog.class); + verify(adminActivityLogRepository).save(captor.capture()); + + AdminActivityLog saved = captor.getValue(); + assertThat(saved.getAdminId()).isEqualTo(10L); + assertThat(saved.getExamId()).isEqualTo(100L); + assertThat(saved.getParticipantId()).isNull(); + assertThat(saved.getType()).isEqualTo(AdminActivityLogType.ROOM_CREATED); + assertThat(saved.getTitle()).isEqualTo("시험 방 생성됨"); + assertThat(saved.getMessage()).isEqualTo("'2026 AI 코딩 테스트' 시험 방이 생성되었습니다."); + } + + @Test + @DisplayName("EXAM_STARTED 로그에 시험 제목을 포함한다") + void logExamStarted_includesExamTitle() { + adminActivityLogService.logExamStarted(10L, 100L, "중간고사 A"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminActivityLog.class); + verify(adminActivityLogRepository).save(captor.capture()); + + AdminActivityLog saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(AdminActivityLogType.EXAM_STARTED); + assertThat(saved.getTitle()).isEqualTo("시험 세션이 시작되었습니다."); + assertThat(saved.getMessage()).isEqualTo("'중간고사 A' 시험이 시작되었습니다."); + } + + @Test + @DisplayName("EVALUATION_COMPLETED 로그에 참가자 표시명과 시험 제목을 포함한다") + void logEvaluationCompleted_includesParticipantAndExamTitle() { + adminActivityLogService.logEvaluationCompleted(10L, 100L, 200L, "기말 시험", "김민준"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminActivityLog.class); + verify(adminActivityLogRepository).save(captor.capture()); + + AdminActivityLog saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(AdminActivityLogType.EVALUATION_COMPLETED); + assertThat(saved.getParticipantId()).isEqualTo(200L); + assertThat(saved.getTitle()).isEqualTo("채점 과정이 성공적으로 완료되었습니다."); + assertThat(saved.getMessage()).isEqualTo("참가자 김민준님의 '기말 시험' 채점이 완료되었습니다."); + assertThat(saved.getMessage()).doesNotContain("token"); + } + + @Test + @DisplayName("EVALUATION_COMPLETED 참가자명 없으면 시험 제목만 포함한다") + void logEvaluationCompleted_withoutParticipantName() { + adminActivityLogService.logEvaluationCompleted(10L, 100L, 200L, "기말 시험", null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminActivityLog.class); + verify(adminActivityLogRepository).save(captor.capture()); + + assertThat(captor.getValue().getMessage()) + .isEqualTo("'기말 시험' 시험의 참가자 채점이 완료되었습니다."); + } + + @Test + @DisplayName("EXAM_ENDED 로그에 시험 제목을 포함한다") + void logExamEnded_includesExamTitle() { + adminActivityLogService.logExamEnded(10L, 100L, "중간고사 A"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminActivityLog.class); + verify(adminActivityLogRepository).save(captor.capture()); + + assertThat(captor.getValue().getMessage()).isEqualTo("'중간고사 A' 시험이 종료되었습니다."); + } + + @Test + @DisplayName("시험 제목이 비어 있으면 examId fallback을 사용한다") + void resolveExamLabel_blankTitleUsesExamId() { + assertThat(AdminActivityLogService.resolveExamLabel(null, 42L)).isEqualTo("시험 ID 42"); + assertThat(AdminActivityLogService.resolveExamLabel(" ", 42L)).isEqualTo("시험 ID 42"); + assertThat(AdminActivityLogService.formatMessage("'%s' 시험이 시작되었습니다.", null, 42L)) + .isEqualTo("'시험 ID 42' 시험이 시작되었습니다."); + } + + @Test + @DisplayName("EXAM_ENDED 기본 호출") + void logExamEnded() { + adminActivityLogService.logExamEnded(10L, 100L, "테스트"); + + verify(adminActivityLogRepository).save(any(AdminActivityLog.class)); + } +} 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 new file mode 100644 index 0000000..ce0d53f --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/service/MasterActivityLogServiceTest.java @@ -0,0 +1,150 @@ +package com.yd.vibecode.domain.admin.domain.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentCaptor.forClass; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLog; +import com.yd.vibecode.domain.admin.domain.entity.MasterActivityLogType; +import com.yd.vibecode.domain.admin.domain.repository.MasterActivityLogRepository; + +@ExtendWith(MockitoExtension.class) +class MasterActivityLogServiceTest { + + @InjectMocks + private MasterActivityLogService masterActivityLogService; + + @Mock + private MasterActivityLogRepository masterActivityLogRepository; + + @Test + @DisplayName("ADMIN_SIGNUP_CODE_ISSUED 로그에 가입 번호 원문을 포함하지 않는다") + void logSignupCodeIssued_doesNotIncludeSignupCode() { + masterActivityLogService.logSignupCodeIssued(1L); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getMasterId()).isEqualTo(1L); + assertThat(saved.getTargetAdminId()).isNull(); + assertThat(saved.getType()).isEqualTo(MasterActivityLogType.ADMIN_SIGNUP_CODE_ISSUED); + assertThat(saved.getTitle()).isEqualTo("관리자 가입 번호가 발급되었습니다"); + assertThat(saved.getMessage()).isEqualTo("새 관리자가 가입할 수 있는 가입 번호가 발급되었습니다."); + assertThat(saved.getMessage()).doesNotContain("ADM-"); + } + + @Test + @DisplayName("연결된 관리자 계정 비활성화 시 계정 기준 message를 생성한다") + void logSignupCodeDeactivated_withAssignedAdmin_usesAccountMessage() { + masterActivityLogService.logSignupCodeDeactivated(1L, 10L, "홍길동"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getTargetAdminId()).isEqualTo(10L); + assertThat(saved.getTitle()).isEqualTo("관리자 계정이 비활성화되었습니다"); + assertThat(saved.getMessage()).isEqualTo("'홍길동' 관리자 계정이 비활성화되었습니다."); + assertThat(saved.getMessage()).doesNotContain("ADM-"); + } + + @Test + @DisplayName("연결된 관리자 계정 재활성화 시 계정 기준 message를 생성한다") + void logSignupCodeReactivated_withAssignedAdmin_usesAccountMessage() { + masterActivityLogService.logSignupCodeReactivated(1L, 10L, "홍길동"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getTargetAdminId()).isEqualTo(10L); + assertThat(saved.getTitle()).isEqualTo("관리자 계정이 재활성화되었습니다"); + assertThat(saved.getMessage()).isEqualTo("'홍길동' 관리자 계정이 재활성화되었습니다."); + assertThat(saved.getMessage()).doesNotContain("ADM-"); + } + + @Test + @DisplayName("연결된 관리자 없는 가입 번호 비활성화 시 가입 번호 기준 message를 생성한다") + void logSignupCodeDeactivated_withoutAssignedAdmin_usesSignupCodeMessage() { + masterActivityLogService.logSignupCodeDeactivated(1L, null, null); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getTargetAdminId()).isNull(); + assertThat(saved.getTitle()).isEqualTo("관리자 가입 번호가 비활성화되었습니다"); + assertThat(saved.getMessage()).isEqualTo("관리자 가입 번호가 비활성화되었습니다."); + } + + @Test + @DisplayName("연결된 관리자 없는 가입 번호 재활성화 시 가입 번호 기준 message를 생성한다") + void logSignupCodeReactivated_withoutAssignedAdmin_usesSignupCodeMessage() { + masterActivityLogService.logSignupCodeReactivated(1L, null, null); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getTargetAdminId()).isNull(); + assertThat(saved.getTitle()).isEqualTo("관리자 가입 번호가 재활성화되었습니다"); + assertThat(saved.getMessage()).isEqualTo("관리자 가입 번호가 다시 사용 가능 상태로 변경되었습니다."); + } + + @Test + @DisplayName("ADMIN_SIGNED_UP 로그에 displayName을 포함한다") + void logAdminSignedUp_includesDisplayName() { + masterActivityLogService.logAdminSignedUp(1L, 10L, "홍길동"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(MasterActivityLogType.ADMIN_SIGNED_UP); + assertThat(saved.getTargetAdminId()).isEqualTo(10L); + assertThat(saved.getMessage()).isEqualTo("'홍길동' 관리자가 가입을 완료했습니다."); + } + + @Test + @DisplayName("displayName이 없으면 관리자 ID fallback을 사용한다") + void resolveTargetDisplayName_blankUsesAdminId() { + assertThat(MasterActivityLogService.resolveTargetDisplayName(null, 42L)).isEqualTo("관리자 ID 42"); + assertThat(MasterActivityLogService.resolveTargetDisplayName(" ", 42L)).isEqualTo("관리자 ID 42"); + } + + @Test + @DisplayName("ADMIN_PASSWORD_RESET 로그에 비밀번호를 포함하지 않는다") + void logAdminPasswordReset_doesNotIncludePassword() { + masterActivityLogService.logAdminPasswordReset(1L, 2L, "김관리"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + MasterActivityLog saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(MasterActivityLogType.ADMIN_PASSWORD_RESET); + assertThat(saved.getMessage()).isEqualTo("'김관리' 관리자 비밀번호가 재설정되었습니다."); + assertThat(saved.getMessage()).doesNotContain("password"); + assertThat(saved.getMessage()).doesNotContain("token"); + } + + @Test + @DisplayName("ADMIN_ACCOUNT_DELETED 로그를 기록한다") + void logAdminAccountDeleted() { + masterActivityLogService.logAdminAccountDeleted(1L, 5L, "이관리"); + + ArgumentCaptor captor = forClass(MasterActivityLog.class); + verify(masterActivityLogRepository).save(captor.capture()); + + assertThat(captor.getValue().getType()).isEqualTo(MasterActivityLogType.ADMIN_ACCOUNT_DELETED); + assertThat(captor.getValue().getMessage()).isEqualTo("'이관리' 관리자 계정이 삭제되었습니다."); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsServiceTest.java b/src/test/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsServiceTest.java new file mode 100644 index 0000000..a52aa68 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/domain/service/PlatformSettingsServiceTest.java @@ -0,0 +1,64 @@ +package com.yd.vibecode.domain.admin.domain.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.Optional; + +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.domain.entity.PlatformSettings; +import com.yd.vibecode.domain.admin.domain.repository.PlatformSettingsRepository; + +@ExtendWith(MockitoExtension.class) +class PlatformSettingsServiceTest { + + @InjectMocks + private PlatformSettingsService platformSettingsService; + + @Mock + private PlatformSettingsRepository platformSettingsRepository; + + @Test + @DisplayName("설정 row가 없으면 기본값으로 생성한다") + void getOrCreate_createsDefaultsWhenMissing() { + given(platformSettingsRepository.findById(PlatformSettings.SINGLETON_ID)).willReturn(Optional.empty()); + given(platformSettingsRepository.save(any(PlatformSettings.class))).willAnswer(inv -> inv.getArgument(0)); + + PlatformSettings result = platformSettingsService.getOrCreate(); + + assertThat(result.getDefaultTokenLimit()).isEqualTo(PlatformSettingsService.DEFAULT_TOKEN_LIMIT); + assertThat(result.getLogRetentionDays()).isEqualTo(PlatformSettingsService.DEFAULT_LOG_RETENTION_DAYS); + assertThat(result.getSubmissionRetentionDays()).isEqualTo(PlatformSettingsService.DEFAULT_SUBMISSION_RETENTION_DAYS); + assertThat(result.getAutoDeleteExpiredData()).isTrue(); + verify(platformSettingsRepository).save(any(PlatformSettings.class)); + } + + @Test + @DisplayName("설정을 업데이트한다") + void update_persistsNewValues() { + PlatformSettings existing = PlatformSettings.builder() + .defaultTokenLimit(10000) + .logRetentionDays(90) + .submissionRetentionDays(90) + .autoDeleteExpiredData(true) + .build(); + + given(platformSettingsRepository.findById(PlatformSettings.SINGLETON_ID)).willReturn(Optional.of(existing)); + given(platformSettingsRepository.save(existing)).willReturn(existing); + + PlatformSettings updated = platformSettingsService.updateRetention(30, 180, false); + + assertThat(updated.getDefaultTokenLimit()).isEqualTo(10000); + assertThat(updated.getLogRetentionDays()).isEqualTo(30); + assertThat(updated.getSubmissionRetentionDays()).isEqualTo(180); + assertThat(updated.getAutoDeleteExpiredData()).isFalse(); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogControllerTest.java b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogControllerTest.java new file mode 100644 index 0000000..c0b6373 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminActivityLogControllerTest.java @@ -0,0 +1,87 @@ +package com.yd.vibecode.domain.admin.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.yd.vibecode.domain.admin.application.dto.response.AdminActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetAdminActivityLogsUseCase; +import com.yd.vibecode.global.interceptor.JwtBlacklistInterceptor; +import com.yd.vibecode.global.security.ExcludeBlacklistPathProperties; +import com.yd.vibecode.global.security.SecurityConfig; +import com.yd.vibecode.global.security.TokenProvider; + +@WebMvcTest( + controllers = AdminActivityLogController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class, + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class) +) +class AdminActivityLogControllerTest { + + private static final String ACCESS_TOKEN = "access-token"; + private static final Long CURRENT_USER_ID = 42L; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GetAdminActivityLogsUseCase getAdminActivityLogsUseCase; + + @MockBean + private JwtBlacklistInterceptor jwtBlacklistInterceptor; + + @MockBean + private ExcludeBlacklistPathProperties excludeBlacklistPathProperties; + + @MockBean + private TokenProvider tokenProvider; + + @BeforeEach + void setUp() throws Exception { + given(jwtBlacklistInterceptor.preHandle( + any(HttpServletRequest.class), + any(HttpServletResponse.class), + any())) + .willReturn(true); + given(excludeBlacklistPathProperties.getExcludeAuthPaths()).willReturn(Collections.emptyList()); + given(tokenProvider.getToken(any(HttpServletRequest.class))).willReturn(Optional.of(ACCESS_TOKEN)); + given(tokenProvider.isAccessToken(ACCESS_TOKEN)).willReturn(true); + given(tokenProvider.getId(ACCESS_TOKEN)).willReturn(Optional.of(String.valueOf(CURRENT_USER_ID))); + } + + @Test + @DisplayName("관리자 활동 로그 조회 성공") + @WithMockUser(roles = "ADMIN") + void getLogs_success() throws Exception { + given(getAdminActivityLogsUseCase.execute( + org.mockito.ArgumentMatchers.eq(CURRENT_USER_ID), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq(0), + org.mockito.ArgumentMatchers.eq(20))) + .willReturn(new AdminActivityLogPageResponse(List.of(), 0, 20, 0, 0)); + + mockMvc.perform(get("/api/admin/logs") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerAvailabilityTest.java b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerAvailabilityTest.java new file mode 100644 index 0000000..5aa472e --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerAvailabilityTest.java @@ -0,0 +1,75 @@ +package com.yd.vibecode.domain.admin.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import 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.dto.request.UpdateProblemAvailabilityRequest; +import com.yd.vibecode.domain.admin.application.dto.response.ProblemResponse; +import com.yd.vibecode.domain.admin.application.usecase.CreateProblemUseCase; +import com.yd.vibecode.domain.admin.application.usecase.DeleteProblemUseCase; +import com.yd.vibecode.domain.admin.application.usecase.GetProblemDetailUseCase; +import com.yd.vibecode.domain.admin.application.usecase.GetProblemSpecsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.GetProblemsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.UpdateProblemAvailabilityUseCase; +import com.yd.vibecode.domain.problem.domain.entity.Difficulty; +import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; +import com.yd.vibecode.global.common.BaseResponse; + +@ExtendWith(MockitoExtension.class) +class AdminProblemControllerAvailabilityTest { + + @InjectMocks + private AdminProblemController adminProblemController; + + @Mock + private GetProblemsUseCase getProblemsUseCase; + + @Mock + private CreateProblemUseCase createProblemUseCase; + + @Mock + private DeleteProblemUseCase deleteProblemUseCase; + + @Mock + private GetProblemSpecsUseCase getProblemSpecsUseCase; + + @Mock + private GetProblemDetailUseCase getProblemDetailUseCase; + + @Mock + private UpdateProblemAvailabilityUseCase updateProblemAvailabilityUseCase; + + @Test + @DisplayName("문제 사용 가능 여부 변경 — UseCase 결과를 BaseResponse로 반환") + void updateProblemAvailability_delegatesToUseCase() { + Long problemId = 1L; + ProblemResponse response = new ProblemResponse( + problemId, + "외판원 순회", + Difficulty.MEDIUM, + "[\"dp\"]", + ProblemStatus.ARCHIVED, + null + ); + given(updateProblemAvailabilityUseCase.execute(eq(problemId), any(UpdateProblemAvailabilityRequest.class))) + .willReturn(response); + + BaseResponse result = adminProblemController.updateProblemAvailability( + problemId, new UpdateProblemAvailabilityRequest(false)); + + assertThat(result.getCode()).isEqualTo("COMMON200"); + assertThat(result.getResult().status()).isEqualTo(ProblemStatus.ARCHIVED); + verify(updateProblemAvailabilityUseCase).execute(eq(problemId), any(UpdateProblemAvailabilityRequest.class)); + } +} + \ No newline at end of file diff --git a/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerTest.java b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerTest.java index 7b44164..5e5ce84 100644 --- a/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerTest.java +++ b/src/test/java/com/yd/vibecode/domain/admin/ui/AdminProblemControllerTest.java @@ -23,6 +23,7 @@ import com.yd.vibecode.domain.admin.application.usecase.GetProblemDetailUseCase; import com.yd.vibecode.domain.admin.application.usecase.GetProblemSpecsUseCase; import com.yd.vibecode.domain.admin.application.usecase.GetProblemsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.UpdateProblemAvailabilityUseCase; import com.yd.vibecode.domain.problem.domain.entity.Difficulty; import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; import com.yd.vibecode.global.interceptor.JwtBlacklistInterceptor; @@ -55,6 +56,9 @@ class AdminProblemControllerTest { @MockBean private GetProblemDetailUseCase getProblemDetailUseCase; + @MockBean + private UpdateProblemAvailabilityUseCase updateProblemAvailabilityUseCase; + @MockBean private JwtBlacklistInterceptor jwtBlacklistInterceptor; @@ -104,4 +108,5 @@ void getProblemDetail_success() throws Exception { mockMvc.perform(get("/api/admin/problems/" + problemId + "/detail")) .andExpect(status().isOk()); } + } diff --git a/src/test/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogControllerTest.java b/src/test/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogControllerTest.java new file mode 100644 index 0000000..8941447 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/ui/MasterActivityLogControllerTest.java @@ -0,0 +1,104 @@ +package com.yd.vibecode.domain.admin.ui; + +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.yd.vibecode.domain.admin.application.dto.response.MasterActivityLogPageResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetMasterActivityLogsUseCase; +import com.yd.vibecode.global.exception.RestApiException; +import com.yd.vibecode.global.exception.code.status.AuthErrorStatus; +import com.yd.vibecode.global.interceptor.JwtBlacklistInterceptor; +import com.yd.vibecode.global.security.ExcludeBlacklistPathProperties; +import com.yd.vibecode.global.security.SecurityConfig; +import com.yd.vibecode.global.security.TokenProvider; + +@WebMvcTest( + controllers = MasterActivityLogController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class, + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class) +) +class MasterActivityLogControllerTest { + + private static final String ACCESS_TOKEN = "access-token"; + private static final Long CURRENT_USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GetMasterActivityLogsUseCase getMasterActivityLogsUseCase; + + @MockBean + private JwtBlacklistInterceptor jwtBlacklistInterceptor; + + @MockBean + private ExcludeBlacklistPathProperties excludeBlacklistPathProperties; + + @MockBean + private TokenProvider tokenProvider; + + @BeforeEach + void setUp() throws Exception { + given(jwtBlacklistInterceptor.preHandle( + any(HttpServletRequest.class), + any(HttpServletResponse.class), + any())) + .willReturn(true); + given(excludeBlacklistPathProperties.getExcludeAuthPaths()).willReturn(Collections.emptyList()); + given(tokenProvider.getToken(any(HttpServletRequest.class))).willReturn(Optional.of(ACCESS_TOKEN)); + given(tokenProvider.isAccessToken(ACCESS_TOKEN)).willReturn(true); + given(tokenProvider.getId(ACCESS_TOKEN)).willReturn(Optional.of(String.valueOf(CURRENT_USER_ID))); + } + + @Test + @DisplayName("MASTER - 마스터 활동 로그 조회 성공") + @WithMockUser(roles = "MASTER") + void getLogs_master_success() throws Exception { + given(getMasterActivityLogsUseCase.execute( + eq(CURRENT_USER_ID), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + eq(0), + eq(20))) + .willReturn(new MasterActivityLogPageResponse(List.of(), 0, 20, 0, 0)); + + mockMvc.perform(get("/api/admin/master/logs") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("일반 ADMIN - MASTER_ONLY로 조회 거부") + @WithMockUser(roles = "ADMIN") + void getLogs_admin_forbidden() throws Exception { + willThrow(new RestApiException(AuthErrorStatus.MASTER_ONLY)) + .given(getMasterActivityLogsUseCase) + .execute(eq(CURRENT_USER_ID), any(), any(), eq(0), eq(20)); + + mockMvc.perform(get("/api/admin/master/logs") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsControllerTest.java b/src/test/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsControllerTest.java new file mode 100644 index 0000000..d1ba110 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/admin/ui/MasterPlatformSettingsControllerTest.java @@ -0,0 +1,110 @@ +package com.yd.vibecode.domain.admin.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yd.vibecode.domain.admin.application.dto.request.UpdateMasterPlatformSettingsRequest; +import com.yd.vibecode.domain.admin.application.dto.response.MasterPlatformSettingsResponse; +import com.yd.vibecode.domain.admin.application.usecase.GetMasterPlatformSettingsUseCase; +import com.yd.vibecode.domain.admin.application.usecase.UpdateMasterPlatformSettingsUseCase; +import com.yd.vibecode.global.interceptor.JwtBlacklistInterceptor; +import com.yd.vibecode.global.security.ExcludeBlacklistPathProperties; +import com.yd.vibecode.global.security.SecurityConfig; +import com.yd.vibecode.global.security.TokenProvider; + +@WebMvcTest( + controllers = MasterPlatformSettingsController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class, + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class) +) +class MasterPlatformSettingsControllerTest { + + private static final String ACCESS_TOKEN = "access-token"; + private static final Long CURRENT_USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private GetMasterPlatformSettingsUseCase getMasterPlatformSettingsUseCase; + + @MockBean + private UpdateMasterPlatformSettingsUseCase updateMasterPlatformSettingsUseCase; + + @MockBean + private JwtBlacklistInterceptor jwtBlacklistInterceptor; + + @MockBean + private ExcludeBlacklistPathProperties excludeBlacklistPathProperties; + + @MockBean + private TokenProvider tokenProvider; + + @BeforeEach + void setUp() throws Exception { + given(jwtBlacklistInterceptor.preHandle( + any(HttpServletRequest.class), + any(HttpServletResponse.class), + any())) + .willReturn(true); + given(excludeBlacklistPathProperties.getExcludeAuthPaths()).willReturn(Collections.emptyList()); + given(tokenProvider.getToken(any(HttpServletRequest.class))).willReturn(Optional.of(ACCESS_TOKEN)); + given(tokenProvider.isAccessToken(ACCESS_TOKEN)).willReturn(true); + given(tokenProvider.getId(ACCESS_TOKEN)).willReturn(Optional.of(String.valueOf(CURRENT_USER_ID))); + } + + @Test + @DisplayName("MASTER - 플랫폼 설정 조회 성공") + @WithMockUser(roles = "MASTER") + void getSettings_success() throws Exception { + given(getMasterPlatformSettingsUseCase.execute(CURRENT_USER_ID)) + .willReturn(new MasterPlatformSettingsResponse(90, 90, true, LocalDateTime.now())); + + mockMvc.perform(get("/api/admin/master/settings") + .header("Authorization", "Bearer " + ACCESS_TOKEN)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("MASTER - 플랫폼 설정 수정 성공") + @WithMockUser(roles = "MASTER") + void updateSettings_success() throws Exception { + UpdateMasterPlatformSettingsRequest request = new UpdateMasterPlatformSettingsRequest( + 90, 90, true); + given(updateMasterPlatformSettingsUseCase.execute(eq(CURRENT_USER_ID), any(UpdateMasterPlatformSettingsRequest.class))) + .willReturn(new MasterPlatformSettingsResponse(90, 90, true, LocalDateTime.now())); + + mockMvc.perform(put("/api/admin/master/settings") + .header("Authorization", "Bearer " + ACCESS_TOKEN) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCaseTest.java index f55a333..71dcb85 100644 --- a/src/test/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/auth/application/usecase/AdminSignupUseCaseTest.java @@ -5,6 +5,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import com.yd.vibecode.domain.admin.domain.service.MasterActivityLogService; import com.yd.vibecode.domain.auth.application.dto.request.AdminSignupRequest; import com.yd.vibecode.domain.auth.domain.entity.Admin; import com.yd.vibecode.domain.auth.domain.entity.AdminNumber; @@ -15,6 +16,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -31,6 +33,9 @@ class AdminSignupUseCaseTest { @Mock private AdminNumberService adminNumberService; + @Mock + private MasterActivityLogService masterActivityLogService; + @Test @DisplayName("관리자 회원가입 성공") void signup_success() { @@ -43,9 +48,11 @@ void signup_success() { .build(); Admin savedAdmin = Admin.builder() .adminNumber("admin123") + .displayName("홍길동") .email("admin@example.com") .passwordHash("encodedPassword") .build(); + ReflectionTestUtils.setField(savedAdmin, "id", 99L); given(adminNumberService.validateUsable(request.adminNumber())).willReturn(adminNumber); given(adminService.existsByAdminNumber(request.adminNumber())).willReturn(false); given(adminService.existsByEmail(request.email())).willReturn(false); @@ -59,6 +66,7 @@ void signup_success() { // then verify(adminNumberService).assign(adminNumber, savedAdmin.getId()); verify(adminService).create(request.adminNumber(), request.displayName(), request.email(), "encodedPassword"); + verify(masterActivityLogService).logAdminSignedUp(1L, 99L, "홍길동"); } @Test diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCaseTest.java new file mode 100644 index 0000000..53744e3 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/AutoSubmitParticipantsOnExamEndUseCaseTest.java @@ -0,0 +1,112 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Optional; + +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.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.repository.ExamParticipantRepository; +import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; +import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; +import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; +import com.yd.vibecode.domain.submission.domain.service.ParticipantSubmitOrchestrationService; + +@ExtendWith(MockitoExtension.class) +class AutoSubmitParticipantsOnExamEndUseCaseTest { + + private static final Long EXAM_ID = 1L; + + @Mock + private ExamParticipantRepository examParticipantRepository; + + @Mock + private ParticipantSubmitOrchestrationService participantSubmitOrchestrationService; + + @InjectMocks + private AutoSubmitParticipantsOnExamEndUseCase autoSubmitParticipantsOnExamEndUseCase; + + @Test + @DisplayName("미제출 + 코드 스냅샷 있으면 자동 제출") + void execute_autoSubmitsWhenCodeSnapshotExists() { + Long participantId = 100L; + ExamParticipant ep = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(participantId) + .lastCodeLang("python") + .lastCodeInline("print(1)") + .build(); + + given(examParticipantRepository.findAllByExamId(EXAM_ID)).willReturn(List.of(ep)); + given(participantSubmitOrchestrationService.submitIfAbsent( + eq(EXAM_ID), eq(participantId), any(SubmitRequest.class))) + .willReturn(Optional.of(new SubmitResponse(50L, SubmissionStatus.QUEUED))); + + AutoSubmitParticipantsOnExamEndUseCase.AutoSubmitResult result = + autoSubmitParticipantsOnExamEndUseCase.execute(EXAM_ID); + + assertThat(result.submittedCount()).isEqualTo(1); + assertThat(result.skippedAlreadySubmitted()).isZero(); + assertThat(result.skippedNoCodeSnapshot()).isZero(); + verify(participantSubmitOrchestrationService).submitIfAbsent( + eq(EXAM_ID), eq(participantId), any(SubmitRequest.class)); + } + + @Test + @DisplayName("이미 제출된 참가자는 submitIfAbsent empty로 skip 집계, 중복 제출 없음") + void execute_skipsAlreadySubmitted() { + Long participantId = 100L; + ExamParticipant ep = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(participantId) + .lastCodeLang("python") + .lastCodeInline("print(1)") + .build(); + + given(examParticipantRepository.findAllByExamId(EXAM_ID)).willReturn(List.of(ep)); + given(participantSubmitOrchestrationService.submitIfAbsent( + eq(EXAM_ID), eq(participantId), any(SubmitRequest.class))) + .willReturn(Optional.empty()); + + AutoSubmitParticipantsOnExamEndUseCase.AutoSubmitResult result = + autoSubmitParticipantsOnExamEndUseCase.execute(EXAM_ID); + + assertThat(result.submittedCount()).isZero(); + assertThat(result.skippedAlreadySubmitted()).isEqualTo(1); + assertThat(result.skippedNoCodeSnapshot()).isZero(); + verify(participantSubmitOrchestrationService).submitIfAbsent( + eq(EXAM_ID), eq(participantId), any(SubmitRequest.class)); + } + + @Test + @DisplayName("코드 스냅샷 없으면 자동 제출 스킵") + void execute_skipsWhenNoCodeSnapshot() { + Long participantId = 100L; + ExamParticipant ep = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(participantId) + .build(); + + given(examParticipantRepository.findAllByExamId(EXAM_ID)).willReturn(List.of(ep)); + + AutoSubmitParticipantsOnExamEndUseCase.AutoSubmitResult result = + autoSubmitParticipantsOnExamEndUseCase.execute(EXAM_ID); + + assertThat(result.submittedCount()).isZero(); + assertThat(result.skippedAlreadySubmitted()).isZero(); + assertThat(result.skippedNoCodeSnapshot()).isEqualTo(1); + verify(participantSubmitOrchestrationService, never()).submitIfAbsent(any(), any(), any()); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCaseTest.java index 2963827..211e5a2 100644 --- a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/EndExamUseCaseTest.java @@ -4,16 +4,22 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; +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.verify; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.yd.vibecode.domain.exam.application.dto.event.ExamStateEvent; 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.service.ExamParticipantService; import com.yd.vibecode.domain.exam.domain.service.ExamService; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import java.time.LocalDateTime; @@ -29,9 +35,15 @@ class EndExamUseCaseTest { @Mock private ExamParticipantService examParticipantService; + @Mock + private AutoSubmitParticipantsOnExamEndUseCase autoSubmitParticipantsOnExamEndUseCase; + @Mock private SimpMessagingTemplate messagingTemplate; + @Mock + private AdminActivityLogService adminActivityLogService; + @Test @DisplayName("시험 종료 UseCase 성공: 서비스 호출 및 WS 브로드캐스트 확인") void execute_Success() { @@ -52,7 +64,32 @@ void execute_Success() { endExamUseCase.execute(examId); // then + verify(autoSubmitParticipantsOnExamEndUseCase).execute(examId); verify(examService).endExam(examId); verify(examParticipantService).endAllParticipants(examId); + verify(adminActivityLogService).logExamEnded(1L, examId, "테스트 시험"); + verify(messagingTemplate).convertAndSend(eq("/topic/exam/" + examId), any(ExamStateEvent.class)); + } + + @Test + @DisplayName("관리자 활동 로그 저장이 WebSocket 브로드캐스트보다 먼저 수행") + void execute_logsBeforeWebSocketBroadcast() { + Long examId = 1L; + Exam exam = Exam.builder() + .title("테스트 시험") + .state(ExamState.ENDED) + .startsAt(LocalDateTime.now().minusHours(2)) + .endsAt(LocalDateTime.now()) + .version(2) + .createdBy(1L) + .build(); + + given(examService.endExam(examId)).willReturn(exam); + + endExamUseCase.execute(examId); + + InOrder inOrder = inOrder(adminActivityLogService, messagingTemplate); + inOrder.verify(adminActivityLogService).logExamEnded(1L, examId, "테스트 시험"); + inOrder.verify(messagingTemplate).convertAndSend(eq("/topic/exam/" + examId), any(ExamStateEvent.class)); } } diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCaseTest.java new file mode 100644 index 0000000..3446d92 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/GetParticipantCodeDraftUseCaseTest.java @@ -0,0 +1,98 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; + +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.exam.application.dto.response.CodeDraftResponse; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.global.exception.RestApiException; + +@ExtendWith(MockitoExtension.class) +class GetParticipantCodeDraftUseCaseTest { + + private static final Long EXAM_ID = 1L; + private static final Long USER_ID = 100L; + + @Mock + private ExamParticipantService examParticipantService; + + @Mock + private SubmissionService submissionService; + + @InjectMocks + private GetParticipantCodeDraftUseCase getParticipantCodeDraftUseCase; + + @Test + @DisplayName("draft가 있으면 language/codeInline/savedAt 반환") + void execute_returnsDraftWhenSnapshotExists() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .lastCodeLang("python") + .lastCodeInline("print(1)") + .build(); + ReflectionTestUtils.setField(participant, "updatedAt", LocalDateTime.of(2026, 5, 24, 12, 0)); + + given(examParticipantService.findByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(false); + + CodeDraftResponse response = getParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID); + + assertThat(response).isNotNull(); + assertThat(response.language()).isEqualTo("python"); + assertThat(response.codeInline()).isEqualTo("print(1)"); + assertThat(response.savedAt()).isEqualTo(LocalDateTime.of(2026, 5, 24, 12, 0)); + } + + @Test + @DisplayName("draft 없으면 null") + void execute_returnsNullWhenNoSnapshot() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .build(); + + given(examParticipantService.findByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(false); + + assertThat(getParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID)).isNull(); + } + + @Test + @DisplayName("이미 제출된 참가자는 null") + void execute_returnsNullWhenAlreadySubmitted() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .lastCodeLang("python") + .lastCodeInline("print(1)") + .build(); + + given(examParticipantService.findByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(true); + + assertThat(getParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID)).isNull(); + } + + @Test + @DisplayName("참가자 없으면 NOT_FOUND") + void execute_throwsWhenParticipantMissing() { + given(examParticipantService.findByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(null); + + assertThatThrownBy(() -> getParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID)) + .isInstanceOf(RestApiException.class); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCaseTest.java new file mode 100644 index 0000000..7d69648 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/SaveParticipantCodeDraftUseCaseTest.java @@ -0,0 +1,142 @@ +package com.yd.vibecode.domain.exam.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +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.dto.request.SaveParticipantCodeDraftRequest; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.global.exception.RestApiException; + +@ExtendWith(MockitoExtension.class) +class SaveParticipantCodeDraftUseCaseTest { + + private static final Long EXAM_ID = 1L; + private static final Long USER_ID = 100L; + + @Mock + private ExamParticipantService examParticipantService; + + @Mock + private SubmissionService submissionService; + + @InjectMocks + private SaveParticipantCodeDraftUseCase saveParticipantCodeDraftUseCase; + + @Test + @DisplayName("제출 전에는 draft 저장 가능") + void execute_savesDraftWhenNoSubmission() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .build(); + SaveParticipantCodeDraftRequest request = + new SaveParticipantCodeDraftRequest("python", "print('draft')"); + + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(false); + + saveParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID, request); + + assertThat(participant.getLastCodeLang()).isEqualTo("python"); + assertThat(participant.getLastCodeInline()).isEqualTo("print('draft')"); + verify(examParticipantService).findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID); + verify(submissionService).existsByExamIdAndParticipantId(EXAM_ID, USER_ID); + verify(examParticipantService, never()).findByExamIdAndParticipantId(EXAM_ID, USER_ID); + } + + @Test + @DisplayName("제출 후 draft PUT은 no-op — clear된 snapshot이 다시 저장되지 않음") + void execute_noOpWhenSubmissionExists_doesNotRestoreDraft() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .build(); + ReflectionTestUtils.setField(participant, "updatedAt", LocalDateTime.of(2026, 5, 24, 10, 0)); + + SaveParticipantCodeDraftRequest request = + new SaveParticipantCodeDraftRequest("python", "print('late debounce')"); + + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(true); + + saveParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID, request); + + assertThat(participant.getLastCodeLang()).isNull(); + assertThat(participant.getLastCodeInline()).isNull(); + assertThat(participant.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 5, 24, 10, 0)); + } + + @Test + @DisplayName("제출 후 draft PUT은 no-op — 기존 snapshot을 덮어쓰지 않음") + void execute_noOpWhenSubmissionExists_doesNotOverwriteExistingSnapshot() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .lastCodeLang("python") + .lastCodeInline("print('old')") + .build(); + ReflectionTestUtils.setField(participant, "updatedAt", LocalDateTime.of(2026, 5, 24, 11, 0)); + + SaveParticipantCodeDraftRequest request = + new SaveParticipantCodeDraftRequest("java", "System.out.println(1);"); + + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(true); + + saveParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID, request); + + assertThat(participant.getLastCodeLang()).isEqualTo("python"); + assertThat(participant.getLastCodeInline()).isEqualTo("print('old')"); + assertThat(participant.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 5, 24, 11, 0)); + } + + @Test + @DisplayName("lock 획득 후 제출 여부를 재확인 — 제출 완료 시 late draft는 snapshot을 복구하지 않음") + void execute_checksSubmissionAfterLock_beforeUpdatingSnapshot() { + ExamParticipant participant = ExamParticipant.builder() + .examId(EXAM_ID) + .participantId(USER_ID) + .build(); + SaveParticipantCodeDraftRequest request = + new SaveParticipantCodeDraftRequest("python", "print('late debounce')"); + + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID)).willReturn(participant); + given(submissionService.existsByExamIdAndParticipantId(EXAM_ID, USER_ID)).willReturn(true); + + saveParticipantCodeDraftUseCase.execute(EXAM_ID, USER_ID, request); + + InOrder inOrder = inOrder(examParticipantService, submissionService); + inOrder.verify(examParticipantService).findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID); + inOrder.verify(submissionService).existsByExamIdAndParticipantId(EXAM_ID, USER_ID); + assertThat(participant.hasCodeSnapshot()).isFalse(); + } + + @Test + @DisplayName("참가자 없으면 NOT_FOUND") + void execute_throwsWhenParticipantMissing() { + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(EXAM_ID, USER_ID)).willReturn(null); + + assertThatThrownBy(() -> saveParticipantCodeDraftUseCase.execute( + EXAM_ID, USER_ID, new SaveParticipantCodeDraftRequest("python", "x"))) + .isInstanceOf(RestApiException.class); + + verify(submissionService, never()).existsByExamIdAndParticipantId(EXAM_ID, USER_ID); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamSyncTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamSyncTest.java index f750239..a703689 100644 --- a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamSyncTest.java +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamSyncTest.java @@ -21,6 +21,7 @@ import com.yd.vibecode.domain.problem.domain.entity.ProblemStatus; import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetItemRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; @@ -63,6 +64,8 @@ class StartExamSyncTest { private EntryCodeRepository entryCodeRepository; @Mock private ProblemSetItemRepository problemSetItemRepository; + @Mock + private AdminActivityLogService adminActivityLogService; @BeforeEach void stubProblemSetItems() { diff --git a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCaseTest.java index a454544..831afa2 100644 --- a/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/exam/application/usecase/StartExamUseCaseTest.java @@ -8,7 +8,9 @@ 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.verify; +import org.mockito.InOrder; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -20,6 +22,7 @@ import com.yd.vibecode.domain.exam.domain.service.ExamService; import com.yd.vibecode.domain.problem.domain.repository.ProblemRepository; import com.yd.vibecode.domain.problem.infrastructure.repository.ProblemSetItemRepository; +import com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService; import java.time.LocalDateTime; import java.util.Collections; @@ -44,6 +47,9 @@ class StartExamUseCaseTest { @Mock private ProblemSetItemRepository problemSetItemRepository; + @Mock + private AdminActivityLogService adminActivityLogService; + @Test @DisplayName("시험 시작 UseCase 성공: 서비스 호출 및 WS 브로드캐스트 확인") void execute_Success() { @@ -66,6 +72,30 @@ void execute_Success() { // then verify(examService).startExam(examId); + verify(adminActivityLogService).logExamStarted(1L, examId, "테스트 시험"); verify(messagingTemplate).convertAndSend(eq("/topic/exam/" + examId), any(ExamStateEvent.class)); } + + @Test + @DisplayName("관리자 활동 로그 저장이 WebSocket 브로드캐스트보다 먼저 수행") + void execute_logsBeforeWebSocketBroadcast() { + Long examId = 1L; + Exam exam = Exam.builder() + .title("테스트 시험") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now()) + .endsAt(LocalDateTime.now().plusHours(2)) + .version(1) + .createdBy(1L) + .build(); + + given(examService.startExam(examId)).willReturn(exam); + given(examParticipantRepository.findByExamId(examId)).willReturn(Collections.emptyList()); + + startExamUseCase.execute(examId); + + InOrder inOrder = inOrder(adminActivityLogService, messagingTemplate); + inOrder.verify(adminActivityLogService).logExamStarted(1L, examId, "테스트 시험"); + inOrder.verify(messagingTemplate).convertAndSend(eq("/topic/exam/" + examId), any(ExamStateEvent.class)); + } } diff --git a/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoStartQueryTest.java b/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoStartQueryTest.java new file mode 100644 index 0000000..affbd28 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/domain/repository/ExamRepositoryAutoStartQueryTest.java @@ -0,0 +1,73 @@ +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 ExamRepositoryAutoStartQueryTest { + + @Autowired + private ExamRepository examRepository; + + @Test + @DisplayName("WAITING·startsAt<=now 만 자동 시작 대상으로 조회된다") + void findByStateAndStartsAtLessThanEqual_onlyEligibleWaitingExams() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime past = now.minusMinutes(10); + LocalDateTime future = now.plusHours(1); + + examRepository.save(exam("past-waiting", ExamState.WAITING, past, now.plusHours(2))); + examRepository.save(exam("future-waiting", ExamState.WAITING, future, future.plusHours(2))); + examRepository.save(exam("past-running", ExamState.RUNNING, past, now.plusHours(2))); + examRepository.save(exam("past-ended", ExamState.ENDED, past, past.plusHours(1))); + + List candidates = examRepository.findByStateAndStartsAtLessThanEqual(ExamState.WAITING, now); + + assertThat(candidates).hasSize(1); + assertThat(candidates.get(0).getTitle()).isEqualTo("past-waiting"); + } + + @Test + @DisplayName("startsAt이 미래인 WAITING 시험은 조회되지 않는다") + void findByStateAndStartsAtLessThanEqual_excludesFutureWaiting() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime future = now.plusHours(2); + + examRepository.save(exam("future-only", ExamState.WAITING, future, future.plusHours(3))); + + List candidates = examRepository.findByStateAndStartsAtLessThanEqual(ExamState.WAITING, 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/ExamAutoStartSchedulerTest.java b/src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoStartSchedulerTest.java new file mode 100644 index 0000000..d47ed44 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/exam/infrastructure/ExamAutoStartSchedulerTest.java @@ -0,0 +1,124 @@ +package com.yd.vibecode.domain.exam.infrastructure; + +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.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.exam.application.usecase.StartExamUseCase; +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 ExamAutoStartSchedulerTest { + + @InjectMocks + private ExamAutoStartScheduler scheduler; + + @Mock + private ExamRepository examRepository; + + @Mock + private StartExamUseCase startExamUseCase; + + @Test + @DisplayName("대상 없으면 StartExamUseCase를 호출하지 않는다") + void pollAndAutoStartExams_noCandidates() { + given(examRepository.findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), org.mockito.ArgumentMatchers.any())) + .willReturn(List.of()); + + scheduler.pollAndAutoStartExams(); + + verify(startExamUseCase, never()).execute(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + @DisplayName("WAITING·startsAt<=now 대상마다 StartExamUseCase를 호출한다") + void pollAndAutoStartExams_startsEligibleExams() { + Exam pastWaiting = waitingExam(1L, LocalDateTime.now().minusMinutes(5)); + Exam justNow = waitingExam(2L, LocalDateTime.now()); + given(examRepository.findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), org.mockito.ArgumentMatchers.any())) + .willReturn(List.of(pastWaiting, justNow)); + + scheduler.pollAndAutoStartExams(); + + verify(startExamUseCase).execute(1L); + verify(startExamUseCase).execute(2L); + } + + @Test + @DisplayName("조회는 WAITING·startsAt<=now 조건으로 수행한다") + void pollAndAutoStartExams_queriesWithWaitingAndNowThreshold() { + given(examRepository.findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), org.mockito.ArgumentMatchers.any())) + .willReturn(List.of()); + + scheduler.pollAndAutoStartExams(); + + ArgumentCaptor thresholdCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(examRepository).findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), thresholdCaptor.capture()); + LocalDateTime threshold = thresholdCaptor.getValue(); + org.assertj.core.api.Assertions.assertThat(threshold).isBeforeOrEqualTo(LocalDateTime.now().plusSeconds(2)); + org.assertj.core.api.Assertions.assertThat(threshold).isAfterOrEqualTo(LocalDateTime.now().minusSeconds(2)); + } + + @Test + @DisplayName("한 시험 실패 시 다른 시험 자동 시작은 계속된다") + void pollAndAutoStartExams_continuesAfterSingleFailure() { + Exam exam1 = waitingExam(10L, LocalDateTime.now().minusMinutes(1)); + Exam exam2 = waitingExam(20L, LocalDateTime.now().minusMinutes(1)); + given(examRepository.findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), org.mockito.ArgumentMatchers.any())) + .willReturn(List.of(exam1, exam2)); + willThrow(new RuntimeException("db error")) + .given(startExamUseCase).execute(10L); + + scheduler.pollAndAutoStartExams(); + + verify(startExamUseCase).execute(10L); + verify(startExamUseCase).execute(20L); + } + + @Test + @DisplayName("이미 시작된 시험(INVALID_EXAM_STATE)은 스킵하고 다음 시험을 처리한다") + void pollAndAutoStartExams_skipsInvalidStateAndContinues() { + Exam exam1 = waitingExam(30L, LocalDateTime.now().minusMinutes(1)); + Exam exam2 = waitingExam(40L, LocalDateTime.now().minusMinutes(1)); + given(examRepository.findByStateAndStartsAtLessThanEqual(eq(ExamState.WAITING), org.mockito.ArgumentMatchers.any())) + .willReturn(List.of(exam1, exam2)); + willThrow(new RestApiException(ExamErrorStatus.INVALID_EXAM_STATE)) + .given(startExamUseCase).execute(30L); + + scheduler.pollAndAutoStartExams(); + + verify(startExamUseCase, times(1)).execute(30L); + verify(startExamUseCase, times(1)).execute(40L); + } + + private static Exam waitingExam(Long id, LocalDateTime startsAt) { + Exam exam = Exam.builder() + .title("auto-start") + .state(ExamState.WAITING) + .startsAt(startsAt) + .endsAt(startsAt.plusHours(2)) + .version(0) + .createdBy(1L) + .build(); + org.springframework.test.util.ReflectionTestUtils.setField(exam, "id", id); + return exam; + } +} diff --git a/src/test/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCaseTest.java index b922e52..eba0c94 100644 --- a/src/test/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/submission/application/usecase/ReceiveScoringResultUseCaseTest.java @@ -10,14 +10,19 @@ import org.junit.jupiter.api.extension.ExtendWith; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import org.mockito.InjectMocks; import org.mockito.Mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import com.yd.vibecode.domain.auth.domain.entity.User; +import com.yd.vibecode.domain.exam.domain.entity.Exam; +import com.yd.vibecode.domain.exam.domain.entity.ExamState; import com.yd.vibecode.domain.submission.application.dto.request.ScoringResultRequest; import com.yd.vibecode.domain.submission.application.event.ScoringResultSseEvent; import com.yd.vibecode.domain.submission.domain.entity.Score; @@ -30,6 +35,10 @@ import com.yd.vibecode.domain.submission.domain.service.SubmissionService; import com.yd.vibecode.global.exception.RestApiException; +import java.time.LocalDateTime; + +import org.springframework.test.util.ReflectionTestUtils; + @ExtendWith(MockitoExtension.class) class ReceiveScoringResultUseCaseTest { @@ -48,6 +57,15 @@ class ReceiveScoringResultUseCaseTest { @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private com.yd.vibecode.domain.exam.domain.repository.ExamRepository examRepository; + + @Mock + private com.yd.vibecode.domain.admin.domain.service.AdminActivityLogService adminActivityLogService; + + @Mock + private com.yd.vibecode.domain.auth.domain.repository.UserRepository userRepository; + @Test @DisplayName("채점 결과 수신 및 처리 성공 - DB 저장 및 SSE 이벤트 발행 확인") void execute_Success() { @@ -174,4 +192,202 @@ void execute_existingScore_updatesInsteadOfDuplicateInsert() { verify(scoreRepository).save(existing); } + + @Test + @DisplayName("RUNNING → DONE 최초 전환 시 평가 완료 로그 기록") + void execute_logsEvaluationCompletedOnFirstTransitionToDone() { + Long submissionId = 1L; + Long examId = 100L; + Long participantId = 200L; + Long adminId = 10L; + + Submission submission = Submission.builder() + .examId(examId) + .participantId(participantId) + .status(SubmissionStatus.RUNNING) + .build(); + Exam exam = Exam.builder() + .title("기말 시험") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now()) + .endsAt(LocalDateTime.now().plusHours(2)) + .createdBy(adminId) + .build(); + ReflectionTestUtils.setField(exam, "id", examId); + User user = User.builder().name("김민준").phone("01012345678").build(); + + ScoringResultRequest request = doneRequestWithScore(); + + given(submissionService.findById(submissionId)).willReturn(submission); + given(scoreRepository.findBySubmissionId(submissionId)).willReturn(Optional.empty()); + given(examRepository.findById(examId)).willReturn(Optional.of(exam)); + given(userRepository.findById(participantId)).willReturn(Optional.of(user)); + + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService).logEvaluationCompleted( + eq(adminId), eq(examId), eq(participantId), eq("기말 시험"), eq("김민준")); + } + + @Test + @DisplayName("RUNNING → DONE 최초 전환 시 score 없어도 평가 완료 로그 기록") + void execute_logsEvaluationCompletedOnFirstTransitionToDone_withoutScore() { + Long submissionId = 1L; + Long examId = 100L; + Long participantId = 200L; + Long adminId = 10L; + + Submission submission = Submission.builder() + .examId(examId) + .participantId(participantId) + .status(SubmissionStatus.RUNNING) + .build(); + Exam exam = Exam.builder() + .title("기말 시험") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now()) + .endsAt(LocalDateTime.now().plusHours(2)) + .createdBy(adminId) + .build(); + ReflectionTestUtils.setField(exam, "id", examId); + + ScoringResultRequest request = new ScoringResultRequest( + SubmissionStatus.DONE, + List.of(new ScoringResultRequest.TestCaseResult( + 0, "SAMPLE", Verdict.AC, 100, 1024, 0, 0)), + null); + + given(submissionService.findById(submissionId)).willReturn(submission); + given(examRepository.findById(examId)).willReturn(Optional.of(exam)); + given(userRepository.findById(participantId)).willReturn(Optional.empty()); + + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService).logEvaluationCompleted( + eq(adminId), eq(examId), eq(participantId), eq("기말 시험"), eq(null)); + verify(scoreRepository, never()).save(any(Score.class)); + } + + @Test + @DisplayName("이미 DONE 상태인 제출에 score 없는 DONE 재콜백 시 평가 완료 로그 미기록") + void execute_duplicateDoneCallbackWithoutScore_doesNotLogEvaluationCompleted() { + Long submissionId = 1L; + Long examId = 100L; + Long participantId = 200L; + + Submission submission = Submission.builder() + .examId(examId) + .participantId(participantId) + .status(SubmissionStatus.DONE) + .build(); + + ScoringResultRequest request = new ScoringResultRequest( + SubmissionStatus.DONE, + List.of(new ScoringResultRequest.TestCaseResult( + 0, "SAMPLE", Verdict.AC, 100, 1024, 0, 0)), + null); + + given(submissionService.findById(submissionId)).willReturn(submission); + + receiveScoringResultUseCase.execute(submissionId, request); + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService, never()).logEvaluationCompleted(any(), any(), any(), any(), any()); + verify(examRepository, never()).findById(any()); + verify(scoreRepository, never()).save(any(Score.class)); + } + + @Test + @DisplayName("이미 DONE 상태인 제출에 DONE 재콜백 시 평가 완료 로그 미기록") + void execute_duplicateDoneCallback_doesNotLogEvaluationCompleted() { + Long submissionId = 1L; + Long examId = 100L; + Long participantId = 200L; + + Submission submission = Submission.builder() + .examId(examId) + .participantId(participantId) + .status(SubmissionStatus.DONE) + .build(); + + ScoringResultRequest request = doneRequestWithScore(); + + given(submissionService.findById(submissionId)).willReturn(submission); + given(scoreRepository.findBySubmissionId(submissionId)).willReturn(Optional.empty()); + + receiveScoringResultUseCase.execute(submissionId, request); + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService, never()).logEvaluationCompleted(any(), any(), any(), any(), any()); + verify(examRepository, never()).findById(any()); + } + + @Test + @DisplayName("DONE이 아닌 상태 요청 시 평가 완료 로그 미기록") + void execute_nonDoneStatus_doesNotLogEvaluationCompleted() { + Long submissionId = 1L; + Submission submission = Submission.builder() + .examId(100L) + .participantId(200L) + .status(SubmissionStatus.RUNNING) + .build(); + + ScoringResultRequest request = new ScoringResultRequest( + SubmissionStatus.FAILED, + Collections.emptyList(), + new ScoringResultRequest.ScoreData( + new BigDecimal("10"), new BigDecimal("20"), new BigDecimal("30"), "{}")); + + given(submissionService.findById(submissionId)).willReturn(submission); + + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService, never()).logEvaluationCompleted(any(), any(), any(), any(), any()); + verify(examRepository, never()).findById(any()); + } + + @Test + @DisplayName("RUNNING → DONE 후 재콜백 시 평가 완료 로그는 1회만 기록") + void execute_runningToDoneThenDuplicateCallback_logsOnce() { + Long submissionId = 1L; + Long examId = 100L; + Long participantId = 200L; + Long adminId = 10L; + + Submission submission = Submission.builder() + .examId(examId) + .participantId(participantId) + .status(SubmissionStatus.RUNNING) + .build(); + Exam exam = Exam.builder() + .title("기말 시험") + .state(ExamState.RUNNING) + .startsAt(LocalDateTime.now()) + .endsAt(LocalDateTime.now().plusHours(2)) + .createdBy(adminId) + .build(); + ReflectionTestUtils.setField(exam, "id", examId); + + ScoringResultRequest request = doneRequestWithScore(); + + given(submissionService.findById(submissionId)).willReturn(submission); + given(scoreRepository.findBySubmissionId(submissionId)).willReturn(Optional.empty()); + given(examRepository.findById(examId)).willReturn(Optional.of(exam)); + given(userRepository.findById(participantId)).willReturn(Optional.empty()); + + receiveScoringResultUseCase.execute(submissionId, request); + receiveScoringResultUseCase.execute(submissionId, request); + + verify(adminActivityLogService, times(1)).logEvaluationCompleted( + eq(adminId), eq(examId), eq(participantId), eq("기말 시험"), eq(null)); + } + + private static ScoringResultRequest doneRequestWithScore() { + return new ScoringResultRequest( + SubmissionStatus.DONE, + List.of(new ScoringResultRequest.TestCaseResult( + 0, "SAMPLE", Verdict.AC, 100, 1024, 0, 0)), + new ScoringResultRequest.ScoreData( + new BigDecimal("30.0"), new BigDecimal("30.0"), new BigDecimal("40.0"), "{}")); + } } diff --git a/src/test/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCaseTest.java b/src/test/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCaseTest.java index dc4e543..ae9282f 100644 --- a/src/test/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCaseTest.java +++ b/src/test/java/com/yd/vibecode/domain/submission/application/usecase/SubmitUseCaseTest.java @@ -2,12 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,22 +11,12 @@ 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.chat.domain.service.PromptSessionService; -import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; -import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; -import com.yd.vibecode.domain.problem.domain.entity.ProblemSpec; -import com.yd.vibecode.domain.problem.domain.service.ProblemSpecService; -import com.yd.vibecode.domain.submission.application.dto.request.AISubmitEvaluationRequest; import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; -import com.yd.vibecode.domain.submission.domain.entity.Submission; import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; -import com.yd.vibecode.domain.submission.domain.service.OutboxEventService; -import com.yd.vibecode.domain.submission.domain.service.SubmissionService; +import com.yd.vibecode.domain.submission.domain.service.ParticipantSubmitOrchestrationService; import com.yd.vibecode.global.exception.RestApiException; -import com.yd.vibecode.global.exception.code.status.ProblemErrorStatus; import com.yd.vibecode.global.exception.code.status.SubmissionErrorStatus; @ExtendWith(MockitoExtension.class) @@ -40,157 +26,38 @@ class SubmitUseCaseTest { private SubmitUseCase submitUseCase; @Mock - private ExamParticipantService examParticipantService; - - @Mock - private SubmissionService submissionService; - - @Mock - private ProblemSpecService problemSpecService; - - @Mock - private PromptSessionService promptSessionService; - - @Mock - private OutboxEventService outboxEventService; + private ParticipantSubmitOrchestrationService participantSubmitOrchestrationService; @Test - @DisplayName("제출 성공") + @DisplayName("제출 성공 — 오케스트레이션 위임") void execute_Success() { Long examId = 1L; Long userId = 100L; - Long specId = 10L; SubmitRequest request = new SubmitRequest("python3.11", "print('hello')"); + SubmitResponse expected = new SubmitResponse(123L, SubmissionStatus.QUEUED); - ExamParticipant examParticipant = ExamParticipant.builder() - .examId(examId) - .participantId(userId) - .specId(specId) - .assignedProblemId(200L) - .build(); - - Submission submission = Submission.builder() - .examId(examId) - .participantId(userId) - .specId(specId) - .lang(request.lang()) - .status(SubmissionStatus.QUEUED) - .build(); - ReflectionTestUtils.setField(submission, "id", 123L); - - given(examParticipantService.findByExamIdAndParticipantId(examId, userId)).willReturn(examParticipant); - given(submissionService.existsByExamIdAndParticipantId(examId, userId)).willReturn(false); - given(submissionService.createAndEnqueue(examId, userId, specId, request.lang(), request.code())) - .willReturn(submission); + given(participantSubmitOrchestrationService.submitOrThrow(examId, userId, request)) + .willReturn(expected); SubmitResponse response = submitUseCase.execute(examId, userId, request); assertThat(response.submissionId()).isEqualTo(123L); assertThat(response.status()).isEqualTo(SubmissionStatus.QUEUED); - verify(submissionService).createAndEnqueue(examId, userId, specId, request.lang(), request.code()); - verify(promptSessionService).getOrCreateSession(examId, userId, specId); - verify(outboxEventService).saveEvent( - eq("SUBMISSION"), - eq(123L), - eq("AI_EVAL_REQUEST"), - argThat((Object payload) -> { - if (!(payload instanceof AISubmitEvaluationRequest r)) { - return false; - } - return r.problemId().equals(200L) - && r.specId().equals(specId) - && r.submissionId().equals(123L); - })); - } - - @Test - @DisplayName("제출 실패: 배정된 스펙이 없는 경우") - void execute_Fail_NoSpec() { - Long examId = 1L; - Long userId = 100L; - SubmitRequest request = new SubmitRequest("python3.11", "print('hello')"); - - ExamParticipant examParticipant = ExamParticipant.builder() - .examId(examId) - .participantId(userId) - .specId(null) - .build(); - - given(examParticipantService.findByExamIdAndParticipantId(examId, userId)).willReturn(examParticipant); - - assertThatThrownBy(() -> submitUseCase.execute(examId, userId, request)) - .isInstanceOf(RestApiException.class) - .extracting("errorCode.code").isEqualTo(ProblemErrorStatus.NO_ASSIGNED_PROBLEM.getCode().getCode()); + verify(participantSubmitOrchestrationService).submitOrThrow(examId, userId, request); } @Test - @DisplayName("제출 실패: 이미 제출한 경우 (동일 시험·참가자)") + @DisplayName("제출 실패: 이미 제출한 경우") void execute_Fail_AlreadySubmitted() { Long examId = 1L; Long userId = 100L; SubmitRequest request = new SubmitRequest("python", "print(1)"); - ExamParticipant examParticipant = ExamParticipant.builder() - .examId(examId) - .participantId(userId) - .specId(10L) - .assignedProblemId(1L) - .build(); - given(examParticipantService.findByExamIdAndParticipantId(examId, userId)).willReturn(examParticipant); - given(submissionService.existsByExamIdAndParticipantId(examId, userId)).willReturn(true); + given(participantSubmitOrchestrationService.submitOrThrow(examId, userId, request)) + .willThrow(new RestApiException(SubmissionErrorStatus.ALREADY_SUBMITTED)); assertThatThrownBy(() -> submitUseCase.execute(examId, userId, request)) .isInstanceOf(RestApiException.class) .extracting("errorCode.code").isEqualTo(SubmissionErrorStatus.ALREADY_SUBMITTED.getCode().getCode()); } - - @Test - @DisplayName("제출 성공: assignedProblemId 없으면 spec에서 problemId 보정") - void execute_Success_ProblemIdFromSpec() { - Long examId = 1L; - Long userId = 100L; - Long specId = 10L; - SubmitRequest request = new SubmitRequest("python", "x"); - - ExamParticipant examParticipant = ExamParticipant.builder() - .examId(examId) - .participantId(userId) - .specId(specId) - .assignedProblemId(null) - .build(); - - ProblemSpec spec = mock(ProblemSpec.class); - when(spec.getProblemId()).thenReturn(55L); - - Submission submission = Submission.builder() - .examId(examId) - .participantId(userId) - .specId(specId) - .lang(request.lang()) - .status(SubmissionStatus.QUEUED) - .build(); - ReflectionTestUtils.setField(submission, "id", 999L); - - given(examParticipantService.findByExamIdAndParticipantId(examId, userId)).willReturn(examParticipant); - given(submissionService.existsByExamIdAndParticipantId(examId, userId)).willReturn(false); - given(problemSpecService.findBySpecId(specId)).willReturn(spec); - given(submissionService.createAndEnqueue(examId, userId, specId, request.lang(), request.code())) - .willReturn(submission); - - SubmitResponse response = submitUseCase.execute(examId, userId, request); - - assertThat(response.submissionId()).isEqualTo(999L); - assertThat(response.status()).isEqualTo(SubmissionStatus.QUEUED); - verify(problemSpecService).findBySpecId(specId); - verify(outboxEventService).saveEvent( - eq("SUBMISSION"), - eq(999L), - eq("AI_EVAL_REQUEST"), - argThat((Object payload) -> { - if (!(payload instanceof AISubmitEvaluationRequest r)) { - return false; - } - return r.problemId().equals(55L) && r.specId().equals(specId); - })); - } } diff --git a/src/test/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationServiceTest.java b/src/test/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationServiceTest.java new file mode 100644 index 0000000..02356e3 --- /dev/null +++ b/src/test/java/com/yd/vibecode/domain/submission/domain/service/ParticipantSubmitOrchestrationServiceTest.java @@ -0,0 +1,89 @@ +package com.yd.vibecode.domain.submission.domain.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.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.chat.domain.service.PromptSessionService; +import com.yd.vibecode.domain.exam.domain.entity.ExamParticipant; +import com.yd.vibecode.domain.exam.domain.service.ExamParticipantService; +import com.yd.vibecode.domain.problem.domain.service.ProblemSpecService; +import com.yd.vibecode.domain.submission.application.dto.request.SubmitRequest; +import com.yd.vibecode.domain.submission.application.dto.response.SubmitResponse; +import com.yd.vibecode.domain.submission.domain.entity.Submission; +import com.yd.vibecode.domain.submission.domain.entity.SubmissionStatus; + +@ExtendWith(MockitoExtension.class) +class ParticipantSubmitOrchestrationServiceTest { + + @Mock + private ExamParticipantService examParticipantService; + + @Mock + private SubmissionService submissionService; + + @Mock + private ProblemSpecService problemSpecService; + + @Mock + private PromptSessionService promptSessionService; + + @Mock + private OutboxEventService outboxEventService; + + @InjectMocks + private ParticipantSubmitOrchestrationService participantSubmitOrchestrationService; + + @Test + @DisplayName("제출 성공 후 draft snapshot clear") + void submitIfAbsent_clearsDraftAfterSubmissionCreated() { + Long examId = 1L; + Long userId = 100L; + Long specId = 10L; + SubmitRequest request = new SubmitRequest("python", "print('hello')"); + + ExamParticipant examParticipant = ExamParticipant.builder() + .examId(examId) + .participantId(userId) + .specId(specId) + .assignedProblemId(200L) + .lastCodeLang("python") + .lastCodeInline("print('hello')") + .build(); + + Submission submission = Submission.builder() + .examId(examId) + .participantId(userId) + .specId(specId) + .lang(request.lang()) + .status(SubmissionStatus.QUEUED) + .build(); + ReflectionTestUtils.setField(submission, "id", 123L); + + given(examParticipantService.findByExamIdAndParticipantIdForUpdate(examId, userId)).willReturn(examParticipant); + given(submissionService.existsByExamIdAndParticipantId(examId, userId)).willReturn(false); + given(submissionService.createAndEnqueue(examId, userId, specId, request.lang(), request.code())) + .willReturn(submission); + + var response = participantSubmitOrchestrationService.submitIfAbsent(examId, userId, request); + + assertThat(response).isPresent(); + assertThat(examParticipant.hasCodeSnapshot()).isFalse(); + verify(examParticipantService).findByExamIdAndParticipantIdForUpdate(examId, userId); + verify(outboxEventService).saveEvent( + eq("SUBMISSION"), + eq(123L), + eq("AI_EVAL_REQUEST"), + argThat(Object.class::isInstance)); + } +} diff --git a/src/test/java/com/yd/vibecode/domain/submission/ui/SubmissionControllerTest.java b/src/test/java/com/yd/vibecode/domain/submission/ui/SubmissionControllerTest.java index a513b01..0f55fc9 100644 --- a/src/test/java/com/yd/vibecode/domain/submission/ui/SubmissionControllerTest.java +++ b/src/test/java/com/yd/vibecode/domain/submission/ui/SubmissionControllerTest.java @@ -27,6 +27,8 @@ import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; +import com.yd.vibecode.domain.exam.application.usecase.GetParticipantCodeDraftUseCase; +import com.yd.vibecode.domain.exam.application.usecase.SaveParticipantCodeDraftUseCase; import com.yd.vibecode.domain.submission.application.dto.response.SubmissionDetailResponse; import com.yd.vibecode.domain.submission.application.usecase.GetSubmissionDetailUseCase; import com.yd.vibecode.domain.submission.application.usecase.SubmitUseCase; @@ -60,6 +62,12 @@ class SubmissionControllerTest { @MockBean private GetSubmissionDetailUseCase getSubmissionDetailUseCase; + @MockBean + private SaveParticipantCodeDraftUseCase saveParticipantCodeDraftUseCase; + + @MockBean + private GetParticipantCodeDraftUseCase getParticipantCodeDraftUseCase; + @MockBean private JwtBlacklistInterceptor jwtBlacklistInterceptor;