Skip to content
Closed
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
@@ -0,0 +1,40 @@
package com.loopers.application.product;

import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@RequiredArgsConstructor
@Component
public class ProductFacade {
private final ProductService productService;

public ProductInfo createProduct(String name, String description, BigDecimal price, Integer stock) {
ProductModel product = productService.createProduct(name, description, price, stock);
return ProductInfo.from(product);
}

public ProductInfo getProduct(Long id) {
ProductModel product = productService.getProduct(id);
return ProductInfo.from(product);
}

public Page<ProductInfo> getProducts(Pageable pageable) {
Page<ProductModel> products = productService.getProducts(pageable);
return products.map(ProductInfo::from);
}

public ProductInfo updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) {
ProductModel product = productService.updateProduct(id, name, description, price, stock);
return ProductInfo.from(product);
}

public void deleteProduct(Long id) {
productService.deleteProduct(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.loopers.application.product;

import com.loopers.domain.product.ProductModel;

import java.math.BigDecimal;
import java.time.ZonedDateTime;

public record ProductInfo(Long id, String name, String description, BigDecimal price, Integer stock, ZonedDateTime createdAt, ZonedDateTime updatedAt) {
public static ProductInfo from(ProductModel model) {
return new ProductInfo(
model.getId(),
model.getName(),
model.getDescription(),
model.getPrice(),
model.getStock(),
model.getCreatedAt(),
model.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.loopers.domain.product;

import com.loopers.domain.BaseEntity;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "product")
public class ProductModel extends BaseEntity {

private String name;
private String description;
private BigDecimal price;
private Integer stock;

protected ProductModel() {}

public ProductModel(String name, String description, BigDecimal price, Integer stock) {
if (name == null || name.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다.");
}
if (price == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다.");
}
Comment on lines +25 to +27
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The error message is inconsistent with the validation being performed. When price is null, the message "가격은 0 이상이어야 합니다." (Price must be greater than or equal to 0) is misleading because the actual problem is that the price is null/missing, not that it's negative. The error message should be "가격은 필수입니다." or similar to indicate that the field is required.

Copilot uses AI. Check for mistakes.
if (price.compareTo(BigDecimal.ZERO) < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다.");
}
if (stock == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다.");
}
Comment on lines +31 to +33
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The error message is inconsistent with the validation being performed. When stock is null, the message "재고는 0 이상이어야 합니다." (Stock must be greater than or equal to 0) is misleading because the actual problem is that the stock is null/missing, not that it's negative. The error message should be "재고는 필수입니다." or similar to indicate that the field is required.

Copilot uses AI. Check for mistakes.
if (stock < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다.");
}

this.name = name;
this.description = description;
this.price = price;
this.stock = stock;
}

public String getName() {
return name;
}

public String getDescription() {
return description;
}

public BigDecimal getPrice() {
return price;
}

public Integer getStock() {
return stock;
}

public void update(String name, String description, BigDecimal price, Integer stock) {
if (name == null || name.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다.");
}
if (price == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다.");
}
Comment on lines +64 to +66
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The error message is inconsistent with the validation being performed. When price is null, the message "가격은 0 이상이어야 합니다." (Price must be greater than or equal to 0) is misleading because the actual problem is that the price is null/missing, not that it's negative. The error message should be "가격은 필수입니다." or similar to indicate that the field is required.

Copilot uses AI. Check for mistakes.
if (price.compareTo(BigDecimal.ZERO) < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다.");
}
if (stock == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다.");
}
Comment on lines +70 to +72
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The error message is inconsistent with the validation being performed. When stock is null, the message "재고는 0 이상이어야 합니다." (Stock must be greater than or equal to 0) is misleading because the actual problem is that the stock is null/missing, not that it's negative. The error message should be "재고는 필수입니다." or similar to indicate that the field is required.

Copilot uses AI. Check for mistakes.
if (stock < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다.");
}
Comment on lines +60 to +75
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The validation logic in the constructor is duplicated in the update method (lines 60-75). This violates the DRY (Don't Repeat Yourself) principle and makes the code harder to maintain. Consider extracting the validation logic into a private method that both the constructor and update method can call.

Copilot uses AI. Check for mistakes.

this.name = name;
this.description = description;
this.price = price;
this.stock = stock;
}
}
Comment on lines +1 to +82
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The ProductModel class lacks test coverage. Based on the test patterns in ExampleModelTest.java, this class should have unit tests covering:

  1. Successful product creation with valid inputs
  2. Validation failures for null/blank name
  3. Validation failures for null price
  4. Validation failures for negative price
  5. Validation failures for null stock
  6. Validation failures for negative stock
  7. Successful product updates
  8. Validation failures in the update method

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.domain.product;

import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ProductRepository {
Optional<ProductModel> find(Long id);
List<ProductModel> findAll();
Page<ProductModel> findAll(Pageable pageable);
ProductModel save(ProductModel product);
void delete(ProductModel product);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.loopers.domain.product;

import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@RequiredArgsConstructor
@Component
public class ProductService {

private final ProductRepository productRepository;

@Transactional
public ProductModel createProduct(String name, String description, BigDecimal price, Integer stock) {
ProductModel product = new ProductModel(name, description, price, stock);
return productRepository.save(product);
}

@Transactional(readOnly = true)
public ProductModel getProduct(Long id) {
return productRepository.find(id)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 상품을 찾을 수 없습니다."));
}

@Transactional(readOnly = true)
public Page<ProductModel> getProducts(Pageable pageable) {
return productRepository.findAll(pageable);
}

@Transactional
public ProductModel updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) {
ProductModel product = getProduct(id);
product.update(name, description, price, stock);
return product;
}

@Transactional
public void deleteProduct(Long id) {
ProductModel product = getProduct(id);
productRepository.delete(product);
}
}
Comment on lines +1 to +48
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The ProductService class lacks integration test coverage. Based on the test patterns in ExampleServiceIntegrationTest.java, this class should have integration tests covering:

  1. Creating a product and verifying it's saved correctly
  2. Retrieving an existing product by ID
  3. Handling NOT_FOUND exception when retrieving a non-existent product
  4. Updating an existing product
  5. Deleting a product (soft delete)
  6. Verifying that deleted products are properly filtered in list queries
  7. Pagination functionality in getProducts

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.loopers.infrastructure.product;

import com.loopers.domain.product.ProductModel;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductJpaRepository extends JpaRepository<ProductModel, Long> {
Page<ProductModel> findAllByDeletedAtIsNull(Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.loopers.infrastructure.product;

import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Component
public class ProductRepositoryImpl implements ProductRepository {
private final ProductJpaRepository productJpaRepository;

@Override
public Optional<ProductModel> find(Long id) {
return productJpaRepository.findById(id);
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

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

The find method does not filter out soft-deleted products, which could allow operations on deleted products. For example, calling getProduct, updateProduct, or deleteProduct on a deleted product ID would succeed, which is likely unintended behavior. Consider filtering soft-deleted products in the find method by returning an empty Optional if the product's deletedAt is not null, or add a dedicated method like findByIdAndDeletedAtIsNull in ProductJpaRepository.

Suggested change
return productJpaRepository.findById(id);
return productJpaRepository.findById(id)
.filter(product -> product.getDeletedAt() == null);

Copilot uses AI. Check for mistakes.
}

@Override
public List<ProductModel> findAll() {
return productJpaRepository.findAll().stream()
.filter(product -> product.getDeletedAt() == null)
.collect(Collectors.toList());
}

@Override
public Page<ProductModel> findAll(Pageable pageable) {
return productJpaRepository.findAllByDeletedAtIsNull(pageable);
}

@Override
public ProductModel save(ProductModel productModel) {
return productJpaRepository.save(productModel);
}

@Override
public void delete(ProductModel productModel) {
productModel.delete();
productJpaRepository.save(productModel);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.loopers.interfaces.api.product;

import com.loopers.interfaces.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "Product V1 API", description = "상품 관리 API 입니다.")
public interface ProductV1ApiSpec {

@Operation(
summary = "상품 생성",
description = "새로운 상품을 생성합니다."
)
ApiResponse<ProductV1Dto.ProductResponse> createProduct(
ProductV1Dto.CreateRequest request
);

@Operation(
summary = "상품 조회",
description = "ID로 상품을 조회합니다."
)
ApiResponse<ProductV1Dto.ProductResponse> getProduct(
@Schema(description = "상품 ID")
Long productId
);

@Operation(
summary = "상품 목록 조회",
description = "상품 목록을 페이징하여 조회합니다."
)
ApiResponse<ProductV1Dto.ProductListResponse> getProducts(
@Schema(description = "페이지 번호")
int page,
@Schema(description = "페이지 크기")
int size
);

@Operation(
summary = "상품 수정",
description = "상품 정보를 수정합니다."
)
ApiResponse<ProductV1Dto.ProductResponse> updateProduct(
@Schema(description = "상품 ID")
Long productId,
ProductV1Dto.UpdateRequest request
);

@Operation(
summary = "상품 삭제",
description = "상품을 삭제합니다."
)
ApiResponse<Object> deleteProduct(
@Schema(description = "상품 ID")
Long productId
);
}
Loading
Loading