diff --git a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java index 10efea91..5b1c0e97 100644 --- a/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java +++ b/src/main/java/com/back/domain/study/plan/service/StudyPlanService.java @@ -557,7 +557,7 @@ private void validateDateTime(LocalDateTime startDate, LocalDateTime endDate) { } if (!startDate.isBefore(endDate)) { - throw new CustomException(ErrorCode.PLAN_INVALID_TIME_RANGE); + throw new CustomException(ErrorCode.INVALID_TIME_RANGE); } } //시간 겹침 검증 메서드 (최적화된 DB 쿼리 + 가상 인스턴스 검증 조합) 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 new file mode 100644 index 00000000..6977a3a0 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/controller/StudyRecordController.java @@ -0,0 +1,46 @@ +package com.back.domain.study.record.controller; + +import com.back.domain.study.record.dto.StudyRecordRequestDto; +import com.back.domain.study.record.dto.StudyRecordResponseDto; +import com.back.domain.study.record.service.StudyRecordService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/plans/records") +public class StudyRecordController { + private final StudyRecordService studyRecordService; + + // ======================= 생성 ====================== + // 학습 기록 생성 + @PostMapping + public ResponseEntity> createStudyRecord( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody StudyRecordRequestDto request + ) { + Long userId = user.getUserId(); + StudyRecordResponseDto response = studyRecordService.createStudyRecord(userId, request); + return ResponseEntity.ok(RsData.success("학습 기록이 생성되었습니다.", response)); + } + // ======================= 조회 ====================== + // 일별 학습 기록 조회 + @GetMapping + public ResponseEntity>> getDailyStudyRecord( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + Long userId = user.getUserId(); + List response = studyRecordService.getStudyRecordsByDate(userId, date); + return ResponseEntity.ok(RsData.success("일별 학습 기록 조회 성공", response)); + } +} 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 new file mode 100644 index 00000000..6d3f6d27 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordRequestDto.java @@ -0,0 +1,26 @@ +package com.back.domain.study.record.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +public class StudyRecordRequestDto { + private Long planId; + private Long roomId; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Long duration; + private List pauseInfos = new ArrayList<>(); + + @Getter + @NoArgsConstructor + public static class PauseInfoRequestDto { + private LocalDateTime pausedAt; + private LocalDateTime restartAt; + } +} diff --git a/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java new file mode 100644 index 00000000..04b65a46 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/dto/StudyRecordResponseDto.java @@ -0,0 +1,51 @@ +package com.back.domain.study.record.dto; + +import com.back.domain.study.record.entity.PauseInfo; +import com.back.domain.study.record.entity.StudyRecord; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor +public class StudyRecordResponseDto { + private Long id; + private Long planId; + private Long roomId; + private LocalDateTime startTime; + private List pauseInfos = new ArrayList<>(); + private LocalDateTime endTime; + private Long duration; + + public static StudyRecordResponseDto from(StudyRecord studyRecord) { + StudyRecordResponseDto dto = new StudyRecordResponseDto(); + dto.id = studyRecord.getId(); + dto.planId = studyRecord.getStudyPlan() != null ? studyRecord.getStudyPlan().getId() : null; + dto.roomId = studyRecord.getRoom() != null ? studyRecord.getRoom().getId() : null; + dto.startTime = studyRecord.getStartTime(); + dto.endTime = studyRecord.getEndTime(); + dto.duration = studyRecord.getDuration(); + dto.pauseInfos = studyRecord.getPauseInfos().stream() + .map(PauseInfoResponseDto::from) + .collect(Collectors.toList()); + return dto; + } + + @Getter + @NoArgsConstructor + public static class PauseInfoResponseDto { + private LocalDateTime pausedAt; + private LocalDateTime restartAt; + + public static PauseInfoResponseDto from(PauseInfo pauseInfo) { + PauseInfoResponseDto dto = new PauseInfoResponseDto(); + dto.pausedAt = pauseInfo.getPausedAt(); + dto.restartAt = pauseInfo.getRestartAt(); + return dto; + } + } +} diff --git a/src/main/java/com/back/domain/study/record/entity/PauseInfo.java b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java new file mode 100644 index 00000000..7500352c --- /dev/null +++ b/src/main/java/com/back/domain/study/record/entity/PauseInfo.java @@ -0,0 +1,42 @@ +package com.back.domain.study.record.entity; + + +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "pause_infos") +public class PauseInfo { + // 일시정지 정보에 생성, 수정일은 필요 없을 것 같아서 + // id만 별도로 생성 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_record_id", nullable = false) + private StudyRecord studyRecord; + + @Column(name = "paused_at", nullable = false) + private LocalDateTime pausedAt; + + @Column(name = "restart_at") + private LocalDateTime restartAt; + + public static PauseInfo of(LocalDateTime pausedAt, LocalDateTime restartAt) { + PauseInfo pauseInfo = new PauseInfo(); + pauseInfo.pausedAt = pausedAt; + pauseInfo.restartAt = restartAt; + return pauseInfo; + } + + // 양방향 연관관계 설정 + void assignStudyRecord(StudyRecord studyRecord) { + this.studyRecord = studyRecord; + } + +} 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 100a0110..756af318 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 @@ -2,31 +2,84 @@ import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.studyroom.entity.Room; +import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @NoArgsConstructor @Getter public class StudyRecord extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "plan_id") + @JoinColumn(name = "plan_id", nullable = false) private StudyPlan studyPlan; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "room_id") private Room room; - private int duration; + // 초 단위 + @Column(nullable = false) + private Long duration; private LocalDateTime startTime; + @OneToMany(mappedBy = "studyRecord", cascade = CascadeType.ALL, orphanRemoval = true) + private List pauseInfos = new ArrayList<>(); + private LocalDateTime endTime; + + public static StudyRecord create(User user, StudyPlan studyPlan, Room room, + LocalDateTime startTime, LocalDateTime endTime, + List pauseInfos) { + StudyRecord record = new StudyRecord(); + record.user = user; + record.studyPlan = studyPlan; + record.room = room; + record.startTime = startTime; + record.endTime = endTime; + + // 일시정지 정보 추가 + if (pauseInfos != null && !pauseInfos.isEmpty()) { + pauseInfos.forEach(record::addPauseInfo); + } + // 총 학습 시간 계산 (검증은 서비스에서) + record.calculateDuration(); + + return record; + } + + // 일시정지 정보 추가 + private void addPauseInfo(PauseInfo pauseInfo) { + this.pauseInfos.add(pauseInfo); + pauseInfo.assignStudyRecord(this); + } + + // 실제 학습 시간 계산 (전체 시간 - 일시정지 시간) + private void calculateDuration() { + // 전체 시간 계산 (초) + long totalSeconds = Duration.between(startTime, endTime).getSeconds(); + + // 일시정지 시간 제외 + long pausedSeconds = pauseInfos.stream() + .filter(pause -> pause.getPausedAt() != null && pause.getRestartAt() != null) + .mapToLong(pause -> Duration.between(pause.getPausedAt(), pause.getRestartAt()).getSeconds()) + .sum(); + + this.duration = totalSeconds - pausedSeconds;; + } } diff --git a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java new file mode 100644 index 00000000..fdac8fd2 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java @@ -0,0 +1,31 @@ +package com.back.domain.study.record.repository; + +import com.back.domain.study.record.entity.StudyRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface StudyRecordRepository extends JpaRepository { + // 시작 시간을 기준으로만 조회 + List findByUserIdAndStartTimeBetween( + Long userId, LocalDateTime start, LocalDateTime end); + + // 시작 시간과 종료 시간을 둘 다 고려하여 조회 + @Query("SELECT sr FROM StudyRecord sr " + + "WHERE sr.user.id = :userId " + + "AND (" + + " (sr.startTime >= :startOfDay AND sr.startTime < :endOfDay) OR " + + " (sr.endTime > :startOfDay AND sr.endTime <= :endOfDay) OR " + + " (sr.startTime < :startOfDay AND sr.endTime > :endOfDay)" + + ") " + + "ORDER BY sr.startTime DESC") + List findByUserIdAndDateRange( + @Param("userId") Long userId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); +} 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 new file mode 100644 index 00000000..008985e3 --- /dev/null +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -0,0 +1,140 @@ +package com.back.domain.study.record.service; + +import com.back.domain.study.plan.entity.StudyPlan; +import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.study.record.dto.StudyRecordRequestDto; +import com.back.domain.study.record.dto.StudyRecordResponseDto; +import com.back.domain.study.record.entity.PauseInfo; +import com.back.domain.study.record.entity.StudyRecord; +import com.back.domain.study.record.repository.StudyRecordRepository; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StudyRecordService { + private final StudyRecordRepository studyRecordRepository; + private final StudyPlanRepository studyPlanRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + // ===================== 생성 ===================== + // 학습 기록 생성 (종료 시 한 번에 기록) + @Transactional + public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestDto request) { + // 계획 조회 + StudyPlan studyPlan = studyPlanRepository.findById(request.getPlanId()) + .orElseThrow(() -> new CustomException(ErrorCode.PLAN_NOT_FOUND)); + // 유저 조회 및 권한 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + if (!studyPlan.getUser().equals(user)) { + 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)); + } + + // 일시정지 정보를 엔티티로 생성 + List pauseInfos = request.getPauseInfos().stream() + .map(dto -> { + LocalDateTime pausedAt = dto.getPausedAt(); + LocalDateTime restartAt = dto.getRestartAt(); + + // 재시작 안 했으면 학습 종료 시간을 재시작 시간으로 간주 + if (restartAt == null) { + restartAt = request.getEndTime(); + } + + // 일시정지 시간 범위 검증 + validateTimeRange(pausedAt, restartAt); + + // 일시정지가 학습 시간 내에 있는지 검증 + validatePauseInStudyRange( + request.getStartTime(), + request.getEndTime(), + pausedAt, + restartAt + ); + return PauseInfo.of(pausedAt, restartAt); + }) + .collect(Collectors.toList()); + + // 학습 기록 생성 (시작, 종료, 일시정지 정보 모두 포함) + StudyRecord record = StudyRecord.create( + user, + studyPlan, + room, + request.getStartTime(), + request.getEndTime(), + pauseInfos + ); + + // 프론트 Duration과 백엔드 Duration 비교 검증 + validateDurationDifference(request.getDuration(), record.getDuration()); + + // 저장 + StudyRecord saved = studyRecordRepository.save(record); + return StudyRecordResponseDto.from(saved); + } + // ===================== 조회 ===================== + // 날짜별 학습 기록 조회 + public List getStudyRecordsByDate(Long userId, LocalDate date) { + // 유저 조회 및 권한 확인 + 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); + + // 시작~종료 시간을 포함하는 일자의 학습 기록 조회 + List records = studyRecordRepository + .findByUserIdAndDateRange(userId, startOfDay, endOfDay); + + return records.stream() + .map(StudyRecordResponseDto::from) + .collect(Collectors.toList()); + } + + // ===================== 유틸 ===================== + // 시간 범위 검증 + private void validateTimeRange(java.time.LocalDateTime startTime, java.time.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) { + if (pauseStart.isBefore(studyStart) || pauseEnd.isAfter(studyEnd)) { + throw new CustomException(ErrorCode.INVALID_TIME_RANGE); + } + } + // 프론트에서 계산한 학습 시간과 백엔드에서 계산한 학습 시간의 차이가 5초 이상이면 예외 발생 + private void validateDurationDifference(Long frontDuration, Long backendDuration) { + long difference = Math.abs(frontDuration - backendDuration); + + // 5초 이상 차이나면 예외 발생 + if (difference > 5) { + throw new CustomException(ErrorCode.DURATION_MISMATCH); + } + } +} diff --git a/src/main/java/com/back/domain/study/todo/controller/TodoController.java b/src/main/java/com/back/domain/study/todo/controller/TodoController.java index 58d0697c..691ff0dc 100644 --- a/src/main/java/com/back/domain/study/todo/controller/TodoController.java +++ b/src/main/java/com/back/domain/study/todo/controller/TodoController.java @@ -42,7 +42,7 @@ public ResponseEntity> createTodo( "date만 제공시 해당 날짜, startDate와 endDate 제공시 기간별, 아무것도 없으면 전체 조회") public ResponseEntity>> getTodos( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + @RequestParam(required = true) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date ) { List response = todoService.getTodosByDate(userDetails.getUserId(), date); diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 36e22fe8..2e347ce9 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -43,7 +43,7 @@ public enum ErrorCode { PLAN_EXCEPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_003", "학습 계획의 예외가 존재하지 않습니다."), PLAN_ORIGINAL_REPEAT_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_004", "해당 날짜에 원본 반복 계획을 찾을 수 없습니다."), INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "PLAN_005", "날짜 형식이 올바르지 않습니다. (YYYY-MM-DD 형식을 사용해주세요)"), - PLAN_INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "PLAN_006", "시작 시간은 종료 시간보다 빨라야 합니다."), + INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "PLAN_006", "시작 시간은 종료 시간보다 빨라야 합니다."), PLAN_TIME_CONFLICT(HttpStatus.CONFLICT, "PLAN_007", "이미 존재하는 학습 계획과 시간이 겹칩니다. 기존 종료 시간과 겹치는 경우는 제외됩니다."), PLAN_CANNOT_UPDATE(HttpStatus.BAD_REQUEST, "PLAN_008", "수정 스위치 로직 탈출. 어떤 경우인지 파악이 필요합니다."), REPEAT_INVALID_UNTIL_DATE(HttpStatus.BAD_REQUEST, "REPEAT_001", "반복 계획의 종료 날짜는 시작 날짜 이전일 수 없습니다."), @@ -53,6 +53,9 @@ public enum ErrorCode { TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_001", "존재하지 않는 할 일입니다."), TODO_FORBIDDEN(HttpStatus.FORBIDDEN, "TODO_002", "할 일에 대한 접근 권한이 없습니다."), + // ======================== 학습 기록 관련 ======================== + DURATION_MISMATCH(HttpStatus.BAD_REQUEST, "RECORD_001", "받은 duration과 계산된 duration이 5초 이상 차이납니다."), + // ======================== 알림 관련 ======================== NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_001", "존재하지 않는 알림입니다."), NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_002", "알림에 대한 접근 권한이 없습니다."), 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 new file mode 100644 index 00000000..906572f9 --- /dev/null +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -0,0 +1,322 @@ +package com.back.domain.study.record.controller; + +import com.back.domain.study.plan.entity.Color; +import com.back.domain.study.plan.entity.Frequency; +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.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.security.jwt.JwtTokenProvider; +import com.back.global.security.user.CustomUserDetails; +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.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("StudyRecordController 테스트") +class StudyRecordControllerTest { + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private MockMvc mvc; + + @Autowired + private StudyPlanRepository studyPlanRepository; + @Autowired + private UserRepository userRepository; + + private User testUser; + private StudyPlan singlePlan; + private StudyPlan dailyPlan; + + @BeforeEach + void setUp() { + testUser = User.builder() + .email("test@example.com") + .username("testuser") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + testUser = userRepository.save(testUser); + + setupJwtMock(testUser); + + singlePlan = createSinglePlan(); + dailyPlan = createDailyPlan(); + } + + private void setupJwtMock(User user) { + given(jwtTokenProvider.validateAccessToken(anyString())).willReturn(true); + + CustomUserDetails userDetails = CustomUserDetails.builder() + .userId(user.getId()) + .username(user.getUsername()) + .role(user.getRole()) + .build(); + + given(jwtTokenProvider.getAuthentication(anyString())) + .willReturn(new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + )); + } + + private StudyPlan createSinglePlan() { + StudyPlan plan = new StudyPlan(); + plan.setUser(testUser); + plan.setSubject("단발성 계획"); + plan.setStartDate(LocalDateTime.of(2025, 10, 1, 10, 0)); + plan.setEndDate(LocalDateTime.of(2025, 10, 1, 12, 0)); + plan.setColor(Color.RED); + return studyPlanRepository.save(plan); + } + private StudyPlan createDailyPlan() { + StudyPlan plan = new StudyPlan(); + plan.setUser(testUser); + plan.setSubject("매일 반복 계획"); + plan.setStartDate(LocalDateTime.of(2025, 10, 1, 12, 0)); + plan.setEndDate(LocalDateTime.of(2025, 10, 1, 13, 0)); + plan.setColor(Color.BLUE); + + RepeatRule repeatRule = new RepeatRule(); + repeatRule.setFrequency(Frequency.DAILY); + repeatRule.setRepeatInterval(1); + repeatRule.setUntilDate(LocalDateTime.of(2025, 12, 31, 0, 0).toLocalDate()); + repeatRule.setStudyPlan(plan); + plan.setRepeatRule(repeatRule); + + return studyPlanRepository.save(plan); + + } + + @Test + @DisplayName("학습 기록 생성 - 일시정지 없음") + void t1() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T10:00:00", + "endTime": "2025-10-03T12:00:00", + "duration": 7200, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(handler().handlerType(StudyRecordController.class)) + .andExpect(handler().methodName("createStudyRecord")) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("학습 기록이 생성되었습니다.")) + .andExpect(jsonPath("$.data.planId").value(singlePlan.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)) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(0))); + } + @Test + @DisplayName("학습 기록 생성 - 일시정지 있음") + void t2() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T14:00:00", + "endTime": "2025-10-03T17:00:00", + "duration": "7500", + "pauseInfos": [ + { + "pausedAt": "2025-10-03T15:00:00", + "restartAt": "2025-10-03T15:30:00" + },{ + "pausedAt": "2025-10-03T15:50:00", + "restartAt": "2025-10-03T16:10:00" + },{ + "pausedAt": "2025-10-03T16:50:00", + "restartAt": "2025-10-03T16:55:00" + } + ] + } + """.formatted(dailyPlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.startTime").value("2025-10-03T14:00:00")) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))) + .andExpect(jsonPath("$.data.duration").value("7500")); + } + + @Test + @DisplayName("학습 기록 생성 - 일시정지 + 마지막 재시작 없음") + void t2_1() throws Exception { + ResultActions resultActions = mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T14:00:00", + "endTime": "2025-10-03T17:00:00", + "duration": "7200", + "pauseInfos": [ + { + "pausedAt": "2025-10-03T15:00:00", + "restartAt": "2025-10-03T15:30:00" + },{ + "pausedAt": "2025-10-03T15:50:00", + "restartAt": "2025-10-03T16:10:00" + },{ + "pausedAt": "2025-10-03T16:50:00" + } + ] + } + """.formatted(dailyPlan.getId()))) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.startTime").value("2025-10-03T14:00:00")) + .andExpect(jsonPath("$.data.pauseInfos", hasSize(3))) + .andExpect(jsonPath("$.data.duration").value("7200")); + } + + @Test + @DisplayName("학습 기록 조회 - 일시정지 없음") + void t3() throws Exception { + mvc.perform(post("/api/plans/records") + .header("Authorization", "Bearer faketoken") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "planId": %d, + "startTime": "2025-10-03T10:00:00", + "endTime": "2025-10-03T12:00:00", + "duration": 7200, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andExpect(status().isOk()); + + // 조회 + ResultActions resultActions = mvc.perform(get("/api/plans/records?date=2025-10-03") + .header("Authorization", "Bearer faketoken")) + .andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("일별 학습 기록 조회 성공")) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].planId").value(singlePlan.getId())) + .andExpect(jsonPath("$.data[0].duration").value(7200)); + } + + @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시 이후 끝난 기록의 경우") + void t5() 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-03T05:00:00", + "duration": 21600, + "pauseInfos": [] + } + """.formatted(singlePlan.getId()))) + .andExpect(status().isOk()); + // 10월 2일 조회 + 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-03T05:00:00")) + .andExpect(jsonPath("$.data[0].duration").value(21600)); + + // 10월 3일 조회 + resultActions = mvc.perform(get("/api/plans/records?date=2025-10-03") + .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-03T05:00:00")) + .andExpect(jsonPath("$.data[0].duration").value(21600)); + } + +} \ No newline at end of file