Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/OrderCalculationCouponValidationTest.php
T
2026-07-01 10:30:32 +09:00

373 lines
14 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Services;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 상품쿠폰 검증 강화 테스트 (U13b/MP06)
*
* - 문제1: min_order_amount 를 적용 대상 옵션 소계 기준으로 검증
* - 문제2: per_user_limit 을 주문 단계에서 used_at 기준 2축(과거사용+주문내중복) 차단
*/
class OrderCalculationCouponValidationTest extends ModuleTestCase
{
protected OrderCalculationService $service;
protected function setUp(): void
{
parent::setUp();
$this->setupTestSettings();
$this->service = app(OrderCalculationService::class);
}
protected function tearDown(): void
{
foreach (['language_currency.json', 'mileage.json'] as $f) {
$path = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/'.$f);
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
/**
* 테스트용 통화/마일리지 설정을 격리 경로에 저장합니다.
*/
protected function setupTestSettings(): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
file_put_contents(
$settingsPath.'/language_currency.json',
json_encode([
'default_language' => 'ko',
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'is_default' => true,
],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
/**
* 테스트용 상품/옵션을 생성합니다.
*
* @param int $price 판매가
* @return array{0: \Modules\Sirsoft\Ecommerce\Models\Product, 1: \Modules\Sirsoft\Ecommerce\Models\ProductOption}
*/
protected function createProductWithOption(int $price = 50000): array
{
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
]);
return [$product, $option];
}
/**
* 상품 쿠폰 + 발급 내역을 생성합니다.
*
* @param int $userId 발급 대상 사용자 ID
* @param int $minOrderAmount 최소 주문금액
* @param int $perUserLimit 1인 사용 한도 (0=무제한)
* @param int $discountValue 정액 할인값
* @return CouponIssue
*/
protected function makeProductCouponIssue(int $userId, int $minOrderAmount = 0, int $perUserLimit = 0, int $discountValue = 1000): CouponIssue
{
$coupon = Coupon::create([
'code' => 'PC'.uniqid(),
'name' => ['ko' => '상품 쿠폰', 'en' => 'Product Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => $discountValue,
'min_order_amount' => $minOrderAmount,
'per_user_limit' => $perUserLimit,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
return CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $userId,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
}
// ========================================
// 문제1: min_order_amount 적용 상품 소계 기준
// ========================================
/**
* 적용 상품 소계가 미달이면 전체 합계가 충족해도 차단된다.
*/
public function test_product_coupon_min_amount_enforced_on_applicable_item(): void
{
$user = User::factory()->create();
[$productA, $optionA] = $this->createProductWithOption(10000); // 적용 대상(소계 10,000)
[$productB, $optionB] = $this->createProductWithOption(50000); // 비적용(합계만 키움)
// min=50,000 쿠폰을 소계 10,000 인 옵션A 에만 적용
$issue = $this->makeProductCouponIssue($user->id, minOrderAmount: 50000, perUserLimit: 0);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
itemCoupons: [$optionA->id => [$issue->id]],
userId: $user->id,
);
$result = $this->service->calculate($input);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertContains('min_amount', $codes, '적용 소계 미달 → min_amount 차단');
}
/**
* 적용 상품 소계가 충족하면 정상 적용된다.
*/
public function test_product_coupon_min_amount_passes_when_applicable_meets(): void
{
$user = User::factory()->create();
[$product, $option] = $this->createProductWithOption(50000);
$issue = $this->makeProductCouponIssue($user->id, minOrderAmount: 30000, perUserLimit: 0, discountValue: 5000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
itemCoupons: [$option->id => [$issue->id]],
userId: $user->id,
);
$result = $this->service->calculate($input);
$this->assertEmpty($result->validationErrors);
$this->assertEquals(5000, $result->summary->productCouponDiscount);
}
// ========================================
// 문제2: per_user_limit (축1 과거사용 + 축2 주문내중복)
// ========================================
/**
* 축1: 과거 사용 1건 + limit=1 → 차단.
*/
public function test_per_user_limit_axis1_prior_used_blocked(): void
{
$user = User::factory()->create();
[$product, $option] = $this->createProductWithOption(50000);
$issue = $this->makeProductCouponIssue($user->id, minOrderAmount: 0, perUserLimit: 1);
// 과거 사용 1건: 동일 coupon_id 로 used_at 세팅된 다른 발급건
CouponIssue::create([
'coupon_id' => $issue->coupon_id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDay(),
'used_at' => now()->subHour(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
itemCoupons: [$option->id => [$issue->id]],
userId: $user->id,
);
$result = $this->service->calculate($input);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertContains('per_user_limit', $codes);
}
/**
* 축2: 한 주문 내 동일 쿠폰을 두 라인 적용 → 둘째 라인 차단.
*/
public function test_per_user_limit_axis2_within_order_blocked(): void
{
$user = User::factory()->create();
[$productA, $optionA] = $this->createProductWithOption(50000);
[$productB, $optionB] = $this->createProductWithOption(50000);
$issue = $this->makeProductCouponIssue($user->id, minOrderAmount: 0, perUserLimit: 1);
// 동일 발급 ID 를 두 옵션 라인에 지정
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
itemCoupons: [
$optionA->id => [$issue->id],
$optionB->id => [$issue->id],
],
userId: $user->id,
);
$result = $this->service->calculate($input);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertContains('per_user_limit', $codes, '주문 내 중복 → 둘째 라인 per_user_limit 차단');
}
/**
* per_user_limit=0(무제한)은 차단하지 않는다.
*/
public function test_per_user_limit_unlimited_never_blocks(): void
{
$user = User::factory()->create();
[$productA, $optionA] = $this->createProductWithOption(50000);
[$productB, $optionB] = $this->createProductWithOption(50000);
$issue = $this->makeProductCouponIssue($user->id, minOrderAmount: 0, perUserLimit: 0);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
itemCoupons: [
$optionA->id => [$issue->id],
$optionB->id => [$issue->id],
],
userId: $user->id,
);
$result = $this->service->calculate($input);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertNotContains('per_user_limit', $codes);
}
/**
* 비회원(userId null)은 축1(과거 사용) 조회를 skip하고 축2만 적용한다.
*/
public function test_per_user_limit_guest_skips_axis1(): void
{
// 회원에게 발급된 쿠폰을 비회원 컨텍스트로 계산 (userId null)
$owner = User::factory()->create();
[$product, $option] = $this->createProductWithOption(50000);
$issue = $this->makeProductCouponIssue($owner->id, minOrderAmount: 0, perUserLimit: 1);
// 과거 사용 1건 존재하지만 비회원은 축1 조회 skip → 단일 라인은 통과
CouponIssue::create([
'coupon_id' => $issue->coupon_id,
'user_id' => $owner->id,
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDay(),
'used_at' => now()->subHour(),
'expires_at' => now()->addDay(),
]);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
itemCoupons: [$option->id => [$issue->id]],
userId: null,
);
$result = $this->service->calculate($input);
$codes = array_map(fn ($e) => $e->code, $result->validationErrors);
$this->assertNotContains('per_user_limit', $codes, '비회원 단일 라인은 축1 skip → 통과');
}
// ========================================
// Repository 단위
// ========================================
/**
* getUserUsedCountForCoupon 은 used_at 세팅 건만 센다.
*/
public function test_repository_counts_only_used_at_set(): void
{
$user = User::factory()->create();
$coupon = Coupon::create([
'code' => 'RC'.uniqid(),
'name' => ['ko' => '카운트 쿠폰', 'en' => 'Count Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'target_scope' => CouponTargetScope::ALL,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 1000,
'min_order_amount' => 0,
'per_user_limit' => 0,
'is_combinable' => true,
'started_at' => now()->subDay(),
'ended_at' => now()->addDay(),
'is_active' => true,
]);
// 사용 완료 2건
foreach (range(1, 2) as $i) {
CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now()->subDay(),
'used_at' => now()->subHour(),
'expires_at' => now()->addDay(),
]);
}
// 미사용 1건 (used_at null)
CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expires_at' => now()->addDay(),
]);
$repo = app(CouponIssueRepositoryInterface::class);
$this->assertEquals(2, $repo->getUserUsedCountForCoupon($user->id, $coupon->id));
$this->assertEquals(0, $repo->getUserUsedCountForCoupon($user->id, 999999));
}
}