diff --git a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java index 28681e3..d866869 100644 --- a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java +++ b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java @@ -8,6 +8,7 @@ @RequiredArgsConstructor(access = AccessLevel.PUBLIC) @Getter(AccessLevel.PUBLIC) public enum AppErrorCode implements ErrorCode { + DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT, "공통: 요청한 데이터가 유효하지 않아 저장할 수 없습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "공통: 서버 내부 오류가 발생했습니다."), SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "공통: 서비스가 일시적으로 불가능합니다."), DEPENDENCY_FAILURE(HttpStatus.BAD_GATEWAY, "공통: 외부/하위 시스템 연동에 실패했습니다."), diff --git a/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java b/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java index 853f314..3ca7aa5 100644 --- a/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.RedisSystemException; import org.springframework.http.HttpStatus; @@ -282,20 +283,40 @@ public ResponseEntity handleAny(Exception ex, HttpServletRequest } - @ExceptionHandler({ - RedisConnectionFailureException.class, - RedisSystemException.class, - DataAccessException.class - }) + @ExceptionHandler({RedisConnectionFailureException.class, RedisSystemException.class}) public ResponseEntity handleRedis(Exception ex, HttpServletRequest request) { log.error("Redis 장애(500): {}", rootCauseMessage(ex), ex); + return handleApp(new AppException(GroupErrorCode.REDIS_READ_FAILED), request); + } - AppException mapped = new AppException(GroupErrorCode.REDIS_READ_FAILED); - return handleApp(mapped, request); + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrity( + DataIntegrityViolationException ex, HttpServletRequest request) { + + String msg = rootCauseMessage(ex); + log.error("DB 무결성 위반(409): {}", msg, ex); + + // 예: H2 메시지에 constraint 이름이 들어옴 + // "PUBLIC.UK_GROUP_ID_SORT_ORDER_INDEX_D" + if (msg != null && msg.contains("UK_GROUP_ID_SORT_ORDER_INDEX_D")) { + return handleApp(new AppException(GroupErrorCode.GROUP_IMAGE_SORT_ORDER_CONFLICT), request); + } + + // 나머지는 공통 무결성 위반 코드로 (AppErrorCode 하나 만드는 걸 추천) + return handleApp(new AppException(AppErrorCode.DATA_INTEGRITY_VIOLATION), request); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDataAccess( + DataAccessException ex, HttpServletRequest request) { + + log.error("DB 접근 오류(500): {}", rootCauseMessage(ex), ex); + return handleApp(new AppException(AppErrorCode.INTERNAL_SERVER_ERROR), request); } @ExceptionHandler(JsonProcessingException.class) - public ResponseEntity handleJson(JsonProcessingException ex, HttpServletRequest request) { + public ResponseEntity handleJson(JsonProcessingException ex, + HttpServletRequest request) { log.error("Jackson 직렬화/역직렬화 실패(500): {}", rootCauseMessage(ex), ex); AppException mapped = new AppException(GroupErrorCode.REDIS_READ_FAILED); return handleApp(mapped, request); diff --git a/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java b/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java index ba45304..666f20d 100644 --- a/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java +++ b/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java @@ -8,9 +8,34 @@ @Getter @RequiredArgsConstructor public enum GroupErrorCode implements ErrorCode { + GROUP_IMAGE_SORT_ORDER_CONFLICT( + HttpStatus.CONFLICT, + "모임: 이미지 정렬 처리 중 충돌이 발생했습니다. 다시 시도해주세요." + ), + GROUP_IMAGE_NOT_FOUND_IN_GROUP_AFTER_UPDATE( + HttpStatus.BAD_REQUEST, + "모임: 요청한 이미지 키(%s)를 반영할 수 없습니다. (모임에 없거나 선업로드 이미지가 아닙니다.)" + ), + TAG_EXCEED_MAX(HttpStatus.BAD_REQUEST, "모임: 태그는 최대 10개까지 가능합니다. (요청=%s)"), + TAG_DUPLICATED(HttpStatus.BAD_REQUEST, "모임: 태그가 중복되었습니다."), + DUPLICATED_IMAGE_KEY_IN_REQUEST(HttpStatus.BAD_REQUEST, "모임: 이미지 키가 중복되었습니다."), + IMAGE_ORDER_EMPTY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "모임: 이미지는 최소 1장 이상이어야 합니다."), + TAG_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "모임: 태그 이름이 중복되었습니다."), + GROUP_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "모임: 제목은 필수입니다."), + GROUP_TITLE_TOO_LONG(HttpStatus.BAD_REQUEST, "모임: 제목은 50자 이하여야 합니다."), + GROUP_DESCRIPTION_REQUIRED(HttpStatus.BAD_REQUEST, "모임: 설명은 필수입니다."), + GROUP_DESCRIPTION_TOO_LONG(HttpStatus.BAD_REQUEST, "모임: 설명은 300자 이하여야 합니다."), + + GROUP_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "모임: 시작 시간은 필수입니다."), + GROUP_TIME_INVALID_RANGE(HttpStatus.BAD_REQUEST, "모임: 시작/종료 시간이 올바르지 않습니다. (start < end)"), + MAX_PARTICIPANTS_BELOW_ATTEND_COUNT(HttpStatus.CONFLICT, "모임: 현재 참석자 수(%s)보다 정원을 줄일 수 없습니다."), + GROUP_ONLY_HOST_CAN_UPDATE(HttpStatus.FORBIDDEN, "모임: 수정 권한이 없습니다."), + GROUP_CANNOT_UPDATE_IN_STATUS(HttpStatus.CONFLICT, "모임: 현재 상태(%s)에서는 수정할 수 없습니다."), + GROUP_DELETED(HttpStatus.NOT_FOUND, "모임: 삭제된 모임입니다."), INVALID_COOLDOWN_SECONDS(HttpStatus.BAD_REQUEST, "모임: 유효하지 않은 쿨다운 정책입니다."), - GROUP_CREATE_COOLDOWN_ACTIVE(HttpStatus.TOO_MANY_REQUESTS, "모임: 모임 생성은 연속으로 할 수 없습니다. {%s}초 후 다시 시도해 주세요."), + GROUP_CREATE_COOLDOWN_ACTIVE(HttpStatus.TOO_MANY_REQUESTS, + "모임: 모임 생성은 연속으로 할 수 없습니다. {%s}초 후 다시 시도해 주세요."), PRE_UPLOADED_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "모임: 선 업로드 이미지가 만료되었거나 존재하지 않습니다."), PRE_UPLOADED_IMAGE_OWNER_MISMATCH(HttpStatus.FORBIDDEN, "모임: 선 업로드 이미지를 업로드한 사용자만 사용할 수 있습니다."), REDIS_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "모임: 선 업로드 이미지 저장에 실패했습니다."), @@ -45,7 +70,8 @@ public enum GroupErrorCode implements ErrorCode { GROUP_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "모임 이미지가 존재하지 않습니다. groupId=%d"), LOCATION_REQUIRED(HttpStatus.BAD_REQUEST, "모임: 모임 위치는 필수입니다."), GROUP_STATUS_REQUIRED(HttpStatus.BAD_REQUEST, "모임: 모임 상태는 필수입니다."), - GROUP_STATUS_TRANSFER_IMPOSSIBLE(HttpStatus.BAD_REQUEST, "모임: 상태 전이가 불가능합니다. 현재 상태: %s, 요청한 상태: %s"), + GROUP_STATUS_TRANSFER_IMPOSSIBLE(HttpStatus.BAD_REQUEST, + "모임: 상태 전이가 불가능합니다. 현재 상태: %s, 요청한 상태: %s"), USER_ID_NULL(HttpStatus.NOT_FOUND, "모임: 회원 ID가 null 입니다."), GROUP_HOST_CANNOT_ATTEND(HttpStatus.BAD_REQUEST, "모임: HOST는 다시 모임에 신청을 할 수 없습니다."), GROUP_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "모임: 모집 상태가 아닙니다. 현재 상태: %s"), diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/dto/common/GroupImageItem.java b/src/main/java/team/wego/wegobackend/group/v2/application/dto/common/GroupImageItem.java index 674064d..bd8b453 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/dto/common/GroupImageItem.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/dto/common/GroupImageItem.java @@ -2,18 +2,54 @@ import java.util.List; import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2; +import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2VariantType; +import team.wego.wegobackend.group.v2.domain.entity.ImageV2Format; public record GroupImageItem( Long groupImageId, + String imageKey, int sortOrder, List variants ) { + private static final String DEFAULT_100 = + "https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_100x100.webp"; + private static final String DEFAULT_440 = + "https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_440x240.webp"; + public static GroupImageItem from(GroupImageV2 image) { return new GroupImageItem( image.getId(), + image.getImageKey(), image.getSortOrder(), image.getVariants().stream().map(GroupImageVariantItem::from).toList() ); } + + // DB에 이미지가 0개일 때 내려주는 기본 이미지(440/100) + public static GroupImageItem defaultLogo() { + return new GroupImageItem( + null, // DB row가 아니다. 그래서 null + "DEFAULT", // 테스트 편하게 DEFAULT로 설정 + 0, // 없으니까 어차피 대표 + List.of( + new GroupImageVariantItem( + null, + GroupImageV2VariantType.CARD_440_240, + GroupImageV2VariantType.CARD_440_240.getWidth(), + GroupImageV2VariantType.CARD_440_240.getHeight(), + ImageV2Format.WEBP, + DEFAULT_440 + ), + new GroupImageVariantItem( + null, + GroupImageV2VariantType.THUMBNAIL_100_100, + GroupImageV2VariantType.THUMBNAIL_100_100.getWidth(), + GroupImageV2VariantType.THUMBNAIL_100_100.getHeight(), + ImageV2Format.WEBP, + DEFAULT_100 + ) + ) + ); + } } diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/dto/request/UpdateGroupV2Request.java b/src/main/java/team/wego/wegobackend/group/v2/application/dto/request/UpdateGroupV2Request.java new file mode 100644 index 0000000..07efd40 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/dto/request/UpdateGroupV2Request.java @@ -0,0 +1,36 @@ +package team.wego.wegobackend.group.v2.application.dto.request; + +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status; + +/** + * + * images는 “최종 순서 리스트”로 받자! (0번이 대표) 기존 imageKey + 새 preUploaded imageKey를 섞어서 보내도 OK! 생략(null)이면 + * “이미지 변경 없음” 으로 가자 빈 리스트([])면 “이미지 전체 삭제”(정책 허용 시) 하자 + */ +public record UpdateGroupV2Request( + @Size(max = 50) + String title, + @Size(max = 300) + String description, + + String location, + String locationDetail, + + LocalDateTime startTime, + LocalDateTime endTime, + + Integer maxParticipants, + + GroupV2Status status, + + @Size(max = 10) + List tags, + + @Size(max = 3) + List imageKeys +) { + +} diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/CreateGroupV2Response.java b/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/CreateGroupV2Response.java index c920a54..e7318b0 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/CreateGroupV2Response.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/CreateGroupV2Response.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import team.wego.wegobackend.group.v2.application.dto.common.Address; import team.wego.wegobackend.group.v2.application.dto.common.CreatedBy; import team.wego.wegobackend.group.v2.application.dto.common.GroupImageItem; import team.wego.wegobackend.group.v2.domain.entity.GroupTagV2; @@ -9,7 +10,6 @@ import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Role; import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status; import team.wego.wegobackend.group.v2.domain.entity.GroupV2; -import team.wego.wegobackend.group.v2.domain.entity.GroupV2Address; import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status; import team.wego.wegobackend.tag.domain.entity.Tag; import team.wego.wegobackend.user.domain.User; @@ -67,16 +67,6 @@ public static CreateGroupV2Response from(GroupV2 group, User currentUser) { ); } - public record Address( - String location, - String locationDetail - ) { - - public static Address from(GroupV2Address address) { - return new Address(address.getLocation(), address.getLocationDetail()); - } - } - public record Membership( Long groupUserId, Long userId, diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/UpdateGroupV2Response.java b/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/UpdateGroupV2Response.java new file mode 100644 index 0000000..fd8e220 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/dto/response/UpdateGroupV2Response.java @@ -0,0 +1,24 @@ +package team.wego.wegobackend.group.v2.application.dto.response; + + +import java.time.LocalDateTime; +import java.util.List; +import team.wego.wegobackend.group.v2.application.dto.common.Address; +import team.wego.wegobackend.group.v2.application.dto.common.GroupImageItem; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status; + +public record UpdateGroupV2Response( + Long id, + String title, + GroupV2Status status, + Address address, + LocalDateTime startTime, + LocalDateTime endTime, + List images, + List tags, + String description, + int maxParticipants, + LocalDateTime updatedAt +) { } + + diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java index f166d21..2638d7a 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java +++ b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2Service.java @@ -360,6 +360,4 @@ public AttendGroupV2Response left(Long userId, Long groupId) { return AttendGroupV2Response.of(group, attendCount, membership); } - - } diff --git a/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2UpdateService.java b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2UpdateService.java new file mode 100644 index 0000000..74e131f --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2UpdateService.java @@ -0,0 +1,307 @@ +package team.wego.wegobackend.group.v2.application.service; + +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; +import team.wego.wegobackend.group.v2.application.dto.common.Address; +import team.wego.wegobackend.group.v2.application.dto.common.GroupImageItem; +import team.wego.wegobackend.group.v2.application.dto.common.GroupImageVariantItem; +import team.wego.wegobackend.group.v2.application.dto.common.PreUploadedGroupImage; +import team.wego.wegobackend.group.v2.application.dto.request.UpdateGroupV2Request; +import team.wego.wegobackend.group.v2.application.dto.response.UpdateGroupV2Response; +import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2; +import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2VariantType; +import team.wego.wegobackend.group.v2.domain.entity.GroupTagV2; +import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2; +import team.wego.wegobackend.group.v2.domain.entity.GroupV2Address; +import team.wego.wegobackend.group.v2.domain.entity.ImageV2Format; +import team.wego.wegobackend.group.v2.domain.repository.GroupImageV2Repository; +import team.wego.wegobackend.group.v2.domain.repository.GroupUserV2Repository; +import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository; +import team.wego.wegobackend.group.v2.infrastructure.redis.PreUploadedGroupImageRedisRepository; +import team.wego.wegobackend.tag.application.service.TagService; +import team.wego.wegobackend.tag.domain.entity.Tag; + +@RequiredArgsConstructor +@Service +public class GroupV2UpdateService { + + private final GroupV2Repository groupV2Repository; + private final GroupImageV2Repository groupImageV2Repository; + private final PreUploadedGroupImageRedisRepository preUploadedGroupImageRedisRepository; + private final GroupUserV2Repository groupUserV2Repository; + + private final TagService tagService; + + private static final int TEMP_SORT_ORDER = Integer.MAX_VALUE; + private static final String DEFAULT_100 = + "https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_100x100.webp"; + private static final String DEFAULT_440 = + "https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_440x240.webp"; + + private final EntityManager em; + + + @Transactional + public UpdateGroupV2Response update(Long userId, Long groupId, UpdateGroupV2Request request) { + if (userId == null) { + throw new GroupException(GroupErrorCode.USER_ID_NULL); + } + + GroupV2 group = groupV2Repository.findById(groupId) + .orElseThrow( + () -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId)); + + // 권한 체크(호스트만 + if (!group.getHost().getId().equals(userId)) { + throw new GroupException(GroupErrorCode.GROUP_ONLY_HOST_CAN_UPDATE, groupId, userId); + } + + // 엔티티 단 공통 가드: deleteAT + CANCELLED, FINISHED 체크하자 + group.assertUpdatable(); + + // 1 스칼라 필드 + applyScalarUpdates(group, groupId, request); + + // 2 상태 변경(요청이 있을 때만) + if (request.status() != null) { + group.changeStatus(request.status()); + } + + // 3 태그 변경(null이면 변경 없음) + if (request.tags() != null) { + applyTags(group, request.tags()); + } + + // 4 이미지 변경(null이면 변경 없음) + if (request.imageKeys() != null) { + applyImagesWithSafeReorder(group, userId, request.imageKeys()); + } + + // dirty checking으로 충분. 그래도 명시적으로 save 해도 무방. + groupV2Repository.save(group); + + // 응답 구성(조회로 안전하게) + List tagNames = group.getGroupTags().stream() + .map(gt -> gt.getTag().getName()) + .toList(); + + // 이미지는 variants 포함해서 다시 조회 + List images = groupImageV2Repository.findAllByGroupIdWithVariants(groupId); + + // 정렬 + DTO 변환 + List imageItems = images.stream() + .sorted(Comparator.comparingInt(GroupImageV2::getSortOrder)) + .map(GroupImageItem::from) + .toList(); + + // 이미지가 0개면 기본 이미지(variants 2개) 1장 내려주기 + if (imageItems.isEmpty()) { + imageItems = List.of(defaultLogoItem()); + } + + return new UpdateGroupV2Response( + group.getId(), + group.getTitle(), + group.getStatus(), + Address.from(group.getAddress()), + group.getStartTime(), + group.getEndTime(), + imageItems, + tagNames, + group.getDescription(), + group.getMaxParticipants(), + group.getUpdatedAt() + ); + } + + private GroupImageItem defaultLogoItem() { + return new GroupImageItem( + null, + "DEFAULT", + 0, + List.of( + new GroupImageVariantItem( + null, + GroupImageV2VariantType.CARD_440_240, + GroupImageV2VariantType.CARD_440_240.width(), + GroupImageV2VariantType.CARD_440_240.height(), + ImageV2Format.WEBP, + DEFAULT_440 + ), + new GroupImageVariantItem( + null, + GroupImageV2VariantType.THUMBNAIL_100_100, + GroupImageV2VariantType.THUMBNAIL_100_100.width(), + GroupImageV2VariantType.THUMBNAIL_100_100.height(), + ImageV2Format.WEBP, + DEFAULT_100 + ) + ) + ); + } + + + private void applyScalarUpdates(GroupV2 group, Long groupId, UpdateGroupV2Request request) { + if (request.title() != null) { // 제목 + group.changeTitle(request.title()); + } + if (request.description() != null) { // 설명 + group.changeDescription(request.description()); + } + + if (request.location() != null || request.locationDetail() != null) { // 주소 + String newLocation = request.location() != null + ? request.location() + : group.getAddress().getLocation(); + + String newDetail = request.locationDetail() != null + ? request.locationDetail() + : group.getAddress().getLocationDetail(); + + group.changeAddress(GroupV2Address.of(newLocation, newDetail)); + } + + // 시간: 둘 중 하나만 와도 엔티티가 최종 검증하도록 설계했으면 각각 호출 + if (request.startTime() != null && request.endTime() != null) { + group.changeTime(request.startTime(), request.endTime()); + } else { + if (request.startTime() != null) { + group.changeStartTime(request.startTime()); + } + if (request.endTime() != null) { + group.changeEndTime(request.endTime()); + } + } + + // 정원 변경: 참석자 수보다 작아지면 409 + if (request.maxParticipants() != null) { + long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId, + GroupUserV2Status.ATTEND); + if (request.maxParticipants() < attendCount) { + throw new GroupException(GroupErrorCode.MAX_PARTICIPANTS_BELOW_ATTEND_COUNT, + attendCount); + } + group.changeMaxParticipants(request.maxParticipants()); + } + } + + private void applyTags(GroupV2 group, List tagNames) { + List cleaned = tagNames.stream() + .filter(t -> t != null && !t.isBlank()) + .map(String::trim) + .toList(); + + if (cleaned.size() > 10) { + throw new GroupException(GroupErrorCode.TAG_EXCEED_MAX, cleaned.size()); + } + + // "중복이면 에러" 정책이라면: + if (new LinkedHashSet<>(cleaned).size() != cleaned.size()) { + throw new GroupException(GroupErrorCode.TAG_DUPLICATED); + } + + List tags = tagService.findOrCreateAll(cleaned); + + // 교체 + new ArrayList<>(group.getGroupTags()).forEach(group::removeTag); + for (Tag tag : tags) { + GroupTagV2.create(group, tag); + } + } + + /** + * 이미지 업데이트: 요청 imageKeys를 "최종 상태(순서)"로 해석한다. - 요청 순서대로 sortOrder=0.. 부여 (0번이 대표) - 요청에 없는 기존 + * 이미지는 삭제(orphanRemoval) - 요청에 있고 기존에 없으면 preupload(REDIS) consume 후 새로 생성(insert) - (group_id, + * sort_order) 유니크를 유지하기 위해 2-phase 임시 sortOrder 사용 + *

+ * 정책: - 최대 3장 - 중복 key 금지 - [] 허용: 전체 삭제 + */ + private void applyImagesWithSafeReorder(GroupV2 group, Long userId, List raw) { + List desiredKeys = raw.stream() + .filter(k -> k != null && !k.isBlank()) + .map(String::trim) + .toList(); + + if (desiredKeys.size() > 3) { + throw new GroupException(GroupErrorCode.IMAGE_UPLOAD_EXCEED, desiredKeys.size()); + } + if (new LinkedHashSet<>(desiredKeys).size() != desiredKeys.size()) { + throw new GroupException(GroupErrorCode.DUPLICATED_IMAGE_KEY_IN_REQUEST); + } + + if (desiredKeys.isEmpty()) { + new ArrayList<>(group.getImages()).forEach(group::removeImage); + em.flush(); // 완전 삭제 즉시 반영 + return; + } + + // 요청에 없는 기존 이미지 삭제 + Set desiredKeySet = new HashSet<>(desiredKeys); + List toRemove = group.getImages().stream() + .filter(img -> !desiredKeySet.contains(img.getImageKey())) + .toList(); + toRemove.forEach(group::removeImage); + + // 남은 이미지들을 임시 음수로 이동: 유니크 충돌 방지 + List remaining = group.getImages(); + for (int i = 0; i < remaining.size(); i++) { + remaining.get(i).changeSortOrder(-(i + 1)); + } + + em.flush(); // 여기서 “임시 음수 update + 삭제”를 DB에 먼저 반영 + + // 삭제 후 기준으로 존재하는 key map 구성 + Map afterRemoveByKey = group.getImages().stream() + .collect(Collectors.toMap(GroupImageV2::getImageKey, img -> img)); + + // 새로 생성해야 하는 키(요청에는 있는데 현재 없는 것) + List toCreateKeys = desiredKeys.stream() + .filter(k -> !afterRemoveByKey.containsKey(k)) + .toList(); + + // 생성 (temp sortOrder는 서로 다르게) + int temp = TEMP_SORT_ORDER; + for (String key : toCreateKeys) { + PreUploadedGroupImage pre = preUploadedGroupImageRedisRepository.consume(key) + .orElseThrow( + () -> new GroupException(GroupErrorCode.PRE_UPLOADED_IMAGE_NOT_FOUND, + key)); + + if (!userId.equals(pre.uploaderId())) { + throw new GroupException(GroupErrorCode.PRE_UPLOADED_IMAGE_OWNER_MISMATCH, key); + } + + GroupImageV2.create(group, temp--, pre.imageKey(), pre.url440x240(), pre.url100x100()); + } + + // 최종 매핑 + 검증 + Map afterByKey = group.getImages().stream() + .collect(Collectors.toMap(GroupImageV2::getImageKey, img -> img, (a, b) -> a)); + + for (String key : desiredKeys) { + if (!afterByKey.containsKey(key)) { + throw new GroupException(GroupErrorCode.GROUP_IMAGE_NOT_FOUND_IN_GROUP_AFTER_UPDATE, + key); + } + } + + // 최종 sortOrder 0.. 부여 + for (int i = 0; i < desiredKeys.size(); i++) { + afterByKey.get(desiredKeys.get(i)).changeSortOrder(i); + } + } +} + diff --git a/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupTagV2.java b/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupTagV2.java index 3169b58..b75cad9 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupTagV2.java +++ b/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupTagV2.java @@ -9,6 +9,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,13 +18,18 @@ @Getter(AccessLevel.PUBLIC) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "v2_group_tags") +@Table( + name = "v2_group_tags", + uniqueConstraints = { + @UniqueConstraint(name = "uk_group_id_tag_id", columnNames = {"group_id", "tag_id"}) + } +) @Entity public class GroupTagV2 extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "group_tag_id", nullable = false, updatable = false) + @Column(name = "group_tag_id", nullable = false, updatable = false) private Long id; @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2.java b/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2.java index 4297b8c..1579802 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2.java +++ b/src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2.java @@ -145,6 +145,117 @@ public void addTag(GroupTagV2 groupTag) { this.groupTags.add(groupTag); groupTag.assignTo(this); } + + public void removeTag(GroupTagV2 groupTag) { + this.groupTags.remove(groupTag); + groupTag.assignTo(null); + } + + public void assertUpdatable() { + if (this.deletedAt != null) { + throw new GroupException(GroupErrorCode.GROUP_DELETED); + } + if (this.status == GroupV2Status.CANCELLED || this.status == GroupV2Status.FINISHED) { + throw new GroupException(GroupErrorCode.GROUP_CANNOT_UPDATE_IN_STATUS, this.status.name()); + } + } + + public void changeTitle(String title) { + assertUpdatable(); + + if (title == null || title.isBlank()) { + throw new GroupException(GroupErrorCode.GROUP_TITLE_REQUIRED); + } + String trimmed = title.trim(); + if (trimmed.length() > 50) { + throw new GroupException(GroupErrorCode.GROUP_TITLE_TOO_LONG); + } + this.title = trimmed; + } + + public void changeDescription(String description) { + assertUpdatable(); + + if (description == null || description.isBlank()) { + throw new GroupException(GroupErrorCode.GROUP_DESCRIPTION_REQUIRED); + } + String trimmed = description.trim(); + if (trimmed.length() > 300) { + throw new GroupException(GroupErrorCode.GROUP_DESCRIPTION_TOO_LONG); + } + this.description = trimmed; + } + + public void changeAddress(GroupV2Address address) { + assertUpdatable(); + + if (address == null) { + throw new GroupException(GroupErrorCode.LOCATION_REQUIRED); + } + this.address = address; + } + + + // 시간은 "부분 수정"을 위해 start/end 단건 변경도 지원하되, 항상 최종 상태에서 start < end 불변식을 만족하자. + public void changeStartTime(LocalDateTime startTime) { + assertUpdatable(); + + if (startTime == null) { + throw new GroupException(GroupErrorCode.GROUP_TIME_REQUIRED); + } + LocalDateTime newStart = startTime; + LocalDateTime newEnd = this.endTime; // end는 nullable일 수 있음 + validateTimeRange(newStart, newEnd); + this.startTime = newStart; + } + + public void changeEndTime(LocalDateTime endTime) { + assertUpdatable(); + + // endTime을 null 허용할지 정책 -> 안하는 중! + LocalDateTime newStart = this.startTime; + LocalDateTime newEnd = endTime; + validateTimeRange(newStart, newEnd); + this.endTime = newEnd; + } + + public void changeTime(LocalDateTime startTime, LocalDateTime endTime) { + assertUpdatable(); + + if (startTime == null) { + throw new GroupException(GroupErrorCode.GROUP_TIME_REQUIRED); + } + validateTimeRange(startTime, endTime); + this.startTime = startTime; + this.endTime = endTime; + } + + private void validateTimeRange(LocalDateTime start, LocalDateTime end) { + // endTime을 필수 아닌 상태다. + if (end == null) { + return; + } + if (!start.isBefore(end)) { + throw new GroupException(GroupErrorCode.GROUP_TIME_INVALID_RANGE); + } + } + + public void changeMaxParticipants(Integer maxParticipants) { + assertUpdatable(); + + if (maxParticipants == null || maxParticipants <= 0) { + throw new GroupException(GroupErrorCode.INVALID_MAX_PARTICIPANTS); + } + this.maxParticipants = maxParticipants; + } + + // 사용할 지 안할지 아직 잘 모름 + public void softDelete() { + if (this.deletedAt != null) { + return; + } + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/team/wego/wegobackend/group/v2/presentation/GroupV2Controller.java b/src/main/java/team/wego/wegobackend/group/v2/presentation/GroupV2Controller.java index 667ff55..a20babb 100644 --- a/src/main/java/team/wego/wegobackend/group/v2/presentation/GroupV2Controller.java +++ b/src/main/java/team/wego/wegobackend/group/v2/presentation/GroupV2Controller.java @@ -7,6 +7,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -17,11 +18,14 @@ import team.wego.wegobackend.common.security.CustomUserDetails; import team.wego.wegobackend.group.v2.application.dto.request.CreateGroupV2Request; import team.wego.wegobackend.group.v2.application.dto.request.GroupListFilter; +import team.wego.wegobackend.group.v2.application.dto.request.UpdateGroupV2Request; import team.wego.wegobackend.group.v2.application.dto.response.AttendGroupV2Response; import team.wego.wegobackend.group.v2.application.dto.response.CreateGroupV2Response; import team.wego.wegobackend.group.v2.application.dto.response.GetGroupListV2Response; import team.wego.wegobackend.group.v2.application.dto.response.GetGroupV2Response; +import team.wego.wegobackend.group.v2.application.dto.response.UpdateGroupV2Response; import team.wego.wegobackend.group.v2.application.service.GroupV2Service; +import team.wego.wegobackend.group.v2.application.service.GroupV2UpdateService; import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status; @RequiredArgsConstructor @@ -30,6 +34,7 @@ public class GroupV2Controller { private final GroupV2Service groupV2Service; + private final GroupV2UpdateService groupV2UpdateService; @PostMapping("/create") public ResponseEntity> create( @@ -94,12 +99,23 @@ public ResponseEntity> getGroupList( @RequestParam(required = false) List excludeStatuses ) { GetGroupListV2Response response = - groupV2Service.getGroupListV2(keyword, cursor, size, filter, includeStatuses, excludeStatuses); + groupV2Service.getGroupListV2(keyword, cursor, size, filter, includeStatuses, + excludeStatuses); return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); } + @PatchMapping("/{groupId}") + public ResponseEntity> update( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long groupId, + @Valid @RequestBody UpdateGroupV2Request request + ) { + UpdateGroupV2Response response = + groupV2UpdateService.update(userDetails.getId(), groupId, request); + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response)); + } } diff --git a/src/main/resources/static/group_logo_100x100.webp b/src/main/resources/static/group_logo_100x100.webp new file mode 100644 index 0000000..f4f004f Binary files /dev/null and b/src/main/resources/static/group_logo_100x100.webp differ diff --git a/src/main/resources/static/group_logo_440x240.webp b/src/main/resources/static/group_logo_440x240.webp new file mode 100644 index 0000000..095244e Binary files /dev/null and b/src/main/resources/static/group_logo_440x240.webp differ diff --git a/src/main/resources/static/limdaeil-logo.jpeg b/src/main/resources/static/limdaeil-logo.jpeg new file mode 100644 index 0000000..acb08df Binary files /dev/null and b/src/main/resources/static/limdaeil-logo.jpeg differ diff --git a/src/test/http/group/v2/v2-group-create.http b/src/test/http/group/v2/v2-group-create.http index 13cdd64..61352fa 100644 --- a/src/test/http/group/v2/v2-group-create.http +++ b/src/test/http/group/v2/v2-group-create.http @@ -37,10 +37,10 @@ Content-Type: image/png < ../../image/resources/img1.png --boundary -Content-Disposition: form-data; name="images"; filename="img2.jpg" +Content-Disposition: form-data; name="images"; filename="img4.jpeg" Content-Type: image/jpeg -< ../../image/resources/img2.jpg +< ../../image/resources/img4.jpeg --boundary-- > {% diff --git a/src/test/http/group/v2/v2-group-get-list.http b/src/test/http/group/v2/v2-group-get-list.http index 53f5659..29869fd 100644 --- a/src/test/http/group/v2/v2-group-get-list.http +++ b/src/test/http/group/v2/v2-group-get-list.http @@ -240,12 +240,14 @@ Authorization: Bearer {{host2AccessToken}} GET http://localhost:8080/api/v2/groups?size=5&filter=ALL&includeStatuses=RECRUITING&includeStatuses=FULL&excludeStatuses=FULL Authorization: Bearer {{host2AccessToken}} -################################################################# -# (선택) 비로그인 V2 목록/상세 조회 -################################################################# - ### 7-1. (선택) 로그인 안 한 상태에서 V2 목록 조회 GET http://localhost:8080/api/v2/groups?size=5 ### 7-2. (선택) 로그인 안 한 상태에서 V2 모임 상세 조회 GET http://localhost:8080/api/v2/groups/{{groupId_cancelTest}} + + + +### V2 로그인 후, 현재 유저 기준 참여 상태 포함하여 상세 조회 +GET http://localhost:8080/api/v2/groups/{{groupId}} +Authorization: Bearer {{hostAccessToken}} \ No newline at end of file diff --git a/src/test/http/group/v2/v2-group-get.http b/src/test/http/group/v2/v2-group-get.http index 41c36c5..01737a3 100644 --- a/src/test/http/group/v2/v2-group-get.http +++ b/src/test/http/group/v2/v2-group-get.http @@ -26,8 +26,8 @@ Content-Type: application/json client.global.set("hostAccessToken", response.body.data.accessToken); %} -### 1-1. 모임 V1 이미지 선 업로드 (png / jpg 2장) -POST http://localhost:8080/api/v1/groups/images/upload +### 1-1. 모임 V2 이미지 선 업로드 (png / jpg 2장) +POST http://localhost:8080/api/v2/groups/images/upload Content-Type: multipart/form-data; boundary=boundary Authorization: Bearer {{hostAccessToken}} diff --git a/src/test/http/group/v2/v2-group-update.http b/src/test/http/group/v2/v2-group-update.http new file mode 100644 index 0000000..2b8560d --- /dev/null +++ b/src/test/http/group/v2/v2-group-update.http @@ -0,0 +1,260 @@ +### 0. 회원가입 +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#", + "nickName": "Beemo" +} + +### 0-1. 로그인(HOST) - accessToken 발급 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); +%} + + +### 1. UPDATE 테스트 준비: 이미지 선 업로드 3장 (A,B,C) +POST http://localhost:8080/api/v2/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="img1.png" +Content-Type: image/png + +< ../../image/resources/img1.png +--boundary +Content-Disposition: form-data; name="images"; filename="img2.jpg" +Content-Type: image/jpeg + +< ../../image/resources/img2.jpg +--boundary +Content-Disposition: form-data; name="images"; filename="img4.jpeg" +Content-Type: image/jpeg + +< ../../image/resources/img4.jpg +--boundary-- + +> {% + const images = response.body.data.images; + client.global.set("imgA_key", images[0].imageKey); + client.global.set("imgB_key", images[1].imageKey); + client.global.set("imgC_key", images[2].imageKey); +%} + + +### 2. 모임 V2 생성 (A,B,C) +POST http://localhost:8080/api/v2/groups/create +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "title": "UPDATE 이미지 테스트 모임", + "location": "서울 강남구", + "locationDetail": "강남역 2번 출구 근처 카페", + "startTime": "2026-12-10T19:00:00", + "endTime": "2026-12-10T21:00:00", + "tags": ["자바","백엔드","스터디"], + "description": "A,B,C 생성 후 update로 A 삭제 + C 대표 + D 추가", + "maxParticipants": 12, + "images": [ + { "sortOrder": 0, "imageKey": "{{imgA_key}}" }, + { "sortOrder": 1, "imageKey": "{{imgB_key}}" }, + { "sortOrder": 2, "imageKey": "{{imgC_key}}" } + ] +} + +> {% + client.global.set("groupId_updateTest", response.body.data.id); +%} + + +### 3. D 이미지 선 업로드 (추가할 신규 이미지) +POST http://localhost:8080/api/v2/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="imgD.png" +Content-Type: image/png + +< ../../image/resources/img1.png +--boundary-- + +> {% + const images = response.body.data.images; + client.global.set("imgD_key", images[0].imageKey); +%} + + +### 4. PATCH - A 삭제 + C 대표 + D 추가 (최종 순서: C,B,D) +PATCH http://localhost:8080/api/v2/groups/{{groupId_updateTest}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "imageKeys": ["{{imgC_key}}", "{{imgB_key}}", "{{imgD_key}}"] +} + +> {% + const body = response.body; + const imgs = (body.data && body.data.images) ? body.data.images : body.images; + + client.test("images 배열 존재", () => client.assert(!!imgs)); + + client.log("imgs keys = " + JSON.stringify(imgs.map(i => i.imageKey))); + client.log("expected C = " + client.global.get("imgC_key")); + client.log("expected B = " + client.global.get("imgB_key")); + client.log("expected D = " + client.global.get("imgD_key")); + + client.test("images 3개인지", () => client.assert(imgs.length === 3)); + client.test("정렬 0,1,2인지", () => client.assert( + imgs[0].sortOrder === 0 && imgs[1].sortOrder === 1 && imgs[2].sortOrder === 2 + )); + + client.test("최종 순서가 C,B,D인지", () => client.assert( + imgs[0].imageKey === client.global.get("imgC_key") && + imgs[1].imageKey === client.global.get("imgB_key") && + imgs[2].imageKey === client.global.get("imgD_key") + )); + + client.test("0번이 C인지", () => client.assert(imgs[0].imageKey === client.global.get("imgC_key"))); + client.test("1번이 B인지", () => client.assert(imgs[1].imageKey === client.global.get("imgB_key"))); + client.test("2번이 D인지", () => client.assert(imgs[2].imageKey === client.global.get("imgD_key"))); +%} + + +### 5. PATCH - 태그 중복 예외 기대(TAG_DUPLICATED) +PATCH http://localhost:8080/api/v2/groups/{{groupId_updateTest}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "tags": ["자바", "자바", "스터디"] +} + + +### 6. PATCH - 태그 10개 초과 예외 기대(TAG_EXCEED_MAX) +PATCH http://localhost:8080/api/v2/groups/{{groupId_updateTest}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "tags": ["t1","t2","t3","t4","t5","t6","t7","t8","t9","t10","t11"] +} + +### 7. PATCH - 이미지 전체 삭제 => 기본 이미지(variants 2개) 내려와야 함 +PATCH http://localhost:8080/api/v2/groups/{{groupId_updateTest}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "imageKeys": [] +} + +> {% + const body = response.body; + const imgs = (body.data && body.data.images) ? body.data.images : body.images; + + client.test("images 배열 존재", () => client.assert(!!imgs)); + client.test("기본 이미지 1개 내려옴", () => client.assert(imgs.length === 1)); + client.test("default imageKey=DEFAULT", () => client.assert(imgs[0].imageKey === "DEFAULT")); + client.test("variants 2개", () => client.assert(imgs[0].variants.length === 2)); + + const urls = imgs[0].variants.map(v => v.imageUrl); + client.test("기본 440 포함", () => client.assert(urls.includes("https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_440x240.webp"))); + client.test("기본 100 포함", () => client.assert(urls.includes("https://we-go-bucket.s3.ap-northeast-2.amazonaws.com/default/group_logo_100x100.webp"))); +%} + +### 이미지 2장 선 업로드 (A, B) +POST http://localhost:8080/api/v2/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="a.png" +Content-Type: image/png + +< ../../image/resources/img1.png +--boundary +Content-Disposition: form-data; name="images"; filename="b.jpg" +Content-Type: image/jpeg + +< ../../image/resources/img2.jpg +--boundary-- + +> {% + const images = response.body.data.images; + client.global.set("imgA_key", images[0].imageKey); + client.global.set("imgB_key", images[1].imageKey); +%} + + +### 모임 생성 (A 대표, B 2번) +POST http://localhost:8080/api/v2/groups/create +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "title": "A,B 이미지 모임", + "location": "서울 강남구", + "locationDetail": "어딘가", + "startTime": "2026-12-10T19:00:00", + "endTime": "2026-12-10T21:00:00", + "tags": ["자바","스터디"], + "description": "A 대표, B 보조", + "maxParticipants": 12, + "images": [ + { "sortOrder": 0, "imageKey": "{{imgA_key}}" }, + { "sortOrder": 1, "imageKey": "{{imgB_key}}" } + ] +} + +> {% + client.global.set("groupId_ab", response.body.data.id); +%} + +### 신규 이미지 1장 선 업로드 (C) +POST http://localhost:8080/api/v2/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="c.png" +Content-Type: image/png + +< ../../image/resources/img4.jpg +--boundary-- + +> {% + const images = response.body.data.images; + client.global.set("imgC_key", images[0].imageKey); +%} + +### PATCH (A 삭제 + B 대표 + C 추가 => 최종 [B, C]) +PATCH http://localhost:8080/api/v2/groups/{{groupId_ab}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "imageKeys": ["{{imgB_key}}", "{{imgC_key}}"] +} + +> {% + const body = response.body; + const imgs = (body.data && body.data.images) ? body.data.images : body.images; + + client.test("2장인지", () => client.assert(imgs.length === 2)); + client.test("0번이 B(대표)", () => client.assert(imgs[0].imageKey === client.global.get("imgB_key"))); + client.test("1번이 C", () => client.assert(imgs[1].imageKey === client.global.get("imgC_key"))); + client.test("sortOrder 0,1", () => client.assert(imgs[0].sortOrder === 0 && imgs[1].sortOrder === 1)); +%} diff --git a/src/test/http/image/resources/img4.jpg b/src/test/http/image/resources/img4.jpg new file mode 100644 index 0000000..730e490 Binary files /dev/null and b/src/test/http/image/resources/img4.jpg differ