Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,7 @@ public class ProductJpaEntity {

@Column(nullable = false)
private Boolean isSeasonal;

@Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE")
private Boolean isAblePurchase;
Comment on lines +69 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

nullable = false 제약 조건이 설정되어 있으므로, Boolean 래퍼 타입 대신 기본형 boolean을 사용하는 것이 좋습니다. 이는 불필요한 박싱/언박싱을 방지하고 null 가능성을 차단하여 코드의 안정성을 높여줍니다. (참고: 타입 변경 시 Lombok이 생성하는 Getter 명칭이 isAblePurchase()로 변경될 수 있으니 매퍼 코드 확인이 필요합니다.)

Suggested change
@Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE")
private Boolean isAblePurchase;
@Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE")
private boolean isAblePurchase;

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public ProductJpaEntity toJpaEntity(Product product) {
product.description(),
product.popularity(),
product.season() != null ? seasonJpaMapper.toJpaEntity(product.season()) : null,
product.isSeasonal()
product.isSeasonal(),
product.isAblePurchase()
);
}

Expand All @@ -48,7 +49,8 @@ public Product toDomain(ProductJpaEntity productJpaEntity) {
productJpaEntity.getDescription(),
productJpaEntity.getPopularity(),
season,
productJpaEntity.getIsSeasonal()
productJpaEntity.getIsSeasonal(),
productJpaEntity.getIsAblePurchase()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ com.process.clash.adapter.web.common.ApiResponse<CreateProductDto.Response> crea
"price": 12000,
"discount": 10,
"description": "프로세스 삼겹살 헌터",
"seasonId": 1
"seasonId": 1,
"isAblePurchase": true
}
""")
))
Expand Down Expand Up @@ -91,7 +92,8 @@ com.process.clash.adapter.web.common.ApiResponse<UpdateProductDto.Response> upda
"price": 15000,
"discount": 15,
"description": "리마스터 버전입니다.",
"seasonId": null
"seasonId": null,
"isAblePurchase": false
}
""")
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ public class CreateProductRequestDocument {

@Schema(description = "시즌 ID (시즌 상품이 아닌 경우 null)")
public Long seasonId;

@Schema(description = "구매 가능 여부 (기본값: true)", defaultValue = "true")
public Boolean isAblePurchase;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ public class UpdateProductRequestDocument {

@Schema(description = "시즌 ID (시즌 상품이 아닌 경우 null)")
public Long seasonId;

@Schema(description = "구매 가능 여부 (null이면 기존 값 유지)")
public Boolean isAblePurchase;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public record CommonProductDto(
Long popularity,
String seasonName,
Boolean isSeasonal,
Boolean isAblePurchase,
Boolean isBought,
Instant createdAt
) {
Expand All @@ -31,6 +32,7 @@ public static CommonProductDto from(ProductVo product) {
product.popularity(),
product.seasonName(),
product.isSeasonal(),
product.isAblePurchase(),
product.isBought(),
product.createdAt()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public record Request(
@Max(value = 100, message = "할인율은 100 이하여야 합니다.")
Integer discount,
String description,
Long seasonId // 시즌 상품이 아니면 null
Long seasonId, // 시즌 상품이 아니면 null
Boolean isAblePurchase
) {
public CreateProductData.Command toCommand(Actor actor) {
return new CreateProductData.Command(
Expand All @@ -40,7 +41,8 @@ public CreateProductData.Command toCommand(Actor actor) {
price,
discount,
description,
seasonId
seasonId,
isAblePurchase
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public record Request(
@Max(value = 100, message = "할인율은 100 이하여야 합니다.")
Integer discount,
String description,
Long seasonId
Long seasonId,
Boolean isAblePurchase
) {
public UpdateProductData.Command toCommand(Long productId, Actor actor) {
return new UpdateProductData.Command(
Expand All @@ -40,7 +41,8 @@ public UpdateProductData.Command toCommand(Long productId, Actor actor) {
price,
discount,
description,
seasonId
seasonId,
isAblePurchase
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public record Command(
Long price,
Integer discount,
String description,
Long seasonId
Long seasonId,
Boolean isAblePurchase
) {}

public record Result(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public record Command(
Long price,
Integer discount,
String description,
Long seasonId
Long seasonId,
Boolean isAblePurchase
) {}

public record Result(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.process.clash.application.shop.product.exception.exception.badrequest;

import com.process.clash.application.common.exception.exception.BadRequestException;
import com.process.clash.application.shop.product.exception.status.ProductStatusCode;

public class NotAblePurchaseException extends BadRequestException {
public NotAblePurchaseException() {
super(ProductStatusCode.NOT_ABLE_PURCHASE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum ProductStatusCode implements StatusCode {
INVALID_TITLE("INVALID_TITLE", "상품 제목은 필수입니다.", HttpStatus.BAD_REQUEST),
INVALID_PRICE("INVALID_PRICE", "가격은 0 이상이어야 합니다.", HttpStatus.BAD_REQUEST),
INVALID_DISCOUNT("INVALID_DISCOUNT", "할인율은 0~100 사이여야 합니다.", HttpStatus.BAD_REQUEST),
NOT_ABLE_PURCHASE("NOT_ABLE_PURCHASE", "구매할 수 없는 상품입니다.", HttpStatus.BAD_REQUEST),
;

private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public CreateProductData.Result execute(CreateProductData.Command command) {
command.price(),
command.discount(),
command.description(),
season
season,
command.isAblePurchase()
);

Product savedProduct = productRepositoryPort.save(product);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public UpdateProductData.Result execute(UpdateProductData.Command command) {
command.price(),
command.discount(),
command.description(),
season
season,
command.isAblePurchase()
);

Product savedProduct = productRepositoryPort.save(updatedProduct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public record ProductVo(
Long popularity,
String seasonName,
Boolean isSeasonal,
Boolean isAblePurchase,
Boolean isBought,
Instant createdAt
) {
Expand All @@ -38,6 +39,7 @@ public static ProductVo from(Product domain, boolean isBought) {
domain.popularity(),
seasonName,
domain.isSeasonal(),
domain.isAblePurchase(),
isBought,
domain.createdAt()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.process.clash.application.shop.purchase.exception.exception.badrequest.PriceTooLargeException;
import com.process.clash.application.shop.purchase.port.in.CreatePurchaseUseCase;
import com.process.clash.application.shop.purchase.port.out.PurchaseRepositoryPort;
import com.process.clash.application.shop.product.exception.exception.badrequest.NotAblePurchaseException;
import com.process.clash.application.shop.product.exception.exception.notfound.ProductNotFoundException;
import com.process.clash.application.shop.product.port.out.ProductRepositoryPort;
import com.process.clash.application.user.user.exception.exception.notfound.UserNotFoundException;
Expand Down Expand Up @@ -39,6 +40,10 @@ public CreatePurchaseData.Result execute(CreatePurchaseData.Command command) {
Product product = productRepositoryPort.findById(command.productId())
.orElseThrow(ProductNotFoundException::new);

if (!product.isAblePurchase()) {
throw new NotAblePurchaseException();
}
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

상품의 구매 가능 여부를 체크하는 로직입니다. 만약 product.isAblePurchase()null을 반환할 경우 NullPointerException이 발생할 수 있습니다. 도메인 모델(Product)에서 해당 필드를 기본형 boolean으로 변경하여 null 가능성을 제거하는 것을 권장합니다.


Long userId = command.actor().id();

if (userItemRepositoryPort.existsByUserIdAndProductId(userId, product.id())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public record Product(
String description,
Long popularity,
Season season,
Boolean isSeasonal
Boolean isSeasonal,
Boolean isAblePurchase
Comment on lines +23 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

isSeasonalisAblePurchase 필드는 null을 허용하지 않는 논리 값이므로, Boolean 래퍼 타입 대신 기본형 boolean을 사용하는 것이 안전합니다. Boolean 타입을 사용하면 CreatePurchaseService 등에서 조건문으로 평가할 때 null인 경우 NullPointerException이 발생할 위험이 있습니다.

또한, isAblePurchase라는 명칭은 상품이 구매를 '행하는' 주체인 것처럼 오해를 불러일으킬 수 있습니다. 상품이 '구매 가능한' 상태임을 나타내는 isPurchasable 또는 purchasable과 같은 명칭이 더 자연스럽습니다.

Suggested change
Boolean isSeasonal,
Boolean isAblePurchase
boolean isSeasonal,
boolean isAblePurchase

) {
public Product {
if (title == null || title.isBlank()) {
Expand All @@ -42,7 +43,8 @@ public static Product create(
Long price,
Integer discount,
String description,
Season season
Season season,
Boolean isAblePurchase
) {
return new Product(
null,
Expand All @@ -57,7 +59,8 @@ public static Product create(
description,
0L,
season,
season != null
season != null,
isAblePurchase != null ? isAblePurchase : true
);
}

Expand All @@ -69,7 +72,8 @@ public Product update(
Long price,
Integer discount,
String description,
Season season
Season season,
Boolean isAblePurchase
) {
return new Product(
this.id,
Expand All @@ -84,7 +88,8 @@ public Product update(
description,
this.popularity,
season,
season != null
season != null,
isAblePurchase != null ? isAblePurchase : this.isAblePurchase
);
}

Expand All @@ -103,7 +108,8 @@ public Product increasePopularity() {
this.description,
nextPopularity,
this.season,
this.isSeasonal
this.isSeasonal,
this.isAblePurchase
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE products
ADD COLUMN is_able_purchase BOOLEAN NOT NULL DEFAULT TRUE;

UPDATE products
SET is_able_purchase = TRUE;
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ void createProduct_returnsCreatedResponse() throws Exception {
"price": 12000,
"discount": 10,
"description": "설명",
"seasonId": 1
"seasonId": 1,
"isAblePurchase": true
}
"""))
.andExpect(status().isCreated())
Expand All @@ -87,6 +88,7 @@ void createProduct_returnsCreatedResponse() throws Exception {
ArgumentCaptor<CreateProductData.Command> captor = ArgumentCaptor.forClass(CreateProductData.Command.class);
verify(createProductUseCase).execute(captor.capture());
org.assertj.core.api.Assertions.assertThat(captor.getValue().seasonId()).isEqualTo(1L);
org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isTrue();
}

@Test
Expand All @@ -97,14 +99,17 @@ void updateProduct_returnsSuccessResponse() throws Exception {

mockMvc.perform(put("/api/admin/shop/products/100")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(java.util.Map.of(
"title", "수정 상품",
"category", "BANNER",
"image", "https://cdn.example.com/products/100-v2.png",
"price", 15000,
"discount", 15,
"description", "수정 설명"
))))
.content("""
{
"title": "수정 상품",
"category": "BANNER",
"image": "https://cdn.example.com/products/100-v2.png",
"price": 15000,
"discount": 15,
"description": "수정 설명",
"isAblePurchase": false
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("상품 수정에 성공했습니다."))
.andExpect(jsonPath("$.data.productId").value(100));
Expand All @@ -113,6 +118,32 @@ void updateProduct_returnsSuccessResponse() throws Exception {
verify(updateProductUseCase).execute(captor.capture());
org.assertj.core.api.Assertions.assertThat(captor.getValue().productId()).isEqualTo(100L);
org.assertj.core.api.Assertions.assertThat(captor.getValue().title()).isEqualTo("수정 상품");
org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isFalse();
}

@Test
@DisplayName("POST /api/admin/shop/products isAblePurchase 미입력 시 Command에 null로 전달된다")
void createProduct_withoutIsAblePurchase_passesNullToCommand() throws Exception {
when(createProductUseCase.execute(any()))
.thenReturn(new CreateProductData.Result(101L));

mockMvc.perform(post("/api/admin/shop/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "신규 상품",
"category": "NAMEPLATE",
"image": "https://cdn.example.com/products/101.png",
"price": 10000,
"discount": 0,
"description": "설명"
}
"""))
.andExpect(status().isCreated());

ArgumentCaptor<CreateProductData.Command> captor = ArgumentCaptor.forClass(CreateProductData.Command.class);
verify(createProductUseCase).execute(captor.capture());
org.assertj.core.api.Assertions.assertThat(captor.getValue().isAblePurchase()).isNull();
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ private Product product(Long id, ProductCategory category) {
"desc",
0L,
null,
false
false,
true
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ private Product product(Long id, ProductCategory category) {
"desc",
0L,
null,
false
false,
true
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ private Product product(Long id, ProductCategory category) {
"desc",
0L,
null,
false
false,
true
);
}
}
Loading
Loading