Files
HeuJung c77ddee03b fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
2026-08-12 10:10:01 +09:00

227 lines
9.4 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\EcommerceUserProfileRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use ReflectionMethod;
/**
* base 통화 스냅샷 정책 테스트 (A2)
*
* - order_currency = resolveActiveCurrency(영속>헤더>base) — 표시 헤더 단독 아님
* - base_currency = default_currency (모든 금액 SSoT)
* - 환불 불변식: 부분환불이 원주문 스냅샷 환율 사용(현재 환율 재조회 금지 → 환율 변경해도 불변)
*/
class OrderProcessingCurrencySnapshotTest extends ModuleTestCase
{
private function invokeBuildSnapshot(): array
{
$service = app(OrderProcessingService::class);
$method = new ReflectionMethod($service, 'buildCurrencySnapshot');
$method->setAccessible(true);
return $method->invoke($service);
}
public function test_snapshot_base_currency_is_default(): void
{
$snapshot = $this->invokeBuildSnapshot();
$this->assertArrayHasKey('base_currency', $snapshot);
// 기본통화 = default_currency (KRW). base 는 금액 SSoT.
$this->assertSame('KRW', $snapshot['base_currency']);
}
public function test_order_currency_resolves_from_header_when_no_persisted(): void
{
// 비로그인/헤더 USD → order_currency = USD (resolveActiveCurrency 2순위)
request()->headers->set('X-Currency', 'USD');
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('USD', $snapshot['order_currency']);
// base 는 여전히 KRW (SSoT 보존, order_currency 와 분리)
$this->assertSame('KRW', $snapshot['base_currency']);
}
public function test_order_currency_falls_back_to_base_without_header(): void
{
request()->headers->remove('X-Currency');
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('KRW', $snapshot['order_currency']);
}
public function test_persisted_user_currency_takes_priority_over_header(): void
{
// 로그인 유저의 영속 통화(EUR)가 헤더(USD)보다 1순위 (§A3-b)
$user = User::factory()->create();
Auth::login($user);
app(EcommerceUserProfileRepositoryInterface::class)->setPreferredCurrency($user->id, 'EUR');
request()->headers->set('X-Currency', 'USD');
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('EUR', $snapshot['order_currency']);
Auth::logout();
}
public function test_snapshot_includes_all_currency_exchange_rates(): void
{
$snapshot = $this->invokeBuildSnapshot();
$this->assertArrayHasKey('exchange_rates', $snapshot);
// 전 통화 환율 동봉(환불/표시 재사용)
$this->assertArrayHasKey('KRW', $snapshot['exchange_rates']);
$this->assertArrayHasKey('USD', $snapshot['exchange_rates']);
}
public function test_snapshot_embeds_base_unit_for_formula_reproduction(): void
{
// 공식 변경 후에도 주문 시점 환산 재현을 위해 base_unit 을 박제한다(환차손 0).
$snapshot = $this->invokeBuildSnapshot();
// 최상위 base_unit = 기본 통화(KRW)의 base_unit = 1000 (폴백)
$this->assertArrayHasKey('base_unit', $snapshot);
$this->assertSame(1000, $snapshot['base_unit']);
// 통화별 base_unit 도 동봉: KRW=1000, JPY=100, USD/EUR=1 (폴백)
$this->assertSame(1000, $snapshot['exchange_rates']['KRW']['base_unit']);
$this->assertSame(1, $snapshot['exchange_rates']['USD']['base_unit']);
}
// ──────────────────────────────────────────────
// 미등록 통화 방어 (공개 #91) — 삭제 영속화로 처음 열리는 경로
// ──────────────────────────────────────────────
/**
* 등록 통화를 KRW 단독으로 축소합니다. (관리자가 나머지를 삭제한 상태)
*/
private function registerOnlyBaseCurrency(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.language_currency', [
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW', 'en' => 'KRW'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
]);
CurrencySettingsCache::clear();
$this->app->forgetInstance(CurrencyConversionService::class);
$this->app->forgetInstance(OrderProcessingService::class);
}
/**
* 사용 중지된 통화를 선호 통화로 가진 유저는 기본 통화로 폴백한다.
*
* 폴백이 없으면 스냅샷 exchange_rates 에 그 코드가 없어 체크아웃이 차단된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects unregistered_active_currency_falls_back_to_base
*/
public function test_unregistered_persisted_currency_falls_back_to_base(): void
{
$user = User::factory()->create();
Auth::login($user);
app(EcommerceUserProfileRepositoryInterface::class)->setPreferredCurrency($user->id, 'EUR');
request()->headers->remove('X-Currency');
$this->registerOnlyBaseCurrency();
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 선호 통화가 결제 통화로 그대로 채택되었습니다.');
$this->assertArrayHasKey('KRW', $snapshot['exchange_rates']);
Auth::logout();
}
/**
* 사용 중지된 통화가 X-Currency 헤더로 들어와도 기본 통화로 폴백한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects unregistered_active_currency_falls_back_to_base
*/
public function test_unregistered_x_currency_header_falls_back_to_base(): void
{
request()->headers->set('X-Currency', 'JPY');
$this->registerOnlyBaseCurrency();
$snapshot = $this->invokeBuildSnapshot();
$this->assertSame('KRW', $snapshot['order_currency'], '미등록 헤더 통화가 결제 통화로 그대로 채택되었습니다.');
request()->headers->remove('X-Currency');
}
// ──────────────────────────────────────────────
// 환불 불변식 (A2 D-BASE-3) — 핵심
// ──────────────────────────────────────────────
public function test_refund_uses_snapshot_rate_not_current_rate(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 주문 시점 스냅샷: USD 환율 0.85 로 고정
$snapshot = [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'exchange_rate' => 0.85,
'exchange_rates' => [
'KRW' => ['rate' => 1.0, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
'USD' => ['rate' => 0.85, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
],
];
// 환불액 10,000(base KRW) 을 스냅샷 환율로 변환
$result = $svc->convertMultipleAmountsWithSnapshot(['refund' => 10000], $snapshot);
// USD 환산 = (10000/1000) * 0.85 = 8.5 (스냅샷 환율 기준)
$this->assertEquals(8.5, $result['USD']['refund']);
// KRW(base) = 정수 그대로 보존(SSoT)
$this->assertEquals(10000, $result['KRW']['refund']);
}
public function test_refund_unchanged_when_current_rate_differs_from_snapshot(): void
{
/** @var CurrencyConversionService $svc */
$svc = app(CurrencyConversionService::class);
// 스냅샷 환율(주문 시점) = 0.85, 현재 환율이 1.50 으로 바뀌었다고 가정해도
// convertMultipleAmountsWithSnapshot 는 스냅샷 환율(0.85)만 사용해야 한다(환차손 0).
$snapshot = [
'base_currency' => 'KRW',
'order_currency' => 'USD',
'exchange_rate' => 0.85,
'exchange_rates' => [
'USD' => ['rate' => 0.85, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
],
];
$result = $svc->convertMultipleAmountsWithSnapshot(['refund' => 20000], $snapshot);
// (20000/1000)*0.85 = 17.0 (스냅샷 0.85, 현재 환율 1.50 이 아님)
$this->assertEquals(17.0, $result['USD']['refund']);
$this->assertNotEquals(30.0, $result['USD']['refund']); // 현재 환율 1.50 이었다면 30.0
}
}