diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 4776cf01..001ad8c6 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -11,7 +11,7 @@ on: java-version: description: 'Java version to use' required: false - default: '17' + default: '21' type: string run-tests: description: 'Run tests' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 620cb4a8..7eb96140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: name: Build & Test uses: ./.github/workflows/_build.yml with: - java-version: '17' + java-version: '21' run-tests: true generate-coverage: true publish-build-scan: true @@ -41,7 +41,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: gradle - name: Setup Gradle diff --git a/.github/workflows/pr-pipeline.yml b/.github/workflows/pr-pipeline.yml index 79d7e954..9b4affda 100644 --- a/.github/workflows/pr-pipeline.yml +++ b/.github/workflows/pr-pipeline.yml @@ -14,7 +14,7 @@ jobs: name: Build & Test uses: ./.github/workflows/_build.yml with: - java-version: '17' + java-version: '21' run-tests: true generate-coverage: true publish-build-scan: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f83dd49..d869484b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: name: Build & Test uses: ./.github/workflows/_build.yml with: - java-version: '17' + java-version: '21' run-tests: true generate-coverage: false publish-build-scan: true @@ -46,7 +46,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 cache: gradle - name: Setup Gradle diff --git a/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java b/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java new file mode 100644 index 00000000..6ed37e82 --- /dev/null +++ b/adapter/infra/src/main/java/me/chan99k/learningmanager/authorization/AttendanceSecurity.java @@ -0,0 +1,139 @@ +package me.chan99k.learningmanager.authorization; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import me.chan99k.learningmanager.attendance.Attendance; +import me.chan99k.learningmanager.attendance.AttendanceQueryRepository; +import me.chan99k.learningmanager.attendance.CorrectionRequested; +import me.chan99k.learningmanager.course.CourseRole; +import me.chan99k.learningmanager.member.Member; +import me.chan99k.learningmanager.member.MemberQueryRepository; +import me.chan99k.learningmanager.member.SystemRole; +import me.chan99k.learningmanager.session.Session; +import me.chan99k.learningmanager.session.SessionQueryRepository; + +@Service("attendanceSecurity") +public class AttendanceSecurity { + private final AttendanceQueryRepository attendanceQueryRepository; + private final SessionQueryRepository sessionQueryRepository; + private final MemberQueryRepository memberQueryRepository; + private final CourseAuthorizationPort courseAuthorizationPort; + + public AttendanceSecurity(AttendanceQueryRepository attendanceQueryRepository, + SessionQueryRepository sessionQueryRepository, MemberQueryRepository memberQueryRepository, + CourseAuthorizationPort courseAuthorizationPort) { + this.attendanceQueryRepository = attendanceQueryRepository; + this.sessionQueryRepository = sessionQueryRepository; + this.memberQueryRepository = memberQueryRepository; + this.courseAuthorizationPort = courseAuthorizationPort; + } + + public boolean canRequestCorrection(String attendanceId, Long memberId) { + Optional foundAttendance = attendanceQueryRepository.findById(attendanceId); + if (foundAttendance.isEmpty()) { + return false; + } + + // SystemRole 우선 확인 + Optional foundMember = memberQueryRepository.findById(memberId); + if (foundMember.isEmpty()) { + return false; + } + + var member = foundMember.get(); + var systemRole = member.getRole(); + if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) { + return true; + } + + var attendance = foundAttendance.get(); + Optional foundSession = sessionQueryRepository.findById(attendance.getSessionId()); + if (foundSession.isEmpty()) { + return false; + } + + var courseId = foundSession.get().getCourseId(); + if (courseId == null) { + return false; + } + + // CourseRole 확인 + return courseAuthorizationPort.hasAnyRole( + memberId, + courseId, + List.of(CourseRole.MENTOR, CourseRole.MANAGER, CourseRole.LEAD_MANAGER) + ); + } + + public boolean canApproveCorrection(String attendanceId, Long memberId) { + Optional foundAttendance = attendanceQueryRepository.findById(attendanceId); + if (foundAttendance.isEmpty()) { + return false; + } + + var attendance = foundAttendance.get(); + CorrectionRequested pending; + try { + pending = attendance.getPendingRequest(); + } catch (IllegalStateException e) { + return false; // 대기중인 수정 요청 없음 + } + + Optional foundMember = memberQueryRepository.findById(memberId); + if (foundMember.isEmpty()) { + return false; + } + + var requestedApprover = foundMember.get(); + var systemRole = requestedApprover.getRole(); + if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) { + return true; + } + + Optional foundSession = sessionQueryRepository.findById(attendance.getSessionId()); + if (foundSession.isEmpty()) { + return false; + } + + var courseId = foundSession.get().getCourseId(); + if (courseId == null) { + return false; // 독립 세션은 시스템 권한으로만 승인 가능 (위에서 이미 처리됨) + } + + boolean isLeadManager = courseAuthorizationPort.hasRole(memberId, courseId, CourseRole.LEAD_MANAGER); + if (isLeadManager) { + return true; // 본인의 수정 요청 포함 모두 승인 가능 + } + + if (pending.requestedBy().equals(memberId)) { + return false; // 본인의 수정 요청을 스스로 승인하는 것을 방지 + } + + return hasHigherCourseRoleThan(memberId, pending.requestedBy(), courseId); + } + + private boolean hasHigherCourseRoleThan(Long approverId, Long requesterId, Long courseId) { + boolean requesterIsMentor = courseAuthorizationPort + .hasRole(requesterId, courseId, CourseRole.MENTOR); + boolean requesterIsManager = courseAuthorizationPort + .hasRole(requesterId, courseId, CourseRole.MANAGER); + + if (requesterIsMentor) { + return courseAuthorizationPort.hasAnyRole( + approverId, courseId, + List.of(CourseRole.MANAGER, CourseRole.LEAD_MANAGER) + ); + } + + if (requesterIsManager) { + return courseAuthorizationPort.hasRole( + approverId, courseId, CourseRole.LEAD_MANAGER + ); + } + + return false; + } +} diff --git a/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/AttendanceQueryAdapter.java b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/AttendanceQueryAdapter.java index b3486c3a..cfb1434e 100644 --- a/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/AttendanceQueryAdapter.java +++ b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/AttendanceQueryAdapter.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; +import org.bson.types.ObjectId; import org.springframework.stereotype.Repository; import me.chan99k.learningmanager.adapter.persistence.attendance.documents.AttendanceDocument; @@ -19,6 +20,20 @@ public AttendanceQueryAdapter(AttendanceMongoRepository repository) { this.repository = repository; } + @Override + public Optional findById(String attendanceId) { + ObjectId objectId; + try { + objectId = new ObjectId(attendanceId); + } catch (IllegalArgumentException e) { + return Optional.empty(); // 서비스 레이어에서 처리 + } + + return repository + .findById(objectId) + .map(AttendanceDocument::toDomain); + } + @Override public Optional findBySessionIdAndMemberId(Long sessionId, Long memberId) { return repository diff --git a/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceDocument.java b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceDocument.java index f4e5549a..8ea40894 100644 --- a/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceDocument.java +++ b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceDocument.java @@ -16,8 +16,6 @@ import me.chan99k.learningmanager.attendance.Attendance; import me.chan99k.learningmanager.attendance.AttendanceEvent; import me.chan99k.learningmanager.attendance.AttendanceStatus; -import me.chan99k.learningmanager.attendance.CheckedIn; -import me.chan99k.learningmanager.attendance.CheckedOut; @Document(collection = "attendances") @CompoundIndex(name = "session_member_idx", @@ -121,24 +119,4 @@ public Long getCreatedBy() { return createdBy; } - public record AttendanceEventDocument( - String type, - Instant timestamp - ) { - public static AttendanceEventDocument from(AttendanceEvent event) { - String type = event instanceof CheckedIn ? "CheckedIn" : "CheckedOut"; - - return new AttendanceEventDocument(type, event.timestamp()); - } - - public AttendanceEvent toDomain() { - return switch (this.type) { - case "CheckedIn" -> new CheckedIn(this.timestamp); - case "CheckedOut" -> new CheckedOut(this.timestamp); - default -> throw new IllegalArgumentException("[System] 유효하지 않은 출석 이벤트 타입입니다: " + this.type); - }; - } - - } - } diff --git a/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceEventDocument.java b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceEventDocument.java new file mode 100644 index 00000000..d38795e4 --- /dev/null +++ b/adapter/mongo/src/main/java/me/chan99k/learningmanager/adapter/persistence/attendance/documents/AttendanceEventDocument.java @@ -0,0 +1,76 @@ +package me.chan99k.learningmanager.adapter.persistence.attendance.documents; + +import java.time.Instant; + +import me.chan99k.learningmanager.attendance.AttendanceEvent; +import me.chan99k.learningmanager.attendance.AttendanceStatus; +import me.chan99k.learningmanager.attendance.CheckedIn; +import me.chan99k.learningmanager.attendance.CheckedOut; +import me.chan99k.learningmanager.attendance.CorrectionRejected; +import me.chan99k.learningmanager.attendance.CorrectionRequested; +import me.chan99k.learningmanager.attendance.StatusCorrected; + +public record AttendanceEventDocument( + String type, + Instant timestamp, + AttendanceStatus previousStatus, + AttendanceStatus newStatus, + String reason, + Long actorId, + String rejectionReason +) { + public static AttendanceEventDocument from(AttendanceEvent event) { + return switch (event) { + case CheckedIn e -> new AttendanceEventDocument( + "CheckedIn", e.timestamp(), + null, null, null, null, null + ); + + case CheckedOut e -> new AttendanceEventDocument( + "CheckedOut", e.timestamp(), + null, null, null, null, null + ); + + case CorrectionRequested e -> new AttendanceEventDocument( + "CorrectionRequested", e.timestamp(), + e.currentStatus(), e.requestedStatus(), e.reason(), e.requestedBy(), null + ); + + case StatusCorrected e -> new AttendanceEventDocument( + "StatusCorrected", e.timestamp(), + e.previousStatus(), e.newStatus(), e.reason(), e.correctedBy(), null + ); + + case CorrectionRejected e -> new AttendanceEventDocument( + "CorrectionRejected", e.timestamp(), + null, null, null, e.rejectedBy(), e.rejectionReason() + ); + }; + } + + public AttendanceEvent toDomain() { + return switch (this.type) { + case "CheckedIn" -> new CheckedIn(this.timestamp); + + case "CheckedOut" -> new CheckedOut(this.timestamp); + + case "CorrectionRequested" -> new CorrectionRequested( + this.timestamp, this.previousStatus, this.newStatus, + this.reason, this.actorId + ); + + case "StatusCorrected" -> new StatusCorrected( + this.timestamp, this.previousStatus, this.newStatus, + this.reason, this.actorId + ); + + case "CorrectionRejected" -> new CorrectionRejected( + this.timestamp, this.rejectionReason, this.actorId + ); + + default -> throw new IllegalArgumentException( + "[System] 유효하지 않은 출석 이벤트 타입입니다: " + this.type + ); + }; + } +} diff --git a/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/AttendanceCorrectionController.java b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/AttendanceCorrectionController.java new file mode 100644 index 00000000..d7dd73f0 --- /dev/null +++ b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/AttendanceCorrectionController.java @@ -0,0 +1,95 @@ +package me.chan99k.learningmanager.controller.attendance; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import me.chan99k.learningmanager.attendance.AttendanceCorrectionApproval; +import me.chan99k.learningmanager.attendance.AttendanceCorrectionRejection; +import me.chan99k.learningmanager.attendance.AttendanceCorrectionRequest; +import me.chan99k.learningmanager.controller.attendance.requests.AttendanceRejectionRequest; +import me.chan99k.learningmanager.controller.attendance.requests.CorrectionRequest; +import me.chan99k.learningmanager.security.CustomUserDetails; + +@Tag(name = "Attendance Correction", description = "출석 수정 요청/승인/거절 API") +@RestController +@RequestMapping("/api/v1/attendance/{attendanceId}/correction-requests") +public class AttendanceCorrectionController { + + private final AttendanceCorrectionRequest correctionRequest; + private final AttendanceCorrectionApproval correctionApproval; + private final AttendanceCorrectionRejection correctionRejection; + + public AttendanceCorrectionController( + AttendanceCorrectionRequest correctionRequest, + AttendanceCorrectionApproval correctionApproval, + AttendanceCorrectionRejection correctionRejection + ) { + this.correctionRequest = correctionRequest; + this.correctionApproval = correctionApproval; + this.correctionRejection = correctionRejection; + } + + @PreAuthorize("@attendanceSecurity.canRequestCorrection(#attendanceId, #user.memberId)") + @Operation(summary = "출석 수정 요청", description = "출석 상태 수정을 요청합니다.") + @PostMapping + public ResponseEntity requestCorrection( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable String attendanceId, + @RequestBody CorrectionRequest requestDto + ) { + AttendanceCorrectionRequest.Request request = new AttendanceCorrectionRequest.Request( + attendanceId, + requestDto.requestedStatus(), + requestDto.reason() + ); + + AttendanceCorrectionRequest.Response response = + correctionRequest.request(user.getMemberId(), request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PreAuthorize("@attendanceSecurity.canApproveCorrection(#attendanceId, #user.memberId)") + @Operation(summary = "출석 수정 승인", description = "출석 수정 요청을 승인합니다.") + @PatchMapping("/approve") + public ResponseEntity approveCorrection( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable String attendanceId + ) { + AttendanceCorrectionApproval.Request request = + new AttendanceCorrectionApproval.Request(attendanceId); + + AttendanceCorrectionApproval.Response response = + correctionApproval.approve(user.getMemberId(), request); + + return ResponseEntity.ok(response); + } + + @PreAuthorize("@attendanceSecurity.canApproveCorrection(#attendanceId, #user.memberId)") + @Operation(summary = "출석 수정 거절", description = "출석 수정 요청을 거절합니다.") + @PatchMapping("/reject") + public ResponseEntity rejectCorrection( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable String attendanceId, + @RequestBody AttendanceRejectionRequest requestDto + ) { + AttendanceCorrectionRejection.Request request = + new AttendanceCorrectionRejection.Request(attendanceId, requestDto.rejectionReason()); + + AttendanceCorrectionRejection.Response response = + correctionRejection.reject(user.getMemberId(), request); + + return ResponseEntity.ok(response); + } + +} diff --git a/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/AttendanceRejectionRequest.java b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/AttendanceRejectionRequest.java new file mode 100644 index 00000000..e39393c7 --- /dev/null +++ b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/AttendanceRejectionRequest.java @@ -0,0 +1,6 @@ +package me.chan99k.learningmanager.controller.attendance.requests; + +public record AttendanceRejectionRequest( + String rejectionReason +) { +} diff --git a/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/CorrectionRequest.java b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/CorrectionRequest.java new file mode 100644 index 00000000..b65e8913 --- /dev/null +++ b/app/api/src/main/java/me/chan99k/learningmanager/controller/attendance/requests/CorrectionRequest.java @@ -0,0 +1,9 @@ +package me.chan99k.learningmanager.controller.attendance.requests; + +import me.chan99k.learningmanager.attendance.AttendanceStatus; + +public record CorrectionRequest( + AttendanceStatus requestedStatus, + String reason +) { +} diff --git a/build-logic/src/main/kotlin/lm.java-library.gradle.kts b/build-logic/src/main/kotlin/lm.java-library.gradle.kts index cb4b919f..d3abc31e 100644 --- a/build-logic/src/main/kotlin/lm.java-library.gradle.kts +++ b/build-logic/src/main/kotlin/lm.java-library.gradle.kts @@ -8,7 +8,7 @@ group = "me.chan99k" java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(21)) } } diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/Attendance.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/Attendance.java index 41247660..c813cb49 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/Attendance.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/Attendance.java @@ -58,6 +58,44 @@ public void checkOut(Clock clock) { recalculateStatus(); } + public void requestCorrection( + AttendanceStatus requestedStatus, + String reason, + Long requestedBy, + Clock clock + ) { + validateNoPendingRequest(); // 이미 대기 중인 요청이 있으면 예외 + validateStatusChange(requestedStatus); // 같은 상태로 변경 요청 방지 + + AttendanceEvent event = AttendanceEvent.correctionRequested( + clock, this.finalStatus, requestedStatus, reason, requestedBy + ); + events.add(event);// 출석 상태 요청만 기록하고 finalStatus는 변경하지 않음 (승인 전까지) + } + + public void approveCorrection(Long approvedBy, Clock clock) { + CorrectionRequested pendingRequest = getPendingRequest(); // 없으면 예외 + + AttendanceEvent event = AttendanceEvent.statusCorrected( + clock, + pendingRequest.currentStatus(), + pendingRequest.requestedStatus(), + pendingRequest.reason(), + approvedBy + ); + events.add(event); + this.finalStatus = pendingRequest.requestedStatus(); // 상태 변경! + } + + public void rejectCorrection(String rejectionReason, Long rejectedBy, Clock clock) { + validateHasPendingRequest(); // 대기 중인 요청이 없으면 예외 + + AttendanceEvent event = AttendanceEvent.correctionRejected( + clock, rejectionReason, rejectedBy + ); + events.add(event); + } + private void recalculateStatus() { boolean hasCheckIn = events.stream().anyMatch(event -> event instanceof CheckedIn); @@ -102,12 +140,66 @@ private AttendanceState getCurrentAttendanceState() { return AttendanceState.NOT_CHECKED_IN; } - public String getId() { - return id; + /** + * events를 역순으로 탐색하여 대기 중인 출석 상태 수정 요청이 있는지 확인 + */ + private boolean hasPendingRequest() { + for (int i = events.size() - 1; i >= 0; i--) { + AttendanceEvent event = events.get(i); + + if (event instanceof CorrectionRequested) { + return true; // 요청이 있고, 아직 처리 안됨 + } + + if (event instanceof StatusCorrected || event instanceof CorrectionRejected) { + return false; // 이미 처리된 요청 + } + } + + return false; // 요청 자체가 없음 + + } + + private void validateNoPendingRequest() { + if (hasPendingRequest()) { + throw new IllegalStateException(PENDING_REQUEST_EXISTS.getMessage()); + } + } + + private void validateHasPendingRequest() { + if (!hasPendingRequest()) { + throw new IllegalStateException(NO_PENDING_REQUEST.getMessage()); + } + } + + public CorrectionRequested getPendingRequest() { + for (int i = events.size() - 1; i >= 0; i--) { + AttendanceEvent event = events.get(i); + + if (event instanceof CorrectionRequested requested) { + return requested; + } + + if (event instanceof StatusCorrected || event instanceof CorrectionRejected) { + break; // 이미 처리됨, 더 볼 필요 없음 + } + } + + throw new IllegalStateException(NO_PENDING_REQUEST.getMessage()); + } + + private void validateStatusChange(AttendanceStatus requestedStatus) { + if (this.finalStatus == requestedStatus) { + throw new IllegalStateException(SAME_STATUS_REQUEST.getMessage()); + } } /* 접근 & 수정자 로직 */ + public String getId() { + return id; + } + public void setId(String id) { if (this.id != null) { throw new IllegalStateException(AttendanceProblemCode.CANNOT_REASSIGN_ID.getMessage()); @@ -135,4 +227,4 @@ public AttendanceStatus getFinalStatus() { private enum AttendanceState { NOT_CHECKED_IN, CHECKED_IN, CHECKED_OUT } -} \ No newline at end of file +} diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceEvent.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceEvent.java index b022177e..137fa47e 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceEvent.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceEvent.java @@ -3,7 +3,9 @@ import java.time.Clock; import java.time.Instant; -public sealed interface AttendanceEvent permits CheckedIn, CheckedOut { +public sealed interface AttendanceEvent + permits CheckedIn, CheckedOut, CorrectionRejected, CorrectionRequested, StatusCorrected { + static CheckedIn checkIn(Clock clock) { return new CheckedIn(clock.instant()); } @@ -12,10 +14,49 @@ static CheckedOut checkOut(Clock clock) { return new CheckedOut(clock.instant()); } - Instant timestamp(); -} - - + static StatusCorrected statusCorrected( + Clock clock, + AttendanceStatus previousStatus, + AttendanceStatus newStatus, + String reason, + Long correctedBy + ) { + return new StatusCorrected( + clock.instant(), + previousStatus, + newStatus, + reason, + correctedBy + ); + } + static CorrectionRequested correctionRequested( + Clock clock, + AttendanceStatus currentStatus, + AttendanceStatus requestedStatus, + String reason, + Long requestedBy + ) { + return new CorrectionRequested( + clock.instant(), + currentStatus, + requestedStatus, + reason, + requestedBy + ); + } + static CorrectionRejected correctionRejected( + Clock clock, + String rejectionReason, + Long rejectedBy + ) { + return new CorrectionRejected( + clock.instant(), + rejectionReason, + rejectedBy + ); + } + Instant timestamp(); +} diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceProblemCode.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceProblemCode.java index bc623927..642e42d6 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceProblemCode.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/AttendanceProblemCode.java @@ -7,6 +7,7 @@ public enum AttendanceProblemCode implements ProblemCode { MEMBER_ID_REQUIRED("DAL002", "[System] 출석을 기록할 회원은 필수입니다."), CANNOT_REASSIGN_ID("DAL003", "[System] ID는 최초 한 번만 설정 가능합니다."), ONLY_ROOT_SESSION_ALLOWED("DAL004", "[System] 루트 세션이 아닙니다."), + ATTENDANCE_ID_REQUIRED("DAL005", "[System] 출석 기록 ID는 필수입니다."), ALREADY_CHECKED_IN("DAL100", "[System] 이미 입실이 완료되었습니다. "), NOT_CHECKED_IN("DAL101", "[System] 입실 상태가 아닙니다."), @@ -14,6 +15,14 @@ public enum AttendanceProblemCode implements ProblemCode { // QR 코드 관련 INVALID_QR_TOKEN("DAL300", "[System] QR 코드 토큰 검증에 실패하였습니다."), + + PENDING_REQUEST_EXISTS("DAL400", "[System] 이미 대기 중인 수정 요청이 있습니다."), + NO_PENDING_REQUEST("DAL401", "[System] 대기 중인 수정 요청이 없습니다."), + SAME_STATUS_REQUEST("DAL402", "[System] 현재 상태와 동일한 상태로 변경할 수 없습니다."), + CORRECTION_REASON_REQUIRED("DAL403", "[System] 출석 상태 수정 요청에 대한 사유는 필수입니다."), + REJECTION_REASON_REQUIRED("DAL404", "[System] 출석 상태 수정 요청 거절에 대한 사유는 필수 입니다."), + + ATTENDANCE_NOT_FOUND("DAL500", "[System] 해당 출석 기록을 찾을 수 없습니다"), ; private final String code; diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRejected.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRejected.java new file mode 100644 index 00000000..86e729c1 --- /dev/null +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRejected.java @@ -0,0 +1,7 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +public record CorrectionRejected(Instant timestamp, String rejectionReason, Long rejectedBy) + implements AttendanceEvent { +} diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRequested.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRequested.java new file mode 100644 index 00000000..8d4c5291 --- /dev/null +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/CorrectionRequested.java @@ -0,0 +1,12 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +public record CorrectionRequested( + Instant timestamp, + AttendanceStatus currentStatus, // 현재 상태 + AttendanceStatus requestedStatus, // 수정 요청 상태 + String reason, + Long requestedBy +) implements AttendanceEvent { +} diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/attendance/StatusCorrected.java b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/StatusCorrected.java new file mode 100644 index 00000000..3a737534 --- /dev/null +++ b/core/domain/src/main/java/me/chan99k/learningmanager/attendance/StatusCorrected.java @@ -0,0 +1,12 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +public record StatusCorrected( + Instant timestamp, + AttendanceStatus previousStatus, + AttendanceStatus newStatus, + String reason, + Long correctedBy +) implements AttendanceEvent { +} diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/course/CourseRole.java b/core/domain/src/main/java/me/chan99k/learningmanager/course/CourseRole.java index 24e69b67..e6a24af7 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/course/CourseRole.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/course/CourseRole.java @@ -1,7 +1,8 @@ package me.chan99k.learningmanager.course; public enum CourseRole { - MANAGER("과정 매니저"), MENTOR("과정 멘토"), MENTEE("과정 멘티"); + MANAGER("과정 매니저"), MENTOR("과정 멘토"), MENTEE("과정 멘티"), + LEAD_MANAGER("과정 총괄 매니저"); public final String value; diff --git a/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java index 60184265..22110a5f 100644 --- a/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java +++ b/core/domain/src/main/java/me/chan99k/learningmanager/member/SystemRole.java @@ -1,7 +1,12 @@ package me.chan99k.learningmanager.member; public enum SystemRole { - ADMIN("시스템 관리자"), MEMBER("회원"); + ADMIN("시스템 관리자"), + MEMBER("회원"), + SUPERVISOR("감독관"), + OPERATOR("운영자"), + REGISTRAR("학적 담당"), + AUDITOR("감사관"); public final String value; diff --git a/core/domain/src/test/java/me/chan99k/learningmanager/attendance/AttendanceTest.java b/core/domain/src/test/java/me/chan99k/learningmanager/attendance/AttendanceTest.java index f1b1fcd3..5526082f 100644 --- a/core/domain/src/test/java/me/chan99k/learningmanager/attendance/AttendanceTest.java +++ b/core/domain/src/test/java/me/chan99k/learningmanager/attendance/AttendanceTest.java @@ -185,4 +185,150 @@ void setId_fail_if_already_set() { } } + @Nested + @DisplayName("출석 수정 요청 테스트") + class CorrectionRequest { + + @Test + @DisplayName("[Success] 수정 요청을 성공적으로 생성한다") + void requestCorrection_success() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + Long requestedBy = 999L; + + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리 요청", requestedBy, clock); + + assertThat(attendance.getEvents()).hasSize(2); + assertThat(attendance.getEvents().get(1)).isInstanceOf(CorrectionRequested.class); + + CorrectionRequested event = (CorrectionRequested)attendance.getEvents().get(1); + + assertThat(event.currentStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(event.requestedStatus()).isEqualTo(AttendanceStatus.LATE); + assertThat(event.reason()).isEqualTo("지각 처리 요청"); + assertThat(event.requestedBy()).isEqualTo(requestedBy); + assertThat(attendance.getFinalStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("[Failure] 이미 대기 중인 요청이 있으면 예외가 발생한다") + void requestCorrection_fail_if_pending_exists() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + attendance.requestCorrection(AttendanceStatus.LATE, "첫 번째 요청", 1L, clock); + + assertThatThrownBy(() -> + attendance.requestCorrection(AttendanceStatus.ABSENT, "두 번째 요청", 2L, clock) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessage(PENDING_REQUEST_EXISTS.getMessage()); + } + + @Test + @DisplayName("[Failure] 같은 상태로 변경 요청하면 예외가 발생한다") + void requestCorrection_fail_if_same_status() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + + assertThatThrownBy(() -> + attendance.requestCorrection(AttendanceStatus.PRESENT, "같은 상태", 1L, clock) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessage(SAME_STATUS_REQUEST.getMessage()); + } + } + + @Nested + @DisplayName("출석 수정 승인 테스트") + class CorrectionApproval { + + @Test + @DisplayName("[Success] 수정 요청을 승인하면 상태가 변경된다") + void approveCorrection_success() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 1L, clock); + Long approvedBy = 999L; + + attendance.approveCorrection(approvedBy, clock); + + assertThat(attendance.getEvents()).hasSize(3); + assertThat(attendance.getEvents().get(2)).isInstanceOf(StatusCorrected.class); + + StatusCorrected event = (StatusCorrected)attendance.getEvents().get(2); + assertThat(event.previousStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(event.newStatus()).isEqualTo(AttendanceStatus.LATE); + assertThat(event.correctedBy()).isEqualTo(approvedBy); + assertThat(attendance.getFinalStatus()).isEqualTo(AttendanceStatus.LATE); + } + + @Test + @DisplayName("[Failure] 대기 중인 요청이 없으면 예외가 발생한다") + void approveCorrection_fail_if_no_pending() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + + assertThatThrownBy(() -> attendance.approveCorrection(1L, clock)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(NO_PENDING_REQUEST.getMessage()); + } + } + + @Nested + @DisplayName("출석 수정 거절 테스트") + class CorrectionRejection { + + @Test + @DisplayName("[Success] 수정 요청을 거절한다") + void rejectCorrection_success() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 1L, clock); + Long rejectedBy = 999L; + + attendance.rejectCorrection("사유 불충분", rejectedBy, clock); + + assertThat(attendance.getEvents()).hasSize(3); + assertThat(attendance.getEvents().get(2)).isInstanceOf(CorrectionRejected.class); + + CorrectionRejected event = (CorrectionRejected)attendance.getEvents().get(2); + assertThat(event.rejectionReason()).isEqualTo("사유 불충분"); + assertThat(event.rejectedBy()).isEqualTo(rejectedBy); + assertThat(attendance.getFinalStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("[Success] 거절 후 새로운 수정 요청이 가능하다") + void canRequestAfterRejection() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + attendance.requestCorrection(AttendanceStatus.LATE, "첫 번째", 1L, clock); + attendance.rejectCorrection("거절", 2L, clock); + + attendance.requestCorrection(AttendanceStatus.ABSENT, "두 번째", 3L, clock); + + assertThat(attendance.getEvents()).hasSize(4); + assertThat(attendance.getEvents().get(3)).isInstanceOf(CorrectionRequested.class); + } + + @Test + @DisplayName("[Failure] 대기 중인 요청이 없으면 예외가 발생한다") + void rejectCorrection_fail_if_no_pending() { + Clock clock = Clock.systemUTC(); + Attendance attendance = Attendance.create(sessionId, memberId); + attendance.checkIn(clock); + + assertThatThrownBy(() -> attendance.rejectCorrection("사유", 1L, clock)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(NO_PENDING_REQUEST.getMessage()); + } + } + } diff --git a/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApproval.java b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApproval.java new file mode 100644 index 00000000..3c0fc347 --- /dev/null +++ b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApproval.java @@ -0,0 +1,30 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +import org.springframework.util.ObjectUtils; + +import me.chan99k.learningmanager.exception.DomainException; + +public interface AttendanceCorrectionApproval { + Response approve(Long approvedBy, Request request); + + record Request( + String attendanceId // 대상 출석 ID + ) { + public Request { + if (ObjectUtils.isEmpty(attendanceId)) { + throw new DomainException(AttendanceProblemCode.ATTENDANCE_ID_REQUIRED); + } + } + } + + record Response( + String attendanceId, + AttendanceStatus previousStatus, + AttendanceStatus newStatus, + Long approvedBy, + Instant approvedAt + ) { + } +} diff --git a/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejection.java b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejection.java new file mode 100644 index 00000000..ddc5ce7d --- /dev/null +++ b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejection.java @@ -0,0 +1,34 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +import org.springframework.util.ObjectUtils; + +import me.chan99k.learningmanager.exception.DomainException; + +public interface AttendanceCorrectionRejection { + + Response reject(Long rejectedBy, Request request); + + record Request( + String attendanceId, + String rejectionReason // 거절 사유 (필수) + ) { + public Request { + if (ObjectUtils.isEmpty(attendanceId)) { + throw new DomainException(AttendanceProblemCode.ATTENDANCE_ID_REQUIRED); + } + if (ObjectUtils.isEmpty(rejectionReason)) { + throw new DomainException(AttendanceProblemCode.REJECTION_REASON_REQUIRED); + } + } + } + + record Response( + String attendanceId, + Long rejectedBy, + String rejectionReason, + Instant rejectedAt + ) { + } +} diff --git a/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequest.java b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequest.java new file mode 100644 index 00000000..2a6f70a8 --- /dev/null +++ b/core/provides/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequest.java @@ -0,0 +1,37 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Instant; + +import org.springframework.util.ObjectUtils; + +import me.chan99k.learningmanager.exception.DomainException; + +public interface AttendanceCorrectionRequest { + Response request(Long requestedBy, Request request); + + record Request( + String attendanceId, + AttendanceStatus requestedStatus, + String reason // 사유 (필수) + ) { + public Request { + if (ObjectUtils.isEmpty(attendanceId)) { + throw new DomainException(AttendanceProblemCode.ATTENDANCE_ID_REQUIRED); + } + if (ObjectUtils.isEmpty(reason)) { + throw new DomainException(AttendanceProblemCode.CORRECTION_REASON_REQUIRED); + } + } + } + + record Response( + String attendanceId, + Long sessionId, + Long targetMemberId, + AttendanceStatus currentStatus, + AttendanceStatus requestedStatus, + String reason, + Instant requestedAt + ) { + } +} diff --git a/core/requires/src/main/java/me/chan99k/learningmanager/attendance/AttendanceQueryRepository.java b/core/requires/src/main/java/me/chan99k/learningmanager/attendance/AttendanceQueryRepository.java index 8d2df2b7..5d93cbaf 100644 --- a/core/requires/src/main/java/me/chan99k/learningmanager/attendance/AttendanceQueryRepository.java +++ b/core/requires/src/main/java/me/chan99k/learningmanager/attendance/AttendanceQueryRepository.java @@ -5,6 +5,8 @@ public interface AttendanceQueryRepository { + Optional findById(String attendanceId); + /** * 단순 조회 */ @@ -30,7 +32,8 @@ record MemberAttendanceResult( Long memberId, List attendances, AttendanceStats stats - ) {} + ) { + } record AttendanceRecord( String attendanceId, @@ -42,5 +45,6 @@ record AttendanceRecord( record AttendanceStats( int total, int present, int absent, int late, int leftEarly, double rate - ) {} + ) { + } } diff --git a/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalService.java b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalService.java new file mode 100644 index 00000000..865cbc8d --- /dev/null +++ b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalService.java @@ -0,0 +1,51 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Clock; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import me.chan99k.learningmanager.exception.DomainException; + +@Service +@Transactional +public class AttendanceCorrectionApprovalService implements AttendanceCorrectionApproval { + + private final AttendanceQueryRepository attendanceQueryRepository; + private final AttendanceCommandRepository attendanceCommandRepository; + private final Clock clock; + + public AttendanceCorrectionApprovalService( + AttendanceQueryRepository attendanceQueryRepository, + AttendanceCommandRepository attendanceCommandRepository, + Clock clock + ) { + this.attendanceQueryRepository = attendanceQueryRepository; + this.attendanceCommandRepository = attendanceCommandRepository; + this.clock = clock; + } + + @Override + public Response approve(Long approvedBy, Request request) { + // 출석 조회 + Attendance attendance = attendanceQueryRepository + .findById(request.attendanceId()) + .orElseThrow(() -> new DomainException(AttendanceProblemCode.ATTENDANCE_NOT_FOUND)); + + // 대기 중인 요청 정보 스냅샷 + CorrectionRequested pending = attendance.getPendingRequest(); + + // 승인 (도메인 메서드 호출 - 상태 변경됨) + attendance.approveCorrection(approvedBy, clock); + + attendanceCommandRepository.save(attendance); + + return new Response( + attendance.getId(), + pending.currentStatus(), + pending.requestedStatus(), + approvedBy, + clock.instant() + ); + } +} diff --git a/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionService.java b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionService.java new file mode 100644 index 00000000..238398ed --- /dev/null +++ b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionService.java @@ -0,0 +1,51 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Clock; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import me.chan99k.learningmanager.exception.DomainException; + +@Service +@Transactional +public class AttendanceCorrectionRejectionService implements AttendanceCorrectionRejection { + + private final AttendanceQueryRepository attendanceQueryRepository; + private final AttendanceCommandRepository attendanceCommandRepository; + private final Clock clock; + + public AttendanceCorrectionRejectionService( + AttendanceQueryRepository attendanceQueryRepository, + AttendanceCommandRepository attendanceCommandRepository, + Clock clock + ) { + this.attendanceQueryRepository = attendanceQueryRepository; + this.attendanceCommandRepository = attendanceCommandRepository; + this.clock = clock; + } + + @Override + public Response reject(Long rejectedBy, Request request) { + // 출석 조회 + Attendance attendance = attendanceQueryRepository + .findById(request.attendanceId()) + .orElseThrow(() -> new DomainException(AttendanceProblemCode.ATTENDANCE_NOT_FOUND)); + + // 거절 + attendance.rejectCorrection( + request.rejectionReason(), + rejectedBy, + clock + ); + + attendanceCommandRepository.save(attendance); + + return new Response( + attendance.getId(), + rejectedBy, + request.rejectionReason(), + clock.instant() + ); + } +} diff --git a/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestService.java b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestService.java new file mode 100644 index 00000000..cf2fbff7 --- /dev/null +++ b/core/service/src/main/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestService.java @@ -0,0 +1,55 @@ +package me.chan99k.learningmanager.attendance; + +import java.time.Clock; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import me.chan99k.learningmanager.exception.DomainException; + +@Service +@Transactional +public class AttendanceCorrectionRequestService implements AttendanceCorrectionRequest { + + private final AttendanceQueryRepository attendanceQueryRepository; + private final AttendanceCommandRepository attendanceCommandRepository; + private final Clock clock; + + public AttendanceCorrectionRequestService(AttendanceQueryRepository attendanceQueryRepository, + AttendanceCommandRepository attendanceCommandRepository, Clock clock) { + this.attendanceQueryRepository = attendanceQueryRepository; + this.attendanceCommandRepository = attendanceCommandRepository; + this.clock = clock; + } + + @Override + public Response request(Long requestedBy, Request request) { + // 출석 조회 + Attendance attendance = attendanceQueryRepository + .findById(request.attendanceId()) + .orElseThrow(() -> new DomainException(AttendanceProblemCode.ATTENDANCE_NOT_FOUND)); + + // 현재 상태 스냅샷 + AttendanceStatus currentStatus = attendance.getFinalStatus(); + + // 수정 요청 + attendance.requestCorrection( + request.requestedStatus(), + request.reason(), + requestedBy, + clock + ); + + Attendance saved = attendanceCommandRepository.save(attendance); + + return new Response( + saved.getId(), + saved.getSessionId(), + saved.getMemberId(), + currentStatus, + request.requestedStatus(), + request.reason(), + clock.instant() + ); + } +} diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java new file mode 100644 index 00000000..5856e081 --- /dev/null +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionApprovalServiceTest.java @@ -0,0 +1,76 @@ +package me.chan99k.learningmanager.attendance; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Instant; +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 me.chan99k.learningmanager.exception.DomainException; + +@ExtendWith(MockitoExtension.class) +class AttendanceCorrectionApprovalServiceTest { + + private static final String ATTENDANCE_ID = "attendance-123"; + private static final Long SESSION_ID = 1L; + private static final Long MEMBER_ID = 100L; + private static final Long APPROVER_ID = 300L; + private static final Instant FIXED_TIME = Instant.parse("2024-01-01T10:00:00Z"); + + @Mock + private AttendanceQueryRepository attendanceQueryRepository; + @Mock + private AttendanceCommandRepository attendanceCommandRepository; + @Mock + private Clock clock; + @InjectMocks + private AttendanceCorrectionApprovalService service; + + @Test + @DisplayName("[Success] 출석 수정 요청을 승인한다") + void approve_success() { + Attendance attendance = createAttendanceWithPendingRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + when(clock.instant()).thenReturn(FIXED_TIME); + when(attendanceCommandRepository.save(any(Attendance.class))).thenReturn(attendance); + + AttendanceCorrectionApproval.Request request = new AttendanceCorrectionApproval.Request(ATTENDANCE_ID); + + AttendanceCorrectionApproval.Response response = service.approve(APPROVER_ID, request); + + assertThat(response.attendanceId()).isEqualTo(ATTENDANCE_ID); + assertThat(response.previousStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(response.newStatus()).isEqualTo(AttendanceStatus.LATE); + assertThat(response.approvedBy()).isEqualTo(APPROVER_ID); + verify(attendanceCommandRepository).save(any(Attendance.class)); + } + + @Test + @DisplayName("[Failure] 존재하지 않는 출석 기록 승인 시 예외가 발생한다") + void approve_fail_if_attendance_not_found() { + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.empty()); + + AttendanceCorrectionApproval.Request request = new AttendanceCorrectionApproval.Request(ATTENDANCE_ID); + + assertThatThrownBy(() -> service.approve(APPROVER_ID, request)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); + } + + private Attendance createAttendanceWithPendingRequest() { + Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); + attendance.setId(ATTENDANCE_ID); + attendance.checkIn(Clock.systemUTC()); + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 200L, Clock.systemUTC()); + return attendance; + } +} diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java new file mode 100644 index 00000000..7d405282 --- /dev/null +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRejectionServiceTest.java @@ -0,0 +1,79 @@ +package me.chan99k.learningmanager.attendance; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Instant; +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 me.chan99k.learningmanager.exception.DomainException; + +@ExtendWith(MockitoExtension.class) +class AttendanceCorrectionRejectionServiceTest { + + private static final String ATTENDANCE_ID = "attendance-123"; + private static final Long SESSION_ID = 1L; + private static final Long MEMBER_ID = 100L; + private static final Long REJECTER_ID = 300L; + private static final Instant FIXED_TIME = Instant.parse("2024-01-01T10:00:00Z"); + + @Mock + private AttendanceQueryRepository attendanceQueryRepository; + @Mock + private AttendanceCommandRepository attendanceCommandRepository; + @Mock + private Clock clock; + @InjectMocks + private AttendanceCorrectionRejectionService service; + + @Test + @DisplayName("[Success] 출석 수정 요청을 거절한다") + void reject_success() { + Attendance attendance = createAttendanceWithPendingRequest(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + when(clock.instant()).thenReturn(FIXED_TIME); + when(attendanceCommandRepository.save(any(Attendance.class))).thenReturn(attendance); + + AttendanceCorrectionRejection.Request request = new AttendanceCorrectionRejection.Request( + ATTENDANCE_ID, "사유 불충분" + ); + + AttendanceCorrectionRejection.Response response = service.reject(REJECTER_ID, request); + + assertThat(response.attendanceId()).isEqualTo(ATTENDANCE_ID); + assertThat(response.rejectedBy()).isEqualTo(REJECTER_ID); + assertThat(response.rejectionReason()).isEqualTo("사유 불충분"); + verify(attendanceCommandRepository).save(any(Attendance.class)); + } + + @Test + @DisplayName("[Failure] 존재하지 않는 출석 기록 거절 시 예외가 발생한다") + void reject_fail_if_attendance_not_found() { + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.empty()); + + AttendanceCorrectionRejection.Request request = new AttendanceCorrectionRejection.Request( + ATTENDANCE_ID, "거절 사유" + ); + + assertThatThrownBy(() -> service.reject(REJECTER_ID, request)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); + } + + private Attendance createAttendanceWithPendingRequest() { + Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); + attendance.setId(ATTENDANCE_ID); + attendance.checkIn(Clock.systemUTC()); + attendance.requestCorrection(AttendanceStatus.LATE, "지각 처리", 200L, Clock.systemUTC()); + return attendance; + } +} diff --git a/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java new file mode 100644 index 00000000..45e52381 --- /dev/null +++ b/core/service/src/test/java/me/chan99k/learningmanager/attendance/AttendanceCorrectionRequestServiceTest.java @@ -0,0 +1,79 @@ +package me.chan99k.learningmanager.attendance; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Instant; +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 me.chan99k.learningmanager.exception.DomainException; + +@ExtendWith(MockitoExtension.class) +class AttendanceCorrectionRequestServiceTest { + + private static final String ATTENDANCE_ID = "attendance-123"; + private static final Long SESSION_ID = 1L; + private static final Long MEMBER_ID = 100L; + private static final Long REQUESTER_ID = 200L; + private static final Instant FIXED_TIME = Instant.parse("2024-01-01T10:00:00Z"); + + @Mock + private AttendanceQueryRepository attendanceQueryRepository; + @Mock + private AttendanceCommandRepository attendanceCommandRepository; + @Mock + private Clock clock; + @InjectMocks + private AttendanceCorrectionRequestService service; + + @Test + @DisplayName("[Success] 출석 수정 요청을 성공적으로 생성한다") + void request_success() { + Attendance attendance = createAttendanceWithCheckIn(); + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.of(attendance)); + when(clock.instant()).thenReturn(FIXED_TIME); + when(attendanceCommandRepository.save(any(Attendance.class))).thenReturn(attendance); + + AttendanceCorrectionRequest.Request request = new AttendanceCorrectionRequest.Request( + ATTENDANCE_ID, AttendanceStatus.LATE, "지각 처리 요청" + ); + + AttendanceCorrectionRequest.Response response = service.request(REQUESTER_ID, request); + + assertThat(response.attendanceId()).isEqualTo(ATTENDANCE_ID); + assertThat(response.currentStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(response.requestedStatus()).isEqualTo(AttendanceStatus.LATE); + assertThat(response.reason()).isEqualTo("지각 처리 요청"); + verify(attendanceCommandRepository).save(any(Attendance.class)); + } + + @Test + @DisplayName("[Failure] 존재하지 않는 출석 기록에 요청하면 예외가 발생한다") + void request_fail_if_attendance_not_found() { + when(attendanceQueryRepository.findById(ATTENDANCE_ID)).thenReturn(Optional.empty()); + + AttendanceCorrectionRequest.Request request = new AttendanceCorrectionRequest.Request( + ATTENDANCE_ID, AttendanceStatus.LATE, "요청 사유" + ); + + assertThatThrownBy(() -> service.request(REQUESTER_ID, request)) + .isInstanceOf(DomainException.class) + .hasFieldOrPropertyWithValue("problemCode", AttendanceProblemCode.ATTENDANCE_NOT_FOUND); + } + + private Attendance createAttendanceWithCheckIn() { + Attendance attendance = Attendance.create(SESSION_ID, MEMBER_ID); + attendance.setId(ATTENDANCE_ID); + attendance.checkIn(Clock.systemUTC()); + return attendance; + } +}