diff --git a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java index 6977a3a0..5febec88 100644 --- a/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -5,6 +5,8 @@ import com.back.domain.study.record.service.StudyRecordService; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -18,12 +20,15 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/plans/records") +@Tag(name = "StudyRecord", description = "학습 조회 관련 API") public class StudyRecordController { private final StudyRecordService studyRecordService; // ======================= 생성 ====================== // 학습 기록 생성 @PostMapping + @Operation( summary = "학습 기록 생성", + description = "기록을 생성합니다.") public ResponseEntity> createStudyRecord( @AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody StudyRecordRequestDto request @@ -35,6 +40,8 @@ public ResponseEntity> createStudyRecord( // ======================= 조회 ====================== // 일별 학습 기록 조회 @GetMapping + @Operation( summary = "학습 기록 조회", + description = "날짜별 기록을 조회합니다.") public ResponseEntity>> getDailyStudyRecord( @AuthenticationPrincipal CustomUserDetails user, @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java index 6d3f6d27..19778e0c 100644 --- a/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java @@ -1,5 +1,6 @@ package com.back.domain.study.record.dto; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,10 +11,15 @@ @Getter @NoArgsConstructor public class StudyRecordRequestDto { + @NotNull(message = "계획 ID는 필수입니다.") private Long planId; + @NotNull(message = "방 ID는 필수입니다.") private Long roomId; + @NotNull(message = "시작 시간은 필수입니다.") private LocalDateTime startTime; + @NotNull(message = "종료 시간은 필수입니다.") private LocalDateTime endTime; + private Long duration; private List pauseInfos = new ArrayList<>(); diff --git a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java index 756af318..57b4ad49 100644 --- a/src/main/java/com/back/domain/study/record/entity/StudyRecord.java +++ b/src/main/java/com/back/domain/study/record/entity/StudyRecord.java @@ -29,18 +29,20 @@ public class StudyRecord extends BaseEntity { private StudyPlan studyPlan; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id") + @JoinColumn(name = "room_id", nullable = false) private Room room; // 초 단위 @Column(nullable = false) private Long duration; + @Column(nullable = false) private LocalDateTime startTime; @OneToMany(mappedBy = "studyRecord", cascade = CascadeType.ALL, orphanRemoval = true) private List pauseInfos = new ArrayList<>(); + @Column(nullable = false) private LocalDateTime endTime; public static StudyRecord create(User user, StudyPlan studyPlan, Room room, diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 9958fc97..fd81097d 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -54,12 +54,9 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD throw new CustomException(ErrorCode.PLAN_FORBIDDEN); } - // 방 조회 (우선은 옵셔널로 설정) - Room room = null; - if (request.getRoomId() != null) { - room = roomRepository.findById(request.getRoomId()) - .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - } + // 방 조회 (필수) + Room room = roomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); // 일시정지 정보를 엔티티로 생성 List pauseInfos = request.getPauseInfos().stream() @@ -125,9 +122,9 @@ public List getStudyRecordsByDate(Long userId, LocalDate User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - // 오전 4시 기준 하루의 시작과 끝 설정 - LocalDateTime startOfDay = date.atTime(4, 0, 0); - LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0, 0); + // 오전 0시 기준 하루의 시작과 끝 설정 + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.plusDays(1).atStartOfDay(); // 시작~종료 시간을 포함하는 일자의 학습 기록 조회 List records = studyRecordRepository @@ -151,8 +148,8 @@ private void checkAndNotifyDailyGoalAchievement(Long userId, LocalDate date) { // 오늘 완료한 계획 개수 int completedCount = 0; - LocalDateTime startOfDay = date.atTime(4, 0); - LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0); + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.plusDays(1).atStartOfDay(); for (StudyPlan plan : todayPlans) { boolean hasRecord = studyRecordRepository.existsByStudyPlanIdAndDate( @@ -200,15 +197,15 @@ private List getTodayStudyPlans(Long userId, LocalDate date) { // ===================== 유틸 ===================== // 시간 범위 검증 - private void validateTimeRange(java.time.LocalDateTime startTime, java.time.LocalDateTime endTime) { + private void validateTimeRange(LocalDateTime startTime, LocalDateTime endTime) { if (startTime.isAfter(endTime) || startTime.isEqual(endTime)) { throw new CustomException(ErrorCode.INVALID_TIME_RANGE); } } // 일시정지 시간이 학습 시간 내에 있는지 검증 - private void validatePauseInStudyRange(java.time.LocalDateTime studyStart, java.time.LocalDateTime studyEnd, - java.time.LocalDateTime pauseStart, java.time.LocalDateTime pauseEnd) { + private void validatePauseInStudyRange(LocalDateTime studyStart, LocalDateTime studyEnd, + LocalDateTime pauseStart, LocalDateTime pauseEnd) { if (pauseStart.isBefore(studyStart) || pauseEnd.isAfter(studyEnd)) { throw new CustomException(ErrorCode.INVALID_TIME_RANGE); } diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index 906572f9..bbdf08ce 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -5,6 +5,8 @@ import com.back.domain.study.plan.entity.RepeatRule; import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomRepository; import com.back.domain.user.entity.Role; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserStatus; @@ -53,8 +55,11 @@ class StudyRecordControllerTest { private StudyPlanRepository studyPlanRepository; @Autowired private UserRepository userRepository; + @Autowired + private RoomRepository roomRepository; private User testUser; + private Room testRoom; private StudyPlan singlePlan; private StudyPlan dailyPlan; @@ -73,6 +78,7 @@ void setUp() { singlePlan = createSinglePlan(); dailyPlan = createDailyPlan(); + testRoom = createRoom(testUser); } private void setupJwtMock(User user) { @@ -115,7 +121,23 @@ private StudyPlan createDailyPlan() { plan.setRepeatRule(repeatRule); return studyPlanRepository.save(plan); + } + private Room createRoom(User owner) { + + testRoom = Room.create( + "코딩테스트 준비", + "알고리즘 문제 풀이 및 코드 리뷰", + false, // 공개방 + null, // 비밀번호 없음 + 20, // 최대 20명 + owner, + null, // 테마 없음 + true // WebRTC 활성화 + ); + + testRoom = roomRepository.save(testRoom); + return testRoom; } @Test @@ -127,12 +149,13 @@ void t1() throws Exception { .content(""" { "planId": %d, + "roomId": %d, "startTime": "2025-10-03T10:00:00", "endTime": "2025-10-03T12:00:00", "duration": 7200, "pauseInfos": [] } - """.formatted(singlePlan.getId()))) + """.formatted(singlePlan.getId(), testRoom.getId()))) .andDo(print()); resultActions @@ -142,6 +165,7 @@ void t1() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("학습 기록이 생성되었습니다.")) .andExpect(jsonPath("$.data.planId").value(singlePlan.getId())) + .andExpect(jsonPath("$.data.roomId").value(testRoom.getId())) .andExpect(jsonPath("$.data.startTime").value("2025-10-03T10:00:00")) .andExpect(jsonPath("$.data.endTime").value("2025-10-03T12:00:00")) .andExpect(jsonPath("$.data.duration").value(7200)) @@ -156,6 +180,7 @@ void t2() throws Exception { .content(""" { "planId": %d, + "roomId": %d, "startTime": "2025-10-03T14:00:00", "endTime": "2025-10-03T17:00:00", "duration": "7500", @@ -172,7 +197,7 @@ void t2() throws Exception { } ] } - """.formatted(dailyPlan.getId()))) + """.formatted(singlePlan.getId(), testRoom.getId()))) .andDo(print()); resultActions @@ -192,6 +217,7 @@ void t2_1() throws Exception { .content(""" { "planId": %d, + "roomId": %d, "startTime": "2025-10-03T14:00:00", "endTime": "2025-10-03T17:00:00", "duration": "7200", @@ -207,7 +233,7 @@ void t2_1() throws Exception { } ] } - """.formatted(dailyPlan.getId()))) + """.formatted(singlePlan.getId(), testRoom.getId()))) .andDo(print()); resultActions @@ -227,12 +253,13 @@ void t3() throws Exception { .content(""" { "planId": %d, + "roomId": %d, "startTime": "2025-10-03T10:00:00", "endTime": "2025-10-03T12:00:00", "duration": 7200, "pauseInfos": [] } - """.formatted(singlePlan.getId()))) + """.formatted(singlePlan.getId(), testRoom.getId()))) .andExpect(status().isOk()); // 조회 @@ -250,36 +277,7 @@ void t3() throws Exception { } @Test - @DisplayName("학습 기록 조회 - 전날 밤~당일 새벽 기록의 경우") - void t4() throws Exception { - mvc.perform(post("/api/plans/records") - .header("Authorization", "Bearer faketoken") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "planId": %d, - "startTime": "2025-10-02T23:00:00", - "endTime": "2025-10-03T02:00:00", - "duration": 10800, - "pauseInfos": [] - } - """.formatted(singlePlan.getId()))) - .andExpect(status().isOk()); - - // 10월 2일로 조회 (04:00 기준이므로 이 기록이 포함되어야 함) - ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-02") - .header("Authorization", "Bearer faketoken")) - .andDo(print()); - - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(1))) - .andExpect(jsonPath("$.data[0].startTime").value("2025-10-02T23:00:00")) - .andExpect(jsonPath("$.data[0].endTime").value("2025-10-03T02:00:00")); - } - - @Test - @DisplayName("학습 기록 조회 - 전날 밤~당일 오전 4시 이후 끝난 기록의 경우") + @DisplayName("학습 기록 조회 - 전날 밤 시작 ~ 당일 끝난 기록의 경우") void t5() throws Exception { mvc.perform(post("/api/plans/records") .header("Authorization", "Bearer faketoken") @@ -287,12 +285,13 @@ void t5() throws Exception { .content(""" { "planId": %d, + "roomId": %d, "startTime": "2025-10-02T23:00:00", "endTime": "2025-10-03T05:00:00", "duration": 21600, "pauseInfos": [] } - """.formatted(singlePlan.getId()))) + """.formatted(singlePlan.getId(), testRoom.getId()))) .andExpect(status().isOk()); // 10월 2일 조회 ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-02")