diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/controller/PostingControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/controller/PostingControllerSpec.java index 1538717d1..b11e2105e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/controller/PostingControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/controller/PostingControllerSpec.java @@ -71,6 +71,10 @@ ResponseEntity> getPostingsWi mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject( + name = "존재하지 않는 공고", + value = "{\"code\" : \"B007\"}" + ), @ExampleObject( name = "지원하고자 하는 공고 일정 찾을 수 없음", value = "{\"code\" : \"B010\"}" @@ -78,7 +82,25 @@ ResponseEntity> getPostingsWi @ExampleObject( name = "이미 근무중인 사용자입니다.", value = "{\"code\" : \"B018\"}" + ), + @ExampleObject( + name = "모집이 종료된 공고 (OPEN 이 아닌 상태)", + value = "{\"code\" : \"B001\", \"message\" : \"모집이 종료된 공고입니다.\"}" + ), + @ExampleObject( + name = "이미 지원한 공고 (같은 공고의 다른 근무일정 포함)", + value = "{\"code\" : \"B001\", \"message\" : \"이미 지원한 공고입니다.\"}" ) + })), + @ApiResponse(responseCode = "429", description = "429 Error 실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "동시 지원 요청이 몰려 잠금 획득에 실패", + value = "{\"code\" : \"E001\"}" + ), })) }) ResponseEntity> applyIntoPosting( diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java index 0fe9f6a8f..4dd6cafcf 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java @@ -1,16 +1,17 @@ package com.dreamteam.alter.adapter.inbound.general.posting.dto; +import com.dreamteam.alter.domain.posting.command.CreatePostingCommand; import com.dreamteam.alter.domain.posting.type.PaymentType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import lombok.*; import java.util.List; -import javax.validation.constraints.NotNull; - @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -47,6 +48,18 @@ public class CreatePostingRequestDto { "}" + "]") @Valid - private List schedules; + @NotEmpty + private List<@NotNull @Valid CreatePostingScheduleRequestDto> schedules; + + public CreatePostingCommand toCommand() { + return new CreatePostingCommand( + workspaceId, + title, + description, + payAmount, + paymentType, + schedules.stream().map(CreatePostingScheduleRequestDto::toCommand).toList() + ); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingScheduleRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingScheduleRequestDto.java index 5fd697d7e..26fbf9941 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingScheduleRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingScheduleRequestDto.java @@ -1,5 +1,6 @@ package com.dreamteam.alter.adapter.inbound.general.posting.dto; +import com.dreamteam.alter.domain.posting.command.PostingScheduleCommand; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -38,4 +39,8 @@ public class CreatePostingScheduleRequestDto { @NotBlank private String position; + public PostingScheduleCommand toCommand() { + return new PostingScheduleCommand(workingDays, startTime, endTime, positionsNeeded, position); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java index 6d34852c7..5f4e68fae 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingController.java @@ -77,7 +77,9 @@ public class ManagerPostingController implements ManagerPostingControllerSpec { public ResponseEntity> createPosting( @Valid CreatePostingRequestDto request ) { - createPosting.execute(request); + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + + createPosting.execute(request.toCommand(), actor); return ResponseEntity.ok(CommonApiResponse.empty()); } @@ -155,7 +157,7 @@ public ResponseEntity> updatePosting( ) { ManagerActor actor = ManagerActionContext.getInstance().getActor(); - managerUpdatePosting.execute(postingId, request, actor); + managerUpdatePosting.execute(postingId, request.toCommand(), actor); return ResponseEntity.ok(CommonApiResponse.empty()); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.java index f7437d7e3..b834c7b5a 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/controller/ManagerPostingControllerSpec.java @@ -39,6 +39,10 @@ public interface ManagerPostingControllerSpec { mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject( + name = "존재하지 않거나, 자신이 관리하지 않거나, 활성화되지 않은 업장", + value = "{\"code\" : \"B008\"}" + ), @ExampleObject( name = "서버 내부 오류", value = "{\"code\" : \"C001\"}" @@ -155,7 +159,10 @@ ResponseEntity> updatePostingStatus( @Valid @RequestBody UpdatePostingStatusRequestDto request ); - @Operation(summary = "매니저 - 내가 등록한 공고 내용 수정", description = "") + @Operation( + summary = "매니저 - 내가 등록한 공고 내용 수정", + description = "수정을 마친 뒤 남은 근무일정이 하나도 없으면 공고가 모집 완료(CLOSED)로 바뀌며 더 이상 지원을 받지 않습니다." + ) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "공고 내용 수정 성공"), @ApiResponse(responseCode = "400", description = "실패 케이스", @@ -167,14 +174,6 @@ ResponseEntity> updatePostingStatus( name = "존재하지 않는 공고", value = "{\"code\" : \"B007\"}" ), - @ExampleObject( - name = "등록되지 않은 키워드로 요청", - value = "{\"code\" : \"B006\"}" - ), - @ExampleObject( - name = "요청에 키워드가 포함되지 않은 경우", - value = "{\"code\" : \"B001\"}" - ), })), @ApiResponse(responseCode = "404", description = "404 Error 실패 케이스", content = @Content( @@ -190,6 +189,16 @@ ResponseEntity> updatePostingStatus( value = "{\"code\" : \"B019\"}" ), })), + @ApiResponse(responseCode = "409", description = "409 Error 실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "DELETED 상태의 공고는 내용 수정 불가", + value = "{\"code\" : \"B020\"}" + ), + })), }) ResponseEntity> updatePosting( @PathVariable Long postingId, diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java index 4500f7498..5d169acb5 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java @@ -1,9 +1,11 @@ package com.dreamteam.alter.adapter.inbound.manager.posting.dto; +import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; import com.dreamteam.alter.adapter.inbound.general.posting.dto.ManagerPostingListWorkspaceResponseDto; import com.dreamteam.alter.adapter.inbound.general.posting.dto.PostingScheduleResponseDto; import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.ManagerPostingListResponse; import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -35,6 +37,10 @@ public class ManagerPostingListResponseDto { @Schema(description = "급여 타입", example = "HOURLY") private PaymentType paymentType; + @NotNull + @Schema(description = "공고 상태") + private DescribedEnumDto status; + @NotNull @Schema(description = "생성일", example = "2023-10-01T12:00:00") private LocalDateTime createdAt; @@ -60,6 +66,7 @@ public static ManagerPostingListResponseDto from(ManagerPostingListResponse resp .title(response.getTitle()) .payAmount(response.getPayAmount()) .paymentType(response.getPaymentType()) + .status(DescribedEnumDto.of(response.getStatus(), PostingStatus.describe())) .createdAt(response.getCreatedAt()) .schedules(response.getSchedules().stream() .map(PostingScheduleResponseDto::from) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java index 5c1af20a8..d67a2d282 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java @@ -1,8 +1,12 @@ package com.dreamteam.alter.adapter.inbound.manager.posting.dto; import java.util.List; +import java.util.function.Function; + +import org.apache.commons.lang3.ObjectUtils; import com.dreamteam.alter.adapter.inbound.general.posting.dto.CreatePostingScheduleRequestDto; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; import com.dreamteam.alter.domain.posting.type.PaymentType; import io.swagger.v3.oas.annotations.media.Schema; @@ -27,6 +31,7 @@ public class UpdatePostingRequestDto { @Schema(description = "공고 제목", example = "홀서빙 구합니다") private String title; + @NotBlank @Schema(description = "공고 설명", example = "홀서빙 구합니다. 주말 근무 가능하신 분 우대합니다.") private String description; @@ -48,4 +53,20 @@ public class UpdatePostingRequestDto { @Schema(description = "삭제할 스케줄 ID", example = "[2, 3]") private List deleteScheduleIds; + + public UpdatePostingCommand toCommand() { + return new UpdatePostingCommand( + title, + description, + payAmount, + paymentType, + toCommands(createSchedules, CreatePostingScheduleRequestDto::toCommand), + toCommands(updateSchedules, UpdatePostingScheduleDto::toCommand), + ObjectUtils.isEmpty(deleteScheduleIds) ? List.of() : deleteScheduleIds + ); + } + + private static List toCommands(List source, Function mapper) { + return ObjectUtils.isEmpty(source) ? List.of() : source.stream().map(mapper).toList(); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingScheduleDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingScheduleDto.java index 12b748c95..4e53c1e7e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingScheduleDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingScheduleDto.java @@ -1,10 +1,15 @@ package com.dreamteam.alter.adapter.inbound.manager.posting.dto; +import com.dreamteam.alter.domain.posting.command.UpdatePostingScheduleCommand; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.*; +import java.time.DayOfWeek; +import java.time.LocalTime; import java.util.List; @Getter @@ -18,23 +23,27 @@ public class UpdatePostingScheduleDto { @Schema(description = "스케줄 ID", example = "1") private Long id; - @NotNull + @NotEmpty @Schema(description = "근무일", example = "[\"MONDAY\", \"WEDNESDAY\"]") - private List workingDays; + private List workingDays; @NotNull @Schema(description = "시작 시간", example = "09:00") - private String startTime; + private LocalTime startTime; @NotNull @Schema(description = "종료 시간", example = "18:00") - private String endTime; + private LocalTime endTime; - @NotNull + @Positive @Schema(description = "필요 인원", example = "3") private int positionsNeeded; @NotBlank @Schema(description = "포지션", example = "홀서빙") private String position; + + public UpdatePostingScheduleCommand toCommand() { + return new UpdatePostingScheduleCommand(id, workingDays, startTime, endTime, positionsNeeded, position); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index 8df55887f..debf56edc 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -56,6 +56,7 @@ public long getCountOfPostings(PostingListFilterDto filter) { .select(qPosting.countDistinct()) .from(qPosting) .leftJoin(qPosting.schedules, qPostingSchedule) + .on(qPostingSchedule.status.ne(PostingStatus.DELETED)) .leftJoin(qPosting.workspace, qWorkspace) .where( qPosting.status.eq(PostingStatus.OPEN), @@ -103,6 +104,7 @@ public List getPostingsWithCursor(CursorPageRequest getManagerPostingDetail(Long posti .leftJoin(qWorkspace.businessType, QBusinessType.businessType).fetchJoin() .where( qPosting.id.eq(postingId), - qWorkspace.managerUser.eq(managerUser) + qWorkspace.managerUser.eq(managerUser), + qPosting.status.ne(PostingStatus.DELETED) ) .fetchOne(); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingScheduleQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingScheduleQueryRepositoryImpl.java index 3d4ad4e11..b3a74d14a 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingScheduleQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingScheduleQueryRepositoryImpl.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.posting.entity.PostingSchedule; import com.dreamteam.alter.domain.posting.entity.QPostingSchedule; import com.dreamteam.alter.domain.posting.port.outbound.PostingScheduleQueryRepository; +import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; @@ -24,7 +25,8 @@ public Optional findByIdAndPostingId(Long postingId, Long posti .from(qPostingSchedule) .where( qPostingSchedule.posting.id.eq(postingId), - qPostingSchedule.id.eq(postingScheduleId) + qPostingSchedule.id.eq(postingScheduleId), + qPostingSchedule.status.ne(PostingStatus.DELETED) ) .fetchOne(); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java index d4e6052fc..d5eee5b07 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java @@ -48,7 +48,7 @@ public static ManagerPostingDetailResponse of( posting.getStatus(), posting.getCreatedAt(), posting.getUpdatedAt(), - posting.getSchedules() + posting.getActiveSchedules() ); } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java index 6c06539a9..fe8727e32 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.entity.PostingSchedule; import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.dreamteam.alter.domain.workspace.entity.Workspace; import lombok.*; @@ -23,6 +24,8 @@ public class ManagerPostingListResponse { private PaymentType paymentType; + private PostingStatus status; + private LocalDateTime createdAt; private List schedules; @@ -35,8 +38,9 @@ public static ManagerPostingListResponse of(Posting posting) { .title(posting.getTitle()) .payAmount(posting.getPayAmount()) .paymentType(posting.getPaymentType()) + .status(posting.getStatus()) .createdAt(posting.getCreatedAt()) - .schedules(posting.getSchedules()) + .schedules(posting.getActiveSchedules()) .workspace(posting.getWorkspace()) .build(); } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java index a1f4f4d69..eb6cf3143 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingDetailResponse.java @@ -44,7 +44,7 @@ public static PostingDetailResponse of( posting.getPayAmount(), posting.getPaymentType(), posting.getCreatedAt(), - posting.getSchedules(), + posting.getActiveSchedules(), scrapped ); } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java index fd4303948..cea8e7c6f 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java @@ -38,7 +38,7 @@ public static PostingListResponse of(Posting posting, boolean scrapped) { .payAmount(posting.getPayAmount()) .paymentType(posting.getPaymentType()) .createdAt(posting.getCreatedAt()) - .schedules(posting.getSchedules()) + .schedules(posting.getActiveSchedules()) .workspace(posting.getWorkspace()) .scrapped(scrapped) .build(); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java index 900b7cf25..95449f670 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java @@ -64,6 +64,21 @@ public Optional findById(Long id) { ); } + @Override + public Optional findByIdAndManagerUser(Long id, ManagerUser managerUser) { + QWorkspace qWorkspace = QWorkspace.workspace; + return Optional.ofNullable( + queryFactory + .selectFrom(qWorkspace) + .where( + qWorkspace.id.eq(id), + qWorkspace.managerUser.eq(managerUser), + qWorkspace.status.eq(WorkspaceStatus.ACTIVATED) + ) + .fetchOne() + ); + } + @Override public List getManagerWorkspaceList(ManagerUser managerUser) { QWorkspace qWorkspace = QWorkspace.workspace; diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java index c477d58d5..0ab8649cc 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java @@ -3,12 +3,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.dreamteam.alter.adapter.inbound.general.posting.dto.CreatePostingRequestDto; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.CreatePostingCommand; import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.port.inbound.CreatePostingUseCase; import com.dreamteam.alter.domain.posting.port.outbound.PostingRepository; +import com.dreamteam.alter.domain.user.context.ManagerActor; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; @@ -23,11 +24,12 @@ public class CreatePosting implements CreatePostingUseCase { private final WorkspaceQueryRepository workspaceQueryRepository; @Override - public void execute(CreatePostingRequestDto request) { - Workspace workspace = workspaceQueryRepository.findById(request.getWorkspaceId()) - .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + public void execute(CreatePostingCommand command, ManagerActor actor) { + Workspace workspace = + workspaceQueryRepository.findByIdAndManagerUser(command.workspaceId(), actor.getManagerUser()) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); - Posting posting = Posting.create(request, workspace); + Posting posting = Posting.create(command, workspace); postingRepository.save(posting); } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java index fcc24cee8..b935051ea 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java @@ -3,9 +3,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.dreamteam.alter.adapter.inbound.manager.posting.dto.UpdatePostingRequestDto; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.port.inbound.ManagerUpdatePostingUseCase; import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; @@ -22,20 +22,12 @@ public class ManagerUpdatePosting implements ManagerUpdatePostingUseCase { private final PostingQueryRepository postingQueryRepository; @Override - public void execute(Long postingId, UpdatePostingRequestDto request, ManagerActor actor) { + public void execute(Long postingId, UpdatePostingCommand command, ManagerActor actor) { ManagerUser managerUser = actor.getManagerUser(); Posting posting = postingQueryRepository.findByManagerAndId(postingId, managerUser) .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); - posting.updateContent( - request.getTitle(), - request.getDescription(), - request.getPayAmount(), - request.getPaymentType(), - request.getCreateSchedules(), - request.getUpdateSchedules(), - request.getDeleteScheduleIds() - ); + posting.updateContent(command); } } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingStatus.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingStatus.java index 30eee0c54..a4a01c3e8 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingStatus.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingStatus.java @@ -6,7 +6,6 @@ import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.port.inbound.ManagerUpdatePostingStatusUseCase; import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; -import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.dreamteam.alter.domain.user.context.ManagerActor; import com.dreamteam.alter.domain.user.entity.ManagerUser; import lombok.RequiredArgsConstructor; @@ -27,11 +26,6 @@ public void execute(Long postingId, UpdatePostingStatusRequestDto request, Manag Posting posting = postingQueryRepository.findByManagerAndId(postingId, managerUser) .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); - // DELETED 상태인 경우 상태 변경 불가 - if (PostingStatus.DELETED.equals(posting.getStatus())) { - throw new CustomException(ErrorCode.CONFLICT); - } - posting.updateStatus(request.getStatus()); } } diff --git a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java index cb61ef9ca..c2fbe0d38 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import jakarta.validation.ConstraintViolationException; import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -83,6 +84,17 @@ public ResponseEntity> handleMethodArgumentTypeMismatchExcep .body(ErrorResponse.of(errorCode)); } + /** + * 요청 본문 역직렬화 실패. 잘못된 JSON 이나 enum · 시각 등 타입 변환 실패가 여기로 온다. + * 핸들러가 없으면 Spring 기본 응답이 나가 프로젝트 오류 포맷과 어긋난다. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + ErrorCode errorCode = ErrorCode.ILLEGAL_ARGUMENT; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, "요청 본문을 해석할 수 없습니다.")); + } + @ExceptionHandler(MissingServletRequestPartException.class) public ResponseEntity> handleMissingServletRequestPartException(MissingServletRequestPartException e) { ErrorCode errorCode = ErrorCode.ILLEGAL_ARGUMENT; diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/CreatePostingCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/CreatePostingCommand.java new file mode 100644 index 000000000..6a87ebfb1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/CreatePostingCommand.java @@ -0,0 +1,14 @@ +package com.dreamteam.alter.domain.posting.command; + +import com.dreamteam.alter.domain.posting.type.PaymentType; + +import java.util.List; + +public record CreatePostingCommand( + Long workspaceId, + String title, + String description, + int payAmount, + PaymentType paymentType, + List schedules +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/PostingScheduleCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/PostingScheduleCommand.java new file mode 100644 index 000000000..beeead2dd --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/PostingScheduleCommand.java @@ -0,0 +1,13 @@ +package com.dreamteam.alter.domain.posting.command; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; + +public record PostingScheduleCommand( + List workingDays, + LocalTime startTime, + LocalTime endTime, + int positionsNeeded, + String position +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingCommand.java new file mode 100644 index 000000000..cb4ecdb6e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingCommand.java @@ -0,0 +1,15 @@ +package com.dreamteam.alter.domain.posting.command; + +import com.dreamteam.alter.domain.posting.type.PaymentType; + +import java.util.List; + +public record UpdatePostingCommand( + String title, + String description, + int payAmount, + PaymentType paymentType, + List createSchedules, + List updateSchedules, + List deleteScheduleIds +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingScheduleCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingScheduleCommand.java new file mode 100644 index 000000000..73d85affb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/UpdatePostingScheduleCommand.java @@ -0,0 +1,14 @@ +package com.dreamteam.alter.domain.posting.command; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; + +public record UpdatePostingScheduleCommand( + Long id, + List workingDays, + LocalTime startTime, + LocalTime endTime, + int positionsNeeded, + String position +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java index 62ab54710..c278b3895 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java @@ -1,16 +1,15 @@ package com.dreamteam.alter.domain.posting.entity; -import com.dreamteam.alter.adapter.inbound.general.posting.dto.CreatePostingRequestDto; -import com.dreamteam.alter.adapter.inbound.general.posting.dto.CreatePostingScheduleRequestDto; -import com.dreamteam.alter.adapter.inbound.manager.posting.dto.UpdatePostingScheduleDto; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.CreatePostingCommand; +import com.dreamteam.alter.domain.posting.command.PostingScheduleCommand; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; +import com.dreamteam.alter.domain.posting.command.UpdatePostingScheduleCommand; import com.dreamteam.alter.domain.posting.type.PaymentType; import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.dreamteam.alter.domain.workspace.entity.Workspace; -import java.time.DayOfWeek; -import java.time.LocalTime; import jakarta.persistence.*; import lombok.*; import org.apache.commons.lang3.ObjectUtils; @@ -19,6 +18,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @@ -67,76 +67,78 @@ public class Posting { @OneToMany(mappedBy = "posting", cascade = CascadeType.ALL, orphanRemoval = true) private List schedules; - public static Posting create(CreatePostingRequestDto request, Workspace workspace) { + public static Posting create(CreatePostingCommand command, Workspace workspace) { Posting posting = Posting.builder() .workspace(workspace) - .title(request.getTitle()) - .description(request.getDescription()) - .payAmount(request.getPayAmount()) - .paymentType(request.getPaymentType()) + .title(command.title()) + .description(command.description()) + .payAmount(command.payAmount()) + .paymentType(command.paymentType()) .status(PostingStatus.OPEN) .build(); - if (ObjectUtils.isNotEmpty(request.getSchedules())) { - posting.schedules = request.getSchedules() - .stream() - .map(scheduleDto -> PostingSchedule.create( - scheduleDto.getWorkingDays(), - scheduleDto.getStartTime(), - scheduleDto.getEndTime(), - scheduleDto.getPositionsNeeded(), - scheduleDto.getPosition(), - posting - )) - .toList(); + posting.schedules = new ArrayList<>(); + if (ObjectUtils.isNotEmpty(command.schedules())) { + posting.addSchedules(command.schedules()); } return posting; } public void updateStatus(PostingStatus status) { + validateModifiable(); + this.status = status; } - public void updateContent( - String title, - String description, - int payAmount, - PaymentType paymentType, - List createSchedules, - List updateSchedules, - List deleteScheduleIds - ) { - this.title = title; - this.description = description; - this.payAmount = payAmount; - this.paymentType = paymentType; + /** + * 삭제되지 않은 근무일정만 반환한다. (응답 노출 기준) + */ + public List getActiveSchedules() { + if (ObjectUtils.isEmpty(this.schedules)) { + return List.of(); + } + + return this.schedules.stream() + .filter(schedule -> !PostingStatus.DELETED.equals(schedule.getStatus())) + .toList(); + } + + public void updateContent(UpdatePostingCommand command) { + validateModifiable(); + + this.title = command.title(); + this.description = command.description(); + this.payAmount = command.payAmount(); + this.paymentType = command.paymentType(); // 스케줄 삭제 처리 - if (ObjectUtils.isNotEmpty(deleteScheduleIds)) - deleteSchedules(deleteScheduleIds); + if (ObjectUtils.isNotEmpty(command.deleteScheduleIds())) + deleteSchedules(command.deleteScheduleIds()); // 스케줄 수정 처리 - if (ObjectUtils.isNotEmpty(updateSchedules)) - updateSchedules(updateSchedules); + if (ObjectUtils.isNotEmpty(command.updateSchedules())) + updateSchedules(command.updateSchedules()); // 스케줄 추가 처리 - if (ObjectUtils.isNotEmpty(createSchedules)) - addSchedules(createSchedules); + if (ObjectUtils.isNotEmpty(command.createSchedules())) + addSchedules(command.createSchedules()); + + closeIfNoActiveSchedules(); } /** * 스케줄 추가 * @param createSchedules 스케줄 추가 정보 List */ - public void addSchedules(List createSchedules) { - for (CreatePostingScheduleRequestDto createDto : createSchedules) { + public void addSchedules(List createSchedules) { + for (PostingScheduleCommand createCommand : createSchedules) { PostingSchedule newSchedule = PostingSchedule.create( - createDto.getWorkingDays(), - createDto.getStartTime(), - createDto.getEndTime(), - createDto.getPositionsNeeded(), - createDto.getPosition(), + createCommand.workingDays(), + createCommand.startTime(), + createCommand.endTime(), + createCommand.positionsNeeded(), + createCommand.position(), this ); this.schedules.add(newSchedule); @@ -147,21 +149,19 @@ public void addSchedules(List createSchedules) * 스케줄 수정 * @param updateSchedules 스케줄 수정 정보 List */ - public void updateSchedules(List updateSchedules) { - for (UpdatePostingScheduleDto updateDto : updateSchedules) { - PostingSchedule existingSchedule = this.schedules.stream() - .filter(schedule -> schedule.getId().equals(updateDto.getId())) + public void updateSchedules(List updateSchedules) { + for (UpdatePostingScheduleCommand updateCommand : updateSchedules) { + PostingSchedule existingSchedule = getActiveSchedules().stream() + .filter(schedule -> schedule.getId().equals(updateCommand.id())) .findFirst() .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "수정할 스케줄을 찾을 수 없습니다.")); existingSchedule.update( - updateDto.getWorkingDays().stream() - .map(DayOfWeek::valueOf) - .toList(), - LocalTime.parse(updateDto.getStartTime()), - LocalTime.parse(updateDto.getEndTime()), - updateDto.getPositionsNeeded(), - updateDto.getPosition() + updateCommand.workingDays(), + updateCommand.startTime(), + updateCommand.endTime(), + updateCommand.positionsNeeded(), + updateCommand.position() ); } } @@ -172,7 +172,7 @@ public void updateSchedules(List updateSchedules) { */ public void deleteSchedules(List deleteScheduleIds) { for (Long scheduleId : deleteScheduleIds) { - PostingSchedule existingSchedule = this.schedules.stream() + PostingSchedule existingSchedule = getActiveSchedules().stream() .filter(schedule -> schedule.getId().equals(scheduleId)) .findFirst() .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "삭제할 스케줄을 찾을 수 없습니다.")); @@ -180,4 +180,23 @@ public void deleteSchedules(List deleteScheduleIds) { existingSchedule.updateStatus(PostingStatus.DELETED); } } + + /** + * 삭제된 공고는 더 이상 변경할 수 없다. + */ + private void validateModifiable() { + if (PostingStatus.DELETED.equals(this.status)) { + throw new CustomException(ErrorCode.CONFLICT); + } + } + + /** + * 모집할 근무일정이 하나도 남지 않으면 마감된 공고로 본다. + * 삭제·수정·추가를 모두 마친 뒤에 판정해야 전부 지우고 새로 추가하는 요청을 마감으로 잘못 처리하지 않는다. + */ + private void closeIfNoActiveSchedules() { + if (PostingStatus.OPEN.equals(this.status) && getActiveSchedules().isEmpty()) { + this.status = PostingStatus.CLOSED; + } + } } diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/CreatePostingUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/CreatePostingUseCase.java index e47a8abdb..88deed4ff 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/CreatePostingUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/CreatePostingUseCase.java @@ -1,7 +1,8 @@ package com.dreamteam.alter.domain.posting.port.inbound; -import com.dreamteam.alter.adapter.inbound.general.posting.dto.CreatePostingRequestDto; +import com.dreamteam.alter.domain.posting.command.CreatePostingCommand; +import com.dreamteam.alter.domain.user.context.ManagerActor; public interface CreatePostingUseCase { - void execute(CreatePostingRequestDto request); + void execute(CreatePostingCommand command, ManagerActor actor); } diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/ManagerUpdatePostingUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/ManagerUpdatePostingUseCase.java index 147f7d285..005084a3d 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/ManagerUpdatePostingUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/ManagerUpdatePostingUseCase.java @@ -1,8 +1,8 @@ package com.dreamteam.alter.domain.posting.port.inbound; -import com.dreamteam.alter.adapter.inbound.manager.posting.dto.UpdatePostingRequestDto; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; import com.dreamteam.alter.domain.user.context.ManagerActor; public interface ManagerUpdatePostingUseCase { - void execute(Long postingId, UpdatePostingRequestDto request, ManagerActor actor); + void execute(Long postingId, UpdatePostingCommand command, ManagerActor actor); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java index e65676357..a8a36bf2a 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java @@ -20,6 +20,7 @@ public interface WorkspaceQueryRepository { Optional findById(Long id); + Optional findByIdAndManagerUser(Long id, ManagerUser managerUser); List getManagerWorkspaceList(ManagerUser managerUser); ManagerWorkspaceResponse getByManagerUserAndId(ManagerUser managerUser, Long workspaceId); diff --git a/src/main/resources/db/migration/V4__unify_chat_schema.sql b/src/main/resources/db/migration/V11__unify_chat_schema.sql similarity index 100% rename from src/main/resources/db/migration/V4__unify_chat_schema.sql rename to src/main/resources/db/migration/V11__unify_chat_schema.sql diff --git a/src/main/resources/db/migration/V5__chat_message_content_nullable.sql b/src/main/resources/db/migration/V12__chat_message_content_nullable.sql similarity index 100% rename from src/main/resources/db/migration/V5__chat_message_content_nullable.sql rename to src/main/resources/db/migration/V12__chat_message_content_nullable.sql diff --git a/src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql b/src/main/resources/db/migration/V13__backfill_workspace_group_chat.sql similarity index 100% rename from src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql rename to src/main/resources/db/migration/V13__backfill_workspace_group_chat.sql diff --git a/src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql b/src/main/resources/db/migration/V14__unique_workspace_group_chat_room.sql similarity index 100% rename from src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql rename to src/main/resources/db/migration/V14__unique_workspace_group_chat_room.sql diff --git a/src/test/java/com/dreamteam/alter/application/posting/usecase/CreatePostingTests.java b/src/test/java/com/dreamteam/alter/application/posting/usecase/CreatePostingTests.java new file mode 100644 index 000000000..32dd21f97 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/posting/usecase/CreatePostingTests.java @@ -0,0 +1,110 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.CreatePostingCommand; +import com.dreamteam.alter.domain.posting.command.PostingScheduleCommand; +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.port.outbound.PostingRepository; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +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.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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.DayOfWeek; +import java.time.LocalTime; +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreatePosting 테스트") +class CreatePostingTests { + + @Mock + private PostingRepository postingRepository; + + @Mock + private WorkspaceQueryRepository workspaceQueryRepository; + + @InjectMocks + private CreatePosting createPosting; + + @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.findByIdAndManagerUser(1L, managerUser)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> createPosting.execute(command(), actor)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + then(postingRepository).should(never()).save(any()); + } + + @Test + @DisplayName("자신이 관리하는 업장이면 공고가 OPEN 상태로 근무일정과 함께 저장된다") + void execute_소유업장_공고저장() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + Workspace workspace = mock(Workspace.class); + + given(actor.getManagerUser()).willReturn(managerUser); + given(workspaceQueryRepository.findByIdAndManagerUser(1L, managerUser)).willReturn(Optional.of(workspace)); + + // when + createPosting.execute(command(), actor); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Posting.class); + then(postingRepository).should().save(captor.capture()); + + Posting saved = captor.getValue(); + assertThat(saved.getTitle()).isEqualTo("홀서빙 구합니다"); + assertThat(saved.getWorkspace()).isSameAs(workspace); + assertThat(saved.getStatus()).isEqualTo(PostingStatus.OPEN); + assertThat(saved.getPaymentType()).isEqualTo(PaymentType.HOURLY); + assertThat(saved.getSchedules()).hasSize(1); + assertThat(saved.getSchedules().getFirst().getPosition()).isEqualTo("홀서빙"); + assertThat(saved.getSchedules().getFirst().getPositionsAvailable()).isEqualTo(3); + } + + private CreatePostingCommand command() { + return new CreatePostingCommand( + 1L, + "홀서빙 구합니다", + "주말 근무 가능하신 분", + 12000, + PaymentType.HOURLY, + List.of(new PostingScheduleCommand( + List.of(DayOfWeek.MONDAY), + LocalTime.of(9, 0), + LocalTime.of(18, 0), + 3, + "홀서빙" + )) + ); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingTests.java b/src/test/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingTests.java new file mode 100644 index 000000000..15443d33c --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePostingTests.java @@ -0,0 +1,82 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; +import com.dreamteam.alter.domain.posting.entity.Posting; +import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +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 java.util.Optional; + +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.BDDMockito.then; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerUpdatePosting 테스트") +class ManagerUpdatePostingTests { + + @Mock + private PostingQueryRepository postingQueryRepository; + + @InjectMocks + private ManagerUpdatePosting managerUpdatePosting; + + @Test + @DisplayName("존재하지 않거나 자신의 공고가 아니면 POSTING_NOT_FOUND 예외가 발생한다") + void execute_공고없음_예외발생() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + given(actor.getManagerUser()).willReturn(managerUser); + given(postingQueryRepository.findByManagerAndId(1L, managerUser)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> managerUpdatePosting.execute(1L, command(), actor)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.POSTING_NOT_FOUND)); + } + + @Test + @DisplayName("조회한 공고에 수정 명령을 그대로 위임한다") + void execute_정상공고_수정() { + // given + ManagerActor actor = mock(ManagerActor.class); + ManagerUser managerUser = mock(ManagerUser.class); + Posting posting = mock(Posting.class); + UpdatePostingCommand command = command(); + + given(actor.getManagerUser()).willReturn(managerUser); + given(postingQueryRepository.findByManagerAndId(1L, managerUser)).willReturn(Optional.of(posting)); + + // when + managerUpdatePosting.execute(1L, command, actor); + + // then + then(posting).should().updateContent(command); + } + + private UpdatePostingCommand command() { + return new UpdatePostingCommand( + "수정된 제목", + "수정된 설명", + 13000, + PaymentType.HOURLY, + List.of(), + List.of(), + List.of() + ); + } +} diff --git a/src/test/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandlerTests.java b/src/test/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandlerTests.java new file mode 100644 index 000000000..9a616df93 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandlerTests.java @@ -0,0 +1,61 @@ +package com.dreamteam.alter.common.exception.handler; + +import com.dreamteam.alter.adapter.inbound.manager.posting.dto.UpdatePostingScheduleDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("GlobalExceptionHandler 테스트") +class GlobalExceptionHandlerTests { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new TestController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("요일 값이 잘못되면 프로젝트 오류 포맷으로 400을 반환한다") + void 잘못된_요일값_400() throws Exception { + String body = """ + {"id": 1, "workingDays": ["monday"], "startTime": "09:00", "endTime": "18:00", "positionsNeeded": 1, "position": "홀서빙"} + """; + + mockMvc.perform(post("/test").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("B001")); + } + + @Test + @DisplayName("시각 값이 잘못되면 프로젝트 오류 포맷으로 400을 반환한다") + void 잘못된_시각값_400() throws Exception { + String body = """ + {"id": 1, "workingDays": ["MONDAY"], "startTime": "25:00", "endTime": "18:00", "positionsNeeded": 1, "position": "홀서빙"} + """; + + mockMvc.perform(post("/test").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("B001")); + } + + @RestController + static class TestController { + + @PostMapping("/test") + void receive(@RequestBody UpdatePostingScheduleDto request) { + } + } +} diff --git a/src/test/java/com/dreamteam/alter/domain/posting/entity/PostingTests.java b/src/test/java/com/dreamteam/alter/domain/posting/entity/PostingTests.java new file mode 100644 index 000000000..0e98aa458 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/domain/posting/entity/PostingTests.java @@ -0,0 +1,217 @@ +package com.dreamteam.alter.domain.posting.entity; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.PostingScheduleCommand; +import com.dreamteam.alter.domain.posting.command.UpdatePostingCommand; +import com.dreamteam.alter.domain.posting.command.UpdatePostingScheduleCommand; +import com.dreamteam.alter.domain.posting.type.PaymentType; +import com.dreamteam.alter.domain.posting.type.PostingStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Posting 테스트") +class PostingTests { + + @Test + @DisplayName("삭제된 근무일정은 활성 일정 목록에서 제외된다") + void getActiveSchedules_삭제일정제외() { + // given + Posting posting = new Posting(); + PostingSchedule open = createSchedule(posting, "홀서빙"); + PostingSchedule deleted = createSchedule(posting, "주방보조"); + deleted.updateStatus(PostingStatus.DELETED); + ReflectionTestUtils.setField(posting, "schedules", List.of(open, deleted)); + + // when + List result = posting.getActiveSchedules(); + + // then + assertThat(result).containsExactly(open); + } + + @Test + @DisplayName("근무일정이 없으면 빈 목록을 반환한다") + void getActiveSchedules_일정없음_빈목록() { + // given + Posting posting = new Posting(); + + // when & then + assertThat(posting.getActiveSchedules()).isEmpty(); + } + + @Test + @DisplayName("삭제된 근무일정은 수정할 수 없다") + void updateSchedules_삭제일정_예외발생() { + // given + Posting posting = new Posting(); + PostingSchedule deleted = createSchedule(posting, 1L, "주방보조"); + deleted.updateStatus(PostingStatus.DELETED); + ReflectionTestUtils.setField(posting, "schedules", List.of(deleted)); + + UpdatePostingScheduleCommand command = new UpdatePostingScheduleCommand( + 1L, + List.of(DayOfWeek.TUESDAY), + LocalTime.of(10, 0), + LocalTime.of(19, 0), + 5, + "홀서빙" + ); + + // when & then + assertThatThrownBy(() -> posting.updateSchedules(List.of(command))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + assertThat(deleted.getPosition()).isEqualTo("주방보조"); + assertThat(deleted.getPositionsAvailable()).isEqualTo(1); + } + + @Test + @DisplayName("이미 삭제된 근무일정은 다시 삭제할 수 없다") + void deleteSchedules_삭제일정_예외발생() { + // given + Posting posting = new Posting(); + PostingSchedule deleted = createSchedule(posting, 1L, "주방보조"); + deleted.updateStatus(PostingStatus.DELETED); + ReflectionTestUtils.setField(posting, "schedules", List.of(deleted)); + + // when & then + assertThatThrownBy(() -> posting.deleteSchedules(List.of(1L))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + } + + @Test + @DisplayName("마지막 근무일정을 삭제하면 공고가 모집 완료로 바뀐다") + void updateContent_마지막일정삭제_자동종료() { + // given + Posting posting = openPosting(); + PostingSchedule only = createSchedule(posting, 1L, "홀서빙"); + ReflectionTestUtils.setField(posting, "schedules", new ArrayList<>(List.of(only))); + + // when + posting.updateContent(updateCommand(null, null, List.of(1L))); + + // then + assertThat(posting.getActiveSchedules()).isEmpty(); + assertThat(posting.getStatus()).isEqualTo(PostingStatus.CLOSED); + } + + @Test + @DisplayName("근무일정을 전부 삭제하고 새로 추가하면 모집 중을 유지한다") + void updateContent_전부삭제후추가_모집중유지() { + // given + Posting posting = openPosting(); + PostingSchedule only = createSchedule(posting, 1L, "홀서빙"); + ReflectionTestUtils.setField(posting, "schedules", new ArrayList<>(List.of(only))); + + PostingScheduleCommand created = new PostingScheduleCommand( + List.of(DayOfWeek.FRIDAY), + LocalTime.of(13, 0), + LocalTime.of(21, 0), + 2, + "주방보조" + ); + + // when + posting.updateContent(updateCommand(List.of(created), null, List.of(1L))); + + // then + assertThat(posting.getActiveSchedules()).hasSize(1); + assertThat(posting.getStatus()).isEqualTo(PostingStatus.OPEN); + } + + @Test + @DisplayName("근무일정이 남아 있으면 모집 중을 유지한다") + void updateContent_일부일정삭제_모집중유지() { + // given + Posting posting = openPosting(); + PostingSchedule first = createSchedule(posting, 1L, "홀서빙"); + PostingSchedule second = createSchedule(posting, 2L, "주방보조"); + ReflectionTestUtils.setField(posting, "schedules", new ArrayList<>(List.of(first, second))); + + // when + posting.updateContent(updateCommand(null, null, List.of(1L))); + + // then + assertThat(posting.getActiveSchedules()).containsExactly(second); + assertThat(posting.getStatus()).isEqualTo(PostingStatus.OPEN); + } + + @Test + @DisplayName("삭제된 공고는 내용을 수정할 수 없다") + void updateContent_삭제공고_예외발생() { + // given + Posting posting = new Posting(); + ReflectionTestUtils.setField(posting, "status", PostingStatus.DELETED); + ReflectionTestUtils.setField(posting, "title", "원래 제목"); + + // when & then + assertThatThrownBy(() -> posting.updateContent(updateCommand(null, null, null))) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + assertThat(posting.getTitle()).isEqualTo("원래 제목"); + } + + @Test + @DisplayName("삭제된 공고는 상태를 변경할 수 없다") + void updateStatus_삭제공고_예외발생() { + // given + Posting posting = new Posting(); + ReflectionTestUtils.setField(posting, "status", PostingStatus.DELETED); + + // when & then + assertThatThrownBy(() -> posting.updateStatus(PostingStatus.OPEN)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + assertThat(posting.getStatus()).isEqualTo(PostingStatus.DELETED); + } + + private Posting openPosting() { + Posting posting = new Posting(); + ReflectionTestUtils.setField(posting, "status", PostingStatus.OPEN); + return posting; + } + + private UpdatePostingCommand updateCommand( + List createSchedules, + List updateSchedules, + List deleteScheduleIds + ) { + return new UpdatePostingCommand( + "제목", + "설명", + 12000, + PaymentType.HOURLY, + createSchedules, + updateSchedules, + deleteScheduleIds + ); + } + + private PostingSchedule createSchedule(Posting posting, String position) { + return PostingSchedule.create( + List.of(DayOfWeek.MONDAY), + LocalTime.of(9, 0), + LocalTime.of(18, 0), + 1, + position, + posting + ); + } + + private PostingSchedule createSchedule(Posting posting, Long id, String position) { + PostingSchedule schedule = createSchedule(posting, position); + ReflectionTestUtils.setField(schedule, "id", id); + return schedule; + } +}