Files
Gnuboard7/tests/Unit/Traits/NormalizesSettingsDataTest.php
HeuJung 6bde24bc75 fix(ecommerce): 숫자 설정 문자열 영속으로 인한 주문 생성 실패 해소
관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의
문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가
발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면
다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고,
저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다.

같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다.
- 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시
 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단
- 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제
- 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해
 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제

엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가
리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시
무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다.

Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의
주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면
폴백을 서버와 같은 규칙으로 맞췄다.
2026-08-02 18:35:28 +09:00

273 lines
10 KiB
PHP

<?php
namespace Tests\Unit\Traits;
use App\Traits\NormalizesSettingsData;
use Tests\TestCase;
/**
* 트레이트 검증용 더미 클래스 (protected 메서드 노출)
*/
class NormalizesSettingsDataDummy
{
use NormalizesSettingsData;
public function normalize(array $settings, array $defaults): array
{
return $this->normalizeSettingsData($settings, $defaults);
}
public function normalizeCategory(array $settings, array $defaults): array
{
return $this->normalizeCategoryData($settings, $defaults);
}
}
/**
* 설정 데이터 정규화 트레이트 테스트
*
* 숫자 설정이 문자열로 영속되어도(HTML number 입력 → Laravel integer 규칙은
* 숫자 문자열을 통과시키되 캐스트하지 않음) 조회 시 defaults 스키마의 스칼라 타입으로
* 정규화되는지 검증한다. 정규화가 없으면 Carbon 등 strict 타입 경계에서 TypeError 가 난다.
*
* @effects settings_read_returns_schema_scalar_type
*/
class NormalizesSettingsDataTest extends TestCase
{
private NormalizesSettingsDataDummy $dummy;
protected function setUp(): void
{
parent::setUp();
$this->dummy = new NormalizesSettingsDataDummy;
}
// ──────────────────────────────────────────────
// 스칼라 숫자 정규화 (int)
// ──────────────────────────────────────────────
public function test_numeric_string_is_cast_to_int_when_default_is_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => '5'],
['auto_cancel_days' => 3],
);
$this->assertSame(5, $result['auto_cancel_days']);
}
public function test_zero_padded_numeric_string_is_cast_to_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => '007'],
['auto_cancel_days' => 3],
);
$this->assertSame(7, $result['auto_cancel_days']);
}
public function test_nested_category_settings_are_normalized(): void
{
$result = $this->dummy->normalize(
['order_settings' => ['auto_cancel_days' => '5', 'cart_expiry_days' => '30']],
['order_settings' => ['auto_cancel_days' => 3, 'cart_expiry_days' => 30]],
);
$this->assertSame(5, $result['order_settings']['auto_cancel_days']);
$this->assertSame(30, $result['order_settings']['cart_expiry_days']);
}
// ──────────────────────────────────────────────
// 스칼라 숫자 정규화 (float)
// ──────────────────────────────────────────────
public function test_numeric_string_is_cast_to_float_when_default_is_float(): void
{
$result = $this->dummy->normalizeCategory(
['point_value' => '0.5'],
['point_value' => 1.0],
);
$this->assertSame(0.5, $result['point_value']);
}
public function test_integer_string_is_cast_to_float_when_default_is_float(): void
{
$result = $this->dummy->normalizeCategory(
['point_value' => '2'],
['point_value' => 1.0],
);
$this->assertSame(2.0, $result['point_value']);
}
// ──────────────────────────────────────────────
// 배열 항목 내부 정규화 (currencies 등)
// ──────────────────────────────────────────────
public function test_numeric_strings_inside_indexed_array_items_are_cast(): void
{
$result = $this->dummy->normalizeCategory(
['currencies' => [
['code' => 'KRW', 'decimal_places' => '0', 'exchange_rate' => '1'],
['code' => 'USD', 'decimal_places' => '2', 'exchange_rate' => '1350.5'],
]],
['currencies' => [
['code' => 'KRW', 'decimal_places' => 0, 'exchange_rate' => 1.0],
]],
);
$this->assertSame(0, $result['currencies'][0]['decimal_places']);
$this->assertSame(1.0, $result['currencies'][0]['exchange_rate']);
$this->assertSame(2, $result['currencies'][1]['decimal_places']);
$this->assertSame(1350.5, $result['currencies'][1]['exchange_rate']);
}
// ──────────────────────────────────────────────
// 비회귀 — 캐스트 대상이 아닌 값은 불변
// ──────────────────────────────────────────────
public function test_non_numeric_string_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => 'abc'],
['auto_cancel_days' => 3],
);
$this->assertSame('abc', $result['auto_cancel_days']);
}
public function test_empty_string_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => ''],
['auto_cancel_days' => 3],
);
$this->assertSame('', $result['auto_cancel_days']);
}
public function test_null_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => null],
['auto_cancel_days' => 3],
);
$this->assertNull($result['auto_cancel_days']);
}
public function test_boolean_default_is_not_treated_as_numeric(): void
{
// PHP 에서 true 는 is_int 가 아니지만, 문자열 "1" 이 불리언 설정에 들어와도
// int 로 바뀌면 안 된다 (기본값 타입이 bool 이므로 캐스트 대상 아님).
$result = $this->dummy->normalizeCategory(
['auto_cancel_expired' => '1'],
['auto_cancel_expired' => true],
);
$this->assertSame('1', $result['auto_cancel_expired']);
}
public function test_string_default_keeps_numeric_string_as_string(): void
{
// 기본값이 문자열이면(예: 사업자번호 조각) 숫자처럼 보여도 문자열을 유지해야 한다.
$result = $this->dummy->normalizeCategory(
['business_number_1' => '007'],
['business_number_1' => ''],
);
$this->assertSame('007', $result['business_number_1']);
}
public function test_default_absent_key_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['unknown_key' => '5'],
[],
);
$this->assertSame('5', $result['unknown_key']);
}
public function test_int_value_stays_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => 5],
['auto_cancel_days' => 3],
);
$this->assertSame(5, $result['auto_cancel_days']);
}
public function test_multilingual_conversion_is_not_broken(): void
{
// 기존 다국어 분기(기본값 배열 + 값 문자열 → 로케일 배열)가 유지되어야 한다.
$result = $this->dummy->normalizeCategory(
['shop_name' => '내 쇼핑몰'],
['shop_name' => ['ko' => '쇼핑몰', 'en' => 'Shop']],
);
$this->assertSame(['ko' => '내 쇼핑몰', 'en' => '내 쇼핑몰'], $result['shop_name']);
}
// ──────────────────────────────────────────────
// 중첩 연관 배열(하위 설정 그룹) 정규화
// ──────────────────────────────────────────────
public function test_numeric_strings_inside_associative_group_are_cast(): void
{
// 카테고리 아래 한 단계 더 깊은 연관 배열도 정규화되어야 한다.
// (인덱스 배열만 내려가던 시절에는 이 값이 문자열로 남아 Carbon 경계까지 갔다)
$result = $this->dummy->normalizeCategory(
['mileage' => ['expiry_days' => '365', 'earn_rate' => '1.5']],
['mileage' => ['expiry_days' => 90, 'earn_rate' => 1.0]],
);
$this->assertSame(365, $result['mileage']['expiry_days']);
$this->assertSame(1.5, $result['mileage']['earn_rate']);
}
public function test_associative_group_normalization_is_recursive(): void
{
$result = $this->dummy->normalizeCategory(
['a' => ['b' => ['c' => '7']]],
['a' => ['b' => ['c' => 1]]],
);
$this->assertSame(7, $result['a']['b']['c']);
}
public function test_multilingual_map_is_untouched_by_group_recursion(): void
{
// 다국어 필드도 연관 배열이지만 기본값이 문자열이라 캐스트 대상이 아니다.
$result = $this->dummy->normalizeCategory(
['shop_name' => ['ko' => '5', 'en' => '5']],
['shop_name' => ['ko' => '쇼핑몰', 'en' => 'Shop']],
);
$this->assertSame(['ko' => '5', 'en' => '5'], $result['shop_name']);
}
public function test_group_without_matching_default_is_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['unknown_group' => ['x' => '5']],
[],
);
$this->assertSame(['x' => '5'], $result['unknown_group']);
}
public function test_numeric_strings_inside_nested_group_of_array_item_are_cast(): void
{
// 인덱스 배열 항목 안에 다시 연관 그룹이 있는 형태
$result = $this->dummy->normalizeCategory(
['rules' => [['code' => 'KRW', 'limits' => ['use_unit' => '100']]]],
['rules' => [['code' => 'KRW', 'limits' => ['use_unit' => 1]]]],
);
$this->assertSame(100, $result['rules'][0]['limits']['use_unit']);
}
}