diff --git a/src/main/java/run/backend/domain/crew/controller/CrewController.java b/src/main/java/run/backend/domain/crew/controller/CrewController.java new file mode 100644 index 0000000..0aa0d01 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/controller/CrewController.java @@ -0,0 +1,123 @@ +package run.backend.domain.crew.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import run.backend.domain.crew.dto.common.CrewInviteCodeDto; +import run.backend.domain.crew.dto.request.CrewInfoRequest; +import run.backend.domain.crew.dto.response.*; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.service.CrewEventService; +import run.backend.domain.crew.service.CrewService; +import run.backend.domain.member.entity.Member; +import run.backend.global.annotation.member.Login; +import run.backend.global.annotation.member.MemberCrew; +import run.backend.global.common.response.CommonResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/crews") +@Tag(name = "Crews", description = "Crew 관련 API") +public class CrewController { + + private final CrewService crewService; + private final CrewEventService crewEventService; + + @PostMapping + @Operation(summary = "크루 생성", description = "크루 생성하는 API 입니다.") + public CommonResponse createCrew( + @Login Member member, + @RequestParam String imageStatus, + @RequestPart(value = "data")CrewInfoRequest data, + @RequestPart(value = "image", required = false) MultipartFile image + ) { + + CrewInviteCodeDto response = crewService.createCrew(member, imageStatus, image, data); + return new CommonResponse<>("크루 생성 성공", response); + } + + @PatchMapping + @PreAuthorize("hasRole('MANAGER') or hasRole('LEADER')") + @Operation(summary = "크루 정보 수정", description = "크루 정보 수정하는 API 입니다.") + public CommonResponse updateCrewInfo( + @MemberCrew Crew crew, + @RequestParam String imageStatus, + @RequestPart(value = "data")CrewInfoRequest data, + @RequestPart(value = "image", required = false) MultipartFile image + ) { + + crewService.updateCrew(crew, imageStatus, image, data); + return new CommonResponse<>("크루 정보 수정 성공"); + } + + @GetMapping("/{crewId}/invite-code") + @Operation(summary = "크루의 초대 코드 조회", description = "크루의 초대 코드 조회하는 API 입니다.") + public CommonResponse getCrewInviteCode(@MemberCrew Crew crew) { + + CrewInviteCodeDto response = crewService.getCrewInviteCode(crew); + return new CommonResponse<>("크루 초대 코드 조회 성공", response); + } + + @GetMapping("/invite-code/{inviteCode}") + @Operation(summary = "초대 코드로 크루 조회", description = "초대 코드로 크루를 조회하는 API 입니다.") + public CommonResponse getCrewByInviteCode(@PathVariable String inviteCode) { + + CrewProfileResponse response = crewService.getCrewByInviteCode(inviteCode); + return new CommonResponse<>("크루 조회 성공", response); + } + + @PostMapping("/{crewId}/join") + @Operation(summary = "크루 가입", description = "크루 가입하는 API 입니다.") + public CommonResponse joinCrew( + @Login Member member, + @PathVariable Long crewId) { + + crewService.joinCrew(member, crewId); + return new CommonResponse<>("크루 가입 성공"); + } + + @GetMapping + @Operation(summary = "크루 기본 정보 조회", description = "크루 기본 정보를 조회하는 API 입니다.") + public CommonResponse getCrewBaseInfo(@MemberCrew Crew crew) { + + CrewBaseInfoResponse response = crewService.getCrewBaseInfo(crew); + return new CommonResponse<>("크루 기본 정보 조회 성공", response); + } + + @GetMapping("/events/weekly") + @Operation(summary = "weekly 기록 조회", description = "크루의 weekly 기록 조회하는 API 입니다.") + public CommonResponse getWeeklyRecord(@MemberCrew Crew crew) { + + CrewWeeklyEventResponse response = crewEventService.getCrewWeeklyEvent(crew); + return new CommonResponse<>("크루 주간 기록 조회 성공", response); + } + + @GetMapping("/events/monthly") + @Operation(summary = "monthly 일정 조회", description = "크루의 monthly 일정 조회하는 API 입니다.") + public CommonResponse getMonthlyEvent( + @MemberCrew Crew crew, + @RequestParam int year, + @RequestParam int month) { + + CrewMonthlyCanlendarResponse response = crewEventService.getCrewMonthlyCalendar(crew, year, month); + return new CommonResponse<>("크루 월간 기록 조회 성공", response); + } + + @GetMapping("/events/upcoming") + @Operation(summary = "upcoming 일정 조회", description = "크루의 upcoming 일정 조회하는 API 입니다.") + public CommonResponse getUpcomingEvent(@MemberCrew Crew crew) { + + CrewUpcomingEventResponse response = crewEventService.getCrewUpcomingEvent(crew); + return new CommonResponse<>("크루 다가오는 일정 조회 성공", response); + } + + @GetMapping("/members") + @Operation(summary = "크루원 조회", description = "크루 내 모든 크루원을 조회하는 API 입니다.") + public CommonResponse getCrewMember(@MemberCrew Crew crew) { + + return new CommonResponse<>("크루원 조회 성공"); + } +} diff --git a/src/main/java/run/backend/domain/crew/dto/common/CrewInviteCodeDto.java b/src/main/java/run/backend/domain/crew/dto/common/CrewInviteCodeDto.java new file mode 100644 index 0000000..c09c2d0 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/dto/common/CrewInviteCodeDto.java @@ -0,0 +1,6 @@ +package run.backend.domain.crew.dto.common; + +public record CrewInviteCodeDto( + String inviteCode +) { +} diff --git a/src/main/java/run/backend/domain/crew/dto/common/DayStatusDto.java b/src/main/java/run/backend/domain/crew/dto/common/DayStatusDto.java new file mode 100644 index 0000000..6cb81e9 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/dto/common/DayStatusDto.java @@ -0,0 +1,9 @@ +package run.backend.domain.crew.dto.common; + +import run.backend.domain.event.enums.RunningStatus; + +public record DayStatusDto( + RunningStatus status, + Long eventId +) { +} diff --git a/src/main/java/run/backend/domain/crew/dto/request/CrewInfoRequest.java b/src/main/java/run/backend/domain/crew/dto/request/CrewInfoRequest.java index cd64f2d..749b075 100644 --- a/src/main/java/run/backend/domain/crew/dto/request/CrewInfoRequest.java +++ b/src/main/java/run/backend/domain/crew/dto/request/CrewInfoRequest.java @@ -1,4 +1,7 @@ package run.backend.domain.crew.dto.request; -public record CrewInfoRequest() { +public record CrewInfoRequest( + String name, + String description +) { } diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewBaseInfoResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewBaseInfoResponse.java new file mode 100644 index 0000000..099874e --- /dev/null +++ b/src/main/java/run/backend/domain/crew/dto/response/CrewBaseInfoResponse.java @@ -0,0 +1,19 @@ +package run.backend.domain.crew.dto.response; + +import lombok.Builder; + +import java.math.BigDecimal; + +@Builder +public record CrewBaseInfoResponse( + + int rank, + String image, + String name, + String description, + Long memberCount, + BigDecimal monthlyDistanceTotal, + Long monthlyTimeTotal + +) { +} diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewEventResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewEventResponse.java deleted file mode 100644 index 7ff7636..0000000 --- a/src/main/java/run/backend/domain/crew/dto/response/CrewEventResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package run.backend.domain.crew.dto.response; - -public record CrewEventResponse() { -} diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewInfoResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewInfoResponse.java deleted file mode 100644 index 3c56000..0000000 --- a/src/main/java/run/backend/domain/crew/dto/response/CrewInfoResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package run.backend.domain.crew.dto.response; - -public record CrewInfoResponse() { -} diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewMonthlyCanlendarResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewMonthlyCanlendarResponse.java index 763cb98..5dda127 100644 --- a/src/main/java/run/backend/domain/crew/dto/response/CrewMonthlyCanlendarResponse.java +++ b/src/main/java/run/backend/domain/crew/dto/response/CrewMonthlyCanlendarResponse.java @@ -1,9 +1,10 @@ package run.backend.domain.crew.dto.response; -import java.util.List; +import run.backend.domain.crew.dto.common.DayStatusDto; +import java.util.Map; public record CrewMonthlyCanlendarResponse( - List records, - List events + + Map monthlyRunningStatus ) { } diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewProfileResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewProfileResponse.java index ded24a1..b6a8a98 100644 --- a/src/main/java/run/backend/domain/crew/dto/response/CrewProfileResponse.java +++ b/src/main/java/run/backend/domain/crew/dto/response/CrewProfileResponse.java @@ -1,4 +1,14 @@ package run.backend.domain.crew.dto.response; -public record CrewProfileResponse() { +import lombok.Builder; + +@Builder +public record CrewProfileResponse( + String crewImage, + String crewName, + String crewDescription, + Long memberCount, + String leaderImage, + String leaderName +) { } diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewRecordResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewRecordResponse.java deleted file mode 100644 index cafedae..0000000 --- a/src/main/java/run/backend/domain/crew/dto/response/CrewRecordResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package run.backend.domain.crew.dto.response; - -public record CrewRecordResponse() { -} diff --git a/src/main/java/run/backend/domain/crew/dto/response/CrewWeeklyEventResponse.java b/src/main/java/run/backend/domain/crew/dto/response/CrewWeeklyEventResponse.java new file mode 100644 index 0000000..960e0b7 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/dto/response/CrewWeeklyEventResponse.java @@ -0,0 +1,13 @@ +package run.backend.domain.crew.dto.response; + +import run.backend.domain.crew.dto.common.DayStatusDto; + +import java.time.DayOfWeek; +import java.util.Map; + +public record CrewWeeklyEventResponse( + int currentDay, + Map weeklyRunningStatus + +) { +} diff --git a/src/main/java/run/backend/domain/crew/dto/response/EventProfileResponse.java b/src/main/java/run/backend/domain/crew/dto/response/EventProfileResponse.java index 042415c..1a8f594 100644 --- a/src/main/java/run/backend/domain/crew/dto/response/EventProfileResponse.java +++ b/src/main/java/run/backend/domain/crew/dto/response/EventProfileResponse.java @@ -1,4 +1,18 @@ package run.backend.domain.crew.dto.response; -public record EventProfileResponse() { +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Builder +public record EventProfileResponse( + + Long eventId, + String title, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + Long participants +) { } diff --git a/src/main/java/run/backend/domain/crew/entity/Crew.java b/src/main/java/run/backend/domain/crew/entity/Crew.java index 5dd1642..31b8f2d 100644 --- a/src/main/java/run/backend/domain/crew/entity/Crew.java +++ b/src/main/java/run/backend/domain/crew/entity/Crew.java @@ -41,6 +41,22 @@ public class Crew extends BaseEntity { @Column(name = "monthly_score_total") private BigDecimal monthlyScoreTotal; + public void incrementMemberCount() { + this.memberCount++; + } + + public void updateName(String newName) { + this.name = newName; + } + + public void updateDescription(String newDescription) { + this.description = newDescription; + } + + public void updateImage(String newImageName) { + this.image = newImageName; + } + @Builder public Crew ( String name, diff --git a/src/main/java/run/backend/domain/crew/entity/JoinCrew.java b/src/main/java/run/backend/domain/crew/entity/JoinCrew.java index de00b68..5716da9 100644 --- a/src/main/java/run/backend/domain/crew/entity/JoinCrew.java +++ b/src/main/java/run/backend/domain/crew/entity/JoinCrew.java @@ -48,13 +48,38 @@ public void approveJoin() { this.joinStatus = JoinStatus.APPROVED; } + public static JoinCrew createLeaderJoin(Member member, Crew crew) { + return JoinCrew.builder() + .crew(crew) + .member(member) + .role(Role.LEADER) + .joinStatus(JoinStatus.APPROVED) + .joinedDate(LocalDate.now()) + .build(); + } + + public static JoinCrew createAppliedJoin(Member member, Crew crew) { + return JoinCrew.builder() + .crew(crew) + .member(member) + .role(Role.MEMBER) + .joinStatus(JoinStatus.APPLIED) + .build(); + } + @Builder - public JoinCrew( + private JoinCrew( Member member, - Crew crew + Crew crew, + Role role, + JoinStatus joinStatus, + LocalDate joinedDate + ) { this.crew = crew; this.member = member; - this.joinStatus = JoinStatus.APPLIED; + this.role = role; + this.joinStatus = joinStatus; + this.joinedDate = joinedDate; } } diff --git a/src/main/java/run/backend/domain/crew/exception/CrewErrorCode.java b/src/main/java/run/backend/domain/crew/exception/CrewErrorCode.java new file mode 100644 index 0000000..7ba5d9e --- /dev/null +++ b/src/main/java/run/backend/domain/crew/exception/CrewErrorCode.java @@ -0,0 +1,17 @@ +package run.backend.domain.crew.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import run.backend.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum CrewErrorCode implements ErrorCode { + + ALREADY_JOINED_CREW(7001, "이미 가입한 크루가 있습니다."), + NOT_FOUND_CREW(7002, "해당 크루를 찾을 수 없습니다."); + + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/run/backend/domain/crew/exception/CrewException.java b/src/main/java/run/backend/domain/crew/exception/CrewException.java new file mode 100644 index 0000000..9853c65 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/exception/CrewException.java @@ -0,0 +1,22 @@ +package run.backend.domain.crew.exception; + +import run.backend.global.exception.CustomException; + +public class CrewException extends CustomException { + + public CrewException(final CrewErrorCode crewErrorCode) { + super(crewErrorCode); + } + + public static class AlreadyJoinedCrew extends CrewException { + public AlreadyJoinedCrew() { + super(CrewErrorCode.ALREADY_JOINED_CREW); + } + } + + public static class NotFoundCrew extends CrewException { + public NotFoundCrew() { + super(CrewErrorCode.NOT_FOUND_CREW); + } + } +} diff --git a/src/main/java/run/backend/domain/crew/mapper/CrewMapper.java b/src/main/java/run/backend/domain/crew/mapper/CrewMapper.java new file mode 100644 index 0000000..d58b5d2 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/mapper/CrewMapper.java @@ -0,0 +1,42 @@ +package run.backend.domain.crew.mapper; + +import org.springframework.stereotype.Component; +import run.backend.domain.crew.dto.response.CrewBaseInfoResponse; +import run.backend.domain.crew.dto.response.CrewProfileResponse; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.member.entity.Member; + +@Component +public class CrewMapper { + + public Crew toEntity(String imageName, String name, String description) { + return Crew.builder() + .image(imageName) + .name(name) + .description(description) + .build(); + } + + public CrewProfileResponse toCrewProfile(Crew crew, Member leader) { + return CrewProfileResponse.builder() + .crewImage(crew.getImage()) + .crewName(crew.getName()) + .crewDescription(crew.getDescription()) + .memberCount(crew.getMemberCount()) + .leaderImage(leader.getProfileImage()) + .leaderName(leader.getNickname()) + .build(); + } + + public CrewBaseInfoResponse toCrewBaseInfo(int rank, Crew crew) { + return CrewBaseInfoResponse.builder() + .rank(rank) + .image(crew.getImage()) + .name(crew.getName()) + .description(crew.getDescription()) + .memberCount(crew.getMemberCount()) + .monthlyDistanceTotal(crew.getMonthlyDistanceTotal()) + .monthlyTimeTotal(crew.getMonthlyTimeTotal()) + .build(); + } +} diff --git a/src/main/java/run/backend/domain/crew/repository/CrewRepository.java b/src/main/java/run/backend/domain/crew/repository/CrewRepository.java new file mode 100644 index 0000000..e6992eb --- /dev/null +++ b/src/main/java/run/backend/domain/crew/repository/CrewRepository.java @@ -0,0 +1,11 @@ +package run.backend.domain.crew.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.crew.entity.Crew; + +import java.util.Optional; + +public interface CrewRepository extends JpaRepository { + + Optional findByInviteCode(String inviteCode); +} diff --git a/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java b/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java index ba72f2a..28561dc 100644 --- a/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java +++ b/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java @@ -1,15 +1,29 @@ package run.backend.domain.crew.repository; -import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import run.backend.domain.crew.entity.Crew; import run.backend.domain.crew.entity.JoinCrew; import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.member.entity.Member; +import run.backend.domain.member.enums.Role; +import java.util.Optional; import run.backend.domain.event.dto.response.EventCreationValidationDto; public interface JoinCrewRepository extends JpaRepository { + boolean existsByMemberAndJoinStatus(Member member, JoinStatus joinStatus); + + @Query(""" + SELECT jc.member + FROM JoinCrew jc + JOIN Crew c ON jc.crew = c + WHERE jc.role = :role + """) + Member findCrewLeader(@Param("role") Role role, Crew crew); + @Query("SELECT jc FROM JoinCrew jc WHERE jc.member.id = :memberId AND jc.joinStatus = :status") Optional findByMemberIdAndJoinStatus(@Param("memberId") Long memberId, @Param("status") JoinStatus status); @@ -19,9 +33,9 @@ Optional findByMemberIdAndJoinStatus(@Param("memberId") Long memberId, requesterJoin.crew, captainJoin.member ) - FROM JoinCrew requesterJoin + FROM JoinCrew requesterJoin INNER JOIN JoinCrew captainJoin ON requesterJoin.crew.id = captainJoin.crew.id - WHERE requesterJoin.member.id = :requesterId + WHERE requesterJoin.member.id = :requesterId AND requesterJoin.joinStatus = :status AND captainJoin.member.id = :runningCaptainId AND captainJoin.joinStatus = :status diff --git a/src/main/java/run/backend/domain/crew/service/CrewEventService.java b/src/main/java/run/backend/domain/crew/service/CrewEventService.java new file mode 100644 index 0000000..1311638 --- /dev/null +++ b/src/main/java/run/backend/domain/crew/service/CrewEventService.java @@ -0,0 +1,64 @@ +package run.backend.domain.crew.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import run.backend.domain.crew.dto.common.DayStatusDto; +import run.backend.domain.crew.dto.response.CrewMonthlyCanlendarResponse; +import run.backend.domain.crew.dto.response.CrewUpcomingEventResponse; +import run.backend.domain.crew.dto.response.CrewWeeklyEventResponse; +import run.backend.domain.crew.dto.response.EventProfileResponse; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.event.mapper.EventMapper; +import run.backend.domain.event.mapper.EventStatusMapper; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.repository.EventRepository; +import run.backend.global.dto.DateRange; +import run.backend.global.util.DateRangeUtil; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CrewEventService { + + private final DateRangeUtil dateRangeUtil; + private final EventRepository eventRepository; + private final EventStatusMapper eventStatusMapper; + private final EventMapper eventMapper; + + public CrewWeeklyEventResponse getCrewWeeklyEvent(Crew crew) { + + LocalDate today = LocalDate.now(); + DateRange dateRange = dateRangeUtil.getWeekRange(today); + + List weeklyEvents = eventRepository.findAllByCrewAndDateBetween(crew, dateRange.start(), dateRange.end()); + Map statusMap = eventStatusMapper.toWeeklyStatus(weeklyEvents, today); + + return new CrewWeeklyEventResponse(today.getDayOfWeek().getValue(), statusMap); + } + + public CrewMonthlyCanlendarResponse getCrewMonthlyCalendar(Crew crew, int year, int month) { + + LocalDate today = LocalDate.now(); + DateRange dateRange = dateRangeUtil.getMonthRange(year, month); + + List events = eventRepository.findAllByCrewAndDateBetween(crew, dateRange.start(), dateRange.end()); + Map statusMap = eventStatusMapper.toMonthlyStatus(events, today, dateRange.end().getDayOfMonth()); + + return new CrewMonthlyCanlendarResponse(statusMap); + } + + public CrewUpcomingEventResponse getCrewUpcomingEvent(Crew crew) { + + LocalDate today = LocalDate.now(); + + List events = eventRepository.findAllByCrewAndDateAfter(crew, today); + List eventProfiles = eventMapper.toEventProfileList(events); + + return new CrewUpcomingEventResponse(eventProfiles); + } +} diff --git a/src/main/java/run/backend/domain/crew/service/CrewService.java b/src/main/java/run/backend/domain/crew/service/CrewService.java index f28d31e..8987602 100644 --- a/src/main/java/run/backend/domain/crew/service/CrewService.java +++ b/src/main/java/run/backend/domain/crew/service/CrewService.java @@ -1,26 +1,96 @@ package run.backend.domain.crew.service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import run.backend.domain.crew.dto.common.CrewInviteCodeDto; import run.backend.domain.crew.dto.request.CrewInfoRequest; import run.backend.domain.crew.dto.response.*; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.entity.JoinCrew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.crew.exception.CrewException; +import run.backend.domain.crew.mapper.CrewMapper; +import run.backend.domain.crew.repository.CrewRepository; +import run.backend.domain.crew.repository.JoinCrewRepository; +import run.backend.domain.file.service.FileService; +import run.backend.domain.member.entity.Member; import run.backend.domain.member.enums.Role; +import run.backend.domain.member.repository.MemberRepository; -import java.time.YearMonth; +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CrewService { -public interface CrewService { + private final CrewMapper crewMapper; + private final FileService fileService; + private final CrewRepository crewRepository; + private final MemberRepository memberRepository; + private final JoinCrewRepository joinCrewRepository; - void updateCrew(CrewInfoRequest crewInfoRequest); + @Transactional + public CrewInviteCodeDto createCrew(Member member, String imageStatus, MultipartFile image, CrewInfoRequest data) { - CrewSearchResponse searchCrew(String crewName); + if (joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)) + throw new CrewException.AlreadyJoinedCrew(); - CrewInfoResponse getCrewInfo(Long crewId); + String imageName = fileService.handleImageUpdate(imageStatus, "default-profile-image.png", image); + Crew crew = crewMapper.toEntity(imageName, data.name(), data.description()); + crewRepository.save(crew); - CrewMonthlyCanlendarResponse getCrewMonthlyCalendar(Long crewId, YearMonth yearMonth); + JoinCrew joinCrew = JoinCrew.createLeaderJoin(member, crew); + joinCrewRepository.save(joinCrew); - CrewUpcomingEventResponse getUpcomingEvents(Long crewId); + member.updateRole(Role.LEADER); + memberRepository.save(member); - CrewMemberResponse getCrewMemberProfile(Long crewId); + return new CrewInviteCodeDto(crew.getInviteCode()); + } - void updateCrewMemberRole(Long memberId, Role role); + @Transactional + public void updateCrew(Crew crew, String imageStatus, MultipartFile image, CrewInfoRequest data) { - CrewSearchResponse getRankCrew(); + String imageName = fileService.handleImageUpdate(imageStatus, crew.getImage(), image); + crew.updateImage(imageName); + + if (data.name() != null) crew.updateName(data.name()); + if (data.description() != null) crew.updateDescription(data.description()); + + crewRepository.save(crew); + } + + public CrewInviteCodeDto getCrewInviteCode(Crew crew) { + + return new CrewInviteCodeDto(crew.getInviteCode()); + } + + public CrewProfileResponse getCrewByInviteCode(String inviteCode) { + + Crew crew = crewRepository.findByInviteCode(inviteCode) + .orElseThrow(CrewException.NotFoundCrew::new); + Member leader = joinCrewRepository.findCrewLeader(Role.LEADER, crew); + + return crewMapper.toCrewProfile(crew, leader); + } + + @Transactional + public void joinCrew(Member member, Long crewId) { + + Crew crew = crewRepository.findById(crewId) + .orElseThrow(CrewException.NotFoundCrew::new); + if (joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)) + throw new CrewException.AlreadyJoinedCrew(); + + JoinCrew joinCrew = JoinCrew.createAppliedJoin(member, crew); + joinCrewRepository.save(joinCrew); + } + + public CrewBaseInfoResponse getCrewBaseInfo(Crew crew) { + + int rank = 0; // [TODO] : 스케줄링 rank 계산 구현 수정 예정 + + return crewMapper.toCrewBaseInfo(rank, crew); + } } diff --git a/src/main/java/run/backend/domain/event/enums/RunningStatus.java b/src/main/java/run/backend/domain/event/enums/RunningStatus.java new file mode 100644 index 0000000..e069ffa --- /dev/null +++ b/src/main/java/run/backend/domain/event/enums/RunningStatus.java @@ -0,0 +1,14 @@ +package run.backend.domain.event.enums; + +public enum RunningStatus { + + NONE("일정 없음"), + SCHEDULED("예정"), + DONE("완료"); + + private final String description; + + RunningStatus(String description) { + this.description = description; + } +} diff --git a/src/main/java/run/backend/domain/event/mapper/EventMapper.java b/src/main/java/run/backend/domain/event/mapper/EventMapper.java new file mode 100644 index 0000000..53e84b2 --- /dev/null +++ b/src/main/java/run/backend/domain/event/mapper/EventMapper.java @@ -0,0 +1,29 @@ +package run.backend.domain.event.mapper; + +import org.springframework.stereotype.Component; +import run.backend.domain.crew.dto.response.EventProfileResponse; +import run.backend.domain.event.entity.Event; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class EventMapper { + + public List toEventProfileList(List events) { + return events.stream() + .map(this::toEventProfile) + .collect(Collectors.toList()); + } + + public EventProfileResponse toEventProfile(Event event) { + return EventProfileResponse.builder() + .eventId(event.getId()) + .title(event.getTitle()) + .date(event.getDate()) + .startTime(event.getStartTime()) + .endTime(event.getEndTime()) + .participants(event.getExpectedParticipants()) + .build(); + } +} diff --git a/src/main/java/run/backend/domain/event/mapper/EventStatusMapper.java b/src/main/java/run/backend/domain/event/mapper/EventStatusMapper.java new file mode 100644 index 0000000..3f083cf --- /dev/null +++ b/src/main/java/run/backend/domain/event/mapper/EventStatusMapper.java @@ -0,0 +1,61 @@ +package run.backend.domain.event.mapper; + +import org.springframework.stereotype.Component; +import run.backend.domain.crew.dto.common.DayStatusDto; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.enums.RunningStatus; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class EventStatusMapper { + + public Map toWeeklyStatus(List events, LocalDate today) { + + // MON ~ SUN 초기화 + Map statusMap = new EnumMap<>(DayOfWeek.class); + for (DayOfWeek day : DayOfWeek.values()) { + statusMap.put(day, new DayStatusDto(RunningStatus.NONE, null)); + } + + // Running Status 바꿔주기 + for (Event event : events) { + + LocalDate eventDate = event.getDate(); + DayOfWeek day = eventDate.getDayOfWeek(); + + RunningStatus status = eventDate.isBefore(today) + ? RunningStatus.DONE + : RunningStatus.SCHEDULED; + statusMap.put(day, new DayStatusDto(status, event.getId())); + } + return statusMap; + } + + public Map toMonthlyStatus(List events, LocalDate today, int endDate) { + + // 1~endDate까지 초기화 + Map statusMap = new HashMap<>(); + for (int i = 1; i <= endDate; i++) { + statusMap.put(i, new DayStatusDto(RunningStatus.NONE, null)); + } + + // Running Status 바꿔주기 + for (Event event : events) { + + LocalDate eventDate = event.getDate(); + int day = eventDate.getDayOfMonth(); + + RunningStatus status = eventDate.isBefore(today) + ? RunningStatus.DONE + : RunningStatus.SCHEDULED; + statusMap.put(day, new DayStatusDto(status, event.getId())); + } + return statusMap; + } +} diff --git a/src/main/java/run/backend/domain/event/repository/EventRepository.java b/src/main/java/run/backend/domain/event/repository/EventRepository.java index 94b76e6..69d8c6c 100644 --- a/src/main/java/run/backend/domain/event/repository/EventRepository.java +++ b/src/main/java/run/backend/domain/event/repository/EventRepository.java @@ -1,8 +1,16 @@ package run.backend.domain.event.repository; import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.crew.entity.Crew; import run.backend.domain.event.entity.Event; +import java.time.LocalDate; +import java.util.List; + public interface EventRepository extends JpaRepository { + List findAllByCrewAndDateBetween(Crew crew, LocalDate startOfWeek, LocalDate endOfWeek); + + List findAllByCrewAndDateAfter(Crew crew, LocalDate today); + } diff --git a/src/main/java/run/backend/domain/file/service/FileService.java b/src/main/java/run/backend/domain/file/service/FileService.java index db7615b..c91988a 100644 --- a/src/main/java/run/backend/domain/file/service/FileService.java +++ b/src/main/java/run/backend/domain/file/service/FileService.java @@ -29,6 +29,23 @@ public class FileService { "gif"); private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + public String handleImageUpdate(String imageStatus, String currentImage, MultipartFile image) { + + switch (imageStatus) { + + case "updated": + deleteImage(currentImage); + return saveProfileImage(image); + + case "removed": + deleteImage(currentImage); + return "default-profile-image.png"; + + default: + return currentImage; + } + } + public void deleteImage(String fileName) { validateFilename(fileName); diff --git a/src/main/java/run/backend/domain/member/entity/Member.java b/src/main/java/run/backend/domain/member/entity/Member.java index bf8d670..4a93f1e 100644 --- a/src/main/java/run/backend/domain/member/entity/Member.java +++ b/src/main/java/run/backend/domain/member/entity/Member.java @@ -63,6 +63,10 @@ public void updateImage(String imageName) { this.profileImage = imageName; } + public void updateRole(Role role) { + this.role = role; + } + @Builder public Member(String username, String nickname, Gender gender, int age, String oauthId, OAuthType oauthType, String profileImage) { this.username = username; diff --git a/src/main/java/run/backend/global/dto/DateRange.java b/src/main/java/run/backend/global/dto/DateRange.java new file mode 100644 index 0000000..a21359d --- /dev/null +++ b/src/main/java/run/backend/global/dto/DateRange.java @@ -0,0 +1,9 @@ +package run.backend.global.dto; + +import java.time.LocalDate; + +public record DateRange( + LocalDate start, + LocalDate end +) { +} diff --git a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java index aba91ea..0187086 100644 --- a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import run.backend.domain.auth.exception.AuthException; +import run.backend.domain.crew.exception.CrewException; import run.backend.domain.file.exception.FileException; import run.backend.domain.member.exception.MemberException; import run.backend.global.common.response.CommonResponse; @@ -20,7 +21,8 @@ public class GlobalExceptionHandler { @ExceptionHandler({ AuthException.RefreshTokenNotFound.class, FileException.FileNotFound.class, - MemberException.MemberNotJoinedCrew.class + MemberException.MemberNotJoinedCrew.class, + CrewException.NotFoundCrew.class }) public ResponseEntity> handleNotFound(final CustomException e) { @@ -36,7 +38,8 @@ public ResponseEntity> handleNotFound(final CustomException FileException.FileSizeExceeded.class, FileException.InvalidFileName.class, FileException.InvalidFileExtension.class, - FileException.InvalidFileType.class + FileException.InvalidFileType.class, + CrewException.AlreadyJoinedCrew.class }) public ResponseEntity> handleConflict(final CustomException e) { diff --git a/src/main/java/run/backend/global/security/SecurityConfig.java b/src/main/java/run/backend/global/security/SecurityConfig.java index 296c76f..da01c0a 100644 --- a/src/main/java/run/backend/global/security/SecurityConfig.java +++ b/src/main/java/run/backend/global/security/SecurityConfig.java @@ -49,7 +49,8 @@ public class SecurityConfig { private final String[] PermitAllPatterns = { "/api/v1/members/**", "/api/v1/auth/**", - "/api/v1/events/**" + "/api/v1/events/**", + "/api/v1/crews/**" }; @Bean diff --git a/src/main/java/run/backend/global/util/DateRangeUtil.java b/src/main/java/run/backend/global/util/DateRangeUtil.java new file mode 100644 index 0000000..c58eefb --- /dev/null +++ b/src/main/java/run/backend/global/util/DateRangeUtil.java @@ -0,0 +1,23 @@ +package run.backend.global.util; + +import org.springframework.stereotype.Component; +import run.backend.global.dto.DateRange; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; + +@Component +public class DateRangeUtil { + + public DateRange getWeekRange(LocalDate today) { + + return new DateRange(today.with(DayOfWeek.MONDAY), today.with(DayOfWeek.SUNDAY)); + } + + public DateRange getMonthRange(int year, int month) { + + YearMonth ym = YearMonth.of(year, month); + return new DateRange(ym.atDay(1), ym.atEndOfMonth()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2af2f57..8c40a93 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: jpa: database: mysql hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect diff --git a/src/test/java/run/backend/domain/crew/entity/CrewTest.java b/src/test/java/run/backend/domain/crew/entity/CrewTest.java new file mode 100644 index 0000000..fad317b --- /dev/null +++ b/src/test/java/run/backend/domain/crew/entity/CrewTest.java @@ -0,0 +1,71 @@ +package run.backend.domain.crew.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Crew 도메인 테스트") +public class CrewTest { + + @Test + @DisplayName("이름을 변경하면 name 필드가 변경된다.") + void updateName() { + + // given + Crew crew = new Crew(); + String newName = "크루 새로운 이름"; + + // when + crew.updateName(newName); + + // then + assertThat(crew.getName()).isEqualTo(newName); + } + + @Test + @DisplayName("설명을 변경하면 description 필드가 변경된다.") + void updateDescription() { + + // given + Crew crew = new Crew(); + String newDescription = "새로운 설명"; + + // when + crew.updateDescription(newDescription); + + // then + assertThat(crew.getDescription()).isEqualTo(newDescription); + } + + @Test + @DisplayName("사진을 변경하면 image 필드가 변경된다.") + void updateImage() { + + // given + Crew crew = new Crew(); + String newImage = "image123.png"; + + // when + crew.updateImage(newImage); + + // then + assertThat(crew.getImage()).isEqualTo(newImage); + } + + @Test + @DisplayName("crew를 생성하면 invite-code가 자동으로 생성된다.") + void generateInviteCode_whenCrewCreated() { + + // when + Crew crew = Crew.builder() + .name("테스트 크루") + .description("설명") + .image("image.png") + .build(); + + // then + assertThat(crew.getInviteCode()).isNotNull(); + assertThat(crew.getInviteCode()).isNotBlank(); + } +} diff --git a/src/test/java/run/backend/domain/crew/entity/JoinCrewTest.java b/src/test/java/run/backend/domain/crew/entity/JoinCrewTest.java new file mode 100644 index 0000000..1cb3dd0 --- /dev/null +++ b/src/test/java/run/backend/domain/crew/entity/JoinCrewTest.java @@ -0,0 +1,49 @@ +package run.backend.domain.crew.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.member.entity.Member; +import run.backend.domain.member.enums.Role; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@DisplayName("JoinCrew 도메인 테스트") +public class JoinCrewTest { + + Member member = mock(Member.class); + Crew crew = mock(Crew.class); + + @Test + @DisplayName("리더가 JoinCrew 생성 시 role은 LEADER, 상태는 APPROVED, joinedDate는 오늘이다.") + void createLeaderJoin_setsCorrectValues() { + + // when + JoinCrew joinCrew = JoinCrew.createLeaderJoin(member, crew); + + // then + assertThat(joinCrew.getMember()).isEqualTo(member); + assertThat(joinCrew.getCrew()).isEqualTo(crew); + assertThat(joinCrew.getRole()).isEqualTo(Role.LEADER); + assertThat(joinCrew.getJoinStatus()).isEqualTo(JoinStatus.APPROVED); + assertThat(joinCrew.getJoinedDate()).isEqualTo(LocalDate.now()); + } + + @Test + @DisplayName("일반 회원이 JoinCrew 생성 시 role은 MEMBER, 상태는 APPLIED, joinedDate는 null이다.") + void createAppliedJoin_setsCorrectValues() { + + // when + JoinCrew joinCrew = JoinCrew.createAppliedJoin(member, crew); + + // then + assertThat(joinCrew.getMember()).isEqualTo(member); + assertThat(joinCrew.getCrew()).isEqualTo(crew); + assertThat(joinCrew.getRole()).isEqualTo(Role.MEMBER); + assertThat(joinCrew.getJoinStatus()).isEqualTo(JoinStatus.APPLIED); + assertThat(joinCrew.getJoinedDate()).isNull(); + } +} diff --git a/src/test/java/run/backend/domain/crew/service/CrewEventServiceTest.java b/src/test/java/run/backend/domain/crew/service/CrewEventServiceTest.java new file mode 100644 index 0000000..5139bd5 --- /dev/null +++ b/src/test/java/run/backend/domain/crew/service/CrewEventServiceTest.java @@ -0,0 +1,141 @@ +package run.backend.domain.crew.service; + +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 run.backend.domain.crew.dto.common.DayStatusDto; +import run.backend.domain.crew.dto.response.CrewMonthlyCanlendarResponse; +import run.backend.domain.crew.dto.response.CrewUpcomingEventResponse; +import run.backend.domain.crew.dto.response.CrewWeeklyEventResponse; +import run.backend.domain.crew.dto.response.EventProfileResponse; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.enums.RunningStatus; +import run.backend.domain.event.mapper.EventMapper; +import run.backend.domain.event.mapper.EventStatusMapper; +import run.backend.domain.event.repository.EventRepository; +import run.backend.global.dto.DateRange; +import run.backend.global.util.DateRangeUtil; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@DisplayName("CrewEvent 서비스 테스트") +@ExtendWith(MockitoExtension.class) +public class CrewEventServiceTest { + + @InjectMocks + private CrewEventService crewEventService; + + @Mock + private DateRangeUtil dateRangeUtil; + + @Mock + private EventRepository eventRepository; + + @Mock + private EventStatusMapper eventStatusMapper; + + @Mock + private EventMapper eventMapper; + + @Mock + private Crew crew; + + @Test + @DisplayName("이번주 일정을 정리해서 반환") + void shouldReturnWeeklyEvent() { + + // given + LocalDate startOfWeek = LocalDate.of(2025, 7, 14); // 월요일 + LocalDate endOfWeek = LocalDate.of(2025, 7, 20); // 일요일 + DateRange weekRange = new DateRange(startOfWeek, endOfWeek); + + List events = List.of(mock(Event.class)); + Map statusMap = Map.of( + DayOfWeek.MONDAY, new DayStatusDto(RunningStatus.SCHEDULED, 1L) + ); + + when(dateRangeUtil.getWeekRange(any(LocalDate.class))).thenReturn(weekRange); + when(eventRepository.findAllByCrewAndDateBetween(crew, startOfWeek, endOfWeek)).thenReturn(events); + when(eventStatusMapper.toWeeklyStatus(eq(events), any(LocalDate.class))).thenReturn(statusMap); + + // when + CrewWeeklyEventResponse response = crewEventService.getCrewWeeklyEvent(crew); + + // then + assertThat(response.currentDay()).isBetween(1, 7); + assertThat(response.weeklyRunningStatus()).isEqualTo(statusMap); + + // verify + verify(dateRangeUtil).getWeekRange(any(LocalDate.class)); + verify(eventRepository).findAllByCrewAndDateBetween(crew, startOfWeek, endOfWeek); + verify(eventStatusMapper).toWeeklyStatus(eq(events), any(LocalDate.class)); + + } + + @Test + @DisplayName("이번달 일정을 정리해서 반환") + void shouldReturnMonthlyEvent() { + + // given + int year = 2025; + int month = 7; + LocalDate start = LocalDate.of(2025, 7, 1); + LocalDate end = LocalDate.of(2025, 7, 31); + DateRange dateRange = new DateRange(start, end); + + List events = List.of(mock(Event.class)); + Map statusMap = Map.of( + 5, new DayStatusDto(RunningStatus.SCHEDULED, 1L) + ); + + when(dateRangeUtil.getMonthRange(year, month)).thenReturn(dateRange); + when(eventRepository.findAllByCrewAndDateBetween(crew, start, end)).thenReturn(events); + when(eventStatusMapper.toMonthlyStatus(eq(events), any(LocalDate.class), eq(31))).thenReturn(statusMap); + + // when + CrewMonthlyCanlendarResponse response = crewEventService.getCrewMonthlyCalendar(crew, year, month); + + // then + assertThat(response.monthlyRunningStatus()).isEqualTo(statusMap); + + // verify + verify(dateRangeUtil).getMonthRange(year, month); + verify(eventRepository).findAllByCrewAndDateBetween(crew, start, end); + verify(eventStatusMapper).toMonthlyStatus(eq(events), any(LocalDate.class), eq(31)); + } + + @Test + @DisplayName("다가오는 일정을 정리해서 반환") + void shouldReturnUpcomingEvent() { + + // given + List events = List.of(mock(Event.class)); + List eventProfiles = List.of( + new EventProfileResponse(1L, "달리기", null, null, null, 1L) + ); + + when(eventRepository.findAllByCrewAndDateAfter(eq(crew), any(LocalDate.class))).thenReturn(events); + when(eventMapper.toEventProfileList(events)).thenReturn(eventProfiles); + + // when + CrewUpcomingEventResponse response = crewEventService.getCrewUpcomingEvent(crew); + + // then + assertThat(response.eventProfiles()).isEqualTo(eventProfiles); + + // verify + verify(eventRepository).findAllByCrewAndDateAfter(eq(crew), any(LocalDate.class)); + verify(eventMapper).toEventProfileList(events); + } + +} diff --git a/src/test/java/run/backend/domain/crew/service/CrewServiceTest.java b/src/test/java/run/backend/domain/crew/service/CrewServiceTest.java new file mode 100644 index 0000000..84e1f13 --- /dev/null +++ b/src/test/java/run/backend/domain/crew/service/CrewServiceTest.java @@ -0,0 +1,284 @@ +package run.backend.domain.crew.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; +import run.backend.domain.crew.dto.common.CrewInviteCodeDto; +import run.backend.domain.crew.dto.request.CrewInfoRequest; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.entity.JoinCrew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.crew.exception.CrewException; +import run.backend.domain.crew.mapper.CrewMapper; +import run.backend.domain.crew.repository.CrewRepository; +import run.backend.domain.crew.repository.JoinCrewRepository; +import run.backend.domain.file.service.FileService; +import run.backend.domain.member.entity.Member; +import run.backend.domain.member.repository.MemberRepository; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + + +@DisplayName("Crew 서비스 테스트") +@ExtendWith(MockitoExtension.class) +public class CrewServiceTest { + + @InjectMocks + private CrewService crewService; + + @Mock + private FileService fileService; + + @Mock + private JoinCrewRepository joinCrewRepository; + + @Mock + private CrewRepository crewRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private CrewMapper crewMapper; + + private Crew crew; + + private Member member; + private CrewInfoRequest request; + + @BeforeEach + void setUp() { + member = Member.builder().username("테스트 유저").build(); + request = new CrewInfoRequest("러너스", "러너스 크루입니다."); + crew = Crew.builder() + .name("테스트 크루") + .description("테스트 설명") + .image("default-profile-image.png") + .build(); + } + + @Nested + @DisplayName("createCrew 메서드는") + class createCrewTest { + + @Test + @DisplayName("이미 크루에 가입된 회원이 크루 생성을 시도하면 예외가 발생한다.") + void throwsException_whenMemberAlreadyInCrew() { + + // given + when(joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)) + .thenReturn(true); + + // when + then + assertThatThrownBy(() -> + crewService.createCrew(member, "unchanged", null, request)) + .isInstanceOf(CrewException.AlreadyJoinedCrew.class); + } + + @Test + @DisplayName("크루 생성 시 응답으로 invite-code가 포함되어야 한다.") + void respondsWithInviteCode_whenCreatingCrew() { + + // given + when(joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)) + .thenReturn(false); + when(fileService.handleImageUpdate(eq("unchanged"), eq("default-profile-image.png"), isNull())) + .thenReturn("default-profile-image.png"); + when(crewMapper.toEntity(eq("default-profile-image.png"), eq(request.name()), eq(request.description()))) + .thenReturn(crew); + when(crewRepository.save(any(Crew.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(joinCrewRepository.save(any(JoinCrew.class))).thenReturn(null); + when(memberRepository.save(any())).thenReturn(member); + + // when + CrewInviteCodeDto response = crewService.createCrew(member, "unchanged", null, request); + + // then + assertThat(response.inviteCode()).isNotNull(); + } + + @Test + @DisplayName("크루 생성 시 joinCrew가 저장된다.") + void saveJoinCrew_whenCreatingCrew() { + + // given + when(joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)).thenReturn(false); + when(fileService.handleImageUpdate(eq("unchanged"), eq("default-profile-image.png"), isNull())) + .thenReturn("default-profile-image.png"); + when(crewMapper.toEntity(eq("default-profile-image.png"), eq(request.name()), eq(request.description()))) + .thenReturn(crew); + when(crewRepository.save(any(Crew.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(joinCrewRepository.save(any(JoinCrew.class))).thenReturn(null); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // when + crewService.createCrew(member, "unchanged", null, request); + + // then + verify(joinCrewRepository).save(any(JoinCrew.class)); + } + + @Test + @DisplayName("크루 생성 시 생성자의 역할이 LEADER로 변경해서 저장한다.") + void updatesMemberRoleToLeader_whenCreatingCrew() { + + // given + when(joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)).thenReturn(false); + when(fileService.handleImageUpdate(eq("unchanged"), eq("default-profile-image.png"), isNull())) + .thenReturn("default-profile-image.png"); + when(crewMapper.toEntity(eq("default-profile-image.png"), eq(request.name()), eq(request.description()))) + .thenReturn(crew); + when(crewRepository.save(any(Crew.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(joinCrewRepository.save(any(JoinCrew.class))).thenReturn(null); + when(memberRepository.save(any(Member.class))).thenReturn(member); + + // when + crewService.createCrew(member, "unchanged", null, request); + + // then + verify(memberRepository).save(any(Member.class)); + } + } + + @Nested + @DisplayName("updateCrew 메서드는") + class updateCrewTest { + + @Test + @DisplayName("imageStatus가 updated인 경우 기존 이미지를 삭제하고 새 이미지를 저장한다.") + void updateImage_whenImageStatusIsUpdated() { + + // given + String oldImageName = "old_image.png"; + String newImageName = "new_image.png"; + MultipartFile mockFile = mock(MultipartFile.class); + Crew crew = mock(Crew.class); + + when(crew.getImage()).thenReturn(oldImageName); + when(fileService.handleImageUpdate(eq("updated"), eq(oldImageName), eq(mockFile))) + .thenReturn(newImageName); + + // when + crewService.updateCrew(crew, "updated", mockFile, request); + + // then + verify(fileService).handleImageUpdate(eq("updated"), eq(oldImageName), eq(mockFile)); + verify(crew).updateImage(newImageName); + verify(crewRepository).save(crew); + } + + @Test + @DisplayName("imageStatus가 removed인 경우 기존 이미지를 삭제하고 기본 이미지를 저장한다.") + void removeImage_whenImageStatusIsRemoved() { + + // given + String oldImageName = "old_image.png"; + MultipartFile mockFile = mock(MultipartFile.class); + Crew crew = mock(Crew.class); + + when(crew.getImage()).thenReturn(oldImageName); + when(fileService.handleImageUpdate(eq("removed"), eq(oldImageName), eq(mockFile))) + .thenReturn("default-profile-image.png"); + + // when + crewService.updateCrew(crew, "removed", mockFile, request); + + // then + verify(fileService).handleImageUpdate(eq("removed"), eq(oldImageName), eq(mockFile)); + verify(crew).updateImage("default-profile-image.png"); + verify(crewRepository).save(crew); + } + + @Test + @DisplayName("name이 null이 아니면 이름을 업데이트한다.") + void updateName_whenNameIsNotNull() { + + // given + Crew crew = mock(Crew.class); + + // when + crewService.updateCrew(crew, "unchanged", null, request); + + // then + verify(crew).updateName("러너스"); + verify(crewRepository).save(crew); + + } + + @Test + @DisplayName("description null이 아니면 설명을 업데이트한다.") + void updateDescription_whenDescriptionIsNotNull() { + + // given + Crew crew = mock(Crew.class); + + // when + crewService.updateCrew(crew, "unchanged", null, request); + + // then + verify(crew).updateDescription("러너스 크루입니다."); + verify(crewRepository).save(crew); + } + } + + @Nested + @DisplayName("joinCrew 메서드는") + class joinCrewTest { + + @Test + @DisplayName("존재하지 않는 crew id이면 예외가 발생한다.") + void throwException_whenCrewNotFound() { + + // given + Long crewId = 2L; + when(crewRepository.findById(crewId)).thenReturn(Optional.empty()); + + // when + then + assertThatThrownBy(() -> crewService.joinCrew(member, crewId)) + .isInstanceOf(CrewException.NotFoundCrew.class); + } + + @Test + @DisplayName("이미 크루에 가입한 회원이 크루 가입 시도를 하면 예외가 발생한다.") + void throwException_whenMemberAlreadyJoinedCrew() { + + // given + Long crewId = 2L; + when(crewRepository.findById(crewId)).thenReturn(Optional.of(crew)); + when(joinCrewRepository.existsByMemberAndJoinStatus(member, JoinStatus.APPROVED)) + .thenReturn(true); + + // when + then + assertThatThrownBy(() -> crewService.joinCrew(member, crewId)) + .isInstanceOf(CrewException.AlreadyJoinedCrew.class); + } + + @Test + @DisplayName("정상적으로 crew에 가입 신청을 저장한다.") + void saveJoinCrew_whenValidCrewIdGiven() { + + // given + Long crewId = 1L; + when(crewRepository.findById(crewId)).thenReturn(Optional.of(crew)); + when(joinCrewRepository.save(any(JoinCrew.class))).thenReturn(null); + + // when + crewService.joinCrew(member, crewId); + + // then + verify(crewRepository).findById(crewId); + verify(joinCrewRepository).save(any(JoinCrew.class)); + } + } +} diff --git a/src/test/java/run/backend/domain/event/mapper/EventStatusMapperTest.java b/src/test/java/run/backend/domain/event/mapper/EventStatusMapperTest.java new file mode 100644 index 0000000..2cef774 --- /dev/null +++ b/src/test/java/run/backend/domain/event/mapper/EventStatusMapperTest.java @@ -0,0 +1,166 @@ +package run.backend.domain.event.mapper; + + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import run.backend.domain.crew.dto.common.DayStatusDto; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.enums.RunningStatus; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Event Status Mapper 테스트") +@ExtendWith(MockitoExtension.class) +public class EventStatusMapperTest { + + @InjectMocks + private EventStatusMapper eventStatusMapper; + + private Event pastEvent; + + private Event futureEvent; + + private final LocalDate today = LocalDate.of(2025, 7, 18); // 금요일 (5) + + @BeforeEach + public void setup() { + + pastEvent = Event.builder() + .date(LocalDate.of(2025, 7, 17)) // 목요일 (4) + .build(); + + futureEvent = Event.builder() + .date(LocalDate.of(2025, 7, 19)) // 토요일 (6) + .build(); + } + + @Nested + @DisplayName("toWeeklyStatus 메서드는") + class toWeeklyStatusTest { + + @Test + @DisplayName("요일별 상태가 7개 모두 반환된다") + void returnsSevenDayStatusEntriesForFullWeek() { + + // given + List events = new ArrayList<>(); + + // when + Map response = eventStatusMapper.toWeeklyStatus(events, today); + + // then + assertThat(response.size()).isEqualTo(7); + } + + @Test + @DisplayName("이벤트가 없으면 NONE") + void shouldBeNone_whenEventEmpty() { + + // given + List events = new ArrayList<>(); + + // when + Map response = eventStatusMapper.toWeeklyStatus(events, today); + + // then + assertThat(response.size()).isEqualTo(7); + + for (DayStatusDto dto : response.values()) { + assertThat(dto.status()).isEqualTo(RunningStatus.NONE); + assertThat(dto.eventId()).isNull(); + } + + } + + @Test + @DisplayName("과거 이벤트이면 DONE") + void shouldBeDone_whenPastEvent() { + + // when + Map response = eventStatusMapper.toWeeklyStatus(List.of(pastEvent), today); + + // then + assertThat(response.size()).isEqualTo(7); + + DayOfWeek dayOfPastEvent = pastEvent.getDate().getDayOfWeek(); + DayStatusDto statusDto = response.get(dayOfPastEvent); + assertThat(statusDto.status()).isEqualTo(RunningStatus.DONE); + } + + @Test + @DisplayName("미래 이벤트이면 SCHEDULED") + void shouldBeSCHEDULED_whenFutureEvent() { + + // when + Map response = eventStatusMapper.toWeeklyStatus(List.of(futureEvent), today); + + // then + assertThat(response.size()).isEqualTo(7); + + DayOfWeek dayOfPastEvent = futureEvent.getDate().getDayOfWeek(); + DayStatusDto statusDto = response.get(dayOfPastEvent); + assertThat(statusDto.status()).isEqualTo(RunningStatus.SCHEDULED); + } + } + + @Nested + @DisplayName("toMonthlyStatus 메서드는") + class toMonthlyStatusTest { + + private final LocalDate today = LocalDate.of(2025, 7, 18); // 18일 (금요일) + private final int endDate = 31; + + @Test + @DisplayName("이벤트가 없으면 모든 날짜가 NONE") + void shouldBeNone_whenNoEvent() { + // given + List events = new ArrayList<>(); + + // when + Map result = eventStatusMapper.toMonthlyStatus(events, today, endDate); + + // then + assertThat(result).hasSize(endDate); + for (int i = 1; i <= endDate; i++) { + DayStatusDto status = result.get(i); + assertThat(status.status()).isEqualTo(RunningStatus.NONE); + assertThat(status.eventId()).isNull(); + } + } + + @Test + @DisplayName("과거 이벤트가 있으면 DONE 상태가 된다") + void shouldBeDone_whenEventInPast() { + + // when + Map result = eventStatusMapper.toMonthlyStatus(List.of(pastEvent), today, endDate); + + // then + DayStatusDto status = result.get(17); + assertThat(status.status()).isEqualTo(RunningStatus.DONE); + } + + @Test + @DisplayName("미래 이벤트가 있으면 SCHEDULED 상태가 된다") + void shouldBeScheduled_whenEventInFuture() { + + // when + Map result = eventStatusMapper.toMonthlyStatus(List.of(futureEvent), today, endDate); + + // then + DayStatusDto status = result.get(19); + assertThat(status.status()).isEqualTo(RunningStatus.SCHEDULED); + } + } +}