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

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

366 lines
14 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 기본 제공 통화의 의도적 삭제 영속화 테스트 (공개 #91)
*
* U11-A 는 array_merge 통째 교체로 defaults 통화가 소실되던 문제를 code 기준 보충으로 막았으나,
* "부작용 소실" 과 "관리자 의도 삭제" 를 구분할 장치가 없어 의도 삭제까지 되돌렸다.
* 저장 시점에 삭제 의도를 서버가 도출해 `removed_default_currencies` 로 기록하고,
* 조회 병합이 그 기록을 존중하는지 검증한다.
*/
class EcommerceSettingsServiceCurrencyTombstoneTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
// ──────────────────────────────────────────────
// 헬퍼
// ──────────────────────────────────────────────
/**
* 통화 코드 목록으로 저장 payload 의 currencies 배열을 만듭니다.
*
* @param array<int, string> $codes 통화 코드 목록
* @param string $defaultCode 기본 통화 코드
* @return array 통화 항목 배열
*/
private function currencies(array $codes, string $defaultCode = 'KRW'): array
{
$meta = [
'KRW' => ['rate' => null, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 1000],
'USD' => ['rate' => 0.85, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'JPY' => ['rate' => 115, 'unit' => '1', 'method' => 'floor', 'dp' => 0, 'base_unit' => 100],
'CNY' => ['rate' => 5.8, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'EUR' => ['rate' => 0.78, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
'GBP' => ['rate' => 0.6, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1],
];
return array_map(function (string $code) use ($meta, $defaultCode) {
$m = $meta[$code] ?? ['rate' => 1.0, 'unit' => '0.01', 'method' => 'round', 'dp' => 2, 'base_unit' => 1];
return [
'code' => $code,
'name' => ['ko' => $code, 'en' => $code],
'exchange_rate' => $code === $defaultCode ? null : $m['rate'],
'base_unit' => $m['base_unit'],
'rounding_unit' => $m['unit'],
'rounding_method' => $m['method'],
'decimal_places' => $m['dp'],
'is_default' => $code === $defaultCode,
];
}, $codes);
}
/**
* 관리자 저장 경로(saveSettings)로 language_currency 를 저장합니다.
*
* @param array $languageCurrency language_currency 카테고리 payload
*/
private function save(array $languageCurrency): void
{
$this->service->saveSettings(['language_currency' => $languageCurrency]);
$this->service->clearCache();
}
/**
* 저장된 language_currency.json 을 그대로 읽습니다.
*
* @return array 저장 파일의 디코드 결과
*/
private function savedFile(): array
{
$path = $this->storagePath.'/language_currency.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 현재 조회 결과의 통화 코드 목록을 반환합니다.
*
* @return array<int, string> 통화 코드 목록
*/
private function resolvedCodes(): array
{
$lc = $this->service->getSettings('language_currency');
return array_values(array_map(fn ($c) => $c['code'] ?? null, $lc['currencies'] ?? []));
}
// ──────────────────────────────────────────────
// 삭제 영속화 (핵심)
// ──────────────────────────────────────────────
/**
* 관리자가 삭제한 기본 제공 통화가 저장 후에도 되살아나지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_deleted_default_currency_stays_deleted_after_save(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
$this->assertNotContains('JPY', $codes, '관리자가 삭제한 JPY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('CNY', $codes, '관리자가 삭제한 CNY 가 defaults 에서 되살아났습니다.');
$this->assertNotContains('EUR', $codes, '관리자가 삭제한 EUR 가 defaults 에서 되살아났습니다.');
}
/**
* 삭제 의도가 저장 파일에 tombstone 으로 기록된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_written_to_saved_file(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$saved = $this->savedFile();
$this->assertArrayHasKey('removed_default_currencies', $saved, '삭제 기록이 저장되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $saved['removed_default_currencies']);
// 비연속 키가 JSON 객체로 직렬화되지 않도록 array_values 재정렬 (CLAUDE.md 규칙)
$this->assertSame([0, 1, 2], array_keys($saved['removed_default_currencies']));
}
/**
* 기본 통화 코드는 제출에서 빠져도 tombstone 되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects base_currency_never_tombstoned
*/
public function test_default_currency_code_never_tombstoned(): void
{
// API 직접 제출로 기본 통화(KRW)가 빠진 목록을 보낸 상황
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['USD'], 'USD'),
]);
$saved = $this->savedFile();
$this->assertNotContains('KRW', $saved['removed_default_currencies'] ?? [], '기본 통화가 tombstone 되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes, '기본 통화가 조회 결과에서 사라졌습니다.');
$lc = $this->service->getSettings('language_currency');
$krw = collect($lc['currencies'])->firstWhere('code', 'KRW');
$this->assertTrue($krw['is_default'], '기본 통화의 is_default 가 유지되어야 합니다.');
}
/**
* 삭제한 통화를 다시 추가하면 tombstone 이 해제되고 복원된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=readded
*
* @effects intentionally_removed_default_currency_stays_deleted, tombstone_recomputed_server_side_on_save
*/
public function test_readding_currency_clears_tombstone_and_resurrects(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
// USD 재등록
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$codes = $this->resolvedCodes();
$this->assertContains('USD', $codes, '재추가한 통화가 복원되지 않았습니다.');
$this->assertSame(['JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 관리자가 추가한 통화(defaults 밖)의 삭제는 종전대로 유지된다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects intentionally_removed_default_currency_stays_deleted
*/
public function test_admin_added_currency_still_deletable(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'GBP']),
]);
$this->assertContains('GBP', $this->resolvedCodes());
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$this->assertNotContains('GBP', $this->resolvedCodes(), '관리자 추가 통화의 삭제가 유지되지 않았습니다.');
// defaults 밖 통화는 tombstone 대상이 아니다 (보충 병합이 되살리지 않으므로 기록 불필요)
$this->assertNotContains('GBP', $this->savedFile()['removed_default_currencies'] ?? []);
}
/**
* 다른 탭 저장은 language_currency 의 삭제 상태를 되돌리지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_other_category_save_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$before = $this->savedFile();
// 기본정보 탭만 저장
$this->service->saveSettings(['basic_info' => ['shop_name' => ['ko' => '테스트샵', 'en' => 'Test Shop']]]);
$this->service->clearCache();
$this->assertSame($before, $this->savedFile(), '타 카테고리 저장이 language_currency 파일을 변경했습니다.');
$this->assertSame(['KRW'], $this->resolvedCodes());
}
/**
* currencies 키 없이 language_currency 를 저장해도 tombstone 이 이월된다.
*
* saveCategorySettings 는 파일 통째 교체라 이월하지 않으면 삭제가 전부 부활한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects other_category_save_preserves_tombstone
*/
public function test_language_currency_save_without_currencies_preserves_tombstone(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
// default_currency 만 담긴 저장 (currencies 키 부재)
$this->save(['default_currency' => 'KRW']);
$this->assertSame(['USD', 'JPY', 'CNY', 'EUR'], $this->savedFile()['removed_default_currencies'] ?? []);
$this->assertSame(['KRW'], $this->resolvedCodes(), 'currencies 미제출 저장이 삭제 통화를 부활시켰습니다.');
}
/**
* tombstone 키가 없는 구 저장본은 U11-A 보충 동작을 그대로 유지한다.
*
* @scenario saved_currency_set=jpy_missing, deletion_tombstone=none_legacy
*
* @effects legacy_file_without_tombstone_keeps_supplement
*/
public function test_legacy_saved_file_without_tombstone_supplements_defaults(): void
{
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/language_currency.json',
json_encode([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->service->clearCache();
$codes = $this->resolvedCodes();
$this->assertContains('JPY', $codes, '구 저장본의 defaults 보충(U11-A)이 깨졌습니다.');
$this->assertContains('CNY', $codes);
$this->assertContains('EUR', $codes);
}
/**
* 클라이언트가 보낸 tombstone 값은 무시되고 서버가 재계산한다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_client_supplied_tombstone_is_recomputed(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD', 'JPY', 'CNY', 'EUR']),
'removed_default_currencies' => ['KRW', 'USD'],
]);
$this->assertSame([], $this->savedFile()['removed_default_currencies'] ?? null, '클라이언트 값이 그대로 저장되었습니다.');
$codes = $this->resolvedCodes();
$this->assertContains('KRW', $codes);
$this->assertContains('USD', $codes);
}
/**
* tombstone 은 프론트엔드 공개 설정에 노출되지 않는다.
*
* @scenario saved_currency_set=default_removed_by_admin, deletion_tombstone=has_removed_codes
*
* @effects tombstone_recomputed_server_side_on_save
*/
public function test_tombstone_not_exposed_in_frontend_settings(): void
{
$this->save([
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW']),
]);
$frontend = $this->service->getFrontendSettings();
$this->assertArrayHasKey('language_currency', $frontend);
$this->assertArrayNotHasKey(
'removed_default_currencies',
$frontend['language_currency'],
'내부 삭제 기록이 공개 설정에 노출되었습니다.'
);
}
}