diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/WorkspaceImageRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/WorkspaceImageRequestDto.java new file mode 100644 index 00000000..90cd2843 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/WorkspaceImageRequestDto.java @@ -0,0 +1,43 @@ +package com.dreamteam.alter.adapter.inbound.common.dto; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(of = "fileId") +@Schema(description = "업장 대표이미지 요청 항목 DTO") +public class WorkspaceImageRequestDto { + + @NotBlank + @Schema(description = "대표이미지 파일 ID", example = "01959b4e-4e5f-7c3a-8d9e-0f1a2b3c4d5e") + private String fileId; + + @Schema(description = "노출 순서 (작을수록 먼저, 미지정 시 마지막)", example = "0") + private Integer sortOrder; + + /** + * sortOrder 오름차순(미지정은 후순위)으로 정렬한 파일 ID 목록을 반환한다. + * fileId 기준 equals/hashCode 로 Set 단계에서 이미 중복이 제거된다. + */ + public static List toOrderedFileIds(Collection images) { + if (images == null || images.isEmpty()) { + return List.of(); + } + return images.stream() + .sorted(Comparator + .comparing(WorkspaceImageRequestDto::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(WorkspaceImageRequestDto::getFileId)) + .map(WorkspaceImageRequestDto::getFileId) + .toList(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.java index ba6b9a6e..7a0c0f41 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/CreateWorkspaceRequestDto.java @@ -1,10 +1,16 @@ package com.dreamteam.alter.adapter.inbound.general.workspace.dto; import java.math.BigDecimal; +import java.util.List; +import java.util.Set; + +import com.dreamteam.alter.adapter.inbound.common.dto.WorkspaceImageRequestDto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -65,4 +71,12 @@ public class CreateWorkspaceRequestDto { @Schema(description = "위임 확인서 파일 ID", example = "01959b4e-4e5f-7c3a-8d9e-0f1a2b3c4d5e") private String workspaceWarrantFileId; + + @Size(max = 5, message = "대표이미지는 최대 5개까지 등록할 수 있습니다.") + @Schema(description = "업장 대표이미지 목록 (최대 5개, fileId 중복 자동 제거, sortOrder 오름차순으로 노출)") + private Set<@Valid WorkspaceImageRequestDto> representativeImages; + + public List getOrderedRepresentativeImageFileIds() { + return WorkspaceImageRequestDto.toOrderedFileIds(representativeImages); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageController.java new file mode 100644 index 00000000..65f18376 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageController.java @@ -0,0 +1,60 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.UpdateWorkspaceImagesRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceImageResponseDto; +import com.dreamteam.alter.application.aop.ManagerActionContext; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.port.inbound.ManagerGetWorkspaceImagesUseCase; +import com.dreamteam.alter.domain.workspace.port.inbound.ManagerUpdateWorkspaceImagesUseCase; + +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/manager/workspaces") +@PreAuthorize("hasAnyRole('MANAGER')") +@RequiredArgsConstructor +@Validated +public class ManagerWorkspaceImageController implements ManagerWorkspaceImageControllerSpec { + + @Resource(name = "managerGetWorkspaceImages") + private final ManagerGetWorkspaceImagesUseCase managerGetWorkspaceImages; + + @Resource(name = "managerUpdateWorkspaceImages") + private final ManagerUpdateWorkspaceImagesUseCase managerUpdateWorkspaceImages; + + @Override + @GetMapping("/{workspaceId}/images") + public ResponseEntity>> getWorkspaceImages( + @PathVariable Long workspaceId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + + return ResponseEntity.ok(CommonApiResponse.of(managerGetWorkspaceImages.execute(actor, workspaceId))); + } + + @Override + @PutMapping("/{workspaceId}/images") + public ResponseEntity> updateWorkspaceImages( + @PathVariable Long workspaceId, + @RequestBody @Valid UpdateWorkspaceImagesRequestDto request + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + managerUpdateWorkspaceImages.execute(actor, workspaceId, request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageControllerSpec.java new file mode 100644 index 00000000..fed0b7ee --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceImageControllerSpec.java @@ -0,0 +1,84 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.UpdateWorkspaceImagesRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceImageResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "MANAGER - 업장 대표이미지 API") +public interface ManagerWorkspaceImageControllerSpec { + + @Operation(summary = "매니저 - 업장 대표이미지 목록 조회", description = "노출 순서(sortOrder) 오름차순으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "대표이미지 목록 조회 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 업장", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "존재하지 않는 업장입니다.", + value = "{\"code\" : \"B008\"}" + ), + })), + }) + ResponseEntity>> getWorkspaceImages( + @PathVariable Long workspaceId + ); + + @Operation(summary = "매니저 - 업장 대표이미지 수정 (전체 교체)", description = "전달한 대표이미지 목록으로 전체 교체합니다. sortOrder 오름차순이 노출 순서이며(미지정 시 후순위), 추가/삭제/순서변경을 한 번에 처리합니다. 최대 5개.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "대표이미지 수정 성공"), + @ApiResponse(responseCode = "400", description = "400 Error 실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "존재하지 않는 업장입니다.", + value = "{\"code\" : \"B008\"}" + ), + @ExampleObject( + name = "대표이미지는 최대 5개까지 등록할 수 있습니다.", + value = "{\"code\" : \"B001\"}" + ), + @ExampleObject( + name = "존재하지 않는 파일입니다.", + value = "{\"code\" : \"B021\"}" + ), + @ExampleObject( + name = "유효하지 않은 파일입니다.", + value = "{\"code\" : \"B022\"}" + ), + })), + @ApiResponse(responseCode = "409", description = "이미 연결된 파일", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "이미 연결된 파일입니다.", + value = "{\"code\" : \"B025\"}" + ), + })), + }) + ResponseEntity> updateWorkspaceImages( + @PathVariable Long workspaceId, + @RequestBody @Valid UpdateWorkspaceImagesRequestDto request + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.java index 615b6251..6fd0f799 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/ManagerWorkspaceResponseDto.java @@ -11,6 +11,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -69,7 +70,13 @@ public class ManagerWorkspaceResponseDto { @Schema(description = "업장 평판 요약 정보") private SelfReputationSummaryDto reputationSummary; - public static ManagerWorkspaceResponseDto of(ManagerWorkspaceResponse entity) { + @Schema(description = "업장 대표이미지 목록 (노출 순서 오름차순, 최대 5개)") + private List representativeImages; + + public static ManagerWorkspaceResponseDto of( + ManagerWorkspaceResponse entity, + List representativeImages + ) { return ManagerWorkspaceResponseDto.builder() .id(entity.getId()) .businessRegistrationNo(entity.getBusinessRegistrationNo()) @@ -84,6 +91,7 @@ public static ManagerWorkspaceResponseDto of(ManagerWorkspaceResponse entity) { .nextMonthShiftGenDay(entity.getNextMonthShiftGenDay()) .createdAt(entity.getCreatedAt()) .reputationSummary(SelfReputationSummaryDto.from(entity.getReputationSummary())) + .representativeImages(representativeImages) .build(); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/UpdateWorkspaceImagesRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/UpdateWorkspaceImagesRequestDto.java new file mode 100644 index 00000000..bb816b37 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/UpdateWorkspaceImagesRequestDto.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.dto; + +import java.util.List; +import java.util.Set; + +import com.dreamteam.alter.adapter.inbound.common.dto.WorkspaceImageRequestDto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "업장 대표이미지 수정 요청 DTO (전체 교체)") +public class UpdateWorkspaceImagesRequestDto { + + @NotNull + @Size(max = 5, message = "대표이미지는 최대 5개까지 등록할 수 있습니다.") + @Schema(description = "대표이미지 목록 (fileId 중복 자동 제거, sortOrder 오름차순으로 노출, 비우면 전체 삭제)") + private Set<@Valid WorkspaceImageRequestDto> images; + + public List getOrderedImageFileIds() { + return WorkspaceImageRequestDto.toOrderedFileIds(images); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceImageResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceImageResponseDto.java new file mode 100644 index 00000000..99d5b249 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceImageResponseDto.java @@ -0,0 +1,33 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "업장 대표이미지 응답 DTO") +public class WorkspaceImageResponseDto { + + @Schema(description = "파일 ID", example = "01959b4e-4e5f-7c3a-8d9e-0f1a2b3c4d5e") + private String fileId; + + @Schema(description = "이미지 URL", example = "https://cdn.example.com/workspace_representative_image/abc.jpg") + private String url; + + @Schema(description = "노출 순서 (0부터 시작)", example = "0") + private int sortOrder; + + public static WorkspaceImageResponseDto of(String fileId, String url, int sortOrder) { + return WorkspaceImageResponseDto.builder() + .fileId(fileId) + .url(url) + .sortOrder(sortOrder) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageJpaRepository.java new file mode 100644 index 00000000..1436aebb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageJpaRepository.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; + +public interface WorkspaceImageJpaRepository extends JpaRepository { + List findByWorkspaceIdOrderBySortOrderAsc(Long workspaceId); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageQueryRepositoryImpl.java new file mode 100644 index 00000000..5ac12353 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageQueryRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkspaceImageQueryRepositoryImpl implements WorkspaceImageQueryRepository { + + private final WorkspaceImageJpaRepository workspaceImageJpaRepository; + + @Override + public List findAllByWorkspaceId(Long workspaceId) { + return workspaceImageJpaRepository.findByWorkspaceIdOrderBySortOrderAsc(workspaceId); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageRepositoryImpl.java new file mode 100644 index 00000000..01372c09 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceImageRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkspaceImageRepositoryImpl implements WorkspaceImageRepository { + + private final WorkspaceImageJpaRepository workspaceImageJpaRepository; + + @Override + public void saveAll(List workspaceImages) { + workspaceImageJpaRepository.saveAll(workspaceImages); + } + + @Override + public void deleteAll(List workspaceImages) { + workspaceImageJpaRepository.deleteAll(workspaceImages); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageJpaRepository.java new file mode 100644 index 00000000..5c6bfa3c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageJpaRepository.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; + +public interface WorkspaceRequestImageJpaRepository extends JpaRepository { + List findByWorkspaceRequestIdOrderBySortOrderAsc(Long workspaceRequestId); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageQueryRepositoryImpl.java new file mode 100644 index 00000000..390d5cc6 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageQueryRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkspaceRequestImageQueryRepositoryImpl implements WorkspaceRequestImageQueryRepository { + + private final WorkspaceRequestImageJpaRepository workspaceRequestImageJpaRepository; + + @Override + public List findAllByWorkspaceRequestId(Long workspaceRequestId) { + return workspaceRequestImageJpaRepository.findByWorkspaceRequestIdOrderBySortOrderAsc(workspaceRequestId); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageRepositoryImpl.java new file mode 100644 index 00000000..c58a444f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceRequestImageRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class WorkspaceRequestImageRepositoryImpl implements WorkspaceRequestImageRepository { + + private final WorkspaceRequestImageJpaRepository workspaceRequestImageJpaRepository; + + @Override + public void saveAll(List workspaceRequestImages) { + workspaceRequestImageJpaRepository.saveAll(workspaceRequestImages); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java index a0e8bf55..8b741867 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequest.java @@ -1,17 +1,23 @@ package com.dreamteam.alter.application.workspace.usecase; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.dreamteam.alter.adapter.inbound.general.workspace.dto.CreateWorkspaceRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; import com.dreamteam.alter.domain.file.type.FileTargetType; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequest; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; import com.dreamteam.alter.domain.workspace.port.inbound.CreateWorkspaceRequestUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestRepository; import lombok.RequiredArgsConstructor; @@ -21,7 +27,10 @@ @Transactional public class CreateWorkspaceRequest implements CreateWorkspaceRequestUseCase { + private static final int MAX_IMAGE_COUNT = 5; + private final WorkspaceRequestRepository workspaceRequestRepository; + private final WorkspaceRequestImageRepository workspaceRequestImageRepository; private final AttachFilesUseCase attachFiles; @Override @@ -49,5 +58,24 @@ public void execute(User user, CreateWorkspaceRequestDto request) { fileMap.put(request.getWorkspaceWarrantFileId(), FileTargetType.WORKSPACE_WARRANT); } attachFiles.executeMap(fileMap, savedWorkspaceRequestId.toString(), user.getId()); + + List representativeImageFileIds = request.getOrderedRepresentativeImageFileIds(); + if (representativeImageFileIds.size() > MAX_IMAGE_COUNT) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "대표이미지는 최대 " + MAX_IMAGE_COUNT + "개까지 등록할 수 있습니다."); + } + if (!representativeImageFileIds.isEmpty()) { + attachFiles.execute( + representativeImageFileIds, + FileTargetType.WORKSPACE_REPRESENTATIVE_IMAGE, + savedWorkspaceRequestId.toString(), + user.getId() + ); + + List images = new ArrayList<>(); + for (int sortOrder = 0; sortOrder < representativeImageFileIds.size(); sortOrder++) { + images.add(WorkspaceRequestImage.create(workspaceRequest, representativeImageFileIds.get(sortOrder), sortOrder)); + } + workspaceRequestImageRepository.saveAll(images); + } } } diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspace.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspace.java index 86b9d2ce..e0e89763 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspace.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspace.java @@ -5,6 +5,7 @@ import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.port.inbound.ManagerGetWorkspaceImagesUseCase; import com.dreamteam.alter.domain.workspace.port.inbound.ManagerGetWorkspaceUseCase; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; import lombok.RequiredArgsConstructor; @@ -18,6 +19,7 @@ public class ManagerGetWorkspace implements ManagerGetWorkspaceUseCase { private final WorkspaceQueryRepository workspaceQueryRepository; + private final ManagerGetWorkspaceImagesUseCase managerGetWorkspaceImages; @Override public ManagerWorkspaceResponseDto execute(ManagerActor actor, Long workspaceId) { @@ -28,7 +30,8 @@ public ManagerWorkspaceResponseDto execute(ManagerActor actor, Long workspaceId) throw new CustomException(ErrorCode.WORKSPACE_NOT_FOUND); } - return ManagerWorkspaceResponseDto.of(workspace); + return ManagerWorkspaceResponseDto.of( + workspace, managerGetWorkspaceImages.getImagesWithoutOwnershipCheck(workspaceId)); } } diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImages.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImages.java new file mode 100644 index 00000000..82a2548e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImages.java @@ -0,0 +1,73 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceImageResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.inbound.ManagerGetWorkspaceImagesUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service("managerGetWorkspaceImages") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ManagerGetWorkspaceImages implements ManagerGetWorkspaceImagesUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final WorkspaceImageQueryRepository workspaceImageQueryRepository; + private final FileQueryRepository fileQueryRepository; + private final FileUrlService fileUrlService; + + @Override + public List execute(ManagerActor actor, Long workspaceId) { + if (!workspaceQueryRepository.existsByIdAndManagerUser(workspaceId, actor.getManagerUser())) { + throw new CustomException(ErrorCode.WORKSPACE_NOT_FOUND); + } + + return getImagesWithoutOwnershipCheck(workspaceId); + } + + @Override + public List getImagesWithoutOwnershipCheck(Long workspaceId) { + List images = workspaceImageQueryRepository.findAllByWorkspaceId(workspaceId); + if (images.isEmpty()) { + return List.of(); + } + + List fileIds = images.stream() + .map(WorkspaceImage::getFileId) + .toList(); + Map fileMap = fileQueryRepository.findAllByIdIn(fileIds).stream() + .collect(Collectors.toMap(File::getId, Function.identity())); + + List result = new ArrayList<>(); + int sortOrder = 0; + for (WorkspaceImage image : images) { + File file = fileMap.get(image.getFileId()); + if (file == null) { + // WorkspaceImage가 참조하는 파일이 조회되지 않음. 데이터 정합성 이상 신호이므로 추적용 경고 + log.warn("업장 대표이미지 조회 중 파일 누락. workspaceId={}, fileId={}", workspaceId, image.getFileId()); + continue; + } + result.add(WorkspaceImageResponseDto.of(file.getId(), fileUrlService.resolve(file).getUrl(), sortOrder++)); + } + return result; + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImages.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImages.java new file mode 100644 index 00000000..838401f1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImages.java @@ -0,0 +1,105 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.UpdateWorkspaceImagesRequestDto; +import com.dreamteam.alter.application.file.FileDeleteService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.inbound.ManagerUpdateWorkspaceImagesUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service("managerUpdateWorkspaceImages") +@RequiredArgsConstructor +@Transactional +public class ManagerUpdateWorkspaceImages implements ManagerUpdateWorkspaceImagesUseCase { + + private static final int MAX_IMAGE_COUNT = 5; + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final WorkspaceImageQueryRepository workspaceImageQueryRepository; + private final WorkspaceImageRepository workspaceImageRepository; + private final FileQueryRepository fileQueryRepository; + private final FileDeleteService fileDeleteService; + private final AttachFilesUseCase attachFiles; + + @Override + public void execute(ManagerActor actor, Long workspaceId, UpdateWorkspaceImagesRequestDto request) { + List newFileIds = request.getOrderedImageFileIds(); + + if (newFileIds.size() > MAX_IMAGE_COUNT) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "대표이미지는 최대 " + MAX_IMAGE_COUNT + "개까지 등록할 수 있습니다."); + } + + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + if (!Objects.equals(workspace.getManagerUser().getId(), actor.getManagerUser().getId())) { + throw new CustomException(ErrorCode.WORKSPACE_NOT_FOUND); + } + + List existingImages = workspaceImageQueryRepository.findAllByWorkspaceId(workspaceId); + Map existingByFileId = existingImages.stream() + .collect(Collectors.toMap(WorkspaceImage::getFileId, Function.identity())); + + // 1. 신규 파일 attach (requestId 단계 없이 바로 workspaceId 로 연결) + // 검증 실패(FILE_NOT_FOUND 등)를 S3 물리 삭제 이전에 조기에 걸러내 정합성 보장 + List addFileIds = newFileIds.stream() + .filter(fileId -> !existingByFileId.containsKey(fileId)) + .toList(); + if (!addFileIds.isEmpty()) { + attachFiles.execute( + addFileIds, + FileTargetType.WORKSPACE_REPRESENTATIVE_IMAGE, + String.valueOf(workspaceId), + actor.getUserId() + ); + } + + // 2. 순서 재계산: 유지 이미지는 sortOrder 갱신, 신규는 생성 + List newImages = new ArrayList<>(); + for (int sortOrder = 0; sortOrder < newFileIds.size(); sortOrder++) { + String fileId = newFileIds.get(sortOrder); + WorkspaceImage existing = existingByFileId.get(fileId); + if (existing != null) { + existing.updateSortOrder(sortOrder); + } else { + newImages.add(WorkspaceImage.create(workspace, fileId, sortOrder)); + } + } + if (!newImages.isEmpty()) { + workspaceImageRepository.saveAll(newImages); + } + + // 3. 빠진 이미지 제거 (파일 soft-delete + WorkspaceImage 삭제) + // S3 물리 삭제는 롤백 불가하므로 attach/검증이 모두 성공한 마지막 단계에서 수행 + List removedImages = existingImages.stream() + .filter(image -> !newFileIds.contains(image.getFileId())) + .toList(); + if (!removedImages.isEmpty()) { + List removedFileIds = removedImages.stream() + .map(WorkspaceImage::getFileId) + .toList(); + fileQueryRepository.findAllByIdIn(removedFileIds).forEach(fileDeleteService::delete); + workspaceImageRepository.deleteAll(removedImages); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java index b0512cb0..e93f173c 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java @@ -1,6 +1,11 @@ package com.dreamteam.alter.application.workspace.usecase; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,15 +21,21 @@ import com.dreamteam.alter.domain.user.port.outbound.ManagerUserRepository; import com.dreamteam.alter.domain.user.type.ManagerUserStatus; import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequest; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; import com.dreamteam.alter.domain.workspace.port.inbound.UpdateWorkspaceRequestStatusUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageQueryRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestQueryRepository; import com.dreamteam.alter.domain.workspace.type.WorkspaceRequestStatus; import com.dreamteam.alter.domain.workspace.type.WorkspaceStatus; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service("updateWorkspaceRequestStatus") @RequiredArgsConstructor @Transactional @@ -34,6 +45,8 @@ public class UpdateWorkspaceRequestStatus implements UpdateWorkspaceRequestStatu private final WorkspaceRepository workspaceRepository; private final ManagerUserQueryRepository managerUserQueryRepository; private final ManagerUserRepository managerUserRepository; + private final WorkspaceImageRepository workspaceImageRepository; + private final WorkspaceRequestImageQueryRepository workspaceRequestImageQueryRepository; private final FileQueryRepository fileQueryRepository; private final FileDeleteService fileDeleteService; @@ -77,9 +90,43 @@ private void approve(WorkspaceRequest workspaceRequest, Long workspaceRequestId) workspaceRepository.save(workspace); + attachRepresentativeImages(workspaceRequestId, workspace); + Optional file = fileQueryRepository.findByTargetTypeAndTargetId( FileTargetType.WORKSPACE_OWN_IDENTITY, String.valueOf(workspaceRequestId)); file.ifPresent(fileDeleteService::delete); } + + private void attachRepresentativeImages(Long workspaceRequestId, Workspace workspace) { + List requestImages = + workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(workspaceRequestId); + + if (requestImages.isEmpty()) { + return; + } + + List fileIds = requestImages.stream() + .map(WorkspaceRequestImage::getFileId) + .toList(); + Map fileMap = fileQueryRepository.findAllByIdIn(fileIds).stream() + .collect(Collectors.toMap(File::getId, Function.identity())); + + List workspaceImages = new ArrayList<>(); + int sortOrder = 0; + for (WorkspaceRequestImage requestImage : requestImages) { + File file = fileMap.get(requestImage.getFileId()); + if (file == null) { + // 신청~승인 사이에 파일이 삭제된 경우. 일부만 마이그레이션되므로 운영 추적용 경고 + log.warn("업장 대표이미지 마이그레이션 중 파일 누락. workspaceRequestId={}, workspaceId={}, fileId={}", + workspaceRequestId, workspace.getId(), requestImage.getFileId()); + continue; + } + // 신청(requestId)에 붙어있던 파일을 생성된 업장(workspaceId)으로 재첨부 + file.attach(String.valueOf(workspace.getId())); + // 누락 파일이 있어도 노출 순서가 비연속되지 않도록 재계산 + workspaceImages.add(WorkspaceImage.create(workspace, requestImage.getFileId(), sortOrder++)); + } + workspaceImageRepository.saveAll(workspaceImages); + } } diff --git a/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java b/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java index 327ea99f..787192ed 100644 --- a/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java +++ b/src/main/java/com/dreamteam/alter/domain/file/type/FileTargetType.java @@ -5,6 +5,7 @@ public enum FileTargetType { USER_CERTIFICATE, POSTING, WORKSPACE, + WORKSPACE_REPRESENTATIVE_IMAGE, // 업장 대표이미지 (최대 5개) WORKSPACE_CERTIFICATE, // 사업자등록증명원 WORKSPACE_OWN_IDENTITY, // 대표자 신분증 사본 WORKSPACE_WARRANT, // 위임 확인서 diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImage.java b/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImage.java new file mode 100644 index 00000000..60970ad7 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImage.java @@ -0,0 +1,78 @@ +package com.dreamteam.alter.domain.workspace.entity; + +import java.time.LocalDateTime; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "workspace_images") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class WorkspaceImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "workspace_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Workspace workspace; + + @Column(name = "file_id", nullable = false) + private String fileId; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static WorkspaceImage create(Workspace workspace, String fileId, int sortOrder) { + validateSortOrder(sortOrder); + return WorkspaceImage.builder() + .workspace(workspace) + .fileId(fileId) + .sortOrder(sortOrder) + .build(); + } + + public void updateSortOrder(int sortOrder) { + validateSortOrder(sortOrder); + this.sortOrder = sortOrder; + } + + private static void validateSortOrder(int sortOrder) { + if (sortOrder < 0) { + throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "노출 순서는 0 이상이어야 합니다."); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequestImage.java b/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequestImage.java new file mode 100644 index 00000000..c740566b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceRequestImage.java @@ -0,0 +1,63 @@ +package com.dreamteam.alter.domain.workspace.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "workspace_request_images") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class WorkspaceRequestImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "workspace_request_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private WorkspaceRequest workspaceRequest; + + @Column(name = "file_id", nullable = false) + private String fileId; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static WorkspaceRequestImage create(WorkspaceRequest workspaceRequest, String fileId, int sortOrder) { + return WorkspaceRequestImage.builder() + .workspaceRequest(workspaceRequest) + .fileId(fileId) + .sortOrder(sortOrder) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerGetWorkspaceImagesUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerGetWorkspaceImagesUseCase.java new file mode 100644 index 00000000..73563d46 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerGetWorkspaceImagesUseCase.java @@ -0,0 +1,13 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import java.util.List; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceImageResponseDto; +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface ManagerGetWorkspaceImagesUseCase { + List execute(ManagerActor actor, Long workspaceId); + + // 호출 측에서 이미 업장 소유를 검증한 경우 사용 (소유 재검증 생략) + List getImagesWithoutOwnershipCheck(Long workspaceId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerUpdateWorkspaceImagesUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerUpdateWorkspaceImagesUseCase.java new file mode 100644 index 00000000..861aa3d4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ManagerUpdateWorkspaceImagesUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.UpdateWorkspaceImagesRequestDto; +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface ManagerUpdateWorkspaceImagesUseCase { + void execute(ManagerActor actor, Long workspaceId, UpdateWorkspaceImagesRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageQueryRepository.java new file mode 100644 index 00000000..7e5cec79 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageQueryRepository.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import java.util.List; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; + +public interface WorkspaceImageQueryRepository { + List findAllByWorkspaceId(Long workspaceId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageRepository.java new file mode 100644 index 00000000..9ca04617 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceImageRepository.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import java.util.List; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; + +public interface WorkspaceImageRepository { + void saveAll(List workspaceImages); + void deleteAll(List workspaceImages); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageQueryRepository.java new file mode 100644 index 00000000..85d152a1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageQueryRepository.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import java.util.List; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; + +public interface WorkspaceRequestImageQueryRepository { + List findAllByWorkspaceRequestId(Long workspaceRequestId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageRepository.java new file mode 100644 index 00000000..8e1f5c46 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceRequestImageRepository.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import java.util.List; + +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; + +public interface WorkspaceRequestImageRepository { + void saveAll(List workspaceRequestImages); +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java new file mode 100644 index 00000000..cc68513c --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/CreateWorkspaceRequestTests.java @@ -0,0 +1,144 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.WorkspaceImageRequestDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.CreateWorkspaceRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateWorkspaceRequest 테스트") +class CreateWorkspaceRequestTests { + + @Mock + private WorkspaceRequestRepository workspaceRequestRepository; + + @Mock + private WorkspaceRequestImageRepository workspaceRequestImageRepository; + + @Mock + private AttachFilesUseCase attachFiles; + + @InjectMocks + private CreateWorkspaceRequest createWorkspaceRequest; + + @Captor + private ArgumentCaptor> fileIdsCaptor; + + @Captor + private ArgumentCaptor> imagesCaptor; + + private CreateWorkspaceRequestDto baseRequest() { + CreateWorkspaceRequestDto dto = new CreateWorkspaceRequestDto(); + dto.setBizName("세븐일레븐"); + dto.setBrn("123-45-12345"); + dto.setAddress("서울특별시 구로구 고척동 123"); + dto.setProvince("서울특별시"); + dto.setDistrict("구로구"); + dto.setTown("고척동"); + dto.setType("음식점"); + dto.setContact("02-1234-5678"); + dto.setWorkspaceCertFileId("cert-file"); + dto.setWorkspaceOwnIdentityFileId("identity-file"); + return dto; + } + + @Test + @DisplayName("대표이미지 없이도 정상 처리되며 대표이미지 저장은 호출되지 않는다") + void execute_대표이미지없음() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(100L); + given(workspaceRequestRepository.save(any())).willReturn(1L); + + // when + createWorkspaceRequest.execute(user, baseRequest()); + + // then + then(attachFiles).should().executeMap(any(), eq("1"), eq(100L)); + then(attachFiles).should(never()).execute(any(), any(), any(), any()); + then(workspaceRequestImageRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("중복 대표이미지 ID는 제거되어 순서대로 저장된다") + void execute_대표이미지중복제거() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(100L); + given(workspaceRequestRepository.save(any())).willReturn(1L); + + CreateWorkspaceRequestDto dto = baseRequest(); + Set images = new LinkedHashSet<>(); + images.add(new WorkspaceImageRequestDto("img1", 0)); + images.add(new WorkspaceImageRequestDto("img1", 1)); // 동일 fileId → 무시 + images.add(new WorkspaceImageRequestDto("img2", 2)); + dto.setRepresentativeImages(images); + + // when + createWorkspaceRequest.execute(user, dto); + + // then + then(attachFiles).should().execute( + fileIdsCaptor.capture(), + eq(FileTargetType.WORKSPACE_REPRESENTATIVE_IMAGE), + eq("1"), + eq(100L) + ); + assertThat(fileIdsCaptor.getValue()).containsExactly("img1", "img2"); + + then(workspaceRequestImageRepository).should().saveAll(imagesCaptor.capture()); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceRequestImage::getFileId) + .containsExactly("img1", "img2"); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceRequestImage::getSortOrder) + .containsExactly(0, 1); + } + + @Test + @DisplayName("대표이미지가 5개를 초과하면 ILLEGAL_ARGUMENT 예외가 발생한다") + void execute_대표이미지5개초과_예외() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(100L); + given(workspaceRequestRepository.save(any())).willReturn(1L); + + CreateWorkspaceRequestDto dto = baseRequest(); + Set images = new LinkedHashSet<>(); + for (int i = 1; i <= 6; i++) { + images.add(new WorkspaceImageRequestDto("img" + i, i)); + } + dto.setRepresentativeImages(images); + + // when & then + assertThatThrownBy(() -> createWorkspaceRequest.execute(user, dto)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); + then(workspaceRequestImageRepository).should(never()).saveAll(any()); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImagesTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImagesTests.java new file mode 100644 index 00000000..4a84dda5 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerGetWorkspaceImagesTests.java @@ -0,0 +1,137 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceImageResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerGetWorkspaceImages 테스트") +class ManagerGetWorkspaceImagesTests { + + @Mock + private WorkspaceQueryRepository workspaceQueryRepository; + + @Mock + private WorkspaceImageQueryRepository workspaceImageQueryRepository; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + + @InjectMocks + private ManagerGetWorkspaceImages managerGetWorkspaceImages; + + @Test + @DisplayName("관리 업장이 아니면 WORKSPACE_NOT_FOUND 예외가 발생한다") + void execute_관리업장아님_예외() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(managerUser); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(false); + + // when & then + assertThatThrownBy(() -> managerGetWorkspaceImages.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + } + + @Test + @DisplayName("대표이미지가 없으면 빈 목록을 반환한다") + void execute_이미지없음_빈목록() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(managerUser); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)).willReturn(List.of()); + + // when + List result = managerGetWorkspaceImages.execute(actor, 1L); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("이미지 순서대로 매핑하며 파일이 없는 항목은 건너뛰고 노출 순서를 0부터 재계산한다") + void execute_정상_순서매핑() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(managerUser); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + + WorkspaceImage imageA = mock(WorkspaceImage.class); + WorkspaceImage imageMissing = mock(WorkspaceImage.class); + WorkspaceImage imageB = mock(WorkspaceImage.class); + given(imageA.getFileId()).willReturn("file-A"); + given(imageMissing.getFileId()).willReturn("file-missing"); + given(imageB.getFileId()).willReturn("file-B"); + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)) + .willReturn(List.of(imageA, imageMissing, imageB)); + + File fileA = mock(File.class); + File fileB = mock(File.class); + given(fileA.getId()).willReturn("file-A"); + given(fileB.getId()).willReturn("file-B"); + given(fileQueryRepository.findAllByIdIn(List.of("file-A", "file-missing", "file-B"))) + .willReturn(List.of(fileA, fileB)); + + FileResponseDto dtoA = FileResponseDto.of(fileA, "https://url/A"); + FileResponseDto dtoB = FileResponseDto.of(fileB, "https://url/B"); + given(fileUrlService.resolve(fileA)).willReturn(dtoA); + given(fileUrlService.resolve(fileB)).willReturn(dtoB); + + // when + List result = managerGetWorkspaceImages.execute(actor, 1L); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(WorkspaceImageResponseDto::getFileId) + .containsExactly("file-A", "file-B"); + assertThat(result).extracting(WorkspaceImageResponseDto::getUrl) + .containsExactly("https://url/A", "https://url/B"); + assertThat(result).extracting(WorkspaceImageResponseDto::getSortOrder) + .containsExactly(0, 1); + } + + @Test + @DisplayName("getImagesWithoutOwnershipCheck는 소유 검증 없이 이미지를 조회한다") + void getImagesWithoutOwnershipCheck_소유검증생략_이미지조회() { + // given + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)).willReturn(List.of()); + + // when + List result = managerGetWorkspaceImages.getImagesWithoutOwnershipCheck(1L); + + // then + assertThat(result).isEmpty(); + verifyNoInteractions(workspaceQueryRepository); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImagesTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImagesTests.java new file mode 100644 index 00000000..0a24fdba --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ManagerUpdateWorkspaceImagesTests.java @@ -0,0 +1,225 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.WorkspaceImageRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.UpdateWorkspaceImagesRequestDto; +import com.dreamteam.alter.application.file.FileDeleteService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerUpdateWorkspaceImages 테스트") +class ManagerUpdateWorkspaceImagesTests { + + @Mock + private WorkspaceQueryRepository workspaceQueryRepository; + + @Mock + private WorkspaceImageQueryRepository workspaceImageQueryRepository; + + @Mock + private WorkspaceImageRepository workspaceImageRepository; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileDeleteService fileDeleteService; + + @Mock + private AttachFilesUseCase attachFiles; + + @InjectMocks + private ManagerUpdateWorkspaceImages managerUpdateWorkspaceImages; + + @Captor + private ArgumentCaptor> imagesCaptor; + + @Captor + private ArgumentCaptor> fileIdsCaptor; + + private UpdateWorkspaceImagesRequestDto request(String... fileIds) { + Set images = new LinkedHashSet<>(); + for (int i = 0; i < fileIds.length; i++) { + images.add(new WorkspaceImageRequestDto(fileIds[i], i)); + } + return new UpdateWorkspaceImagesRequestDto(images); + } + + @Test + @DisplayName("5개를 초과하면 ILLEGAL_ARGUMENT 예외가 발생한다") + void execute_5개초과_예외() { + // given + ManagerActor actor = mock(ManagerActor.class); + + // when & then + assertThatThrownBy(() -> managerUpdateWorkspaceImages.execute( + actor, 1L, request("f1", "f2", "f3", "f4", "f5", "f6"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); + then(workspaceImageRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("존재하지 않는 업장이면 WORKSPACE_NOT_FOUND 예외가 발생한다") + void execute_업장없음_예외() { + // given + ManagerActor actor = mock(ManagerActor.class); + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> managerUpdateWorkspaceImages.execute(actor, 1L, request("f1"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + } + + @Test + @DisplayName("다른 매니저의 업장이면 WORKSPACE_NOT_FOUND 예외가 발생한다") + void execute_다른매니저_예외() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser actorManager = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(actorManager); + given(actorManager.getId()).willReturn(2L); + + Workspace workspace = mock(Workspace.class); + ManagerUser owner = mock(ManagerUser.class); + given(workspace.getManagerUser()).willReturn(owner); + given(owner.getId()).willReturn(1L); + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + + // when & then + assertThatThrownBy(() -> managerUpdateWorkspaceImages.execute(actor, 1L, request("f1"))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + then(attachFiles).should(never()).execute(any(), any(), any(), any()); + } + + @Test + @DisplayName("신규 파일은 attach 후 순서대로 WorkspaceImage를 생성한다") + void execute_신규추가() { + // given + ManagerActor actor = mockOwnerActor(1L); + given(actor.getUserId()).willReturn(100L); + + Workspace workspace = mockOwnerWorkspace(1L); + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)).willReturn(List.of()); + + // when + managerUpdateWorkspaceImages.execute(actor, 1L, request("f1", "f2")); + + // then + then(attachFiles).should().execute( + fileIdsCaptor.capture(), + eq(FileTargetType.WORKSPACE_REPRESENTATIVE_IMAGE), + eq("1"), + eq(100L) + ); + assertThat(fileIdsCaptor.getValue()).containsExactly("f1", "f2"); + + then(workspaceImageRepository).should().saveAll(imagesCaptor.capture()); + assertThat(imagesCaptor.getValue()).hasSize(2); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceImage::getFileId).containsExactly("f1", "f2"); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceImage::getSortOrder).containsExactly(0, 1); + + then(workspaceImageRepository).should(never()).deleteAll(any()); + then(fileDeleteService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("빠진 이미지는 파일 soft-delete 후 제거하고, 유지 이미지는 순서를 갱신한다") + void execute_삭제및순서변경() { + // given + ManagerActor actor = mockOwnerActor(1L); + Workspace workspace = mockOwnerWorkspace(1L); + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + + WorkspaceImage imageA = mock(WorkspaceImage.class); // 삭제 대상 + WorkspaceImage imageB = mock(WorkspaceImage.class); // 유지 + 순서 변경 + given(imageA.getFileId()).willReturn("fileA"); + given(imageB.getFileId()).willReturn("fileB"); + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)).willReturn(List.of(imageA, imageB)); + + File fileA = mock(File.class); + given(fileQueryRepository.findAllByIdIn(List.of("fileA"))).willReturn(List.of(fileA)); + + // when: fileB만 유지 (fileA 삭제), fileB는 0번으로 이동 + managerUpdateWorkspaceImages.execute(actor, 1L, request("fileB")); + + // then + then(fileDeleteService).should().delete(fileA); + then(workspaceImageRepository).should().deleteAll(List.of(imageA)); + then(imageB).should().updateSortOrder(0); + then(attachFiles).should(never()).execute(any(), any(), any(), any()); + then(workspaceImageRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("중복 파일 ID는 제거되어 한 번만 반영된다") + void execute_중복제거() { + // given + ManagerActor actor = mockOwnerActor(1L); + given(actor.getUserId()).willReturn(100L); + Workspace workspace = mockOwnerWorkspace(1L); + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceImageQueryRepository.findAllByWorkspaceId(1L)).willReturn(List.of()); + + // when + managerUpdateWorkspaceImages.execute(actor, 1L, request("f1", "f1", "f2")); + + // then + then(attachFiles).should().execute(fileIdsCaptor.capture(), any(), any(), any()); + assertThat(fileIdsCaptor.getValue()).containsExactly("f1", "f2"); + then(workspaceImageRepository).should().saveAll(imagesCaptor.capture()); + assertThat(imagesCaptor.getValue()).hasSize(2); + } + + private ManagerActor mockOwnerActor(Long managerId) { + ManagerActor actor = mock(ManagerActor.class); + ManagerUser actorManager = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(actorManager); + given(actorManager.getId()).willReturn(managerId); + return actor; + } + + private Workspace mockOwnerWorkspace(Long managerId) { + Workspace workspace = mock(Workspace.class); + ManagerUser owner = mock(ManagerUser.class); + given(workspace.getManagerUser()).willReturn(owner); + given(owner.getId()).willReturn(managerId); + return workspace; + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java index f665bfb8..0f3b294a 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java @@ -11,23 +11,31 @@ import com.dreamteam.alter.domain.user.port.outbound.ManagerUserQueryRepository; import com.dreamteam.alter.domain.user.port.outbound.ManagerUserRepository; import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceImage; import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequest; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceRequestImage; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceImageRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestImageQueryRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceRequestQueryRepository; import com.dreamteam.alter.domain.workspace.type.WorkspaceRequestStatus; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -49,6 +57,12 @@ class UpdateWorkspaceRequestStatusTests { @Mock private ManagerUserRepository managerUserRepository; + @Mock + private WorkspaceImageRepository workspaceImageRepository; + + @Mock + private WorkspaceRequestImageQueryRepository workspaceRequestImageQueryRepository; + @Mock private FileQueryRepository fileQueryRepository; @@ -58,6 +72,9 @@ class UpdateWorkspaceRequestStatusTests { @InjectMocks private UpdateWorkspaceRequestStatus updateWorkspaceRequestStatus; + @Captor + private ArgumentCaptor> imagesCaptor; + @Nested @DisplayName("execute") class ExecuteTests { @@ -106,6 +123,7 @@ class ExecuteTests { given(user.getId()).willReturn(1L); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(managerUser)); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) .willReturn(Optional.of(identityFile)); @@ -129,6 +147,7 @@ class ExecuteTests { given(user.getId()).willReturn(1L); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.empty()); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) .willReturn(Optional.empty()); @@ -158,5 +177,88 @@ class ExecuteTests { then(managerUserRepository).should(never()).save(any()); then(fileDeleteService).should(never()).delete(any()); } + + @Test + @DisplayName("대표이미지가 없으면 WorkspaceImage 저장을 호출하지 않는다") + void execute_대표이미지없음() { + // given + mockApprovedRequest(); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(10L)).willReturn(List.of()); + given(fileQueryRepository.findByTargetTypeAndTargetId(any(), any())).willReturn(Optional.empty()); + + // when + updateWorkspaceRequestStatus.execute(10L, WorkspaceRequestStatus.ACTIVATED); + + // then + then(workspaceImageRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("신청 단계 파일을 업장으로 재첨부하고 순서대로 WorkspaceImage를 생성한다") + void execute_대표이미지마이그레이션() { + // given + mockApprovedRequest(); + WorkspaceRequestImage riA = requestImage("fileA"); + WorkspaceRequestImage riB = requestImage("fileB"); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(10L)) + .willReturn(List.of(riA, riB)); + + File fileA = mock(File.class); + File fileB = mock(File.class); + given(fileA.getId()).willReturn("fileA"); + given(fileB.getId()).willReturn("fileB"); + given(fileQueryRepository.findAllByIdIn(List.of("fileA", "fileB"))).willReturn(List.of(fileA, fileB)); + given(fileQueryRepository.findByTargetTypeAndTargetId(any(), any())).willReturn(Optional.empty()); + + // when + updateWorkspaceRequestStatus.execute(10L, WorkspaceRequestStatus.ACTIVATED); + + // then + then(fileA).should().attach(anyString()); + then(fileB).should().attach(anyString()); + then(workspaceImageRepository).should().saveAll(imagesCaptor.capture()); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceImage::getFileId).containsExactly("fileA", "fileB"); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceImage::getSortOrder).containsExactly(0, 1); + } + + @Test + @DisplayName("신청~승인 사이 삭제된 파일은 건너뛰고 존재하는 파일만 마이그레이션한다") + void execute_일부파일누락() { + // given + mockApprovedRequest(); + WorkspaceRequestImage riA = requestImage("fileA"); + WorkspaceRequestImage riB = requestImage("fileB"); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(10L)) + .willReturn(List.of(riA, riB)); + + File fileA = mock(File.class); + given(fileA.getId()).willReturn("fileA"); + given(fileQueryRepository.findAllByIdIn(List.of("fileA", "fileB"))).willReturn(List.of(fileA)); + given(fileQueryRepository.findByTargetTypeAndTargetId(any(), any())).willReturn(Optional.empty()); + + // when + updateWorkspaceRequestStatus.execute(10L, WorkspaceRequestStatus.ACTIVATED); + + // then + then(fileA).should().attach(anyString()); + then(workspaceImageRepository).should().saveAll(imagesCaptor.capture()); + assertThat(imagesCaptor.getValue()).extracting(WorkspaceImage::getFileId).containsExactly("fileA"); + } + } + + private WorkspaceRequest mockApprovedRequest() { + WorkspaceRequest request = mock(WorkspaceRequest.class); + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(request.getUser()).willReturn(user); + given(workspaceRequestQueryRepository.findByIdWithUser(10L)).willReturn(Optional.of(request)); + given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(mock(ManagerUser.class))); + return request; + } + + private WorkspaceRequestImage requestImage(String fileId) { + WorkspaceRequestImage image = mock(WorkspaceRequestImage.class); + given(image.getFileId()).willReturn(fileId); + return image; } } diff --git a/src/test/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImageTests.java b/src/test/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImageTests.java new file mode 100644 index 00000000..837249e7 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/domain/workspace/entity/WorkspaceImageTests.java @@ -0,0 +1,57 @@ +package com.dreamteam.alter.domain.workspace.entity; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("WorkspaceImage 엔티티 테스트") +class WorkspaceImageTests { + + @Test + @DisplayName("정상 sortOrder로 생성된다") + void create_validSortOrder_createsSuccessfully() { + // when + WorkspaceImage image = WorkspaceImage.create(null, "file-1", 0); + + // then + assertThat(image.getFileId()).isEqualTo("file-1"); + assertThat(image.getSortOrder()).isZero(); + } + + @Test + @DisplayName("음수 sortOrder로 생성하면 ILLEGAL_ARGUMENT 예외가 발생한다") + void create_negativeSortOrder_throwsIllegalArgumentException() { + // when & then + assertThatThrownBy(() -> WorkspaceImage.create(null, "file-1", -1)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); + } + + @Test + @DisplayName("updateSortOrder는 0 이상 값을 허용한다") + void updateSortOrder_validValue_updatesSuccessfully() { + // given + WorkspaceImage image = WorkspaceImage.create(null, "file-1", 0); + + // when & then + assertThatCode(() -> image.updateSortOrder(3)).doesNotThrowAnyException(); + assertThat(image.getSortOrder()).isEqualTo(3); + } + + @Test + @DisplayName("updateSortOrder에 음수를 주면 ILLEGAL_ARGUMENT 예외가 발생한다") + void updateSortOrder_negativeValue_throwsIllegalArgumentException() { + // given + WorkspaceImage image = WorkspaceImage.create(null, "file-1", 0); + + // when & then + assertThatThrownBy(() -> image.updateSortOrder(-1)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.ILLEGAL_ARGUMENT)); + } +}