diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 9a8bbce9b..f39c53cb7 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -82,6 +82,8 @@ public enum ErrorCode implements ResponseCode { ROOM_PASSWORD_NOT_REQUIRED(HttpStatus.BAD_REQUEST, 100003, "공개방은 비밀번호가 필요하지 않습니다."), ROOM_RECRUITMENT_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, 100004, "모집기간이 만료된 방입니다."), INVALID_ROOM_SEARCH_SORT(HttpStatus.BAD_REQUEST, 100005, "방 검색 시 정렬 조건이 잘못되었습니다."), + ROOM_MEMBER_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, 100006, "방의 최대 인원 수를 초과했습니다."), + ROOM_MEMBER_COUNT_UNDERFLOW(HttpStatus.BAD_REQUEST, 100007, "방의 인원 수가 1 이하(방장 포함)입니다."), /** * 110000 : vote error @@ -109,6 +111,11 @@ public enum ErrorCode implements ResponseCode { ROOM_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, 140000, "존재하지 않는 RoomParticipant (방과 사용자 관계) 입니다."), USER_NOT_BELONG_TO_ROOM(HttpStatus.BAD_REQUEST, 140001, "현재 모임방에 속하지 않는 유저입니다."), ROOM_PARTICIPANT_ROLE_NOT_MATCH(HttpStatus.BAD_REQUEST, 140002, "일치하는 방에서의 사용자 역할이 없습니다."), + ROOM_JOIN_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 140003, "일치하는 방 참여 상태가 없습니다."), + USER_CANNOT_JOIN_OR_CANCEL(HttpStatus.BAD_REQUEST, 140004, "존재하지 않는 방은 참여하기 또는 취소하기가 불가능합니다."), + USER_ALREADY_PARTICIPATE(HttpStatus.BAD_REQUEST, 140005, "사용자가 이미 방에 참여한 상태입니다."), + USER_NOT_PARTICIPATED(HttpStatus.BAD_REQUEST, 140006, "사용자가 방에 참여하지 않은 상태에서 취소하기는 불가합니다."), + HOST_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, 140007, "방장은 참여 취소를 할 수 없습니다."), /** * 150000 : Category error diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java index acfd5076f..09dfe1db8 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java @@ -4,9 +4,12 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.room.adapter.in.web.request.RoomCreateRequest; +import konkuk.thip.room.adapter.in.web.request.RoomJoinRequest; import konkuk.thip.room.adapter.in.web.response.RoomCreateResponse; import konkuk.thip.room.application.port.in.RoomCreateUseCase; +import konkuk.thip.room.application.port.in.RoomJoinUsecase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -16,11 +19,27 @@ public class RoomCommandController { private final RoomCreateUseCase roomCreateUseCase; + private final RoomJoinUsecase roomJoinUsecase; + /** + * 방 생성 요청 + */ @PostMapping("/rooms") public BaseResponse createRoom(@Valid @RequestBody RoomCreateRequest request, @UserId Long userId) { return BaseResponse.ok(RoomCreateResponse.of( roomCreateUseCase.createRoom(request.toCommand(), userId) )); } + + /** + * 방 참여하기/취소하기 요청 + */ + @PostMapping("/rooms/{roomId}/join") + public BaseResponse joinRoom(@Valid @RequestBody final RoomJoinRequest request, + @UserId final Long userId, + @PathVariable final Long roomId) { + + roomJoinUsecase.changeJoinState(request.toCommand(userId, roomId)); + return BaseResponse.ok(null); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java new file mode 100644 index 000000000..d58ff3380 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java @@ -0,0 +1,13 @@ +package konkuk.thip.room.adapter.in.web.request; + +import jakarta.validation.constraints.NotBlank; +import konkuk.thip.room.application.port.in.dto.RoomJoinCommand; + +public record RoomJoinRequest( + @NotBlank(message = "방 참여 유형 파라미터는 필수입니다..") + String type +) { + public RoomJoinCommand toCommand(Long userId, Long roomId) { + return new RoomJoinCommand(userId, roomId, type); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java index c28a1e056..e8b44cfe7 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java @@ -55,4 +55,8 @@ public class RoomJpaEntity extends BaseJpaEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id", nullable = false) private CategoryJpaEntity categoryJpaEntity; + + public void updateMemberCount(int count) { + this.memberCount = count; + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java index 608a8ec8d..861425f0a 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java @@ -4,10 +4,12 @@ import konkuk.thip.common.entity.BaseJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import lombok.*; +import org.hibernate.annotations.SQLDelete; @Entity @Table(name = "room_participants") @Getter +@SQLDelete(sql = "UPDATE room_participants SET status = 'INACTIVE' WHERE room_participant_id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantRole.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantRole.java index 6639c6135..9c818af65 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantRole.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantRole.java @@ -11,7 +11,7 @@ public enum RoomParticipantRole { HOST("호스트"), MEMBER("팀원"); - private String type; + private final String type; RoomParticipantRole(String type) { this.type = type; diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java index 2d7194530..97801088d 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java @@ -54,4 +54,15 @@ public Category findCategoryByValue(String value) { () -> new EntityNotFoundException(CATEGORY_NOT_FOUND)); return Category.from(categoryJpaEntity.getValue()); } + + @Override + public void updateMemberCount(Room room) { + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(room.getId()).orElseThrow( + () -> new EntityNotFoundException(ROOM_NOT_FOUND) + ); + + roomJpaEntity.updateMemberCount(room.getMemberCount()); + + roomJpaRepository.save(roomJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantCommandPersistenceAdapter.java index c58543572..d5aaf0890 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantCommandPersistenceAdapter.java @@ -2,16 +2,23 @@ import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.mapper.RoomParticipantMapper; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; import konkuk.thip.room.domain.RoomParticipant; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import static konkuk.thip.common.exception.code.ErrorCode.ROOM_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + @Repository @RequiredArgsConstructor public class RoomParticipantCommandPersistenceAdapter implements RoomParticipantCommandPort { @@ -19,9 +26,12 @@ public class RoomParticipantCommandPersistenceAdapter implements RoomParticipant private final RoomParticipantJpaRepository roomParticipantJpaRepository; private final RoomParticipantMapper roomParticipantMapper; + private final UserJpaRepository userJpaRepository; + private final RoomJpaRepository roomJpaRepository; + @Override public RoomParticipant findByUserIdAndRoomId(Long userId, Long roomId) { - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findByUserJpaEntity_UserIdAndRoomJpaEntity_RoomId(userId, roomId).orElseThrow( + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findByUserIdAndRoomId(userId, roomId).orElseThrow( () -> new EntityNotFoundException(ErrorCode.ROOM_PARTICIPANT_NOT_FOUND) ); @@ -30,8 +40,31 @@ public RoomParticipant findByUserIdAndRoomId(Long userId, Long roomId) { @Override public List findAllByRoomId(Long roomId) { - return roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(roomId).stream() + return roomParticipantJpaRepository.findAllByRoomId(roomId).stream() .map(roomParticipantMapper::toDomainEntity) .toList(); } + + @Override + public void save(RoomParticipant roomParticipant) { + UserJpaEntity userJpaEntity = userJpaRepository.findById(roomParticipant.getUserId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND)); + + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(roomParticipant.getRoomId()).orElseThrow( + () -> new EntityNotFoundException(ROOM_NOT_FOUND) + ); + + roomParticipantJpaRepository.save(roomParticipantMapper.toJpaEntity( + roomParticipant, userJpaEntity, roomJpaEntity + )); + } + + @Override + public void deleteByUserIdAndRoomId(Long userId, Long roomId) { + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findByUserIdAndRoomId(userId, roomId).orElseThrow( + () -> new EntityNotFoundException(ErrorCode.ROOM_PARTICIPANT_NOT_FOUND) + ); + + roomParticipantJpaRepository.delete(roomParticipantJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantQueryPersistenceAdapter.java new file mode 100644 index 000000000..be0761aa1 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomParticipantQueryPersistenceAdapter.java @@ -0,0 +1,20 @@ +package konkuk.thip.room.adapter.out.persistence; + +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.application.port.out.RoomParticipantQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RoomParticipantQueryPersistenceAdapter implements RoomParticipantQueryPort { + + private final RoomParticipantJpaRepository roomParticipantJpaRepository; + + @Override + public boolean existByUserIdAndRoomId(Long userId, Long roomId) { + return roomParticipantJpaRepository.existByUserIdAndRoomId(userId, roomId); + } + + +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java index bcc0e88ad..987aeda67 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantJpaRepository.java @@ -2,12 +2,24 @@ import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; -public interface RoomParticipantJpaRepository extends JpaRepository{ +public interface RoomParticipantJpaRepository extends JpaRepository, RoomParticipantQueryRepository{ + + @Query(value = "SELECT * FROM room_participants WHERE user_id = :userId AND room_id = :roomId AND status = 'ACTIVE'", nativeQuery = true) + Optional findByUserIdAndRoomId(@Param("userId") Long userId, @Param("roomId") Long roomId); + + @Query(value = "SELECT * FROM room_participants WHERE room_id = :roomId AND status = 'ACTIVE'", nativeQuery = true) + List findAllByRoomId(Long roomId); + + @Query( + value = "SELECT EXISTS (SELECT 1 FROM room_participants rp WHERE rp.user_id = :userId AND rp.room_id = :roomId AND rp.status = 'ACTIVE')", + nativeQuery = true + ) + boolean existByUserIdAndRoomId(@Param("userId") Long userId, @Param("roomId") Long roomId); - Optional findByUserJpaEntity_UserIdAndRoomJpaEntity_RoomId(Long userId, Long roomId); - List findAllByRoomJpaEntity_RoomId(Long roomId); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepository.java new file mode 100644 index 000000000..37494f0b0 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepository.java @@ -0,0 +1,4 @@ +package konkuk.thip.room.adapter.out.persistence.repository.roomparticipant; + +public interface RoomParticipantQueryRepository { +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepositoryImpl.java new file mode 100644 index 000000000..7a04b36be --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/roomparticipant/RoomParticipantQueryRepositoryImpl.java @@ -0,0 +1,10 @@ +package konkuk.thip.room.adapter.out.persistence.repository.roomparticipant; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RoomParticipantQueryRepositoryImpl implements RoomParticipantQueryRepository{ + +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomJoinUsecase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomJoinUsecase.java new file mode 100644 index 000000000..13b913a7e --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomJoinUsecase.java @@ -0,0 +1,9 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.application.port.in.dto.RoomJoinCommand; + +public interface RoomJoinUsecase { + + void changeJoinState(RoomJoinCommand roomJoinCommand); + +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/dto/RoomJoinCommand.java b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomJoinCommand.java new file mode 100644 index 000000000..4a9a79f9f --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomJoinCommand.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in.dto; + +public record RoomJoinCommand( + Long userId, + Long roomId, + String type +) { +} diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java index dd0aa4716..878aa8dc8 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java @@ -10,4 +10,6 @@ public interface RoomCommandPort { Long save(Room room); Category findCategoryByValue(String value); + + void updateMemberCount(Room room); } diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java index c8edc43aa..22a523ed8 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantCommandPort.java @@ -9,4 +9,8 @@ public interface RoomParticipantCommandPort { RoomParticipant findByUserIdAndRoomId(Long userId, Long roomId); List findAllByRoomId(Long roomId); + void save(RoomParticipant roomParticipant); + + void deleteByUserIdAndRoomId(Long userId, Long roomId); + } diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantQueryPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantQueryPort.java index 2799e04cd..de1cb1372 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantQueryPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomParticipantQueryPort.java @@ -1,4 +1,6 @@ package konkuk.thip.room.application.port.out; + public interface RoomParticipantQueryPort { + boolean existByUserIdAndRoomId(Long userId, Long roomId); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java new file mode 100644 index 000000000..eddfa5fe2 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java @@ -0,0 +1,80 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.application.port.in.RoomJoinUsecase; +import konkuk.thip.room.application.port.in.dto.RoomJoinCommand; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; +import konkuk.thip.room.application.port.out.RoomParticipantQueryPort; +import konkuk.thip.room.domain.Room; +import konkuk.thip.room.domain.RoomJoinType; +import konkuk.thip.room.domain.RoomParticipant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static konkuk.thip.room.adapter.out.jpa.RoomParticipantRole.MEMBER; + +@Service +@RequiredArgsConstructor +public class RoomJoinService implements RoomJoinUsecase { + + private final RoomParticipantQueryPort roomParticipantQueryPort; + private final RoomCommandPort roomCommandPort; + private final RoomParticipantCommandPort roomParticipantCommandPort; + + @Override + @Transactional + public void changeJoinState(RoomJoinCommand roomJoinCommand) { + RoomJoinType type = RoomJoinType.from(roomJoinCommand.type()); + + // 방이 존재하지 않거나 만료된 경우 + Room room; + try { + room = roomCommandPort.findById(roomJoinCommand.roomId()); + } catch (EntityNotFoundException e) { + throw new InvalidStateException(ErrorCode.USER_CANNOT_JOIN_OR_CANCEL); + } + + boolean isParticipate = roomParticipantQueryPort.existByUserIdAndRoomId(roomJoinCommand.userId(), roomJoinCommand.roomId()); + room.validateRoomExpired(); + + // 참여하기 요청 + if(type.isJoinType()) { + // 이미 참여한 상태 + if(isParticipate) { + throw new InvalidStateException(ErrorCode.USER_ALREADY_PARTICIPATE); + } + + RoomParticipant roomParticipant = RoomParticipant.withoutId(roomJoinCommand.userId(), roomJoinCommand.roomId(), MEMBER.getType()); + roomParticipantCommandPort.save(roomParticipant); + + //Room의 memberCount 업데이트 + room.increaseMemberCount(); + } + + // 취소하기 요청 + if(!type.isJoinType()) { + // 참여하지 않은 상태 + if(!isParticipate) { + throw new InvalidStateException(ErrorCode.USER_NOT_PARTICIPATED); + } + + // 방장이 참여 취소를 요청한 경우 + RoomParticipant roomParticipant = roomParticipantCommandPort.findByUserIdAndRoomId(roomJoinCommand.userId(), roomJoinCommand.roomId()); + roomParticipant.validateHostCancelRoom(); + + roomParticipantCommandPort.deleteByUserIdAndRoomId(roomJoinCommand.userId(), roomJoinCommand.roomId()); + + //Room의 memberCount 업데이트 + room.decreaseMemberCount(); + } + + // 방의 상태 업데이트 + roomCommandPort.updateMemberCount(room); + } + + +} diff --git a/src/main/java/konkuk/thip/room/domain/Room.java b/src/main/java/konkuk/thip/room/domain/Room.java index ffa4b52fa..1e190ba63 100644 --- a/src/main/java/konkuk/thip/room/domain/Room.java +++ b/src/main/java/konkuk/thip/room/domain/Room.java @@ -119,14 +119,7 @@ public boolean matchesPassword(String rawPassword) { public void verifyPassword(String rawPassword) { - // 모집기간 만료 체크 - LocalDate deadline = this.startDate.minusDays(1); - if (isRecruitmentPeriodExpired()) { - String message = String.format("모집기간(%s까지)이 만료된 방에는 참여할 수 없습니다.", deadline); - throw new BusinessException( - ErrorCode.ROOM_RECRUITMENT_PERIOD_EXPIRED, new IllegalArgumentException(message) - ); - } + validateRoomExpired(); // 공개방일 경우 비밀번호 입력 요청 예외 처리 if (this.isPublic()) { @@ -139,10 +132,44 @@ ErrorCode.ROOM_RECRUITMENT_PERIOD_EXPIRED, new IllegalArgumentException(message) } } + public void validateRoomExpired() { + // 모집기간 만료 체크 + LocalDate deadline = this.startDate.minusDays(1); + if (isRecruitmentPeriodExpired()) { + String message = String.format("모집기간(%s까지)이 만료된 방에는 참여할 수 없습니다.", deadline); + throw new BusinessException( + ErrorCode.ROOM_RECRUITMENT_PERIOD_EXPIRED, new IllegalArgumentException(message) + ); + } + } + public boolean isRecruitmentPeriodExpired() { LocalDate today = LocalDate.now(); // 모집 마감일: startDate.minusDays(1) return today.isAfter(this.startDate.minusDays(1)); } + public void increaseMemberCount() { + checkJoinPossible(); + memberCount++; + } + + public void decreaseMemberCount() { + checkCancelPossible(); + memberCount--; + } + + private void checkJoinPossible() { + if (memberCount >= recruitCount) { + throw new InvalidStateException(ErrorCode.ROOM_MEMBER_COUNT_EXCEEDED); + } + } + + private void checkCancelPossible() { + if (memberCount <= 1) { // 방장 포함 항상 1명 이상이어야 함 + throw new InvalidStateException(ErrorCode.ROOM_MEMBER_COUNT_UNDERFLOW); + } + } + + } diff --git a/src/main/java/konkuk/thip/room/domain/RoomJoinType.java b/src/main/java/konkuk/thip/room/domain/RoomJoinType.java new file mode 100644 index 000000000..a0cd7dad8 --- /dev/null +++ b/src/main/java/konkuk/thip/room/domain/RoomJoinType.java @@ -0,0 +1,30 @@ +package konkuk.thip.room.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum RoomJoinType { + JOIN("join"), + CANCEL("cancel"); + + private final String type; + + public static RoomJoinType from(String type) { + return Arrays.stream(RoomJoinType.values()) + .filter(param -> param.getType().equals(type)) + .findFirst() + .orElseThrow( + () -> new InvalidStateException(ErrorCode.ROOM_JOIN_TYPE_NOT_MATCH) + ); + } + + public boolean isJoinType() { + return JOIN.equals(this); + } +} diff --git a/src/main/java/konkuk/thip/room/domain/RoomParticipant.java b/src/main/java/konkuk/thip/room/domain/RoomParticipant.java index f451e2a9c..7e07e744e 100644 --- a/src/main/java/konkuk/thip/room/domain/RoomParticipant.java +++ b/src/main/java/konkuk/thip/room/domain/RoomParticipant.java @@ -1,9 +1,14 @@ package konkuk.thip.room.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; import lombok.Getter; import lombok.experimental.SuperBuilder; +import java.util.Objects; + @Getter @SuperBuilder public class RoomParticipant extends BaseDomainEntity { @@ -20,6 +25,16 @@ public class RoomParticipant extends BaseDomainEntity { private Long roomId; + public static RoomParticipant withoutId(Long userId, Long roomId, String roomParticipantRole) { + return RoomParticipant.builder() + .currentPage(0) + .userPercentage(0.0) + .userId(userId) + .roomId(roomId) + .roomParticipantRole(roomParticipantRole) + .build(); + } + public boolean canWriteOverview() { return userPercentage >= 80; } @@ -34,4 +49,13 @@ public boolean updateUserProgress(int requestPage, int totalPageCount) { return false; } + + // 방장이 참여 취소를 요청한 경우 + public void validateHostCancelRoom() { + if (Objects.equals(this.roomParticipantRole, RoomParticipantRole.HOST.getType())) { + throw new BusinessException(ErrorCode.HOST_CANNOT_CANCEL); + } + } + + } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java index 882b80bce..cfe0c1253 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java @@ -4,11 +4,9 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; public interface FollowingQueryRepository { - Map countByFollowingUserIds(List userIds); Optional findByUserAndTargetUser(Long userId, Long targetUserId); List findFollowersByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java index 9b50430d9..ec2a2a118 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java @@ -1,7 +1,6 @@ package konkuk.thip.user.adapter.out.persistence.repository.following; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.common.entity.StatusType; import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; @@ -13,9 +12,7 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; @Repository @RequiredArgsConstructor @@ -23,26 +20,6 @@ public class FollowingQueryRepositoryImpl implements FollowingQueryRepository { private final JPAQueryFactory jpaQueryFactory; - // 주어진 userId 리스트에 대해 각 userId의 팔로워(구독자) 수를 집계하여 Map으로 반환 - public Map countByFollowingUserIds(List userIds) { - - QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; - - List results = jpaQueryFactory - .select(following.followingUserJpaEntity.userId, following.count()) - .from(following) - .where(following.followingUserJpaEntity.userId.in(userIds)) - .groupBy(following.followingUserJpaEntity.userId) - .fetch(); - - // 결과를 Map로 변환 - return results.stream() - .collect(Collectors.toMap( - tuple -> tuple.get(following.followingUserJpaEntity.userId), - tuple -> tuple.get(following.count()).intValue() - )); - } - @Override public Optional findByUserAndTargetUser(Long userId, Long targetUserId) { QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java index 26993b81a..9e0bdf3fd 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookDetailSearchControllerTest.java @@ -1,30 +1,33 @@ package konkuk.thip.book.adapter.in.web; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.book.application.service.BookSearchService; -import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; -import konkuk.thip.common.security.util.JwtUtil; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; -import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; -import konkuk.thip.user.adapter.out.jpa.*; -import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; -import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; -import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; -import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; -import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; import konkuk.thip.saved.adapter.out.persistence.repository.SavedBookJpaRepository; -import org.junit.jupiter.api.*; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.AfterEach; +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.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDate; @@ -119,7 +122,7 @@ void setup() { void tearDown() { savedBookJpaRepository.deleteAll(); feedJpaRepository.deleteAll(); - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); @@ -150,7 +153,7 @@ void searchDetailBooks_NoRecruitingRooms_ReturnsZero() { BookJpaEntity book = bookJpaRepository.findAll().get(0); // 기존 방 삭제 - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); // startDate가 과거인 새로운 방 생성 (모집 중 아님) @@ -179,7 +182,7 @@ void searchDetailBooks_NoFeedOrRoomParticipants_ReturnsZero() { UserJpaEntity user = userJpaRepository.findAll().get(0); feedJpaRepository.deleteAll(); - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); var result = bookSearchService.searchDetailBooks(isbn, user.getUserId()); assertThat(result.recruitingReadCount()).isEqualTo(0); diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index ee6c8c707..08a38c86e 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -1,18 +1,22 @@ package konkuk.thip.common.util; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity; import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; -import konkuk.thip.user.adapter.out.jpa.*; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; -import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; -import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import java.time.LocalDate; +import java.util.UUID; public class TestEntityFactory { @@ -66,7 +70,7 @@ public static BookJpaEntity createBook() { return BookJpaEntity.builder() .title("책제목") .authorName("저자") - .isbn("isbn") + .isbn(UUID.randomUUID().toString().replace("-", "").substring(0, 13)) .bestSeller(false) .publisher("출판사") .imageUrl("img") diff --git a/src/test/java/konkuk/thip/record/adapter/in/web/RecordCreateControllerTest.java b/src/test/java/konkuk/thip/record/adapter/in/web/RecordCreateControllerTest.java index 1c1037fc8..551063a33 100644 --- a/src/test/java/konkuk/thip/record/adapter/in/web/RecordCreateControllerTest.java +++ b/src/test/java/konkuk/thip/record/adapter/in/web/RecordCreateControllerTest.java @@ -74,7 +74,7 @@ class RecordCreateControllerTest { @AfterEach void tearDown() { recordJpaRepository.deleteAll(); - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); categoryJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java index 006b9418b..4b2ed29d1 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java @@ -39,7 +39,7 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@DisplayName("방 생성 api 통합 테스트") +@DisplayName("[통합] 방 생성 api 통합 테스트") class RoomCreateAPITest { @Autowired diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java index 1c56de3ed..058cdd1e7 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java @@ -93,7 +93,7 @@ void setUp() { @AfterEach void tearDown() { - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java index b7b4ffc2e..8f58228f7 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetMemberListApiTest.java @@ -125,7 +125,7 @@ void setUp() { @AfterEach void tearDown() { followingJpaRepository.deleteAllInBatch(); - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java new file mode 100644 index 000000000..80c3a9d9d --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java @@ -0,0 +1,204 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.AfterEach; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.room.domain.RoomJoinType.CANCEL; +import static konkuk.thip.room.domain.RoomJoinType.JOIN; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("방 참여/취소 API 통합 테스트") +class RoomJoinApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private AliasJpaRepository aliasJpaRepository; + + private RoomJpaEntity room; + private UserJpaEntity host; + private UserJpaEntity participant; + + private void setUpWithOnlyHost() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + createUsers(alias); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + createRoom(book, category,1); // 방장만 포함 + roomParticipantJpaRepository.save(TestEntityFactory.createUserRoom(room, host, RoomParticipantRole.HOST, 0.0)); + } + + private void setUpWithParticipant() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + createUsers(alias); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + createRoom(book, category,2); // 방장과 참여자 포함 + roomParticipantJpaRepository.save(TestEntityFactory.createUserRoom(room, host, RoomParticipantRole.HOST, 0.0)); + roomParticipantJpaRepository.save(TestEntityFactory.createUserRoom(room, participant, RoomParticipantRole.MEMBER, 0.0)); + } + + private void createRoom(BookJpaEntity book, CategoryJpaEntity category, int memberCount) { + room = roomJpaRepository.save(RoomJpaEntity.builder() + .title("방이름") + .description("설명") + .isPublic(true) + .startDate(LocalDate.now().plusDays(1)) + .endDate(LocalDate.now().plusDays(30)) + .recruitCount(3) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .memberCount(memberCount) // 방장과 참여자 포함 + .build()); + } + + private void createUsers(AliasJpaEntity alias) { + host = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_432708231") + .nickname("user") + .imageUrl("img") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + participant = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_12345678") + .nickname("user123") + .imageUrl("img") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + } + + @AfterEach + void tearDown() { + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + @Test + @DisplayName("방 참여 성공 - 참여자 저장 및 인원수 증가 확인") + void joinRoom_success() throws Exception { + setUpWithOnlyHost(); + Map request = new HashMap<>(); + request.put("type", JOIN.getType()); + + mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 참여자 저장 확인 + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository + .findByUserIdAndRoomId(participant.getUserId(), room.getRoomId()) + .orElse(null); + assertThat(roomParticipantJpaEntity).isNotNull(); + + // 인원수 증가 확인 + room = roomJpaRepository.findById(room.getRoomId()).orElseThrow(); + assertThat(room.getMemberCount()).isEqualTo(2); // 방 생성 시 1명 + 참여 1명 + } + + + @Test + @DisplayName("방 중복 참여 실패") + void joinRoom_alreadyParticipated() throws Exception { + // 이미 참여한 상태로 설정 + setUpWithParticipant(); + + Map request = new HashMap<>(); + request.put("type", JOIN.getType()); + + ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("방 참여 취소 성공 - 참여자 제거 및 인원수 감소 확인") + void cancelJoin_success() throws Exception { + // 이미 참여한 상태로 설정 + setUpWithParticipant(); + + Map request = new HashMap<>(); + request.put("type", CANCEL.getType()); + + mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 참여자 삭제 확인 + boolean exists = roomParticipantJpaRepository + .existByUserIdAndRoomId(participant.getUserId(), room.getRoomId()); + assertThat(exists).isFalse(); + + // 인원수 감소 확인 + room = roomJpaRepository.findById(room.getRoomId()).orElseThrow(); + assertThat(room.getMemberCount()).isEqualTo(1); // 다시 원래 인원 + } + + @Test + @DisplayName("방 미참여자 취소 실패") + void cancelJoin_notParticipated() throws Exception { + setUpWithOnlyHost(); + + Map request = new HashMap<>(); + request.put("type", CANCEL.getType()); + + ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java index 80e5e1e53..b82f16912 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java @@ -8,12 +8,14 @@ import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; -import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; -import konkuk.thip.user.adapter.out.jpa.*; -import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; -import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import konkuk.thip.vote.adapter.out.persistence.repository.VoteItemJpaRepository; @@ -34,7 +36,6 @@ import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_BELONG_TO_ROOM; import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertThrows; 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; @@ -76,7 +77,7 @@ class RoomPlayingDetailViewApiTest { void tearDown() { voteItemJpaRepository.deleteAll(); voteJpaRepository.deleteAll(); - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); @@ -173,7 +174,7 @@ void get_playing_room_detail() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); roomParticipantJpaRepository.delete(roomParticipantJpaEntity); RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) @@ -221,7 +222,7 @@ void get_playing_room_detail_host() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); roomParticipantJpaRepository.delete(roomParticipantJpaEntity); RoomParticipantJpaEntity roomHost = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) @@ -269,7 +270,7 @@ void get_playing_room_detail_not_belong_to_room() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); roomParticipantJpaRepository.delete(roomParticipantJpaEntity); RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) @@ -295,7 +296,7 @@ void get_playing_room_detail_too_many_votes() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); roomParticipantJpaRepository.delete(roomParticipantJpaEntity); RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) @@ -347,7 +348,7 @@ void get_playing_room_detail_no_votes() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); roomParticipantJpaRepository.delete(roomParticipantJpaEntity); RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java index 3e2a8c575..397531726 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java @@ -63,7 +63,7 @@ class RoomRecruitingDetailViewApiTest { @AfterEach void tearDown() { - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); @@ -163,7 +163,7 @@ void get_recruiting_room_detail() throws Exception { //given RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(targetRoom, 4); - UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); saveUsersToRoom(science_room_2, 5); @@ -211,7 +211,7 @@ void get_recruiting_room_detail_host() throws Exception { //given RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(targetRoom, 4); - RoomParticipantJpaEntity firstMember = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1); + RoomParticipantJpaEntity firstMember = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1); roomParticipantJpaRepository.delete(firstMember); RoomParticipantJpaEntity roomCreator = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() .userJpaEntity(firstMember.getUserJpaEntity()) @@ -264,7 +264,7 @@ void get_recruiting_room_detail_too_many_recommend_rooms() throws Exception { //given RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(targetRoom, 4); - UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); saveUsersToRoom(science_room_2, 5); @@ -316,7 +316,7 @@ void get_recruiting_room_detail_no_recommend_rooms() throws Exception { //given RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); saveUsersToRoom(targetRoom, 4); - UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + UserJpaEntity joiningUser = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); RoomJpaEntity room_3 = saveLiteratureRoom("문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); saveUsersToRoom(room_3, 6); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java index 1ff8b70cb..94eccf5e4 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java @@ -62,7 +62,7 @@ class RoomSearchApiTest { @AfterEach void tearDown() { - roomParticipantJpaRepository.deleteAll(); + roomParticipantJpaRepository.deleteAllInBatch(); roomJpaRepository.deleteAll(); bookJpaRepository.deleteAll(); userJpaRepository.deleteAll(); diff --git a/src/test/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntityTest.java b/src/test/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntityTest.java index fdc7b5c19..7d5194580 100644 --- a/src/test/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntityTest.java +++ b/src/test/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntityTest.java @@ -43,7 +43,8 @@ class RoomJpaEntityTest { @DisplayName("RoomJpaEntity 저장 및 조회 테스트") void saveAndFindRoom() { // given - BookJpaEntity book = bookRepository.save(TestEntityFactory.createBook()); + String isbn = "1234567890"; + BookJpaEntity book = bookRepository.save(TestEntityFactory.createBookWithISBN(isbn)); AliasJpaEntity alias = aliasRepository.save(TestEntityFactory.createLiteratureAlias()); CategoryJpaEntity category = categoryRepository.save(TestEntityFactory.createLiteratureCategory(alias)); RoomJpaEntity room = roomRepository.save(TestEntityFactory.createRoom(book, category)); @@ -61,6 +62,6 @@ void saveAndFindRoom() { assertThat(foundRoom.getRecruitCount()).isEqualTo(3); assertThat(foundRoom.getBookJpaEntity().getTitle()).isEqualTo("책제목"); assertThat(foundRoom.getBookJpaEntity().getAuthorName()).isEqualTo("저자"); - assertThat(foundRoom.getBookJpaEntity().getIsbn()).isEqualTo("isbn"); + assertThat(foundRoom.getBookJpaEntity().getIsbn()).isEqualTo(isbn); } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java new file mode 100644 index 000000000..57a585506 --- /dev/null +++ b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java @@ -0,0 +1,118 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.application.port.in.dto.RoomJoinCommand; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; +import konkuk.thip.room.application.port.out.RoomParticipantQueryPort; +import konkuk.thip.room.domain.Room; +import konkuk.thip.room.domain.RoomParticipant; +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 java.time.LocalDate; + +import static konkuk.thip.room.adapter.out.jpa.RoomParticipantRole.MEMBER; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.mock; + +@DisplayName("[단위] 방 참여/취소 서비스 단위 테스트") +class RoomJoinServiceTest { + private RoomParticipantQueryPort roomParticipantQueryPort; + private RoomCommandPort roomCommandPort; + private RoomParticipantCommandPort roomParticipantCommandPort; + private RoomJoinService roomJoinService; + + private final Long ROOM_ID = 1L; + private final Long USER_ID = 2L; + private final Room room = Room.withoutId("제목", "설명", true, null, + LocalDate.now().plusDays(1), + LocalDate.now().plusDays(30), + 5, 100L, null); + + @BeforeEach + void setUp() { + roomParticipantQueryPort = mock(RoomParticipantQueryPort.class); + roomCommandPort = mock(RoomCommandPort.class); + roomParticipantCommandPort = mock(RoomParticipantCommandPort.class); + + roomJoinService = new RoomJoinService( + roomParticipantQueryPort, + roomCommandPort, + roomParticipantCommandPort + ); + } + + @Nested + @DisplayName("참여하기 요청") + class Join { + + @Test + @DisplayName("이미 참여한 경우 예외 발생") + void alreadyParticipated() { + RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, "join"); + + given(roomCommandPort.findById(ROOM_ID)).willReturn(room); + given(roomParticipantQueryPort.existByUserIdAndRoomId(USER_ID, ROOM_ID)).willReturn(true); + + assertThatThrownBy(() -> roomJoinService.changeJoinState(command)) + .isInstanceOf(InvalidStateException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_ALREADY_PARTICIPATE); + } + + @Test + @DisplayName("정상적으로 참여 시 참여자 저장 및 인원수 증가") + void successJoin() { + RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, "join"); + + given(roomCommandPort.findById(ROOM_ID)).willReturn(room); + given(roomParticipantQueryPort.existByUserIdAndRoomId(USER_ID, ROOM_ID)).willReturn(false); + + roomJoinService.changeJoinState(command); + + then(roomParticipantCommandPort).should().save(any(RoomParticipant.class)); + then(roomCommandPort).should().updateMemberCount(any(Room.class)); + } + } + + @Nested + @DisplayName("취소하기 요청") + class Cancel { + + @Test + @DisplayName("참여하지 않은 경우 예외 발생") + void notParticipated() { + RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, "cancel"); + + given(roomCommandPort.findById(ROOM_ID)).willReturn(room); + given(roomParticipantQueryPort.existByUserIdAndRoomId(USER_ID, ROOM_ID)).willReturn(false); + + assertThatThrownBy(() -> roomJoinService.changeJoinState(command)) + .isInstanceOf(InvalidStateException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_PARTICIPATED); + } + + @Test + @DisplayName("정상적으로 취소 시 참여자 제거 및 인원수 감소") + void successCancel() { + RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, "cancel"); + RoomParticipant participant = RoomParticipant.withoutId(USER_ID, ROOM_ID, MEMBER.getType()); + + given(roomCommandPort.findById(ROOM_ID)).willReturn(room); + given(roomParticipantQueryPort.existByUserIdAndRoomId(USER_ID, ROOM_ID)).willReturn(true); + given(roomParticipantCommandPort.findByUserIdAndRoomId(USER_ID, ROOM_ID)).willReturn(participant); + + room.increaseMemberCount(); // 현재 2명 이상으로 만들어 줌 + + roomJoinService.changeJoinState(command); + + then(roomParticipantCommandPort).should().deleteByUserIdAndRoomId(USER_ID, ROOM_ID); + then(roomCommandPort).should().updateMemberCount(any(Room.class)); + } + } + +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/room/domain/RoomTest.java b/src/test/java/konkuk/thip/room/domain/RoomTest.java index 1c532a351..b25b1e9f0 100644 --- a/src/test/java/konkuk/thip/room/domain/RoomTest.java +++ b/src/test/java/konkuk/thip/room/domain/RoomTest.java @@ -5,7 +5,6 @@ import konkuk.thip.common.exception.code.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.context.annotation.Primary; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -241,4 +240,28 @@ void verifyPassword_success() { assertDoesNotThrow(() -> room.verifyPassword("1234")); } + @Test + @DisplayName("increaseMemberCount: 정원이 다 찬 상태에서 호출하면 InvalidStateException 발생") + void increaseMemberCount_exceed_limit() { + Room room = Room.withoutId( + "제목", "설명", false, "1234", + START, END, 1, 123L, validCategory // recruitCount = 1 (방장 포함) + ); + // 이미 memberCount = 1 이므로 더 이상 참여 불가 + InvalidStateException ex = assertThrows(InvalidStateException.class, room::increaseMemberCount); + assertEquals(ErrorCode.ROOM_MEMBER_COUNT_EXCEEDED, ex.getErrorCode()); + } + + @Test + @DisplayName("decreaseMemberCount: 인원이 1명(방장만 존재)일 때 호출하면 InvalidStateException 발생") + void decreaseMemberCount_underflow() { + Room room = Room.withoutId( + "제목", "설명", false, "1234", + START, END, 5, 123L, validCategory + ); + // memberCount = 1 인 상태에서 감소 시도 + InvalidStateException ex = assertThrows(InvalidStateException.class, room::decreaseMemberCount); + assertEquals(ErrorCode.ROOM_MEMBER_COUNT_UNDERFLOW, ex.getErrorCode()); + } + }