diff --git a/src/main/java/project/flipnote/group/controller/GroupController.java b/src/main/java/project/flipnote/group/controller/GroupController.java index e706783c..8b39ba96 100644 --- a/src/main/java/project/flipnote/group/controller/GroupController.java +++ b/src/main/java/project/flipnote/group/controller/GroupController.java @@ -6,7 +6,9 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,6 +19,8 @@ import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; import project.flipnote.group.model.GroupDetailResponse; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.model.GroupPutResponse; import project.flipnote.group.service.GroupService; @RequiredArgsConstructor @@ -34,6 +38,16 @@ public ResponseEntity create( return ResponseEntity.status(HttpStatus.CREATED).body(res); } + //그룹 수정 + @PutMapping("/{groupId}") + public ResponseEntity changeGroup( + @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Valid @RequestBody GroupPutRequest req, + @PathVariable("groupId") Long groupId) { + GroupPutResponse res = groupService.changeGroup(authPrinciple, req, groupId); + return ResponseEntity.ok(res); + } + //그룹 상세 API @GetMapping("/{groupId}") public ResponseEntity findGroupDetail( diff --git a/src/main/java/project/flipnote/group/entity/Group.java b/src/main/java/project/flipnote/group/entity/Group.java index 39887dc5..cd000f8a 100644 --- a/src/main/java/project/flipnote/group/entity/Group.java +++ b/src/main/java/project/flipnote/group/entity/Group.java @@ -14,6 +14,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -24,6 +25,7 @@ import project.flipnote.common.entity.BaseEntity; import project.flipnote.common.exception.BizException; import project.flipnote.group.exception.GroupErrorCode; +import project.flipnote.group.model.GroupPutRequest; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -94,8 +96,24 @@ public void validateJoinable() { } } + public void validateMaxMemberUpdatable(int changeNumber) { + if (memberCount > changeNumber) { + throw new BizException(GroupErrorCode.INVALID_MEMBER_COUNT); + } + } + public void increaseMemberCount() { validateJoinable(); memberCount++; } + + public void changeGroup(GroupPutRequest req) { + this.name = req.name(); + this.category = req.category(); + this.description = req.description(); + this.applicationRequired = req.applicationRequired(); + this.publicVisible = req.publicVisible(); + this.maxMember = req.maxMember(); + this.imageUrl = req.image(); + } } diff --git a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java index c927d8ef..ff58248f 100644 --- a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java +++ b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java @@ -15,7 +15,8 @@ public enum GroupErrorCode implements ErrorCode { USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_004", "그룹에 유저가 존재하지 않습니다."), OTHER_USER_EXIST_IN_GROUP(HttpStatus.CONFLICT, "GROUP_005", "그룹내 오너 제외 유저가 존재합니다."), GROUP_IS_ALREADY_MAX_MEMBER(HttpStatus.CONFLICT, "GROUP_006", "그룹 정원이 가득 찼습니다."), - ALREADY_GROUP_MEMBER(HttpStatus.CONFLICT, "GROUP_007", "이미 그룹 회원입니다."); + ALREADY_GROUP_MEMBER(HttpStatus.CONFLICT, "GROUP_007", "이미 그룹 회원입니다."), + INVALID_MEMBER_COUNT(HttpStatus.BAD_REQUEST, "GROUP_008", "그룹 내에 인원수보다 많게 수정해야합니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/group/model/GroupPutRequest.java b/src/main/java/project/flipnote/group/model/GroupPutRequest.java new file mode 100644 index 00000000..6f3b614e --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupPutRequest.java @@ -0,0 +1,31 @@ +package project.flipnote.group.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import project.flipnote.group.entity.Category; + +public record GroupPutRequest( + @NotBlank + @Size(max = 50) + String name, + + @NotNull + Category category, + + @NotBlank + @Size(max = 150) + String description, + + @NotNull + Boolean applicationRequired, + + @NotNull + Boolean publicVisible, + + @NotNull + Integer maxMember, + + String image +) { +} diff --git a/src/main/java/project/flipnote/group/model/GroupPutResponse.java b/src/main/java/project/flipnote/group/model/GroupPutResponse.java new file mode 100644 index 00000000..74d42986 --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupPutResponse.java @@ -0,0 +1,40 @@ +package project.flipnote.group.model; + +import java.time.LocalDateTime; + +import project.flipnote.group.entity.Category; +import project.flipnote.group.entity.Group; + +public record GroupPutResponse( + String name, + + Category category, + + String description, + + Boolean applicationRequired, + + Boolean publicVisible, + + Integer maxMember, + + String imageUrl, + + LocalDateTime createdAt, + + LocalDateTime modifiedAt +) { + public static GroupPutResponse from(Group group) { + return new GroupPutResponse( + group.getName(), + group.getCategory(), + group.getDescription(), + group.getApplicationRequired(), + group.getPublicVisible(), + group.getMaxMember(), + group.getImageUrl(), + group.getCreatedAt(), + group.getModifiedAt() + ); + } +} diff --git a/src/main/java/project/flipnote/group/service/GroupPolicyService.java b/src/main/java/project/flipnote/group/service/GroupPolicyService.java new file mode 100644 index 00000000..0d2864df --- /dev/null +++ b/src/main/java/project/flipnote/group/service/GroupPolicyService.java @@ -0,0 +1,54 @@ +package project.flipnote.group.service; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.BizException; +import project.flipnote.common.exception.CommonErrorCode; +import project.flipnote.group.entity.Group; +import project.flipnote.group.exception.GroupErrorCode; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.repository.GroupRepository; + +@Service +@RequiredArgsConstructor +public class GroupPolicyService { + private final GroupRepository groupRepository; + private final RedissonClient redissonClient; + + @Transactional + public Group changeGroup(Long groupId, GroupPutRequest req) { + String lockKey = "group_lock:" + groupId; + RLock lock = redissonClient.getLock(lockKey); + + boolean isLocked = false; + try { + isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS); + if (!isLocked) { + throw new BizException(CommonErrorCode.SERVICE_TEMPORARILY_UNAVAILABLE); + } + + Group lockedGroup = groupRepository.findByIdForUpdate(groupId) + .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND)); + + lockedGroup.validateMaxMemberUpdatable(req.maxMember()); + + lockedGroup.changeGroup(req); + + return lockedGroup; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BizException(CommonErrorCode.SERVICE_TEMPORARILY_UNAVAILABLE); + } finally { + if (isLocked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index d3297902..deb58193 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.exception.BizException; @@ -20,6 +21,8 @@ import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; import project.flipnote.group.model.GroupDetailResponse; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.model.GroupPutResponse; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupPermissionRepository; import project.flipnote.group.repository.GroupRepository; @@ -41,6 +44,7 @@ public class GroupService { private final GroupPermissionRepository groupPermissionRepository; private final GroupRolePermissionRepository groupRolePermissionRepository; private final UserProfileRepository userProfileRepository; + private final GroupPolicyService groupPolicyService; /* 유저 정보 조회 @@ -87,7 +91,7 @@ public GroupCreateResponse create(AuthPrinciple authPrinciple, GroupCreateReques //4. 그룹 회원 정보 생성 saveGroupOwner(group, user); - //5. 그룹 내의 모든 권한 조회 + //5. 그룹 내의 모든 권한 생성 initializeGroupPermissions(group); return GroupCreateResponse.from(group.getId()); @@ -164,6 +168,39 @@ private void validateMaxMember(int maxMember) { } } + //유저수 검증 + private void validateUserCount(Group group, int maxMember) { + if (group.getMemberCount() > maxMember) { + throw new BizException(GroupErrorCode.INVALID_MEMBER_COUNT); + } + } + + //그룹 수정 + @Transactional + public GroupPutResponse changeGroup(AuthPrinciple authPrinciple, GroupPutRequest req, Long groupId) { + + //1. 유저 조회 + UserProfile user = validateUser(authPrinciple); + + //2. 인원수 검증 + validateMaxMember(req.maxMember()); + + //3. 그룹 가져오기 + Group group = validateGroup(groupId); + + //4. 그룹 내 유저 조회 + GroupMember groupMember = validateGroupInUser(user, groupId); + + //5. 유저 권환 조회 + if (!groupMember.getRole().equals(GroupMemberRole.OWNER)) { + throw new BizException(GroupErrorCode.USER_NOT_PERMISSION); + } + + //6. 그룹 수정 + Group changeGroup = groupPolicyService.changeGroup(groupId, req); + + return GroupPutResponse.from(changeGroup); + } /* 그룹 내 오너를 제외한 인원이 존재하는 경우 체크 */ diff --git a/src/test/java/project/flipnote/group/service/GroupPolicyServiceTest.java b/src/test/java/project/flipnote/group/service/GroupPolicyServiceTest.java new file mode 100644 index 00000000..3a63863d --- /dev/null +++ b/src/test/java/project/flipnote/group/service/GroupPolicyServiceTest.java @@ -0,0 +1,103 @@ +package project.flipnote.group.service; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +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.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.test.util.ReflectionTestUtils; + +import project.flipnote.common.exception.BizException; +import project.flipnote.group.entity.Category; +import project.flipnote.group.entity.Group; +import project.flipnote.group.exception.GroupErrorCode; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.repository.GroupRepository; + +@ExtendWith(MockitoExtension.class) +class GroupPolicyServiceTest { + + @InjectMocks + GroupPolicyService groupPolicyService; + + @Mock + GroupRepository groupRepository; + + @Mock + RedissonClient redissonClient; + + @Mock + RLock rLock; + + @Test + void 실패_유저수보다_작게_변경() throws Exception { + Long groupId = 1L; + Group group = Group.builder() + .name("그룹1") + .category(Category.IT) + .description("설명1") + .publicVisible(true) + .applicationRequired(true) + .maxMember(100) + .imageUrl("www.~~~") + .build(); + + ReflectionTestUtils.setField(group, "id", 1L); + ReflectionTestUtils.setField(group, "memberCount", 100); + + GroupPutRequest req = new GroupPutRequest("그룹1", Category.ENGLISH, "설명1", true, true, 50, "www.~~"); + + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), anyLong(), any())).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(groupRepository.findByIdForUpdate(groupId)).willReturn(Optional.of(group)); + + + //when & then + BizException exception = + assertThrows(BizException.class, () -> groupPolicyService.changeGroup(groupId, req)); + + assertEquals(GroupErrorCode.INVALID_MEMBER_COUNT, exception.getErrorCode()); + then(rLock).should().unlock(); + } + + @Test + void 그룹_수정_성공() throws Exception { + Long groupId = 1L; + Group group = Group.builder() + .name("그룹1") + .category(Category.IT) + .description("설명1") + .publicVisible(true) + .applicationRequired(true) + .maxMember(100) + .imageUrl("www.~~~") + .build(); + + ReflectionTestUtils.setField(group, "id", 1L); + ReflectionTestUtils.setField(group, "memberCount", 3); + + GroupPutRequest req = new GroupPutRequest("그룹1", Category.ENGLISH, "설명1", true, true, 50, "www.~~"); + + given(redissonClient.getLock(anyString())).willReturn(rLock); + given(rLock.tryLock(anyLong(), anyLong(), any())).willReturn(true); + given(rLock.isHeldByCurrentThread()).willReturn(true); + given(groupRepository.findByIdForUpdate(groupId)).willReturn(Optional.of(group)); + + + //when + Group changeGroup = groupPolicyService.changeGroup(groupId, req); + + assertEquals(req.name(), changeGroup.getName()); + assertEquals(req.category(), changeGroup.getCategory()); + + } +} diff --git a/src/test/java/project/flipnote/group/service/GroupServiceTest.java b/src/test/java/project/flipnote/group/service/GroupServiceTest.java index a59116f6..e4bd6996 100644 --- a/src/test/java/project/flipnote/group/service/GroupServiceTest.java +++ b/src/test/java/project/flipnote/group/service/GroupServiceTest.java @@ -14,6 +14,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.test.util.ReflectionTestUtils; @@ -33,6 +35,8 @@ import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; import project.flipnote.group.model.GroupDetailResponse; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.model.GroupPutResponse; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupPermissionRepository; import project.flipnote.group.repository.GroupRepository; @@ -67,6 +71,9 @@ class GroupServiceTest { @Mock GroupMemberRepository groupMemberRepository; + @Mock + GroupPolicyService groupPolicyService; + UserProfile userProfile; AuthPrinciple authPrinciple; @@ -154,7 +161,7 @@ void before() { .role(GroupMemberRole.MEMBER) .build(); - given(groupRepository.findByIdAndDeletedAtIsNull(any())).willReturn(Optional.ofNullable(group)); + given(groupRepository.findByIdAndDeletedAtIsNull(any())).willReturn(Optional.of(group)); given(groupMemberRepository.findByGroup_IdAndUser_Id(any(), any())).willReturn(Optional.of(groupMember)); // 사용자 검증 로직 given(userProfileRepository.findByIdAndStatus(userProfile.getId(), UserStatus.ACTIVE)) @@ -306,4 +313,118 @@ void before() { assertEquals(GroupErrorCode.OTHER_USER_EXIST_IN_GROUP, exception.getErrorCode()); } + + @Test + public void 그룹_수정_성공() throws Exception { + //given + Group group = Group.builder() + .name("그룹1") + .category(Category.IT) + .description("설명1") + .publicVisible(true) + .applicationRequired(true) + .maxMember(100) + .imageUrl("www.~~~") + .build(); + ReflectionTestUtils.setField(group, "id", 1L); + + GroupMember groupMember = GroupMember.builder() + .group(group) + .role(GroupMemberRole.OWNER) + .user(userProfile) + .build(); + ReflectionTestUtils.setField(groupMember, "id", 1L); + + GroupPutRequest req = new GroupPutRequest("그룹1", Category.ENGLISH, "설명1", true, true, 100, "www.~~"); + + given(groupRepository.findByIdAndDeletedAtIsNull(group.getId())).willReturn(Optional.of(group)); + given(userProfileRepository.findByIdAndStatus(userProfile.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(userProfile)); + given(groupMemberRepository.findByGroup_IdAndUser_Id(group.getId(),userProfile.getId())).willReturn(Optional.of(groupMember)); + + willAnswer(inv -> { + Long idArg = inv.getArgument(0, Long.class); + GroupPutRequest reqArg = inv.getArgument(1, GroupPutRequest.class); + // 실제 서비스 로직처럼 그룹 변경을 흉내냄 + group.changeGroup(reqArg); + return group; // 변경된 그룹 반환 + }).given(groupPolicyService).changeGroup(group.getId(), req); + + //when + GroupPutResponse res = groupService.changeGroup(authPrinciple, req, group.getId()); + + //then + assertEquals(req.name(), group.getName()); + assertEquals(req.category(), group.getCategory()); + } + + @Test + public void 그룹_수정_실패_오너아닌경우() throws Exception { + //given + Group group = Group.builder() + .name("그룹1") + .category(Category.IT) + .description("설명1") + .publicVisible(true) + .applicationRequired(true) + .maxMember(100) + .imageUrl("www.~~~") + .build(); + ReflectionTestUtils.setField(group, "id", 1L); + + GroupMember groupMember = GroupMember.builder() + .group(group) + .role(GroupMemberRole.MEMBER) + .user(userProfile) + .build(); + ReflectionTestUtils.setField(groupMember, "id", 1L); + + GroupPutRequest req = new GroupPutRequest("그룹1", Category.ENGLISH, "설명1", true, true, 100, "www.~~"); + + given(groupRepository.findByIdAndDeletedAtIsNull(group.getId())).willReturn(Optional.of(group)); + given(userProfileRepository.findByIdAndStatus(userProfile.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(userProfile)); + given(groupMemberRepository.findByGroup_IdAndUser_Id(group.getId(),userProfile.getId())).willReturn(Optional.of(groupMember)); + //when + BizException exception = + assertThrows(BizException.class, () -> groupService.changeGroup(authPrinciple, req, group.getId())); + + assertEquals(GroupErrorCode.USER_NOT_PERMISSION, exception.getErrorCode()); + } + + @Test + public void 그룹_수정_실패_유저수보다_적게한경우() throws Exception { + //given + Group group = Group.builder() + .name("그룹1") + .category(Category.IT) + .description("설명1") + .publicVisible(true) + .applicationRequired(true) + .maxMember(100) + .imageUrl("www.~~~") + .build(); + ReflectionTestUtils.setField(group, "id", 1L); + ReflectionTestUtils.setField(group, "memberCount", 100); + + GroupMember groupMember = GroupMember.builder() + .group(group) + .role(GroupMemberRole.OWNER) + .user(userProfile) + .build(); + ReflectionTestUtils.setField(groupMember, "id", 1L); + + GroupPutRequest req = new GroupPutRequest("그룹1", Category.ENGLISH, "설명1", true, true, 50, "www.~~"); + + given(groupRepository.findByIdAndDeletedAtIsNull(group.getId())).willReturn(Optional.of(group)); + given(userProfileRepository.findByIdAndStatus(userProfile.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(userProfile)); + given(groupMemberRepository.findByGroup_IdAndUser_Id(group.getId(),userProfile.getId())).willReturn(Optional.of(groupMember)); + + willThrow(new BizException(GroupErrorCode.INVALID_MEMBER_COUNT)) + .given(groupPolicyService).changeGroup(group.getId(), req); + + //when + BizException exception = + assertThrows(BizException.class, () -> groupService.changeGroup(authPrinciple, req, group.getId())); + + assertEquals(GroupErrorCode.INVALID_MEMBER_COUNT, exception.getErrorCode()); + } }