diff --git a/src/main/java/com/demo/pteam/PteamApplication.java b/src/main/java/com/demo/pteam/PteamApplication.java index 1a8e4d96..7a1f4eaa 100644 --- a/src/main/java/com/demo/pteam/PteamApplication.java +++ b/src/main/java/com/demo/pteam/PteamApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @EnableJpaAuditing @EnableScheduling +@EnableMethodSecurity @SpringBootApplication public class PteamApplication { diff --git a/src/main/java/com/demo/pteam/global/config/ApplicationConfig.java b/src/main/java/com/demo/pteam/global/config/ApplicationConfig.java index 93e06297..5ce9409d 100644 --- a/src/main/java/com/demo/pteam/global/config/ApplicationConfig.java +++ b/src/main/java/com/demo/pteam/global/config/ApplicationConfig.java @@ -1,6 +1,7 @@ package com.demo.pteam.global.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,6 +9,8 @@ public class ApplicationConfig { @Bean public ObjectMapper objectMapper() { - return new ObjectMapper(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; } } diff --git a/src/main/java/com/demo/pteam/global/entity/BaseEntity.java b/src/main/java/com/demo/pteam/global/entity/BaseEntity.java index e898c418..4dcd7032 100644 --- a/src/main/java/com/demo/pteam/global/entity/BaseEntity.java +++ b/src/main/java/com/demo/pteam/global/entity/BaseEntity.java @@ -5,11 +5,13 @@ import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Getter +@NoArgsConstructor @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @@ -21,4 +23,8 @@ public abstract class BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; + protected BaseEntity(LocalDateTime createdAt) { + this.createdAt = createdAt; + this.updatedAt = createdAt; + } } diff --git a/src/main/java/com/demo/pteam/global/entity/SoftDeletableEntity.java b/src/main/java/com/demo/pteam/global/entity/SoftDeletableEntity.java index 8cc13b2a..a4aee275 100644 --- a/src/main/java/com/demo/pteam/global/entity/SoftDeletableEntity.java +++ b/src/main/java/com/demo/pteam/global/entity/SoftDeletableEntity.java @@ -1,12 +1,19 @@ package com.demo.pteam.global.entity; +import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor @MappedSuperclass public abstract class SoftDeletableEntity extends BaseEntity { + protected SoftDeletableEntity(LocalDateTime createdAt) { + super(createdAt); + } - protected LocalDateTime deletedAt; + @Column(insertable = false) + private LocalDateTime deletedAt; } diff --git a/src/main/java/com/demo/pteam/schedule/controller/ScheduleController.java b/src/main/java/com/demo/pteam/schedule/controller/ScheduleController.java index 2d1e8ba1..8e39a743 100644 --- a/src/main/java/com/demo/pteam/schedule/controller/ScheduleController.java +++ b/src/main/java/com/demo/pteam/schedule/controller/ScheduleController.java @@ -1,4 +1,28 @@ package com.demo.pteam.schedule.controller; +import com.demo.pteam.global.response.ApiResponse; +import com.demo.pteam.schedule.controller.dto.ReadScheduleRequest; +import com.demo.pteam.schedule.controller.dto.ScheduleResponse; +import com.demo.pteam.schedule.service.ScheduleService; +import com.demo.pteam.security.principal.UserPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/schedules") +@RequiredArgsConstructor public class ScheduleController { + private final ScheduleService scheduleService; + + @GetMapping + public ResponseEntity>> readSchedules(@AuthenticationPrincipal UserPrincipal principal, + @ModelAttribute @Valid ReadScheduleRequest requestParams) { + List schedules = scheduleService.findAllSchedules(principal.id(), requestParams); + return ResponseEntity.ok(ApiResponse.success("회원정보 조회 성공", schedules)); + } } diff --git a/src/main/java/com/demo/pteam/schedule/controller/dto/ReadScheduleRequest.java b/src/main/java/com/demo/pteam/schedule/controller/dto/ReadScheduleRequest.java new file mode 100644 index 00000000..72e21f18 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/controller/dto/ReadScheduleRequest.java @@ -0,0 +1,21 @@ +package com.demo.pteam.schedule.controller.dto; + +import com.demo.pteam.schedule.domain.ScheduleDate; +import com.demo.pteam.schedule.validator.YearRange; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.hibernate.validator.constraints.Range; + +public record ReadScheduleRequest( + @NotBlank(message = "roleType을 입력해주세요.") + @Pattern(regexp = "^(user|trainer)$", message = "user 또는 trainer만 입력 가능합니다.") + String roleType, + @YearRange + Integer year, + @Range(min = 1, max = 12, message = "1 이상 12 이하인 값으로 입력해주세요.") + Integer month +) { + public ScheduleDate toScheduleDate() { + return ScheduleDate.of(year, month); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/controller/dto/RequestSchedule.java b/src/main/java/com/demo/pteam/schedule/controller/dto/RequestSchedule.java deleted file mode 100644 index f7d3ab43..00000000 --- a/src/main/java/com/demo/pteam/schedule/controller/dto/RequestSchedule.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.demo.pteam.schedule.controller.dto; - -public class RequestSchedule { -} diff --git a/src/main/java/com/demo/pteam/schedule/controller/dto/ResponseSchedule.java b/src/main/java/com/demo/pteam/schedule/controller/dto/ResponseSchedule.java deleted file mode 100644 index 70b0b351..00000000 --- a/src/main/java/com/demo/pteam/schedule/controller/dto/ResponseSchedule.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.demo.pteam.schedule.controller.dto; - -public class ResponseSchedule { -} diff --git a/src/main/java/com/demo/pteam/schedule/controller/dto/ScheduleResponse.java b/src/main/java/com/demo/pteam/schedule/controller/dto/ScheduleResponse.java new file mode 100644 index 00000000..14615524 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/controller/dto/ScheduleResponse.java @@ -0,0 +1,31 @@ +package com.demo.pteam.schedule.controller.dto; + +import com.demo.pteam.schedule.domain.Schedule; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Builder +public record ScheduleResponse( + Long id, + Long userId, + Long trainerId, + String nickname, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startTime, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endTime +) { + public static ScheduleResponse from(Schedule schedule) { + return ScheduleResponse.builder() + .id(schedule.getId()) + .userId(schedule.getUserId()) + .trainerId(schedule.getTrainerId()) + .nickname(schedule.getNickname()) + .startTime(schedule.getStartTime()) + .endTime(schedule.getEndTime()) + .build(); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/domain/Schedule.java b/src/main/java/com/demo/pteam/schedule/domain/Schedule.java index 57ea91ea..07d97883 100644 --- a/src/main/java/com/demo/pteam/schedule/domain/Schedule.java +++ b/src/main/java/com/demo/pteam/schedule/domain/Schedule.java @@ -1,4 +1,17 @@ package com.demo.pteam.schedule.domain; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder public class Schedule { + private final Long id; + private final Long userId; + private final Long trainerId; + private final String nickname; + private final LocalDateTime startTime; + private final LocalDateTime endTime; } diff --git a/src/main/java/com/demo/pteam/schedule/domain/ScheduleDate.java b/src/main/java/com/demo/pteam/schedule/domain/ScheduleDate.java new file mode 100644 index 00000000..2bc22fa8 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/domain/ScheduleDate.java @@ -0,0 +1,40 @@ +package com.demo.pteam.schedule.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +public class ScheduleDate { + private static final int MIN_YEAR = 1900; + private static final int MAX_YEAR = LocalDate.now().getYear() + 10; + + private final int year; + private final int month; + + private ScheduleDate(int year, int month) { + if (year < MIN_YEAR || year > MAX_YEAR) { + throw new IllegalArgumentException( + "Year must be between " + MIN_YEAR + " and " + MAX_YEAR + ". Invalid year: " + year); + } + if (month < 1 || month > 12) { + throw new IllegalArgumentException("Invalid month: " + month); + } + this.year = year; + this.month = month; + } + + public static ScheduleDate of(Integer year, Integer month) { + LocalDate localDate = LocalDate.now(); + int safeYear = Objects.isNull(year) ? localDate.getYear() : year; + int safeMonth = Objects.isNull(month) ? localDate.getMonthValue() : month; + return new ScheduleDate(safeYear, safeMonth); + } + + public LocalDateTime getStartOfMonth() { + return LocalDate.of(year, month, 1).atStartOfDay(); + } + + public LocalDateTime getEndOfMonth() { + return getStartOfMonth().plusMonths(1); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepository.java b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepository.java new file mode 100644 index 00000000..a7dd6522 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepository.java @@ -0,0 +1,7 @@ +package com.demo.pteam.schedule.repository; + +import com.demo.pteam.schedule.repository.entity.ScheduleEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleJPARepository extends JpaRepository, ScheduleJPARepositoryCustom { +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustom.java b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustom.java new file mode 100644 index 00000000..92c24214 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustom.java @@ -0,0 +1,11 @@ +package com.demo.pteam.schedule.repository; + +import com.demo.pteam.schedule.repository.dto.ScheduleDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ScheduleJPARepositoryCustom { + List findByUserIdWithinPeriod(Long userId, LocalDateTime start, LocalDateTime end); + List findByTrainerIdWithinPeriod(Long trainerId, LocalDateTime start, LocalDateTime end); +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustomImpl.java b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustomImpl.java new file mode 100644 index 00000000..f9d543f7 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/repository/ScheduleJPARepositoryCustomImpl.java @@ -0,0 +1,65 @@ +package com.demo.pteam.schedule.repository; + +import com.demo.pteam.schedule.repository.dto.ScheduleDto; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.demo.pteam.authentication.repository.entity.QAccountEntity.accountEntity; +import static com.demo.pteam.schedule.repository.entity.QScheduleEntity.scheduleEntity; + +@Repository +@RequiredArgsConstructor +public class ScheduleJPARepositoryCustomImpl implements ScheduleJPARepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public List findByUserIdWithinPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return findByAccountRoleWithinPeriod(userId, start, end, scheduleEntity.userAccountEntity.id); + } + + @Override + public List findByTrainerIdWithinPeriod(Long trainerId, LocalDateTime start, LocalDateTime end) { + return findByAccountRoleWithinPeriod(trainerId, start, end, scheduleEntity.trainerAccountEntity.id); + } + + private List findByAccountRoleWithinPeriod(Long accountId, LocalDateTime start, LocalDateTime end, NumberPath joinTarget) { + List result = queryFactory + .select( + scheduleEntity.id, + accountEntity.id, + scheduleEntity.userAccountEntity.id, + scheduleEntity.trainerAccountEntity.id, + accountEntity.nickname, + scheduleEntity.startTime, + scheduleEntity.endTime) + .from(accountEntity) + .join(scheduleEntity).on( + joinTarget.eq(accountEntity.id), + scheduleEntity.startTime.goe(start), + scheduleEntity.endTime.lt(end)) + .where(accountEntity.id.eq(accountId), + accountEntity.deletedAt.isNull()) + .fetch(); + return createScheduleDto(result); + } + + private List createScheduleDto(List result) { + return result.stream() + .map(tuple -> ScheduleDto.builder() + .id(tuple.get(scheduleEntity.id)) + .accountId(tuple.get(accountEntity.id)) + .userId(tuple.get(scheduleEntity.userAccountEntity.id)) + .trainerId(tuple.get(scheduleEntity.trainerAccountEntity.id)) + .nickname(tuple.get(accountEntity.nickname)) + .startTime(tuple.get(scheduleEntity.startTime)) + .endTime(tuple.get(scheduleEntity.endTime)) + .build() + ).toList(); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepository.java b/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepository.java index b25d3d50..a9ad1074 100644 --- a/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepository.java +++ b/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepository.java @@ -1,4 +1,11 @@ package com.demo.pteam.schedule.repository; +import com.demo.pteam.schedule.repository.dto.ScheduleDto; + +import java.time.LocalDateTime; +import java.util.List; + public interface ScheduleRepository { + List findByUserIdWithinPeriod(Long userId, LocalDateTime start, LocalDateTime end); + List findByTrainerIdWithinPeriod(Long trainerId, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepositoryImpl.java b/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepositoryImpl.java new file mode 100644 index 00000000..215b081d --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/repository/ScheduleRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.demo.pteam.schedule.repository; + +import com.demo.pteam.schedule.repository.dto.ScheduleDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ScheduleRepositoryImpl implements ScheduleRepository { + private final ScheduleJPARepository scheduleJPARepository; + + @Override + public List findByUserIdWithinPeriod(Long userId, LocalDateTime start, LocalDateTime end) { + return scheduleJPARepository.findByUserIdWithinPeriod(userId, start, end); + } + + @Override + public List findByTrainerIdWithinPeriod(Long trainerId, LocalDateTime start, LocalDateTime end) { + return scheduleJPARepository.findByTrainerIdWithinPeriod(trainerId, start, end); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/dto/ScheduleDto.java b/src/main/java/com/demo/pteam/schedule/repository/dto/ScheduleDto.java new file mode 100644 index 00000000..1d89ed69 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/repository/dto/ScheduleDto.java @@ -0,0 +1,28 @@ +package com.demo.pteam.schedule.repository.dto; + +import com.demo.pteam.schedule.domain.Schedule; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record ScheduleDto( + Long id, + Long accountId, + Long userId, + Long trainerId, + String nickname, + LocalDateTime startTime, + LocalDateTime endTime +) { + public Schedule toSchedule() { + return Schedule.builder() + .id(id) + .userId(userId) + .trainerId(trainerId) + .nickname(nickname) + .startTime(startTime) + .endTime(endTime) + .build(); + } +} diff --git a/src/main/java/com/demo/pteam/schedule/repository/entity/ScheduleEntity.java b/src/main/java/com/demo/pteam/schedule/repository/entity/ScheduleEntity.java index ce85226f..3463dbaf 100644 --- a/src/main/java/com/demo/pteam/schedule/repository/entity/ScheduleEntity.java +++ b/src/main/java/com/demo/pteam/schedule/repository/entity/ScheduleEntity.java @@ -1,6 +1,7 @@ package com.demo.pteam.schedule.repository.entity; import com.demo.pteam.authentication.repository.entity.AccountEntity; +import com.demo.pteam.global.entity.SoftDeletableEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -13,7 +14,7 @@ @Table(name = "schedule") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class ScheduleEntity { +public class ScheduleEntity extends SoftDeletableEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -29,16 +30,10 @@ public class ScheduleEntity { private LocalDateTime startTime; private LocalDateTime endTime; - // TODO: 임시 구현 - @Column(updatable = false) - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - @Column(insertable = false) - private LocalDateTime deletedAt; - @Builder - public ScheduleEntity(AccountEntity userAccountEntity, AccountEntity trainerAccountEntity, LocalDateTime startTime, LocalDateTime endTime) { + public ScheduleEntity(LocalDateTime createdAt, AccountEntity userAccountEntity, AccountEntity trainerAccountEntity, + LocalDateTime startTime, LocalDateTime endTime) { + super(createdAt); this.userAccountEntity = userAccountEntity; this.trainerAccountEntity = trainerAccountEntity; this.startTime = startTime; diff --git a/src/main/java/com/demo/pteam/schedule/service/ScheduleService.java b/src/main/java/com/demo/pteam/schedule/service/ScheduleService.java index 70de0b3c..cee39256 100644 --- a/src/main/java/com/demo/pteam/schedule/service/ScheduleService.java +++ b/src/main/java/com/demo/pteam/schedule/service/ScheduleService.java @@ -1,4 +1,42 @@ package com.demo.pteam.schedule.service; +import com.demo.pteam.schedule.controller.dto.ReadScheduleRequest; +import com.demo.pteam.schedule.controller.dto.ScheduleResponse; +import com.demo.pteam.schedule.domain.ScheduleDate; +import com.demo.pteam.schedule.repository.ScheduleRepository; +import com.demo.pteam.schedule.repository.dto.ScheduleDto; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor public class ScheduleService { + private final ScheduleRepository scheduleRepository; + + @PreAuthorize("authentication.principal.id == #userId") + @Transactional(readOnly = true) + public List findAllSchedules(Long userId, ReadScheduleRequest request) { + ScheduleDate date = request.toScheduleDate(); + List scheduleDtoList = switch (request.roleType()) { + case "user" -> findAllUserSchedules(userId, date); + case "trainer" -> findAllTrainerSchedules(userId, date); + default -> throw new IllegalStateException("Unexpected value: " + request.roleType()); + }; + return scheduleDtoList.stream() + .filter(dto -> dto.accountId().equals(userId)) + .map(dto -> ScheduleResponse.from(dto.toSchedule())) + .toList(); + } + + public List findAllUserSchedules(Long userId, ScheduleDate date) { + return scheduleRepository.findByUserIdWithinPeriod(userId, date.getStartOfMonth(), date.getEndOfMonth()); + } + + public List findAllTrainerSchedules(Long trainerId, ScheduleDate date) { + return scheduleRepository.findByTrainerIdWithinPeriod(trainerId, date.getStartOfMonth(), date.getEndOfMonth()); + } } diff --git a/src/main/java/com/demo/pteam/schedule/validator/YearRange.java b/src/main/java/com/demo/pteam/schedule/validator/YearRange.java new file mode 100644 index 00000000..f39b5fdd --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/validator/YearRange.java @@ -0,0 +1,31 @@ +package com.demo.pteam.schedule.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 입력된 연도가 유효한 범위 내에 있는지 검증하는 어노테이션입니다. + * + *

이 어노테이션은 필드 및 메서드 파라미터에 적용되며, 연도가 다음 조건을 만족하는지 확인합니다: + *

    + *
  • 1900년 이상
  • + *
  • 현재 연도 기준으로 최대 10년 이하
  • + *
+ * + *

유효하지 않은 값이 입력되면 검증 오류가 발생합니다. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = YearRangeValidator.class) +public @interface YearRange { + String message() default "입력 형식이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/demo/pteam/schedule/validator/YearRangeValidator.java b/src/main/java/com/demo/pteam/schedule/validator/YearRangeValidator.java new file mode 100644 index 00000000..bb6bd384 --- /dev/null +++ b/src/main/java/com/demo/pteam/schedule/validator/YearRangeValidator.java @@ -0,0 +1,27 @@ +package com.demo.pteam.schedule.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.LocalDate; +import java.util.Objects; + +public class YearRangeValidator implements ConstraintValidator { + private static final int MIN_YEAR = 1900; + private static final int MAX_YEAR = LocalDate.now().getYear() + 10; + + @Override + public boolean isValid(Integer year, ConstraintValidatorContext context) { + if (Objects.isNull(year)) { + return true; // year이 null인 경우는 검증 대상 아님 + } + if (year < MIN_YEAR || year > MAX_YEAR) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( + String.format("%d 이상 %d 이하의 값으로 입력해주세요.", MIN_YEAR, MAX_YEAR) + ).addConstraintViolation(); + return false; + } + return true; + } +} diff --git a/src/main/java/com/demo/pteam/security/config/SecurityConfig.java b/src/main/java/com/demo/pteam/security/config/SecurityConfig.java index 3c888db1..390ae8a6 100644 --- a/src/main/java/com/demo/pteam/security/config/SecurityConfig.java +++ b/src/main/java/com/demo/pteam/security/config/SecurityConfig.java @@ -9,7 +9,6 @@ import com.demo.pteam.security.authentication.handler.JwtAuthenticationSuccessHandler; import com.demo.pteam.security.configurer.ApiLoginConfigurer; import com.demo.pteam.security.configurer.JwtAuthenticationConfigurer; -import com.demo.pteam.security.jwt.JwtProvider; import com.demo.pteam.security.login.handler.LoginAuthenticationFailureHandler; import com.demo.pteam.security.login.handler.LoginAuthenticationSuccessHandler; import com.demo.pteam.security.login.LoginAuthenticationProvider; diff --git a/src/test/java/com/demo/pteam/AuthenticatedTest.java b/src/test/java/com/demo/pteam/AuthenticatedTest.java new file mode 100644 index 00000000..e8be0a79 --- /dev/null +++ b/src/test/java/com/demo/pteam/AuthenticatedTest.java @@ -0,0 +1,116 @@ +package com.demo.pteam; + +import com.demo.pteam.security.jwt.InMemoryTokenStore; +import com.demo.pteam.security.jwt.JwtProvider; +import com.demo.pteam.security.jwt.TokenData; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import javax.crypto.SecretKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Map; + +import static org.mockito.Mockito.*; + +@SpringBootTest +@Transactional +public abstract class AuthenticatedTest { + protected static final String PREFIX = "Bearer "; + protected static final String ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6IlJPTEVfVVNFUiIsInZlcmlmaWVkIjp0cnVlLCJpYXQiOjE3NDcyMDc2OTksImV4cCI6MTc0NzIxMTI5OX0.M2IjaJJCfnV7Eheijp72nKtVlL1pgkghNr-Zc1i6Oks"; + protected static final String REFRESH_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzQ3MjA3NzAwLCJleHAiOjE3NDc4MTI1MDB9.NXnMg9s2NpZFIgf6EmRdGpC9qyXDOWGbRklF39vOLBg"; + protected static final String AUTHORIZATION_HEADER = PREFIX + ACCESS_TOKEN; + protected static final String REFRESH_TOKEN_HEADER = PREFIX + REFRESH_TOKEN; + + private static final Date NOW = createDate(2025, 5, 14, 16, 28, 20); + + private static final long REFRESH_TOKEN_EXPIRATION = 1747812500000L; + + @Value("${jwt.secret}") + protected String jwtSecretKey; + + @Autowired + private WebApplicationContext context; + protected MockMvc mockMvc; + + @Autowired + private FilterChainProxy filterChainProxy; + + @MockitoSpyBean + private JwtProvider jwtProvider; + + @MockitoSpyBean + private InMemoryTokenStore spyTokenStore; + + @BeforeEach + public void authenticationSetUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .addFilters(filterChainProxy) + .build(); + + // refreshToken 저장 + TokenData tokenData = new TokenData(REFRESH_TOKEN, REFRESH_TOKEN_EXPIRATION); + TokenData spyTokenData = spy(tokenData); + Map store = (Map) ReflectionTestUtils.getField(spyTokenStore, "store"); + store.clear(); + store.put(1L, spyTokenData); + doReturn(NOW.getTime() > REFRESH_TOKEN_EXPIRATION).when(spyTokenData).isExpired(); + + doAnswer(invocation -> { + String token = invocation.getArgument(0); + return jwtDecode(token, jwtSecretKey, NOW); + }).when(jwtProvider).parseClaims(anyString()); + } + + public static HttpHeaders getHttpHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", AUTHORIZATION_HEADER); + headers.set("Refresh-Token", REFRESH_TOKEN_HEADER); + return headers; + } + + public static HttpHeaders getHttpHeaders(String authHeader, String refreshHeader) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", authHeader); + headers.set("Refresh-Token", refreshHeader); + return headers; + } + + private static Date createDate(int year, int month, int dayOfMonth, int hour, int minute, int second) { + return Date.from( + LocalDateTime.of(year, month, dayOfMonth, hour, minute, second) + .atZone(ZoneId.of("Asia/Seoul")) + .toInstant() + ); + } + + private static Claims jwtDecode(String token, String secretKey, Date now) throws JwtException { + return Jwts.parser() + .clock(() -> now) + .verifyWith(createSigningKey(secretKey)) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private static SecretKey createSigningKey(String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/test/java/com/demo/pteam/schedule/ScheduleIntegrationTest.java b/src/test/java/com/demo/pteam/schedule/ScheduleIntegrationTest.java new file mode 100644 index 00000000..b8f14123 --- /dev/null +++ b/src/test/java/com/demo/pteam/schedule/ScheduleIntegrationTest.java @@ -0,0 +1,155 @@ +package com.demo.pteam.schedule; + +import com.demo.pteam.AuthenticatedTest; +import com.demo.pteam.security.exception.AuthenticationErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ScheduleIntegrationTest extends AuthenticatedTest { + private static final String SCHEDULE_URL = "/api/schedules"; + + // year, month 생략시 현재 날짜 기준으로 조회 + @DisplayName("일정 조회 성공") + @CsvSource(value = { + "2025, 5", + "2025,", // month 생략 + ",5", // year 생략 + "," // month, year 생략 + }) + @ParameterizedTest + void readScheduleByUser(String year, String month) throws Exception { + // given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("roleType", "user"); + params.add("year", year); + params.add("month", month); + + // when + ResultActions resultActions = mockMvc.perform( + get(SCHEDULE_URL).params(params).headers(getHttpHeaders()) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("message").value("회원정보 조회 성공")) + .andExpect(jsonPath("data[*].userId", everyItem(equalTo(1)))) + .andExpect(jsonPath("data[*].startTime", everyItem(startsWith("2025-05")))); + } + + /* + // year, month 생략시 현재 날짜 기준으로 조회 + @DisplayName("일정 조회 성공") + @CsvSource(value = { + "2025, 5", + "2025,", // month 생략 + ",5", // year 생략 + "," // month, year 생략 + }) + @ParameterizedTest + void readScheduleByTrainer(String year, String month) throws Exception { + // given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("roleType", "trainer"); + params.add("year", year); + params.add("month", month); + + // when + ResultActions resultActions = mockMvc.perform( + get(SCHEDULE_URL).params(params).headers(getHttpHeaders()) + ); + + // then + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("message").value("회원정보 조회 성공")) + .andExpect(jsonPath("data[*].trainerId", everyItem(equalTo(1)))) + .andExpect(jsonPath("data[*].startTime", everyItem(startsWith("2025-05")))); + } + */ + + @DisplayName("로그인 x") + @Test + void notLogin() throws Exception { + // given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("roleType", "user"); + params.add("year", "2025"); + params.add("month", "5"); + + // when + ResultActions resultActions = mockMvc.perform( + get(SCHEDULE_URL).params(params) + ); + + // then + AuthenticationErrorCode errorCode = AuthenticationErrorCode.NOT_AUTHENTICATED; + + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("code").value(errorCode.getCode())) + .andExpect(jsonPath("message").value(errorCode.getMessage())); + } + + @DisplayName("유효하지 않은 파라미터") + @CsvSource(value = { + ",2025,5", // roleType 생략 + "role,2025,5", // 유효하지 않는 roleType + "user,-2025,5", // 유효하지 않는 year + "user,1899,5", // 유효하지 않는 year + "user,2036,5", // 유효하지 않는 year + "user,2025,0", // 유효하지 않는 month + "user,2025,13" // 유효하지 않는 month + }) + @ParameterizedTest + void invalidParams(String roleType, String year, String month) throws Exception { + // given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("roleType", roleType); + params.add("year", year); + params.add("month", month); + + // when + ResultActions resultActions = mockMvc.perform( + get(SCHEDULE_URL).params(params).headers(getHttpHeaders()) + ); + + // then + resultActions.andExpect(status().isBadRequest()); + } + + @DisplayName("유효한 파라미터") + @CsvSource(value = { + "user,1900,5", + "trainer,1900,5", + "user,2035,5", + "trainer,2035,5", + "user,2025,1", + "trainer,2025,1", + "user,2025,12", + "trainer,2025,12" + }) + @ParameterizedTest + void validParams(String roleType, String year, String month) throws Exception { + // given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("roleType", roleType); + params.add("year", year); + params.add("month", month); + + // when + ResultActions resultActions = mockMvc.perform( + get(SCHEDULE_URL).params(params).headers(getHttpHeaders()) + ); + + // then + resultActions.andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/resources/testdb/data/R__test_data.sql b/src/test/resources/testdb/data/R__test_data.sql index 30d37020..f76f4704 100644 --- a/src/test/resources/testdb/data/R__test_data.sql +++ b/src/test/resources/testdb/data/R__test_data.sql @@ -3,7 +3,9 @@ -- entity_type: 1(로컬), 2(소셜) insert ignore into accounts(id, `name`, nickname, role, entity_type) values (1, '회원1', '회원닉네임1', 1, 1), - (2, '회원2', '회원닉네임2', 1, 1); + (2, '회원2', '회원닉네임2', 1, 1), + (3, '트레이너1', '트레이너닉네임1', 1, 1), + (4, '트레이너2', '트레이너닉네임2', 1, 1); -- 로컬 계정 -- status: -1(삭제)/0(정지)/1(활성)/2(미인증) @@ -19,4 +21,13 @@ set @user_id2 = (select id from accounts where nickname = '회원닉네임2'); set @test_pwd = '{bcrypt}$2a$10$eXthWEeajRbGgRfvlfVBl.LlD6jDWoyAgyRSDa.FdRUTM4vfnYh86'; -- password = 1234567aA! insert ignore into local_accounts(id, username, `password`, email, `status`) values (@user_id1,'usertest1', @test_pwd, 'usertest1@gmail.com', 1), -- 활성 - (@user_id2, 'usertest2', @test_pwd, 'usertest2@gmail.com', 0); -- 정지 + (@user_id2, 'usertest2', @test_pwd, 'usertest2@gmail.com', 0), -- 정지 + (3,'trainertest1', @test_pwd, 'trainertest1@gmail.com', 1), -- 활성 + (4,'trainertest2', @test_pwd, 'trainertest2@gmail.com', 1); -- 활성 + + +-- 일정 +insert IGNORE into `schedule`(user_accounts_id, trainer_accounts_id, start_time, end_time) values + (1, 5, '2025-05-12 14:00:00', '2025-05-12 15:30:00'), + (1, 6, '2025-05-17 12:00:00', '2025-05-12 13:00:00'), + (1, 5, '2025-05-24 17:00:00', '2025-05-12 18:00:00'); \ No newline at end of file