fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합

확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
This commit is contained in:
HeuJung
2026-08-14 17:28:49 +09:00
parent d7ef20668a
commit d970117149
113 changed files with 6313 additions and 168 deletions
+6
View File
@@ -27,6 +27,12 @@
### Fixed
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
+1 -1
View File
@@ -516,8 +516,8 @@ cp .env.example .env
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
+1 -1
View File
@@ -530,8 +530,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
* 모든 템플릿을 로드하고 초기화합니다.
* 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
*/
public function loadTemplates(): void;
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
* (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
*/
public function ensureLoaded(): void;
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
+28
View File
@@ -51,6 +51,14 @@ class TemplateManager implements TemplateManagerInterface
protected array $templates = [];
/**
* 템플릿 디렉토리 스캔이 1회 이상 수행되었는지 여부
*
* `ensureLoaded()` 의 멱등 판정에만 쓴다 — 명시적 `loadTemplates()` 는 이 값과 무관하게
* 항상 재스캔한다(설치/삭제 직후 갱신 계약).
*/
protected bool $templatesLoaded = false;
/**
* _pending 디렉토리의 템플릿 메타데이터 배열
*
@@ -98,13 +106,33 @@ class TemplateManager implements TemplateManagerInterface
return new CoreCacheDriver(config('cache.default', 'array'));
}
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* 소비자가 "템플릿 맵이 채워져 있음" 만 필요로 할 때 쓴다. `loadTemplates()` 는 맵을
* 리셋하고 디렉토리를 통째로 재스캔하므로, 그것을 무조건 호출하면 공유 인스턴스의
* 상태를 매번 갈아엎으면서 풀스캔 비용까지 반복된다.
*/
public function ensureLoaded(): void
{
if ($this->templatesLoaded) {
return;
}
$this->loadTemplates();
}
/**
* 모든 템플릿을 로드하고 초기화합니다.
*
* 항상 재스캔한다 — 설치/삭제/업데이트 직후 갱신을 보장하는 계약이다.
* 단순히 "채워져 있으면 됨" 인 호출자는 `ensureLoaded()` 를 쓴다.
*/
public function loadTemplates(): void
{
// 기존 템플릿 캐시 초기화 (테스트 환경에서 재로드 지원)
$this->templates = [];
$this->templatesLoaded = true;
if (! File::exists($this->templatesPath)) {
return;
@@ -366,7 +366,9 @@ class IdentityVerificationController extends PublicBaseController
'scope' => $policy->scope,
'target' => $policy->target,
'purpose' => $policy->purpose,
'provider_id' => $policy->provider_id,
// 저장값을 그대로 내보내지 않는다 — 제거된 플러그인의 provider ID 가 공개 응답에
// 남지 않도록, 428 강제 경로와 같은 게터로 레지스트리 대조·폴백을 거친다 (A6a).
'provider_id' => $this->policyService->resolveProviderId($policy),
'grace_minutes' => $policy->grace_minutes,
'applies_to' => $policy->applies_to,
'fail_mode' => $policy->fail_mode,
@@ -2,34 +2,198 @@
namespace App\Http\Requests\Settings;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
/**
* 단건 설정 저장 요청 검증
*
* 벌크 저장(`SaveSettingsRequest`)은 키마다 타입 규칙을 갖지만, 이 경로는 키를 URL 로 받아
* 값 하나만 싣는다. 그래서 값의 형태는 `config/settings/defaults.json` 의 기본값에서 도출한다
* — 기본값이 그 설정의 타입 선언이기 때문이다.
*/
class UpdateSettingRequest extends FormRequest
{
/**
* 문자열 값의 최대 길이
*/
private const MAX_STRING_LENGTH = 1000;
/**
* 배열 값의 JSON 직렬화 최대 길이
*/
private const MAX_ARRAY_JSON_LENGTH = 5000;
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 체크는 permission 미들웨어 체인이 담당하므로 항상 true 반환
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 입력 값을 설정의 선언 타입으로 정규화합니다.
*
* 폼 전송(`application/x-www-form-urlencoded`)은 모든 값을 문자열로 실어 보낸다.
* 정규화 없이 저장하면 boolean 설정에 `"1"`, 정수 설정에 `"3600"` 같은 문자열이 남고,
* 그 값을 읽는 쪽은 타입 비교(`=== true`)에서 조용히 어긋난다.
*
* 해석할 수 없는 값(예: boolean 설정에 `"maybe"`)은 건드리지 않는다 — null 이나 false 로
* 바꿔 두면 오타 입력이 "정상 저장" 으로 통과한다. 그 판정은 규칙이 맡는다.
*/
protected function prepareForValidation(): void
{
if (! $this->has('value')) {
return;
}
$default = $this->declaredDefault();
$value = $this->input('value');
// 문자열 설정을 비우면 빈 문자열로 남긴다. `ConvertEmptyStringsToNull` 미들웨어가
// 이미 `''` 를 null 로 바꿔 두므로, 여기서 되돌리지 않으면 선언 타입이 문자열인
// 설정에 null 이 저장되어 그 값을 읽는 쪽이 기본값(`''`)과 다른 형태를 받는다.
if (is_string($default) && $value === null) {
$this->merge(['value' => '']);
return;
}
// 선언 타입이 문자열/배열이거나 기본값이 없으면 원본 유지
if (! is_bool($default) && ! is_int($default) && ! is_float($default)) {
return;
}
// 비-문자열 설정에서 빈 문자열은 "값을 지운다" 는 뜻이다
if ($value === '') {
$this->merge(['value' => null]);
return;
}
if (! is_string($value)) {
return;
}
if (is_bool($default)) {
$casted = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($casted !== null) {
$this->merge(['value' => $casted]);
}
return;
}
if (is_int($default) && preg_match('/^-?\d+$/', $value) === 1) {
$this->merge(['value' => (int) $value]);
return;
}
if (is_float($default) && is_numeric($value)) {
$this->merge(['value' => (float) $value]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'value' => 'required|string|max:1000',
// `required` 가 아니라 `present` 다 — 빈 문자열/null 은 "이 설정을 비운다" 는 정상
// 입력이고, 이를 거부하면 운영자가 한 번 넣은 값을 화면에서 지울 수 없다.
// 대신 `value` 키 자체가 빠진 요청은 그대로 거부한다.
'value' => ['present', 'nullable', $this->valueShapeRule()],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.settings.update_validation_rules', $rules, $this);
}
/**
* 값의 형태(타입·길이)를 검증하는 규칙을 반환합니다.
*
* @return \Closure 검증 클로저
*/
private function valueShapeRule(): \Closure
{
return function (string $attribute, mixed $value, callable $fail): void {
if ($value === null) {
return;
}
$default = $this->declaredDefault();
// 선언 타입과 다른 값은 거부한다 — 정규화가 해석하지 못한 입력이 여기로 온다.
if (is_bool($default) && ! is_bool($value)) {
$fail(__('validation.setting.value.boolean'));
return;
}
if (is_int($default) && ! is_int($value)) {
$fail(__('validation.setting.value.integer'));
return;
}
if (is_float($default) && ! is_int($value) && ! is_float($value)) {
$fail(__('validation.setting.value.numeric'));
return;
}
if (is_string($value)) {
if (mb_strlen($value) > self::MAX_STRING_LENGTH) {
$fail(__('validation.setting.value.max', ['max' => self::MAX_STRING_LENGTH]));
}
return;
}
if (is_array($value)) {
if (mb_strlen((string) json_encode($value)) > self::MAX_ARRAY_JSON_LENGTH) {
$fail(__('validation.setting.value.array_max', ['max' => self::MAX_ARRAY_JSON_LENGTH]));
}
return;
}
if (! is_bool($value) && ! is_int($value) && ! is_float($value)) {
$fail(__('validation.setting.value.type'));
}
};
}
/**
* 저장 대상 키의 선언 기본값을 반환합니다.
*
* 반환값의 타입이 곧 그 설정의 선언 타입입니다. 선언이 없으면 null 을 반환하며,
* 이 경우 타입 강제 없이 형태 검증만 수행합니다 (확장이 추가한 키 등).
*
* @return mixed 선언 기본값 또는 null
*/
private function declaredDefault(): mixed
{
$key = $this->route('key');
if (! is_string($key) || $key === '') {
return null;
}
return Arr::get(app(ConfigRepositoryInterface::class)->getDefaults(), $key);
}
/**
* Get custom messages for validator errors.
*
@@ -38,9 +202,7 @@ class UpdateSettingRequest extends FormRequest
public function messages(): array
{
return [
'value.required' => __('validation.setting.value.required'),
'value.string' => __('validation.setting.value.string'),
'value.max' => __('validation.setting.value.max'),
'value.present' => __('validation.setting.value.present'),
];
}
}
+43 -3
View File
@@ -28,6 +28,14 @@ class SeoSettingsCacheListener implements HookListenerInterface
'method' => 'onSettingsSave',
'priority' => 20,
],
// 단건 저장(`PUT /api/admin/settings/{key}`)은 after_save 가 아니라 after_set 을
// 발화한다. 이 구독이 없으면 SEO 설정을 단건으로 바꿔도 캐시가 남아 있다.
// (after_save 를 추가 발화하는 대신 구독을 늘리는 이유: 활동로그 리스너가 두 훅을
// 각각 기록해 저장 1회가 로그 2건이 된다)
'core.settings.after_set' => [
'method' => 'onSettingSet',
'priority' => 20,
],
];
}
@@ -57,6 +65,40 @@ class SeoSettingsCacheListener implements HookListenerInterface
return;
}
$this->clearSeoCaches(['tab' => $tab]);
}
/**
* 코어 설정 단건 저장 시 SEO 캐시를 무효화합니다.
*
* seo 카테고리 키가 실제로 저장된 경우에만 전체 캐시를 삭제합니다.
*
* @param mixed ...$args 훅 인자 ($key, $value, $result)
*/
public function onSettingSet(...$args): void
{
$key = $args[0] ?? null;
$result = $args[2] ?? false;
if (! is_string($key) || ! str_starts_with($key, 'seo.')) {
return;
}
// 저장 실패는 상태를 바꾸지 않았으므로 캐시도 그대로 둔다
if ($result !== true) {
return;
}
$this->clearSeoCaches(['key' => $key]);
}
/**
* SEO 전체 캐시와 sitemap 캐시를 삭제합니다.
*
* @param array $logContext 로그에 남길 컨텍스트
*/
private function clearSeoCaches(array $logContext): void
{
try {
$cache = app(SeoCacheManagerInterface::class);
@@ -66,9 +108,7 @@ class SeoSettingsCacheListener implements HookListenerInterface
// Sitemap 캐시 삭제
app(CacheInterface::class)->forget('seo.sitemap');
Log::info('[SEO] Core SEO settings changed — all cache cleared', [
'tab' => $tab,
]);
Log::info('[SEO] Core SEO settings changed — all cache cleared', $logContext);
} catch (\Throwable $e) {
Log::warning('[SEO] Core SEO settings cache invalidation failed', [
'error' => $e->getMessage(),
+7
View File
@@ -95,6 +95,7 @@ use App\Services\AttachmentService;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use App\Services\UniqueIdService;
use App\Support\ExtensionSettingsMirror;
use App\Support\PrivilegedDatabaseAccounts;
@@ -347,6 +348,12 @@ class CoreServiceProvider extends ServiceProvider
$this->app->singleton(TemplateManagerInterface::class, function ($app) {
return $app->make(TemplateManager::class);
});
// 템플릿 서비스도 공유 인스턴스로 등록한다.
// 미등록 상태에서는 주입 지점마다 새로 만들어지고, 그 생성자가 매번 템플릿
// 디렉토리를 재스캔했다. 요청 단위 상태는 라우트 병합 열화 플래그 하나뿐이며
// 그 플래그는 병합 진입 시 재설정되므로 공유해도 안전하다.
$this->app->singleton(TemplateService::class);
}
/**
+49
View File
@@ -34,6 +34,9 @@ class DriverRegistryService
'log' => ['single', 'daily'],
'websocket' => ['reverb'],
'mail' => ['smtp', 'mailgun', 'ses'],
// 검색엔진도 저장 가능한 드라이버 선택이다. 레지스트리에 등재해야 폴백 가드
// (플러그인 제거 후 죽은 값 → 기본 드라이버)가 이 카테고리에도 적용된다.
'search' => ['mysql-fulltext'],
];
/**
@@ -50,6 +53,7 @@ class DriverRegistryService
'log' => 'daily',
'websocket' => '',
'mail' => 'smtp',
'search' => 'mysql-fulltext',
];
/**
@@ -69,6 +73,8 @@ class DriverRegistryService
'log' => 'logging.channels.stack.channels',
'websocket' => 'broadcasting.default',
'mail' => 'mail.default',
// SettingsServiceProvider::applyDriverConfig() 가 기록하는 키와 동일해야 폴백이 실효한다
'search' => 'scout.driver',
];
/**
@@ -87,6 +93,7 @@ class DriverRegistryService
// 종전의 'websocket_driver' 는 어떤 저장 경로에도 없는 유령 키라 항상 skip 이었다.
// 카테고리 제외로 같은 동작을 명시화한다 (getSettingsKey('websocket') === null).
'mail' => ['category' => 'mail', 'key' => 'mailer'],
'search' => ['category' => 'drivers', 'key' => 'search_engine_driver'],
];
/**
@@ -113,11 +120,53 @@ class DriverRegistryService
{
$coreDrivers = $this->buildCoreDrivers($category);
// 검색엔진은 Scout 엔진 등록 훅이 SSoT 다 — 플러그인에 제2의 등록 훅을 요구하지 않고
// 그 훅을 함께 읽는다. 일반 드라이버 훅도 그대로 유지해 양쪽 등록 방식을 모두 인식한다.
if ($category === 'search') {
$coreDrivers = $this->mergeSearchEngineDrivers($coreDrivers);
}
$hookName = self::HOOK_PREFIX.$category.self::HOOK_SUFFIX;
return HookManager::applyFilters($hookName, $coreDrivers);
}
/**
* Scout 엔진 등록 훅의 드라이버를 카탈로그 형태로 병합합니다.
*
* `core.search.engine_drivers` 는 `[id => EngineClass]` 맵이므로 키만 취해
* `{id, label}` 형태로 변환한다. 이미 코어 목록에 있는 ID 는 중복 추가하지 않는다.
*
* @param array<array{id: string, label: array<string, string>}> $coreDrivers 코어 드라이버 목록
* @return array<array{id: string, label: array<string, string>}> 병합된 목록
*/
private function mergeSearchEngineDrivers(array $coreDrivers): array
{
$engines = HookManager::applyFilters('core.search.engine_drivers', []);
if (! is_array($engines)) {
return $coreDrivers;
}
$existing = array_map(fn ($driver) => $driver['id'] ?? null, $coreDrivers);
$locales = config('app.translatable_locales', ['ko', 'en']);
foreach (array_keys($engines) as $id) {
if (! is_string($id) || $id === '' || in_array($id, $existing, true)) {
continue;
}
$label = [];
foreach ($locales as $locale) {
$label[$locale] = Lang::get("settings.drivers.search.{$id}", [], $locale) ?: $id;
}
$coreDrivers[] = ['id' => $id, 'label' => $label];
}
return $coreDrivers;
}
/**
* 모든 카테고리의 사용 가능한 드라이버 목록을 반환합니다.
*
+12 -5
View File
@@ -8,11 +8,15 @@ use App\Enums\IdentityOriginType;
use App\Enums\IdentityPolicyAppliesTo;
use App\Enums\IdentityPolicyFailMode;
use App\Enums\IdentityPolicySourceType;
use App\Enums\IdentityVerificationStatus;
use App\Exceptions\IdentityVerificationRequiredException;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Models\IdentityPolicy;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* 본인인증 정책 해석/강제 Service.
@@ -159,10 +163,13 @@ class IdentityPolicyService
* `resolveRenderHint` 와 동일한 우선순위 체인을 적용하여, 정책에 provider 를 지정하지 않아도
* 환경설정의 기본값이 launcher payload 의 provider_id 로 전달되도록 한다.
*
* 428 강제 경로와 프론트 프리페치 엔드포인트가 이 게터 하나로 수렴한다 — 같은 데이터에
* 두 개의 해석이 존재하면 한쪽만 조용히 raw 값을 내보낸다(A6a).
*
* @param IdentityPolicy $policy 대상 정책
* @return string|null 해석된 provider id (해석 실패 시 정책의 원본 값)
*/
protected function resolveProviderId(IdentityPolicy $policy): ?string
public function resolveProviderId(IdentityPolicy $policy): ?string
{
try {
$providerId = $policy->provider_id;
@@ -193,7 +200,7 @@ class IdentityPolicyService
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지 크기
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
* @return LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20)
{
@@ -345,7 +352,7 @@ class IdentityPolicyService
if ($policy->source_type === IdentityPolicySourceType::Module) {
try {
$manager = app(\App\Extension\ModuleManager::class);
$manager = app(ModuleManager::class);
$module = $manager->getModuleByIdentifier($policy->source_identifier)
?? $manager->getModule($policy->source_identifier);
if ($module && method_exists($module, 'getIdentityPolicies')) {
@@ -364,7 +371,7 @@ class IdentityPolicyService
if ($policy->source_type === IdentityPolicySourceType::Plugin) {
try {
$manager = app(\App\Extension\PluginManager::class);
$manager = app(PluginManager::class);
$plugin = $manager->getPlugin($policy->source_identifier);
if ($plugin && method_exists($plugin, 'getIdentityPolicies')) {
foreach ($plugin->getIdentityPolicies() as $data) {
@@ -542,7 +549,7 @@ class IdentityPolicyService
'channel' => 'policy',
'user_id' => $user?->id,
'target_hash' => $this->resolveTargetHash($user, $context) ?? str_repeat('0', 64),
'status' => \App\Enums\IdentityVerificationStatus::PolicyViolationLogged->value,
'status' => IdentityVerificationStatus::PolicyViolationLogged->value,
'origin_type' => $context['origin_type'] ?? IdentityOriginType::Policy->value,
'origin_identifier' => $context['origin_identifier'] ?? null,
'origin_policy_key' => $policy->key,
+46 -7
View File
@@ -51,6 +51,26 @@ class SettingsService
app(ExtensionSettingsMirror::class)->refreshCore();
}
/**
* 큐 워커에 정상 종료 후 재시작 신호를 보냅니다.
*
* drivers 카테고리(queue/broadcasting/cache 등)는 long-running worker 에 영향을 준다.
* SettingsServiceProvider 는 worker boot 시점에 한 번만 config 를 적용하므로, 재시작
* 신호가 없으면 워커가 부팅 시점의 옛 드라이버로 계속 동작한다.
*
* 신호 전송 실패가 설정 저장을 되돌리지는 않는다 (경고 로깅 후 계속).
*/
private function restartQueueWorkers(): void
{
try {
Artisan::call('queue:restart');
} catch (\Throwable $e) {
Log::warning('queue:restart 실행 실패', [
'error' => $e->getMessage(),
]);
}
}
/**
* 자산 URL 방식 변경에 따라 SEO 프리렌더 캐시를 비웁니다.
*
@@ -528,13 +548,7 @@ class SettingsService
// SettingsServiceProvider는 worker boot 시점에 한 번만 config 적용하므로
// 워커가 정상 종료 후 재시작되도록 신호 전송 (cache 기반, 즉시 종료 X)
if ($tab === 'drivers') {
try {
Artisan::call('queue:restart');
} catch (\Throwable $e) {
Log::warning('queue:restart 실행 실패', [
'error' => $e->getMessage(),
]);
}
$this->restartQueueWorkers();
}
}
@@ -696,6 +710,18 @@ class SettingsService
/**
* 단일 설정 값을 저장합니다.
*
* 벌크 저장(saveSettings)이 수행하는 부수효과 중 저장 키에 해당하는 것을 함께 수행한다
* (공개 #114 동종). 예전에는 값만 쓰고 SEO 프리렌더 캐시 삭제·큐 워커 재시작 신호를
* 건너뛰어, 같은 값을 어느 경로로 바꾸느냐에 따라 시스템 상태가 달라졌다.
*
* 키는 **원본 저장소 키**로 받는다 — 벌크 저장의 `reverseFrontendKeys()`(화면 키 → 저장소
* 키 역변환)를 적용하지 않는다. 이 경로의 프로그램 호출자(본인인증 플러그인 설치/삭제의
* `identity.purpose_providers.*`)가 저장소 키를 직접 넘기고 있어, 역변환을 끼우면 그
* 호출들이 엉뚱한 키에 저장된다.
*
* 벌크 위임도 하지 않는다 — 벌크의 shallow `array_merge` 로는 깊은 키를 저장할 때
* 형제 매핑이 통째로 소실된다.
*
* @param string $key 설정 키 (예: 'general.site_name')
* @param mixed $value 저장할 값
* @return bool 저장 성공 여부
@@ -706,12 +732,25 @@ class SettingsService
HookManager::doAction('core.settings.before_set', $key, $value);
try {
// 자산 URL 방식이 바뀌는지 저장 **전에** 판정한다 (이슈 #486 동형).
// 저장 후에는 이전 값을 알 수 없어 변경 여부를 판별할 수 없다.
$assetUrlModeChanged = $key === 'general.asset_url_mode'
&& $this->configRepository->get($key) !== $value;
$result = $this->configRepository->set($key, $value);
if ($result) {
// 인라인 무효화 대신 공통 경로를 탄다 — saveSettings/saveAdvancedSettings 와
// 같은 처리를 받아야 미러 재채움·디스크 config 캐시 재생성이 빠지지 않는다.
$this->invalidateSettingsCache();
if ($assetUrlModeChanged) {
$this->clearSeoCacheForAssetUrlMode();
}
if (str_starts_with($key, 'drivers.') || $key === 'drivers') {
$this->restartQueueWorkers();
}
}
// After 훅
+11 -2
View File
@@ -36,8 +36,12 @@ class TemplateService
private PluginManagerInterface $pluginManager,
private LayoutVersionRepositoryInterface $layoutVersionRepository
) {
// TemplateManager 초기화 (템플릿 스캔)
$this->templateManager->loadTemplates();
// TemplateManager 초기화 — 아직 로드되지 않았을 때만 스캔한다.
// 무조건 loadTemplates() 를 부르면 공유 싱글톤의 템플릿 맵을 리셋한 뒤 디렉토리를
// 통째로 재스캔하므로, 이 서비스가 주입될 때마다 풀스캔과 상태 변형이 반복된다.
// (웹/serve/test 는 CoreServiceProvider::boot 가 로드를 보장하지만, 그 외 콘솔 경로는
// 로딩을 건너뛰므로 이 초기화 자체를 없앨 수는 없다)
$this->templateManager->ensureLoaded();
}
/**
@@ -1213,6 +1217,11 @@ class TemplateService
*/
public function getEditorRoutesDataWithModules(string $identifier): array
{
// 열화 판정은 이 호출의 병합 결과만 가리켜야 한다. 이 서비스는 공유 인스턴스라
// 리셋하지 않으면 직전 호출(업데이트 스왑 창)의 판정이 인스턴스에 눌어붙어,
// 모듈 디렉토리가 복구된 뒤의 병합까지 열화로 보고된다.
$this->routeMergeDegraded = false;
// 1. routes.json 경로 — 활성 디렉토리 우선, _bundled 폴백 (활성/비활성 무관).
$candidates = [
base_path("templates/{$identifier}/routes.json"),
+1 -1
View File
@@ -348,7 +348,7 @@
"expose": true,
"_comment": "본인인증 인프라 provider 기술 파라미터 — signup/enabled 등 정책 분기 키는 IdentityPolicy 로 흡수됨",
"fields": {
"default_provider": { "type": "string", "sensitive": false },
"default_provider": { "type": "string", "sensitive": false, "expose": false, "_comment": "저장값을 그대로 SSR 로 내보내면 제거된 플러그인의 provider ID 가 전 공개 페이지에 남는다. 소비처 0 이므로 미노출 — 프론트가 provider 를 알아야 하는 지점(428 응답 / 정책 프리페치)은 레지스트리 대조를 거친 값을 따로 받는다" },
"purpose_providers": { "type": "array", "sensitive": false, "expose": false },
"challenge_ttl_minutes": { "type": "integer", "sensitive": false, "expose": false },
"max_attempts": { "type": "integer", "sensitive": false, "expose": false }
+1 -1
View File
@@ -2634,7 +2634,7 @@ _단건 응답: `data` 객체의 필드 (매칭·활성 정책이 없으면 `dat
| scope | string | `hook` | 정책 적용 범위 (route / hook / custom) |
| target | string | `sirsoft-board.report.before_create` | 매칭 대상 |
| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 |
| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null) |
| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null). 정책의 저장값을 그대로 내보내지 않고 **현재 등록된 프로바이더로 해석한 값**이다 — 저장값이 등록 목록에 있으면 그대로, 없거나 비어 있으면 인증 목적 기준 폴백 체인(환경설정 기본 프로바이더 → 목적별 지정 → 등록된 첫 프로바이더)의 결과가 실린다. 428 강제 응답의 `verification.provider_id` 와 같은 해석을 공유하므로, 삭제된 플러그인의 식별자가 이 응답에 남지 않는다 |
| grace_minutes | integer | `30` | 재인증 유예 시간(분) — 0=매번 요구 |
| applies_to | string | `self` | 적용 대상 사용자 (self / admin / both) |
| fail_mode | string | `block` | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만) |
+14 -1
View File
@@ -313,7 +313,20 @@ public function boot(): void
}
```
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하면 해당 엔진이 사용됩니다.
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하거나, 관리자 환경설정 > 드라이버의
검색엔진 항목에서 선택할 수 있습니다.
#### 드라이버 폴백 가드
검색엔진은 다른 드라이버 카테고리(스토리지·캐시·세션·큐·로그·메일 등)와 같은 폴백 가드를
받습니다. 저장된 엔진을 제공하던 플러그인이 삭제되면, 부팅 시 그 값이 사용 불가로 판정되어
기본 엔진(`mysql-fulltext`)으로 되돌아갑니다. 이 가드가 없으면 `scout.driver` 가 죽은 값으로
남아 공개 검색이 오류로 멈춥니다.
카탈로그 조회는 **두 훅을 함께 읽습니다** — 위의 `core.search.engine_drivers`(Scout 엔진 맵)와
일반 드라이버 훅 `core.settings.available_search_drivers`(`{id, label}` 목록). 그래서 검색엔진
플러그인은 Scout 등록 훅 하나만 구현하면 되고, 관리자 화면 선택지에도 자동으로 나타납니다.
라벨은 `settings.drivers.search.{id}` 다국어 키에서 조회하며, 키가 없으면 ID 를 그대로 씁니다.
### ScoutServiceProvider 동작 흐름
+23
View File
@@ -559,6 +559,29 @@ public function clearCache(): void
- 스키마에 `sensitive: true` 로 선언한 필드는 미러에 담기지 않는다. 민감값은 자체 설정 서비스(복호화 경로)로 읽는다.
- 배경과 코어 축(코어/플러그인)의 처리는 [admin-settings-access.md](../backend/admin-settings-access.md) "config 미러 갱신 시점" 참조.
### 8.5 저장 완료 훅 발화
모듈 설정 저장을 알리는 코어 훅은 `core.module_settings.after_save` 이며, payload 는
`($identifier, [category => fields], $result)` 다. SEO 캐시 무효화·활동 로그 등 코어와 타 확장의
리스너가 이 훅을 구독한다.
발화 지점은 **관리자 컨트롤러**다 — 설정 서비스가 아니다.
- 훅의 의미가 "관리자가 설정을 저장했다" 이다. 서비스에 두면 내부 저장 호출(시드·마이그레이션·
테스트 픽스처) 전부가 활동 로그와 캐시 무효화를 유발한다.
- 각 모듈의 설정은 그 모듈의 SettingsService 가 직접 파일에 쓰므로, 코어에는 이 훅을 발화할
공통 지점이 없다. 모듈이 자기 컨트롤러의 저장 성공 분기에서 직접 발화한다.
```php
if ($result) {
HookManager::doAction('core.module_settings.after_save', 'vendor-module', $settings, $result);
}
```
단건 저장 경로(dot-key)는 payload 를 카테고리 하위 구조로 되돌려 벌크 저장과 같은 형태로
맞춘다(`Arr::set($payload, $key, $value)`). 구독 리스너가 카테고리 기준으로 관심 키를 찾으므로,
평탄한 dot-key 를 그대로 넘기면 그 리스너들이 아무것도 감지하지 못한다.
---
## 9. 카탈로그 병합 설정의 공개 응답
@@ -8,10 +8,12 @@
### Added
- 검색엔진 드라이버 라벨 일본어 번역을 추가했습니다 (`settings.drivers.search.*`) — 환경설정 > 드라이버 탭의 검색엔진 선택지가 일본어 로케일에서 표시됩니다.
- S3 호환 스토리지 설정 신설 항목(엔드포인트 URL·Path-style 주소)과 리전 형식 안내의 검증 메시지 일본어 번역 추가 (`validation.settings.s3_endpoint_*`, `s3_use_path_style_boolean`, `s3_region_*`) — 드라이버 설정 저장 시 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 드라이버 사용 불능 안내 문구 일본어 번역 추가 (`validation.settings.driver_unusable`, `settings.driver_unusable_*`, `settings.s3_adapter_missing`) — 필요한 라이브러리·PHP 확장이 없는 드라이버를 저장·테스트할 때의 사유 안내가 일본어 로케일에서 표시됩니다.
- 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`).
- 공개 자산 스토리지(공개 이미지 직접 URL 서빙) 설정의 드라이버 라벨과 검증 메시지 일본어 번역을 추가했습니다. 환경설정 > 드라이버 탭의 새 설정이 일본어 로케일에서 자연스럽게 표시됩니다.
- 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다.
### Changed
@@ -67,6 +67,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
'search' => [
'mysql-fulltext' => 'MySQL 全文検索',
],
],
'driver_test_success' => 'すべてのドライバ接続テストが成功しました。',
'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
@@ -750,9 +750,13 @@ return [
],
'setting' => [
'value' => [
'required' => '設定値は必須です。',
'string' => '設定値は文字列である必要があります。',
'present' => 'リクエストに設定値(value)項目が含まれている必要があります。',
'boolean' => 'この設定には有効/無効の値のみ保存できます。',
'integer' => 'この設定には整数のみ保存できます。',
'numeric' => 'この設定には数値のみ保存できます。',
'type' => '保存できない形式の設定値です。',
'max' => '設定値は :max 文字を超えることはできません。',
'array_max' => '設定値のサイズは :max 文字を超えることはできません。',
],
],
'exclude_current_user' => '現在ログインしているユーザーは一括変更の対象に含めることはできません。',
@@ -8,6 +8,8 @@
### Added
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다.
- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다.
- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다.
- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다.
@@ -787,6 +787,7 @@ return [
],
],
'order' => [
'payment_method_unavailable' => '現在ご利用いただけない決済手段です。他の決済手段を選択してください。',
'ids' => [
'required' => '変更する注文を選択してください。',
'array' => '注文IDは配列形式である必要があります。',
@@ -151,6 +151,7 @@
"min_order_amount": "最小注文金額",
"is_active": "使用有無",
"orphaned_badge": "プラグイン未インストール",
"orphaned_pg_badge": "指定PG削除済み",
"remove_orphaned": "削除",
"pg_provider": "PG社",
"pg_use_default": "デフォルトPG",
@@ -12,6 +12,8 @@
- 환경설정 드라이버 탭의 "공개 자산 스토리지" 카드 문구 일본어 번역을 추가했습니다.
- 회원 정보 수정 화면의 탈퇴 확인 창 문구 일본어 번역 추가 (`admin.users.modals.withdraw_confirm_*`) — 되돌릴 수 없는 처리라는 안내와 함께 처리되는 항목 목록이 일본어 로케일에서 표시됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 드라이버 선택란에 저장된 값이 현재 사용할 수 없는 값일 때 표시되는 안내 문구 일본어 번역을 추가했습니다.
### Changed
- S3 URL 항목의 이름·설명을 공개 URL(CDN) 용도로 명확히 한 문구 개정을 반영했습니다.
@@ -19,6 +21,7 @@
### Removed
- 리전 목록 선택 라벨(`settings.drivers.storage.regions.*`) 5종 제거 — 리전이 자유 입력으로 바뀌어 더 이상 사용되지 않습니다.
- 검색엔진 선택 옵션 라벨(`settings.drivers.search_engine.options.*`) 제거 — 검색엔진 목록이 서버 제공 목록으로 바뀌어 더 이상 사용되지 않습니다.
## [1.0.4] - 2026-08-10
@@ -1628,7 +1628,9 @@
},
"drivers": {
"common": {
"optional": "オプション"
"optional": "オプション",
"unavailable_saved_value_prefix": "保存された値 ",
"unavailable_saved_value_suffix": " は現在使用できません。利用可能な値を選択して保存してください。"
},
"storage": {
"title": "ファイルストレージ",
@@ -1747,9 +1749,6 @@
"desc": "統合検索および管理者検索に使用する検索エンジンドライバーを設定します。",
"driver": "検索エンジンドライバー",
"driver_desc": "デフォルト提供のMySQL FULLTEXT(ngram)エンジンは、追加設定なしで日本語検索をサポートしています。",
"options": {
"mysql_fulltext": "MySQL FULLTEXT (ngram)"
},
"plugin_notice": "検索エンジンプラグイン(Meilisearch、Elasticsearchなど)をインストールすると、追加ドライバーが表示されます。"
},
"test_connection": "接続テスト",
+3
View File
@@ -74,6 +74,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
'search' => [
'mysql-fulltext' => 'MySQL Full-Text',
],
],
// Driver connection test messages
+6 -2
View File
@@ -825,9 +825,13 @@ return [
// Setting value validation messages
'setting' => [
'value' => [
'required' => 'Setting value is required.',
'string' => 'Setting value must be a string.',
'present' => 'The value field must be present in the request.',
'boolean' => 'This setting only accepts an on/off value.',
'integer' => 'This setting only accepts an integer.',
'numeric' => 'This setting only accepts a number.',
'type' => 'The setting value has an unsupported format.',
'max' => 'Setting value may not be greater than :max characters.',
'array_max' => 'Setting value may not be larger than :max characters.',
],
],
+3
View File
@@ -74,6 +74,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
'search' => [
'mysql-fulltext' => 'MySQL 전문검색',
],
],
// 드라이버 연결 테스트 메시지
+6 -2
View File
@@ -826,9 +826,13 @@ return [
// 설정값 검증 메시지
'setting' => [
'value' => [
'required' => '설정 값은 필수입니다.',
'string' => '설정 값은 문자열이어야 합니다.',
'present' => '설정 값 항목(value)이 요청에 포함되어야 합니다.',
'boolean' => '이 설정은 사용/사용 안 함 값만 저장할 수 있습니다.',
'integer' => '이 설정은 정수만 저장할 수 있습니다.',
'numeric' => '이 설정은 숫자만 저장할 수 있습니다.',
'type' => '저장할 수 없는 형식의 설정 값입니다.',
'max' => '설정 값은 :max자를 초과할 수 없습니다.',
'array_max' => '설정 값의 크기가 :max자를 초과할 수 없습니다.',
],
],
@@ -14,6 +14,8 @@
### Fixed
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
@@ -775,6 +775,8 @@ HTTP/1.1 200
**설명** 게시판 모듈의 환경설정을 저장합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.update` 권한이 필요합니다. `_tab`으로 지정한 탭 단위로 검증된 설정을 저장하며, `report_permissions`가 포함된 경우 신고 권한 역할도 함께 동기화합니다. 저장 성공 시 갱신된 전체 설정과 신고 권한 역할을 반환합니다.
저장이 성공하면 코어 모듈 설정 저장 훅(`core.module_settings.after_save`)이 `('sirsoft-board', 저장한 카테고리 배열, true)` 페이로드로 발화합니다. 이 훅을 구독하는 리스너가 게시판 SEO 미리 생성 캐시를 무효화하고 설정 변경을 활동 로그에 남기므로, 확장도 같은 훅으로 저장 완료 시점에 개입할 수 있습니다. 검증 실패(422)나 저장 실패 시에는 발화하지 않습니다.
### POST /api/modules/sirsoft-board/admin/settings/bulk-apply
<!-- @generated:start:api.modules.sirsoft-board.admin.settings.bulk-apply -->
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -117,6 +118,10 @@ class BoardSettingsController extends AdminBaseController
$this->settingsService->syncReportPermissionRoles($request->input('report_permissions'));
}
// 코어 모듈 설정 저장 훅 — SEO 캐시 무효화 등 코어/타 확장 리스너가 구독한다.
// 훅 의미가 "관리자가 설정을 저장했다" 이므로 서비스가 아니라 여기서 발화한다.
HookManager::doAction('core.module_settings.after_save', 'sirsoft-board', $settings, $result);
$updatedSettings = $this->settingsService->getAllSettings();
$updatedSettings['report_permissions'] = $this->settingsService->getReportPermissionRoles();
@@ -27,6 +27,12 @@ class SeoBoardSettingsCacheListener implements HookListenerInterface
'method' => 'onModuleSettingsSave',
'priority' => 20,
],
// 환경설정 기본값 일괄 적용도 게시판 화면 출력을 바꾸므로 같은 무효화가 필요하다.
// payload 가 ($fields, $updatedCount) 라 식별자 인자가 없어 전용 핸들러로 받는다.
'sirsoft-board.settings.after_bulk_apply' => [
'method' => 'onBulkApply',
'priority' => 20,
],
];
}
@@ -56,6 +62,27 @@ class SeoBoardSettingsCacheListener implements HookListenerInterface
return;
}
$this->invalidateBoardSeoCache();
}
/**
* 환경설정 기본값 일괄 적용 후 게시판 SEO 캐시를 무효화합니다.
*
* 이 훅의 payload 는 ($fields, $updatedCount) 로 모듈 식별자를 담지 않는다 —
* 게시판 모듈이 발행하는 훅이므로 식별자 가드 자체가 불필요하다.
*
* @param mixed ...$args 훅 인자 ($fields, $updatedCount)
*/
public function onBulkApply(...$args): void
{
$this->invalidateBoardSeoCache();
}
/**
* 게시판 관련 SEO 레이아웃 캐시와 sitemap 캐시를 무효화합니다.
*/
private function invalidateBoardSeoCache(): void
{
try {
$cache = app(SeoCacheManagerInterface::class);
@@ -75,20 +75,39 @@ class BoardSettingsService implements ModuleSettingsInterface
/**
* 설정값 저장
*
* @param string $key 설정 키
* 벌크 저장(saveSettings)과 같은 본문을 경유해 정규화·신고 알림 강제 활성·캐시 무효화·
* 알림 정의 동기화를 함께 수행한다 (공개 #114 동종). 예전에는 `Arr::set` 결과를 카테고리
* 파일에 통째로 덮어써서 이 단계들을 모두 건너뛰었고, 자기 캐시도 비우지 않아 같은
* 요청 안의 곧이은 조회가 저장 전 값을 반환했다.
*
* 위임 payload 의 기저는 **저장본**(loadCategorySettings)이다. 조회 결과를 기저로 삼으면
* defaults 병합분이 저장 파일에 통째로 영속화된다.
*
* boolean backfill 은 적용하지 않는다 — 폼 Toggle-OFF 미전송 대응이라 부분 저장에 태우면
* 제출하지 않은 boolean 이 전부 false 로 박제되어 기본값 true 인 항목이 뒤집힌다.
*
* @param string $key 설정 키 (예: 'basic_defaults.per_page', 카테고리 통째 지정도 허용)
* @param mixed $value 저장할 값
* @return bool 성공 여부
*/
public function setSetting(string $key, mixed $value): bool
{
$settings = $this->getAllSettings();
Arr::set($settings, $key, $value);
// 카테고리 추출
$parts = explode('.', $key);
$category = $parts[0];
$category = array_shift($parts);
return $this->saveCategorySettings($category, $settings[$category] ?? []);
if ($parts === []) {
// 카테고리 통째 저장 — 배열이 아니면 저장할 카테고리 데이터가 없다
if (! is_array($value)) {
return false;
}
$categoryData = $value;
} else {
$categoryData = $this->loadCategorySettings($category);
Arr::set($categoryData, implode('.', $parts), $value);
}
return $this->persistCategories([$category => $categoryData], backfillBooleans: false);
}
/**
@@ -141,6 +160,19 @@ class BoardSettingsService implements ModuleSettingsInterface
* @return bool 성공 여부
*/
public function saveSettings(array $settings): bool
{
return $this->persistCategories($settings, backfillBooleans: true);
}
/**
* 카테고리 설정을 정규화 파이프라인에 태워 저장합니다. (벌크/단건 공통 본문)
*
* @param array $settings [카테고리 => 카테고리 설정] 배열
* @param bool $backfillBooleans 미제출 boolean 필드를 false 로 채울지 여부
* (폼 전체 제출을 전제로 하는 벌크 저장만 true)
* @return bool 성공 여부
*/
private function persistCategories(array $settings, bool $backfillBooleans): bool
{
$success = true;
$defaults = $this->getDefaults();
@@ -160,9 +192,12 @@ class BoardSettingsService implements ModuleSettingsInterface
$categoryDefaults = $defaultValues[$category] ?? [];
// Toggle/체크박스 OFF 시 키 미전송 대응: boolean 기본값 필드가 누락되면 false로 채움
foreach ($categoryDefaults as $key => $defaultValue) {
if (is_bool($defaultValue) && ! array_key_exists($key, $categorySettings)) {
$categorySettings[$key] = false;
// (폼 전체 제출을 전제로 한 보정이라 부분 저장 경로에서는 적용하지 않는다)
if ($backfillBooleans) {
foreach ($categoryDefaults as $key => $defaultValue) {
if (is_bool($defaultValue) && ! array_key_exists($key, $categorySettings)) {
$categorySettings[$key] = false;
}
}
}
@@ -418,10 +453,18 @@ class BoardSettingsService implements ModuleSettingsInterface
/**
* 설정 저장 경로 반환
*
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
* 격리된 임시 경로를 사용합니다. 설정 저장을 수행하는 테스트가 운영 basic_defaults.json
* 등을 덮어쓰거나 지우는 것을 차단합니다(운영 설정 영구 보존).
*
* @return string 설정 파일 저장 디렉토리 경로
*/
private function getStoragePath(): string
{
if (app()->runningUnitTests()) {
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
}
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
}
}
@@ -0,0 +1,180 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
use App\Extension\HookManager;
use App\Models\User;
use App\Seo\Contracts\SeoCacheManagerInterface;
use Mockery;
use Modules\Sirsoft\Board\Listeners\SeoBoardSettingsCacheListener;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 게시판 모듈 설정 저장 시 SEO 캐시 무효화 훅 발화 (B-5)
*
* `SeoBoardSettingsCacheListener` 는 `core.module_settings.after_save` 를 구독하지만
* 그 훅의 유일한 발화 지점(`ModuleSettingsService::save()`)이 프로덕션에서 호출되지 않아
* 게시판 환경설정을 저장해도 SEO 캐시가 무효화되지 않았다.
*
* 일괄 적용(bulkApply)은 이미 발화 중인 `sirsoft-board.settings.after_bulk_apply` 를
* 리스너가 함께 구독해 커버한다 (신규 훅 발명 없음).
*/
class BoardSettingsSeoCacheInvalidationTest extends ModuleTestCase
{
private string $apiBase = '/api/modules/sirsoft-board/admin/settings';
private User $adminUser;
/**
* 훅 수신 기록
*
* @var array<int, array>
*/
private array $received = [];
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-board.settings.read',
'sirsoft-board.settings.update',
]);
$this->received = [];
HookManager::addAction('core.module_settings.after_save', function (...$args) {
$this->received[] = $args;
}, 5);
}
/**
* 게시판 설정 저장 API 가 모듈 설정 저장 훅을 발화한다. (실패-먼저)
*
* @scenario save_path=bulk
*
* @effects module_settings_after_save_hook_fired
*/
public function test_settings_save_fires_module_settings_after_save_hook(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'basic_defaults',
'basic_defaults' => ['per_page' => 30],
])->assertOk();
$this->assertCount(1, $this->received, '모듈 설정 저장 훅이 발화되지 않았습니다.');
[$identifier, $settings, $result] = $this->received[0];
$this->assertSame('sirsoft-board', $identifier);
$this->assertArrayHasKey('basic_defaults', $settings);
$this->assertTrue($result);
}
/**
* 게시판 SEO 캐시 리스너가 일괄 적용 훅도 구독한다. (실패-먼저)
*
* @scenario save_path=bulk_apply
*
* @effects seo_cache_invalidated_on_bulk_apply
*/
public function test_seo_listener_subscribes_bulk_apply_hook(): void
{
$hooks = SeoBoardSettingsCacheListener::getSubscribedHooks();
$this->assertArrayHasKey(
'sirsoft-board.settings.after_bulk_apply',
$hooks,
'일괄 적용 경로가 SEO 캐시 무효화를 거치지 않습니다.'
);
$this->assertSame('onBulkApply', $hooks['sirsoft-board.settings.after_bulk_apply']['method']);
}
/**
* 일괄 적용 훅 핸들러는 식별자 인자 없이도 안전하게 동작한다.
*
* `after_bulk_apply` payload 는 ($fields, $updatedCount) 라 모듈 식별자가 없다 —
* 식별자 가드를 그대로 재사용하면 조용히 무효화가 건너뛰어진다.
*
* @scenario save_path=bulk_apply
*
* @effects seo_cache_invalidated_on_bulk_apply
*/
public function test_bulk_apply_handler_invalidates_without_identifier_arg(): void
{
$listener = new SeoBoardSettingsCacheListener;
$this->assertTrue(
method_exists($listener, 'onBulkApply'),
'일괄 적용 전용 핸들러가 없습니다.'
);
// 예외 없이 완료되어야 한다 (내부 실패는 로깅으로 흡수)
$listener->onBulkApply(['per_page' => 20], 3);
$this->addToAssertionCount(1);
}
// ─── 실제 무효화 수행 ──────────────────────────────────────
/**
* 일괄 적용 핸들러가 게시판 레이아웃 캐시를 실제로 지운다.
*
* 예외가 안 나는 것과 무효화를 수행하는 것은 다르다 — 리스너 본문이 통째로 비어도
* "예외 없음" 은 그대로 통과한다. 캐시 매니저를 mock 해 호출 자체를 단언한다.
*
* @scenario save_path=bulk_apply
*
* @effects seo_cache_invalidated_on_bulk_apply
*/
public function test_bulk_apply_actually_invalidates_board_layouts(): void
{
$cache = Mockery::mock(SeoCacheManagerInterface::class);
foreach (['board/index', 'board/show', 'board/boards'] as $layout) {
$cache->shouldReceive('invalidateByLayout')->with($layout)->once();
}
$cache->shouldReceive('invalidateByLayout')->andReturnNull();
$this->app->instance(SeoCacheManagerInterface::class, $cache);
(new SeoBoardSettingsCacheListener)->onBulkApply(['per_page' => 20], 3);
$this->addToAssertionCount(1);
}
/**
* 설정 저장 훅도 같은 무효화를 수행한다.
*
* @scenario save_path=bulk
*
* @effects seo_cache_invalidated_on_module_settings_save
*/
public function test_settings_save_actually_invalidates_board_layouts(): void
{
$cache = Mockery::mock(SeoCacheManagerInterface::class);
$cache->shouldReceive('invalidateByLayout')->with('board/index')->atLeast()->once();
$cache->shouldReceive('invalidateByLayout')->andReturnNull();
$this->app->instance(SeoCacheManagerInterface::class, $cache);
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'basic_defaults',
'basic_defaults' => ['per_page' => 30],
])->assertOk();
$this->addToAssertionCount(1);
}
/**
* 다른 모듈의 저장 훅에는 반응하지 않는다.
*
* @scenario save_path=bulk
*
* @effects seo_cache_untouched_for_other_module
*/
public function test_other_module_save_does_not_invalidate_board_layouts(): void
{
$cache = Mockery::mock(SeoCacheManagerInterface::class);
$cache->shouldNotReceive('invalidateByLayout');
$this->app->instance(SeoCacheManagerInterface::class, $cache);
(new SeoBoardSettingsCacheListener)->onModuleSettingsSave('sirsoft-ecommerce', ['seo' => []], true);
$this->addToAssertionCount(1);
}
}
@@ -5,14 +5,19 @@ namespace Modules\Sirsoft\Board\Tests\Feature;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\ModuleManager;
use App\Listeners\NotificationHookListener;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Notifications\GenericNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Models\ReportLog;
use App\Notifications\GenericNotification;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -47,7 +52,7 @@ class ReportNotificationTest extends ModuleTestCase
// NotificationHookListener 동적 훅 등록 (테스트 환경에서는 부트 시점에 실행되지 않음 —
// 알림 정의 기반 훅 구독을 수동으로 활성화하여 actual notification flow 복원)
$this->syncBoardNotificationDefinitions();
app(\App\Listeners\NotificationHookListener::class)->registerDynamicHooks();
app(NotificationHookListener::class)->registerDynamicHooks();
// 테스트 잔여 데이터 정리
DB::table('boards_reports')->delete();
@@ -191,7 +196,7 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 처리 정책 OFF → 알림 미발송
*/
#[Test]
public function test_게시글_신고_처리시_정책OFF_알림_미발송(): void
public function test_게시글_신고_처리시_정책_off_알림_미발송(): void
{
// Given: 신고 처리 알림 정책 OFF
$this->setReportPolicy(false);
@@ -254,7 +259,7 @@ class ReportNotificationTest extends ModuleTestCase
* 댓글 신고 처리 정책 OFF → 댓글 작성자에게 알림 미발송
*/
#[Test]
public function test_댓글_신고_처리시_정책OFF_알림_미발송(): void
public function test_댓글_신고_처리시_정책_off_알림_미발송(): void
{
// Given: 신고 처리 알림 정책 OFF
$this->setReportPolicy(false);
@@ -286,7 +291,7 @@ class ReportNotificationTest extends ModuleTestCase
* → 관리자에게 ReportReceivedAdminNotification 발송 (postTitle, reasonType 내용 포함)
*/
#[Test]
public function test_신고_접수시_정책ON_권한자에게_관리자알림_발송(): void
public function test_신고_접수시_정책_on_권한자에게_관리자알림_발송(): void
{
// Given: 관리자 알림 정책 ON
$this->setAdminReportPolicy(true);
@@ -322,7 +327,7 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 접수 관리자 알림 정책 OFF → 관리자 알림 미발송
*/
#[Test]
public function test_신고_접수시_정책OFF_관리자알림_미발송(): void
public function test_신고_접수시_정책_off_관리자알림_미발송(): void
{
// Given: 관리자 알림 정책 OFF
$this->setAdminReportPolicy(false);
@@ -345,14 +350,14 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 접수 관리자 알림 정책 ON + reports.manage 권한자 없음 → 알림 미발송
*/
#[Test]
public function test_신고_접수시_정책ON_권한자없음_알림_미발송(): void
public function test_신고_접수시_정책_on_권한자없음_알림_미발송(): void
{
// Given: 관리자 알림 정책 ON + admin 역할에서 권한 제거 (권한자 0명 상태 시뮬레이션)
$this->setAdminReportPolicy(true);
// admin 역할에서 reports.manage 권한 제거
$permission = \App\Models\Permission::where('identifier', 'sirsoft-board.reports.manage')->first();
$adminRole = \App\Models\Role::where('identifier', 'admin')->first();
$permission = Permission::where('identifier', 'sirsoft-board.reports.manage')->first();
$adminRole = Role::where('identifier', 'admin')->first();
if ($permission && $adminRole) {
$adminRole->permissions()->detach($permission->id);
}
@@ -508,7 +513,7 @@ class ReportNotificationTest extends ModuleTestCase
/**
* report_policy.notify_author_on_report_action 설정을 주입합니다.
*
* @param bool $enabled 알림 발송 여부
* @param bool $enabled 알림 발송 여부
* @return void
*/
/**
@@ -535,12 +540,22 @@ class ReportNotificationTest extends ModuleTestCase
/**
* report_policy.notify_admin_on_report 설정을 주입합니다.
*
* @param bool $enabled 알림 발송 여부
* @param string $scope 발송 범위 ('per_case' | 'per_report')
* @param bool $enabled 알림 발송 여부
* @param string $scope 발송 범위 ('per_case' | 'per_report')
* @return void
*/
private function setAdminReportPolicy(bool $enabled, string $scope = 'per_case'): void
{
// checkAndApplyAutoHide()는 BoardSettingsService(파일)에서 읽으므로
// auto_hide_threshold를 충분히 높게 설정하여 테스트 중 자동 블라인드 방지
//
// 순서 주의: 설정 저장은 config 미러를 저장본 기준으로 다시 채운다. 그래서 아래
// config() 주입을 먼저 하면 이 호출이 그 주입을 덮어써 정책이 기본값(활성)으로 돌아간다.
/** @var BoardSettingsService $settingsService */
$settingsService = app(BoardSettingsService::class);
$settingsService->setSetting('report_policy.auto_hide_threshold', 100);
$settingsService->clearCache();
// g7_module_settings()는 Config에서 읽으므로 config 주입으로 알림 정책 제어
config([
'g7_settings.modules.sirsoft-board.report_policy' => [
@@ -549,22 +564,14 @@ class ReportNotificationTest extends ModuleTestCase
'notify_admin_on_report_channels' => ['mail'],
],
]);
// checkAndApplyAutoHide()는 BoardSettingsService(파일)에서 읽으므로
// auto_hide_threshold를 충분히 높게 설정하여 테스트 중 자동 블라인드 방지
/** @var \Modules\Sirsoft\Board\Services\BoardSettingsService $settingsService */
$settingsService = app(\Modules\Sirsoft\Board\Services\BoardSettingsService::class);
$settingsService->setSetting('report_policy.auto_hide_threshold', 100);
$settingsService->clearCache();
}
/**
* 테스트용 게시글 생성 헬퍼
*
* @param int $userId 작성자 ID
* @param string $status 게시글 상태
* @param string $triggerType 처리 유형
* @param int $userId 작성자 ID
* @param string $status 게시글 상태
* @param string $triggerType 처리 유형
* @return int 생성된 게시글 ID
*/
private function createTestPost(int $userId, string $status = 'published', string $triggerType = 'admin'): int
@@ -589,8 +596,8 @@ class ReportNotificationTest extends ModuleTestCase
/**
* 테스트용 댓글 생성 헬퍼
*
* @param int $postId 게시글 ID
* @param int $userId 작성자 ID
* @param int $postId 게시글 ID
* @param int $userId 작성자 ID
* @return int 생성된 댓글 ID
*/
private function createTestComment(int $postId, int $userId): int
@@ -619,12 +626,12 @@ class ReportNotificationTest extends ModuleTestCase
*/
private function syncBoardNotificationDefinitions(): void
{
$module = app(\App\Extension\ModuleManager::class)->getModule('sirsoft-board');
$module = app(ModuleManager::class)->getModule('sirsoft-board');
if (! $module) {
return;
}
$helper = app(\App\Extension\Helpers\NotificationSyncHelper::class);
$helper = app(NotificationSyncHelper::class);
foreach ($module->getNotificationDefinitions() as $data) {
$data['extension_type'] = 'module';
$data['extension_identifier'] = 'sirsoft-board';
@@ -0,0 +1,253 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Models\NotificationDefinition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Board\Services\BoardPermissionService;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 게시판 단건 설정 저장(setSetting)의 파이프라인 경유 / 캐시 정합 테스트 (공개 #114 동종)
*
* 이커머스와 같은 결함에 더해 한 단계 더 나쁜 상태였다 — 저장 후 자기 캐시(`$settings`)를
* 비우지 않아, 같은 요청 안에서 곧이은 조회가 저장 전 값을 반환했다. 기존 테스트는
* `clearCache()` 를 수동으로 끼워 넣어 그 결함을 우회하고 있었다.
*
* 벌크 저장의 boolean backfill 은 폼 Toggle-OFF 미전송 대응이라 단건 저장에 그대로 태우면
* 안 된다 — 제출하지 않은 boolean 이 전부 false 로 박제되어 기본값 true 인 항목이 뒤집힌다.
* 그래서 공통 본문만 공유하고 backfill 여부만 갈라 놓는다.
*/
class BoardSetSettingPipelineTest extends ModuleTestCase
{
private BoardSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$permissionService = $this->createMock(BoardPermissionService::class);
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$notificationDefinitionRepository->method('getByExtension')->willReturn(new Collection);
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
$this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 카테고리 저장 파일을 그대로 읽습니다.
*
* @param string $category 카테고리명
* @return array 저장 파일의 디코드 결과 (파일 부재 시 빈 배열)
*/
private function savedFile(string $category): array
{
$path = $this->storagePath.'/'.$category.'.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 단건 저장 직후 같은 인스턴스의 조회가 신값을 반환한다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects single_key_save_invalidates_service_cache
*/
public function test_single_key_save_invalidates_cache_without_manual_clear(): void
{
// 조회로 캐시를 채운 뒤 저장
$this->assertNotSame(31, $this->service->getSetting('basic_defaults.per_page'));
$this->service->setSetting('basic_defaults.per_page', 31);
$this->assertSame(
31,
$this->service->getSetting('basic_defaults.per_page'),
'저장 후에도 조회가 저장 전 값을 반환했습니다 (캐시 무효화 누락).'
);
}
/**
* 단건 저장은 미제출 boolean 을 false 로 박제하지 않는다.
*
* 벌크 저장의 backfill 은 폼 전체 제출을 전제로 한 보정이므로, 부분 저장에 태우면
* 기본값 true 인 항목이 통째로 뒤집힌다.
*
* @scenario save_path=single_key
*
* @effects single_key_save_does_not_backfill_booleans
*/
public function test_single_key_save_does_not_backfill_unsubmitted_booleans(): void
{
$this->service->setSetting('basic_defaults.per_page', 25);
$saved = $this->savedFile('basic_defaults');
$this->assertArrayNotHasKey(
'use_comment',
$saved,
'제출하지 않은 boolean 이 단건 저장으로 박제되었습니다.'
);
$this->assertTrue(
$this->service->getSetting('basic_defaults.use_comment'),
'기본값 true 인 boolean 설정이 단건 저장으로 뒤집혔습니다.'
);
}
/**
* 벌크 저장의 boolean backfill 은 그대로 유지된다. (비회귀 pin)
*
* @scenario save_path=bulk
*
* @effects bulk_save_backfills_unsubmitted_booleans
*/
public function test_bulk_save_still_backfills_unsubmitted_booleans(): void
{
$this->service->saveSettings(['basic_defaults' => ['per_page' => 25]]);
$saved = $this->savedFile('basic_defaults');
$this->assertArrayHasKey('use_comment', $saved, '벌크 저장의 boolean backfill 이 사라졌습니다.');
$this->assertFalse($saved['use_comment']);
}
/**
* 신고 정책 단건 저장도 알림 강제 활성 규칙을 거친다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_forces_report_notification_flags(): void
{
$this->service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => false,
'notify_author_on_report_action' => false,
]);
$saved = $this->savedFile('report_policy');
$this->assertTrue(
$saved['notify_admin_on_report'] ?? false,
'신고 알림 강제 활성 규칙이 단건 저장 경로에서 적용되지 않았습니다.'
);
$this->assertTrue($saved['notify_author_on_report_action'] ?? false);
$this->assertSame(7, $saved['auto_hide_threshold'] ?? null);
}
/**
* 신고 정책 단건 저장은 알림 정의의 활성 상태까지 동기화한다.
*
* 저장 파일만 보면 강제 활성 규칙이 통과한 것처럼 보인다. 그러나 알림 정의가 비활성인
* 채로 남으면 `NotificationHookListener` 가 훅을 구독하지 않아 설정은 켜져 있는데
* 알림만 오지 않는다 — 화면에도 로그에도 흔적이 없다.
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_syncs_notification_definition_status(): void
{
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
$definition->is_active = false;
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
$repository->expects($this->once())
->method('update')
->with($definition, ['is_active' => true]);
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
$service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => false,
]);
}
/**
* 이미 원하는 상태인 알림 정의는 다시 쓰지 않는다. (불필요 쓰기 차단)
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_skips_notification_definition_update_when_already_active(): void
{
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
$definition->is_active = true;
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
$repository->expects($this->never())->method('update');
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
$service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => true,
]);
}
/**
* 단건 저장도 defaults 스키마 기준 숫자 정규화를 거친다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects numeric_string_normalized_on_single_key_save
*/
public function test_single_key_save_normalizes_numeric_strings(): void
{
$this->service->setSetting('basic_defaults.per_page', '42');
$saved = $this->savedFile('basic_defaults');
$this->assertSame(42, $saved['per_page'] ?? null, '숫자 문자열이 정규화되지 않고 저장되었습니다.');
}
/**
* 테스트 실행 중에는 운영 설정 경로를 쓰지 않는다. (실패-먼저)
*
* @effects board_settings_storage_isolated_in_tests
*/
public function test_storage_path_is_isolated_during_tests(): void
{
$productionPath = storage_path('app/modules/sirsoft-board/settings/basic_defaults.json');
// 운영 파일의 존재 여부·내용을 그대로 스냅샷 (환경마다 상태가 다르므로 변화 없음만 단언)
$before = File::exists($productionPath) ? File::get($productionPath) : null;
$this->service->setSetting('basic_defaults.per_page', 33);
$this->assertFileExists(
$this->storagePath.'/basic_defaults.json',
'테스트 격리 경로에 저장되지 않았습니다.'
);
$after = File::exists($productionPath) ? File::get($productionPath) : null;
$this->assertSame($before, $after, '테스트가 운영 설정 파일을 생성/변경했습니다.');
}
}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Board\Tests\Unit;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Board\Services\BoardPermissionService;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
@@ -27,9 +28,10 @@ class BoardSettingsServiceTest extends ModuleTestCase
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
// report_policy 저장 시 호출되는 알림 정의 동기화 경로가 빈 컬렉션을 안전하게 다루도록 기본 stub
$notificationDefinitionRepository->method('getByExtension')
->willReturn(new \Illuminate\Database\Eloquent\Collection());
->willReturn(new Collection);
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
$this->storagePath = storage_path('app/modules/sirsoft-board/settings');
// 테스트 격리 경로 — 운영 설정(storage/app/modules/...)을 지우거나 덮어쓰지 않는다
$this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
// 테스트 전 저장소 정리
if (File::isDirectory($this->storagePath)) {
@@ -213,8 +215,7 @@ class BoardSettingsServiceTest extends ModuleTestCase
$this->assertTrue($result);
// 캐시 초기화 후 다시 조회
$this->service->clearCache();
// 단건 저장이 자기 캐시를 비우므로 수동 초기화 없이 신값이 조회된다 (공개 #114 동종)
$perPage = $this->service->getSetting('basic_defaults.per_page');
$this->assertEquals(30, $perPage);
}
@@ -359,8 +360,17 @@ class BoardSettingsServiceTest extends ModuleTestCase
// 첫 번째 조회로 캐시 생성
$first = $this->service->getAllSettings();
// 파일 직접 수정 (캐시 우회)
$this->service->setSetting('basic_defaults.per_page', 77);
// 파일 직접 수정 (서비스를 경유하지 않아 캐시가 갱신되지 않는 상태를 만든다)
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/basic_defaults.json',
json_encode(['per_page' => 77], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->assertEquals(
$first['basic_defaults']['per_page'],
$this->service->getAllSettings()['basic_defaults']['per_page'],
'캐시 초기화 전인데 파일 변경이 조회에 반영되었습니다.'
);
// 캐시 초기화
$this->service->clearCache();
@@ -0,0 +1,42 @@
feature: 게시판 설정 저장 파이프라인과 저장 훅 (B-2 / B-5)
description: |
게시판 설정도 저장 경로가 셋이다 — 관리자 화면의 벌크 저장, 값 하나만 바꾸는 단건 저장,
여러 게시판에 한꺼번에 적용하는 일괄 적용. 파이프라인 단계(캐시 무효화, 신고 알림 플래그
강제, 숫자 문자열 정규화)를 벌크 경로만 통과하고 있으면, 같은 값을 어느 경로로 저장했느냐에
따라 결과가 달라진다. 저장은 성공하고 파생 값만 어긋나므로 오류가 남지 않는다.
boolean 백필은 반대다 — 벌크 저장은 화면이 보낸 폼 전체가 기준이라 미제출 체크박스를
false 로 채워야 하지만, 단건 저장은 그 키 하나만 바꾸겠다는 뜻이다. 여기서 백필하면
운영자가 건드리지도 않은 다른 토글이 전부 꺼진다.
저장 훅(`core.module_settings.after_save`)의 구독자는 SEO 캐시 무효화다. 게시판 설정의
SEO 메타 템플릿을 바꿔도 캐시가 그대로면 봇 화면만 옛 값을 계속 보여준다.
axis_notes:
save_path: |
bulk_apply 는 게시판 여러 개에 같은 설정을 적용하는 경로다. 대상이 N개라 캐시 무효화도
N개 게시판에 걸려야 한다 — 한 건만 무효화하면 나머지는 옛 값을 유지한다.
카테고리·파이프라인 단계는 조합이 아니라 각 경로가 통과해야 할 목록이라 effects 로 둔다.
axes:
save_path: [single_key, bulk, bulk_apply]
effects:
# 파이프라인 통과 (B-2)
- single_key_save_invalidates_service_cache # 저장 직후 조회가 새 값을 본다
- single_key_save_does_not_backfill_booleans # 단건 저장이 미제출 토글을 끄지 않는다
- bulk_save_backfills_unsubmitted_booleans # 벌크 저장은 폼 전체가 기준 (기존 계약 유지)
- report_notification_flags_forced_on_single_key_save
- numeric_string_normalized_on_single_key_save
- board_settings_storage_isolated_in_tests # 테스트 저장소 격리 (실제 설정 파일 오염 차단)
# 저장 훅과 구독자 (B-5)
- module_settings_after_save_hook_fired
- seo_cache_invalidated_on_module_settings_save
- seo_cache_invalidated_on_bulk_apply # 일괄 적용은 대상 게시판 전부에 걸린다
- seo_cache_untouched_for_other_module # 다른 모듈 식별자의 저장에는 반응하지 않는다
test_files:
- modules/_bundled/sirsoft-board/tests/Unit/BoardSetSettingPipelineTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsSeoCacheInvalidationTest.php
@@ -17,6 +17,13 @@
### Fixed
- 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다.
- 현금영수증 발급사 플러그인을 삭제해도 주문서의 현금영수증 신청 폼과 마이페이지 발급 버튼이 계속 표시되던 문제를 수정했습니다. 신청해도 발급 실패로만 기록됐습니다. 이미 발급했거나 발급을 시도한 이력이 있는 주문은 종전대로 내역과 영수증 링크가 계속 표시됩니다.
- 비회원 주문 상세와 관리자 주문 상세에서 현금영수증 금액의 소수 자릿수가 주문 시점이 아닌 현재 통화 설정을 따르던 문제를 수정했습니다.
- 관리자 환경설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 메타 정보가 계속 노출되던 문제를 수정했습니다. 쇼핑몰 SEO 설정 저장 시 관련 캐시가 즉시 정리됩니다.
- 환경설정 저장 직후 같은 요청 안에서 결제수단·통화 정보가 저장 전 값으로 계산되던 문제를 수정했습니다. (#116 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 배송정책을 저장·삭제하거나 기본 배송정책을 바꾼 직후, 같은 요청 안에서 변경 전 배송정책으로 배송비가 계산될 수 있던 문제를 수정했습니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 설정 파일 안에서 값이 서로 어긋난 채 남을 수 있던 문제를 수정했습니다. 기본 통화를 바꿨는데 통화 목록의 기본 표시가 따라오지 않거나, 은행 목록 저장 시 은행명이 다국어 형식으로 정리되지 않는 등의 경우입니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 관리자 쿠폰 등록·수정에서 「타 쿠폰과 중복 사용」을 선택하면 `is combinable 필드는 true 또는 false여야 합니다.` 오류로 저장이 실패하던 문제를 수정했습니다. (#97 @lyg-kaban 님께서 제보해주셨습니다.)
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
@@ -1656,7 +1656,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다 |
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다. **지정된 PG 사가 현재 등록되어 있지 않은 결제수단도 같은 이유로 제외된다** — 수단 자체는 카탈로그에 남아 있지만 주문 시 PG 라우팅이 매칭에 실패해 결제창 없이 주문이 완료되기 때문이다. 유효 PG 판정은 결제수단의 `pg_provider` 가 비어 있을 때만 `default_pg_provider` 로 폴백하며(런타임 라우팅과 동일), 양쪽 모두 미설정이면 PG 비경유 수단으로 종전대로 노출된다. `default_pg_provider` 와 `cash_receipt_provider` 도 등록되지 않은 값이면 `null` 로 정규화된다. 관리자 응답(`GET admin/settings`)은 이 필터를 적용하지 않고 `_orphaned` / `_orphaned_pg` 플래그를 그대로 실어 운영자가 확인·수정할 수 있게 한다 |
**응답 예시**
@@ -39,6 +39,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SeoSettingsCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\ShippingPolicyActivityLogListener;
use Modules\Sirsoft\Ecommerce\Listeners\ShippingPolicyCacheListener;
use Modules\Sirsoft\Ecommerce\Listeners\SyncOptionGroupsListener;
use Modules\Sirsoft\Ecommerce\Listeners\SyncProductFromOptionListener;
use Modules\Sirsoft\Ecommerce\Listeners\UserCurrencyInfoListener;
@@ -2129,6 +2130,7 @@ class Module extends AbstractModule
SeoCategoryCacheListener::class,
CategoryTreeCacheListener::class,
SeoSettingsCacheListener::class,
ShippingPolicyCacheListener::class,
MileageTransactionListener::class,
UserMileageInfoListener::class,
UserCurrencyInfoListener::class,
@@ -253,10 +253,10 @@
"components": [
{
"id": "ext_mypage_cash_receipt_card",
"comment": "상태 ① — 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다",
"comment": "상태 ① — 무통장이 아니면 카드 자체를 렌더하지 않는다. 프로바이더가 미설정(또는 제공 확장 제거)이어도 이미 발급/시도된 이력이 있으면 계속 표시한다 — 영수증 URL 에 도달할 방법이 사라지면 안 된다(A3)",
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.payment_method === 'dbank' && !!order.data.payment?.cash_receipt_provider}}",
"if": "{{order.data.payment?.payment_method === 'dbank' && (!!order.data.payment?.cash_receipt_provider || !!order.data.cash_receipt || (order.data.cash_receipts ?? []).length > 0)}}",
"props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" },
"responsive": {
"portable": { "props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700" } }
@@ -305,10 +305,10 @@
},
{
"id": "ext_mypage_cash_receipt_issuable",
"comment": "상태 ③ 입금완료 + 미발급 — 발급 버튼. 재발급 실패 이력이 있으면 상태 ⑤(경고)가 담당하므로 여기서 제외한다(두 상태는 배타적이어야 한다).",
"comment": "상태 ③ 입금완료 + 미발급 — 발급 버튼. 재발급 실패 이력이 있으면 상태 ⑤(경고)가 담당하므로 여기서 제외한다(두 상태는 배타적이어야 한다). 발급 프로바이더가 해석되지 않으면(제공 확장 제거 등) 버튼을 내리지 않는다 — 눌러도 구독자 없는 훅을 호출해 실패로만 기록된다(A3).",
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.payment_status === 'paid' && !order.data.cash_receipt && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
"if": "{{!!order.data.payment?.cash_receipt_provider && order.data.payment?.payment_status === 'paid' && !order.data.cash_receipt && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
"props": { "className": "flex flex-row items-center justify-between gap-2" },
"responsive": {
"portable": { "props": { "className": "flex flex-col items-start gap-2 w-full" } }
@@ -382,6 +382,59 @@ describe('결제수단 Sortable 리스트 구조 검증 (_payment_methods_list.j
);
});
// 지정 PG 가 레지스트리에서 사라진 상태(A2). 수단 자체는 카탈로그에 남아 있어
// _orphaned 로는 잡히지 않으므로 별도 배지가 필요하다.
it('죽은 PG 배지가 _orphaned_pg 조건에서만 표시되어야 한다', () => {
const badge = findFirst(tpl, (n: any) =>
typeof n?.props?.['data-testid'] === 'string'
&& n.props['data-testid'].includes('orphaned-pg-badge-'),
);
expect(badge).not.toBeNull();
expect(badge.if).toBe('{{$method._orphaned_pg}}');
expect(badge.text).toBe(
'$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_pg_badge',
);
});
// 브라우저 실측에서 드러난 결함(A2 매트릭스 T1): 배지가 줄바꿈 가능·축소 가능이라
// 좁은 폭에서 글자 단위로 접히고, 그 압력이 이름 열까지 밀어 이름도 세로로 무너졌다.
it('상태 배지는 줄바꿈·축소되지 않아야 한다 (이름 열 붕괴 차단)', () => {
const badges = [
findFirst(tpl, (n: any) => n?.if === '{{$method._orphaned}}' && n?.name === 'Span'),
findFirst(tpl, (n: any) => n?.if === '{{$method._orphaned_pg}}' && n?.name === 'Span'),
];
for (const badge of badges) {
expect(badge).not.toBeNull();
expect(badge.props.className).toContain('whitespace-nowrap');
expect(badge.props.className).toContain('shrink-0');
}
});
it('이름 줄은 축소 가능해야 하고 이름은 말줄임 처리되어야 한다', () => {
const nameSpan = findFirst(tpl, (n: any) =>
typeof n?.text === 'string' && n.text.includes('_cached_name') && n?.name === 'Span',
);
expect(nameSpan).not.toBeNull();
// truncate 가 없으면 좁은 폭에서 글자 단위 줄바꿈으로 무너진다
expect(nameSpan.props.className).toContain('truncate');
const nameRow = findFirst(tpl, (n: any) =>
Array.isArray(n?.children) && n.children.includes(nameSpan),
);
// flex 항목 기본 min-width:auto 때문에 min-w-0 없이는 축소 자체가 안 된다
expect(nameRow.props.className).toContain('min-w-0');
});
// _orphaned 와 달리 행 편집 컨트롤은 막지 않는다 — 살아있는 PG 로 바꿔 복구해야 하므로
it('죽은 PG 상태는 PG 선택 셀렉트를 감추지 않아야 한다', () => {
const select = findFirst(tpl, (n: any) =>
typeof n?.props?.['data-testid'] === 'string'
&& n.props['data-testid'].includes('pg-select-'),
);
expect(select).not.toBeNull();
expect(select.if).not.toContain('_orphaned_pg');
});
it('재고차감시점 Select가 3개 옵션(order_placed/payment_complete/none)을 가져야 한다', () => {
// 2개 옵션 → 3개 (none 추가: 차감 안함)
const select = findFirst(tpl, (n: any) =>
@@ -630,6 +683,16 @@ describe('결제수단 모바일 카드 구조 검증 (_payment_methods_cards.js
expect(findByTestidExpr(needle)).not.toBeNull();
}
});
// A2 — 죽은 PG 배지는 PC/모바일 양쪽에 같은 조건·같은 키로 있어야 한다
it('죽은 PG 배지가 _orphaned_pg 조건에서만 표시되어야 한다', () => {
const badge = findByTestidExpr('orphaned-pg-badge-');
expect(badge).not.toBeNull();
expect(badge.if).toBe('{{$method._orphaned_pg}}');
expect(badge.text).toBe(
'$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_pg_badge',
);
});
});
});
@@ -163,10 +163,30 @@ describe('W-2 유저 주문상세 현금영수증 카드 — 상태머신 5종',
...over,
});
it('① 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다', () => {
it('① 무통장이 아니면 카드 자체를 렌더하지 않는다', () => {
expect(evalIf(card.if, { order: { data: { payment: payment() } } })).toBe(true);
expect(evalIf(card.if, { order: { data: { payment: payment({ payment_method: 'card' }) } } })).toBe(false);
expect(evalIf(card.if, { order: { data: { payment: payment({ cash_receipt_provider: null }) } } })).toBe(false);
});
// A3 — 제공 확장이 사라지면 프로바이더는 미설정으로 해석된다. 그때 이력까지 감추면
// 이미 발급된 영수증 URL 에 도달할 방법이 없어진다.
it('① 프로바이더 미설정 + 이력 없음이면 카드를 렌더하지 않는다', () => {
const ctx = { order: { data: { payment: payment({ cash_receipt_provider: null }), cash_receipt: null, cash_receipts: [] } } };
expect(evalIf(card.if, ctx)).toBe(false);
});
it('① 프로바이더 미설정이어도 발급/시도 이력이 있으면 카드를 유지한다', () => {
const issued = { order: { data: { payment: payment({ cash_receipt_provider: null }), cash_receipt: { id: 1 }, cash_receipts: [{ id: 1 }] } } };
expect(evalIf(card.if, issued)).toBe(true);
const failedOnly = { order: { data: { payment: payment({ cash_receipt_provider: null }), cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
expect(evalIf(card.if, failedOnly)).toBe(true);
});
it('③ 발급 버튼은 프로바이더가 해석될 때만 렌더한다 (죽은 provider 로 신청 불가)', () => {
const node = byId(mypageExt, 'ext_mypage_cash_receipt_issuable');
const dead = { order: { data: { payment: payment({ cash_receipt_provider: null }), cash_receipt: null, cash_receipts: [] } } };
expect(evalIf(node.if, dead)).toBe(false);
});
it('② 입금 전에는 안내만 노출한다 — 무통장은 ready, 가상계좌는 waiting_deposit', () => {
@@ -267,10 +287,23 @@ describe('W-2 유저 주문상세 현금영수증 카드 — 상태머신 5종',
describe('W-3 관리자 주문상세 현금영수증 카드', () => {
const card = byRowId(paymentInfo, 'payment_cash_receipt_card');
it('결제카드 반복(payment) 컨텍스트에서 무통장 + 프로바이더 설정 시에만 렌더된다', () => {
it('결제카드 반복(payment) 컨텍스트에서 무통장일 때만 렌더된다', () => {
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: 'toss' } })).toBe(true);
expect(evalIf(card.if, { payment: { payment_method: 'vbank', cash_receipt_provider: 'toss' } })).toBe(false);
expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: '' } })).toBe(false);
});
// A3 — 유저 화면과 같은 규칙: 프로바이더가 사라져도 이력이 있으면 관리자도 계속 봐야 한다
it('프로바이더 미설정 + 이력 없음이면 카드를 렌더하지 않는다', () => {
const ctx = { payment: { payment_method: 'dbank', cash_receipt_provider: '' }, order: { data: { cash_receipt: null, cash_receipts: [] } } };
expect(evalIf(card.if, ctx)).toBe(false);
});
it('프로바이더 미설정이어도 발급/시도 이력이 있으면 카드를 유지한다', () => {
const issued = { payment: { payment_method: 'dbank', cash_receipt_provider: '' }, order: { data: { cash_receipt: { id: 1 }, cash_receipts: [{ id: 1 }] } } };
expect(evalIf(card.if, issued)).toBe(true);
const failedOnly = { payment: { payment_method: 'dbank', cash_receipt_provider: null }, order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } };
expect(evalIf(card.if, failedOnly)).toBe(true);
});
it('입금 전 안내는 무통장(ready)과 가상계좌(waiting_deposit) 모두에서 노출된다', () => {
@@ -152,6 +152,7 @@
"min_order_amount": "Min. Order Amount",
"is_active": "Active",
"orphaned_badge": "Plugin Not Installed",
"orphaned_pg_badge": "PG removed",
"remove_orphaned": "Remove",
"pg_provider": "PG Provider",
"pg_use_default": "Default PG",
@@ -152,6 +152,7 @@
"min_order_amount": "최소 주문금액",
"is_active": "사용여부",
"orphaned_badge": "플러그인 미설치",
"orphaned_pg_badge": "지정 PG 삭제됨",
"remove_orphaned": "삭제",
"pg_provider": "PG사",
"pg_use_default": "기본 PG",
@@ -743,10 +743,10 @@
},
{
"id": "payment_cash_receipt_card_{{payIdx}}",
"comment": "현금영수증 카드 (W-3 / #454). 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다. 카드 바깥의 기존 결제정보 본문은 .grid-2col-responsive 등 CSS 유틸을 쓰지만, 신설 카드는 responsive.portable 로 작성한다(액션 버튼의 마크업 구조가 바뀌므로 Tailwind breakpoint 로 처리 불가 — 편집기 디바이스 미리보기가 overrideWidth 를 무시한다).",
"comment": "현금영수증 카드 (W-3 / #454). 무통장이 아니면 카드 자체를 렌더하지 않는다. 카드 바깥의 기존 결제정보 본문은 .grid-2col-responsive 등 CSS 유틸을 쓰지만, 신설 카드는 responsive.portable 로 작성한다(액션 버튼의 마크업 구조가 바뀌므로 Tailwind breakpoint 로 처리 불가 — 편집기 디바이스 미리보기가 overrideWidth 를 무시한다). 프로바이더 미설정이어도 이미 발급/시도된 이력이 있으면 계속 표시한다 — 제공 확장이 사라졌다고 과거 발급 내역까지 화면에서 사라지면 안 된다(A3).",
"type": "basic",
"name": "Div",
"if": "{{payment?.payment_method === 'dbank' && !!payment?.cash_receipt_provider}}",
"if": "{{payment?.payment_method === 'dbank' && (!!payment?.cash_receipt_provider || !!order.data?.cash_receipt || (order.data?.cash_receipts ?? []).length > 0)}}",
"props": {
"className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
},
@@ -1077,13 +1077,13 @@
"if": "{{payment?.payment_status === 'paid' && !order.data?.cash_receipt && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
"className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
"className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white"
}
}
},
@@ -1118,13 +1118,13 @@
"if": "{{!order.data?.cash_receipt && (order.data?.cash_receipts ?? []).length > 0 && String((order.data?.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}",
"props": {
"type": "button",
"className": "px-3 py-1.5 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
"className": "px-3 py-1.5 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 text-white"
},
"responsive": {
"portable": {
"props": {
"type": "button",
"className": "w-full px-3 py-2 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 text-white"
"className": "w-full px-3 py-2 text-sm rounded-lg bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 text-white"
}
}
},
@@ -94,9 +94,19 @@
"name": "Span",
"if": "{{$method._orphaned}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
"className": "inline-flex items-center whitespace-nowrap shrink-0 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_badge"
},
{
"type": "basic",
"name": "Span",
"if": "{{$method._orphaned_pg}}",
"props": {
"data-testid": "orphaned-pg-badge-{{$method.id}}",
"className": "inline-flex items-center whitespace-nowrap shrink-0 px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_pg_badge"
}
]
},
@@ -76,15 +76,16 @@
{
"type": "basic",
"name": "Div",
"comment": "이름 + 상태 배지 줄. 배지는 shrink-0 이라 좁아지면 이름이 줄어야 하는데, min-w-0 이 없으면 flex 항목의 기본 min-width:auto 때문에 줄지 못하고 글자 단위로 접힌다.",
"props": {
"className": "flex-center gap-2"
"className": "flex-center gap-2 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-medium text-gray-900 dark:text-gray-100"
"className": "font-medium text-gray-900 dark:text-gray-100 truncate"
},
"text": "{{$localized($method._cached_name, 'sirsoft-ecommerce::settings.payment_methods.' + ($method.id ?? '') + '.name') || $method.id}}"
},
@@ -93,9 +94,20 @@
"name": "Span",
"if": "{{$method._orphaned}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
"className": "inline-flex items-center whitespace-nowrap shrink-0 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_badge"
},
{
"comment": "지정 PG 가 레지스트리에서 사라진 상태. _orphaned 와 달리 행 편집 컨트롤은 그대로 둔다 — 살아있는 PG 로 바꿔 복구하는 경로를 막지 않기 위함.",
"type": "basic",
"name": "Span",
"if": "{{$method._orphaned_pg}}",
"props": {
"data-testid": "orphaned-pg-badge-{{$method.id}}",
"className": "inline-flex items-center whitespace-nowrap shrink-0 px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.payment_methods.orphaned_pg_badge"
}
]
},
@@ -12,6 +12,7 @@ use App\Services\NotificationChannelService;
use App\Services\NotificationDefinitionService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiRequestField;
@@ -166,6 +167,12 @@ class EcommerceSettingsController extends AdminBaseController
// 설정 저장 활동로그 (저장된 카테고리 목록 전달)
HookManager::doAction('sirsoft-ecommerce.settings.after_save', array_keys($settings));
// 코어 모듈 설정 저장 훅 — SEO 캐시 무효화 등 코어/타 확장 리스너가 구독한다.
// 발화 지점을 서비스가 아니라 관리자 컨트롤러에 두는 이유: 훅 의미가
// "관리자가 설정을 저장했다" 이고, 서비스에 두면 내부 저장 호출 전부가
// 활동로그·캐시 무효화를 유발한다.
HookManager::doAction('core.module_settings.after_save', 'sirsoft-ecommerce', $settings, $result);
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$updatedSettings = $this->settingsService->getAllSettings();
$updatedSettings = $this->appendCarriersToSettings($updatedSettings);
@@ -214,6 +221,13 @@ class EcommerceSettingsController extends AdminBaseController
$result = $this->settingsService->saveBanks($banks);
if ($result) {
HookManager::doAction(
'core.module_settings.after_save',
'sirsoft-ecommerce',
['order_settings' => ['banks' => $banks]],
$result
);
$updatedSettings = $this->settingsService->getAllSettings();
return ResponseHelper::moduleSuccess(
@@ -282,6 +296,11 @@ class EcommerceSettingsController extends AdminBaseController
$result = $this->settingsService->setSetting($key, $value);
if ($result) {
// dot-key 를 카테고리 하위 구조로 되돌려 벌크 저장과 같은 payload 형태로 만든다
$payload = [];
Arr::set($payload, $key, $value);
HookManager::doAction('core.module_settings.after_save', 'sirsoft-ecommerce', $payload, $result);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.update_success',
@@ -68,6 +68,16 @@ class CreateOrderRequest extends FormRequest
'required',
'string',
Rule::in(app(PaymentMethodResolver::class)->allValidIds()),
// 지금 주문을 받을 수 있는 수단인지 별도 판정 (A2 / 공개 #111 서버 대칭).
// 공급 확장이 사라진 수단, 지정 PG 가 사라진 수단은 화이트리스트를 통과하지만
// 주문하면 PG 라우팅이 매칭에 실패해 결제창 없이 주문완료로 넘어간다.
// 화이트리스트(allValidIds) 자체는 조이지 않는다 — 과거 주문 목록 필터가
// 같은 목록을 쓰므로, 조이면 예전 수단으로 결제한 주문을 조회할 수 없게 된다.
function (string $attribute, mixed $value, callable $fail) {
if (is_string($value) && ! app(PaymentMethodResolver::class)->isOrderable($value)) {
$fail(__('sirsoft-ecommerce::validation.order.payment_method_unavailable'));
}
},
],
'expected_total_amount' => 'required|numeric|min:0',
@@ -140,7 +140,10 @@ class GuestOrderResource extends BaseApiResource
// "미발급" 으로 남고 영수증 URL 에 도달할 방법이 사라진다(오류·경고 없음).
// CashReceiptResource 는 식별번호를 마스킹 값으로만 내보내고 프로바이더 원응답을
// 노출하지 않으므로 비회원에게 내려도 안전하다.
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency) {
// 통화 스냅샷은 명시 캡처가 필요하다 — 형제 항목들이 쓰는 화살표 함수와 달리
// 이 클로저는 자동 캡처가 없어, use 목록에서 빠지면 undefined 로 떨어져 주문 시점
// 통화(자릿수·절사 규칙)가 전파되지 않는다.
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency, $currencySnapshot) {
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
@@ -194,7 +194,10 @@ class OrderResource extends BaseApiResource
)),
// 현금영수증 — 현재 활성 영수증 1건(없으면 null). 발급 카드의 "발급완료" 상태 근거.
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency) {
// 통화 스냅샷은 명시 캡처가 필요하다 — 형제 항목들이 쓰는 화살표 함수와 달리 이
// 클로저는 자동 캡처가 없어, use 목록에서 빠지면 undefined 로 떨어져 주문 시점
// 통화(자릿수·절사 규칙)가 전파되지 않는다.
'cash_receipt' => $this->whenLoaded('cashReceipts', function () use ($orderCurrency, $paymentCurrency, $currencySnapshot) {
$active = OrderCashReceipt::filterActive($this->cashReceipts)[0] ?? null;
return $active
@@ -0,0 +1,73 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyResolver;
/**
* 배송정책 변경 시 해석기 캐시 무효화 리스너
*
* `ShippingPolicyResolver` 는 싱글톤이라 기본 배송정책을 요청 내 1회만 조회한다.
* 그래서 한 요청이 배송정책을 바꾼 뒤에도 그 요청 안에서는 변경 전 정책이 계속 쓰인다.
*
* 무효화 호출을 서비스 메서드마다 흩어 놓지 않고 이 리스너 한 곳에 모은다 — 배송정책을
* 바꾸는 지점은 이미 전부 `after_*` 훅을 발화하므로, 새 변경 경로가 생겨도 같은 훅만
* 발화하면 무효화가 자동으로 따라온다.
*/
class ShippingPolicyCacheListener implements HookListenerInterface
{
/**
* 구독할 훅 목록 반환
*
* @return array<string, array{method: string, priority: int}> 훅 이름 → 메서드/우선순위 매핑
*/
public static function getSubscribedHooks(): array
{
$hooks = [];
foreach ([
'after_create',
'after_update',
'after_delete',
'after_bulk_delete',
'after_toggle_active',
'after_bulk_toggle_active',
'after_set_default',
] as $event) {
$hooks['sirsoft-ecommerce.shipping_policy.'.$event] = [
'method' => 'onShippingPolicyChanged',
'priority' => 5,
];
}
return $hooks;
}
/**
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
*
* @param mixed ...$args 훅 인자
*/
public function handle(...$args): void
{
$this->onShippingPolicyChanged(...$args);
}
/**
* 배송정책이 바뀌면 해석기의 기본 배송정책 캐시를 비웁니다.
*
* 아직 해석기를 해석하지 않은 요청에서는 새로 만들 필요가 없다 — 처음 조회할 때
* 이미 변경 후 값을 읽는다.
*
* @param mixed ...$args 훅 인자 (배송정책 또는 ID 목록 — 무효화는 대상과 무관)
*/
public function onShippingPolicyChanged(...$args): void
{
if (! app()->resolved(ShippingPolicyResolver::class)) {
return;
}
app(ShippingPolicyResolver::class)->flushCache();
}
}
@@ -96,6 +96,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\UserAddressRepository;
use Modules\Sirsoft\Ecommerce\Seo\EcommerceSitemapContributor;
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
use Modules\Sirsoft\Ecommerce\Services\ProductReviewImageService;
@@ -216,6 +217,12 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
// PaymentMethodResolver를 싱글톤으로 등록 (요청 내 결제수단 카탈로그 조회 1회 캐시)
$this->app->singleton(PaymentMethodResolver::class);
// EcommerceSettingsService를 싱글톤으로 등록 (공개 #116)
// 위 3종과 달리 미등록이라 주입 지점마다 별개 인스턴스가 만들어졌고, 각자 자기 설정
// 캐시를 들고 있었다. 싱글톤 리졸버가 비-싱글톤 설정 서비스를 captive 로 보유하는
// 비대칭도 함께 생긴다. 쓰기 메서드가 모두 자기 캐시를 무효화하므로 공유해도 안전하다.
$this->app->singleton(EcommerceSettingsService::class);
}
/**
@@ -73,28 +73,41 @@ class EcommerceSettingsService implements ModuleSettingsInterface
/**
* 설정값 저장
*
* @param string $key 설정 키
* 벌크 저장(saveSettings)과 동일한 정규화 파이프라인을 경유한다 (공개 #114).
* 예전에는 `Arr::set` 결과를 카테고리 파일에 통째로 덮어써서 분리 입력 필드 병합·
* 기본 통화 동기화·defaults 스키마 정규화·결제수단 메타데이터 스냅샷·삭제 통화 기록·
* 통화 캐시 무효화를 전부 건너뛰었고, 저장 파일이 서로 어긋난 상태로 남았다
* (예: default_currency 는 USD 인데 통화 목록의 is_default 는 KRW).
*
* 위임 payload 의 기저는 **저장본**(loadCategorySettings)이다. 조회 결과
* (getAllSettings)를 기저로 삼으면 읽기 시점 보강분(통화 symbol/flag, 결제수단
* 병합 메타)이 영속화되고, 삭제 통화 기록(공개 #91)이 재계산되며 지워져 관리자가
* 삭제한 통화가 부활한다.
*
* @param string $key 설정 키 (예: 'basic_info.shop_name', 카테고리 통째 지정도 허용)
* @param mixed $value 저장할 값
* @return bool 성공 여부
*/
public function setSetting(string $key, mixed $value): bool
{
$settings = $this->getAllSettings();
Arr::set($settings, $key, $value);
// 카테고리 추출
$parts = explode('.', $key);
$category = $parts[0];
$category = array_shift($parts);
$result = $this->saveCategorySettings($category, $settings[$category] ?? []);
if ($parts === []) {
// 카테고리 통째 저장 — 배열이 아니면 저장할 카테고리 데이터가 없다
// (기존 경로도 배열 아닌 값은 저장 대상이 되지 못했다)
if (! is_array($value)) {
return false;
}
// 파일 저장 후 캐시 초기화 (다음 조회 시 재계산)
$this->settings = null;
$categoryData = $value;
} else {
$categoryData = $this->loadCategorySettings($category);
Arr::set($categoryData, implode('.', $parts), $value);
}
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
g7_refresh_module_settings_config('sirsoft-ecommerce');
return $result;
// saveSettings 는 제공된 카테고리만 저장하므로 다른 카테고리 파일은 건드리지 않는다
return $this->saveSettings([$category => $categoryData]);
}
/**
@@ -148,7 +161,8 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// 결제수단 병합 (기본 + 플러그인 필터 + 사용자 저장 설정)
if (isset($settings['order_settings'])) {
$settings['order_settings']['payment_methods'] = $this->getMergedPaymentMethods(
$settings['order_settings']['payment_methods'] ?? []
$settings['order_settings']['payment_methods'] ?? [],
$settings['order_settings']['default_pg_provider'] ?? null
);
}
@@ -189,7 +203,16 @@ class EcommerceSettingsService implements ModuleSettingsInterface
}
}
$this->settings = $settings;
// 부팅이 끝나기 전 결과는 캐시하지 않는다.
//
// 이 결과에는 훅 카탈로그와 병합된 값이 섞여 있다 — 확장이 등록한 결제수단과 그 수단·PG 의
// 생사 판정이다. 그런데 코어는 부팅 중(CoreServiceProvider::boot)에 config 미러를 채우려고
// 설정을 한 번 읽고, 그 시점은 플러그인이 자기 훅을 등록하기 전이라 카탈로그가 비어 있다.
// 서비스가 공유 인스턴스이므로(공개 #116) 그 빈 카탈로그 기준 판정을 캐시하면 요청 내내
// 남아, 살아 있는 PG 를 지정한 결제수단이 주문서에서 통째로 사라진다.
if (app()->isBooted()) {
$this->settings = $settings;
}
return $settings;
}
@@ -215,6 +238,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface
* is_active 는 그대로 남아 있으므로 걸러내지 않으면 체크아웃이 선택 가능한
* 결제수단으로 계속 노출한다(관리자 화면은 _orphaned 를 읽어 이미 차단).
*
* 수단은 살아 있는데 그 수단이 지정한 **PG 가 사라진** 경우도 같은 결함이다(A2).
* 이쪽은 카탈로그에 남아 있어 `_orphaned` 로 걸리지 않지만, 주문 시 PG 라우팅이
* 매칭에 실패해 결제창 없이 주문완료로 넘어간다 — `_orphaned_pg` 로 함께 차단한다.
* 관리자 응답은 두 플래그를 그대로 유지한다(운영자가 확인하고 고쳐야 할 대상).
*
* @return array 고아 항목이 제거되고 은행명이 포함된 결제 설정
*/
public function getPublicPaymentSettings(): array
@@ -222,12 +250,27 @@ class EcommerceSettingsService implements ModuleSettingsInterface
$orderSettings = $this->getSettings('order_settings');
if (isset($orderSettings['payment_methods']) && is_array($orderSettings['payment_methods'])) {
// 비연속 키가 JSON 객체로 직렬화되지 않도록 array_values 로 재정렬
$orderSettings['payment_methods'] = array_values(array_filter(
$orderSettings['payment_methods'],
fn ($method) => ! ($method['_orphaned'] ?? false)
fn ($method) => ! ($method['_orphaned'] ?? false) && ! ($method['_orphaned_pg'] ?? false)
));
}
// 죽은 기본 PG 는 공개 응답에서 미설정으로 정규화한다 (프론트가 그 값을 그대로 쓰지 않도록)
$defaultPg = $orderSettings['default_pg_provider'] ?? null;
if (is_string($defaultPg) && $defaultPg !== ''
&& ! in_array($defaultPg, $this->registeredPgProviderIds(), true)) {
$orderSettings['default_pg_provider'] = null;
}
// 현금영수증 프로바이더도 같은 정규화를 거친다 (A3).
// 체크아웃 신청 폼은 이 카테고리 raw 값을 truthy 로 읽으므로, 여기서 정규화하지 않으면
// 제공 확장이 사라진 뒤에도 신청 폼이 계속 렌더된다.
if (array_key_exists('cash_receipt_provider', $orderSettings)) {
$orderSettings['cash_receipt_provider'] = $this->getCashReceiptProvider();
}
if (isset($orderSettings['bank_accounts'], $orderSettings['banks'])) {
$banks = collect($orderSettings['banks']);
$orderSettings['bank_accounts'] = array_map(function ($account) use ($banks) {
@@ -298,9 +341,32 @@ class EcommerceSettingsService implements ModuleSettingsInterface
// 저장 전 통화 구성으로 금액이 계산된다)
CurrencySettingsCache::clear();
$this->flushResolvedCaches();
return $success;
}
/**
* 이미 해석된 싱글톤 서비스들의 요청 단위 캐시를 비웁니다. (공개 #116)
*
* 싱글톤 리졸버들은 자기 캐시를 따로 들고 있어서, 설정 서비스의 캐시만 비우면 같은 요청
* 안에서 이미 해석된 리졸버가 저장 전 카탈로그를 계속 답한다. 저장 경로가 이 한 지점으로
* 모여 있으므로(단건 저장도 saveSettings 에 위임) 여기서 함께 무효화한다.
*
* 생성자 상호 주입은 순환이라 lazy 해석하며, `resolved()` 가드로 아직 필요하지 않은
* 서비스를 저장이 강제로 인스턴스화하지 않게 한다.
*/
private function flushResolvedCaches(): void
{
if (app()->resolved(PaymentMethodResolver::class)) {
app(PaymentMethodResolver::class)->flushCache();
}
if (app()->resolved(CurrencyConversionService::class)) {
app(CurrencyConversionService::class)->clearCache();
}
}
/**
* 관리자가 삭제한 기본 제공 통화를 저장본에 기록합니다. (공개 #91)
*
@@ -376,6 +442,9 @@ class EcommerceSettingsService implements ModuleSettingsInterface
*
* 기존 order_settings의 다른 설정은 유지하고 banks만 교체합니다.
*
* 저장은 벌크 저장(saveSettings)에 위임해 정규화 파이프라인을 함께 경유한다 (공개 #114) —
* 은행명 다국어 정규화와 결제수단 메타데이터 스냅샷이 이 경로에서도 적용된다.
*
* @param array $banks 은행 목록 배열
* @return bool 성공 여부
*/
@@ -384,15 +453,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface
$currentSettings = $this->loadCategorySettings('order_settings');
$currentSettings['banks'] = $banks;
$result = $this->saveCategorySettings('order_settings', $currentSettings);
// 캐시 초기화
$this->settings = null;
// 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
g7_refresh_module_settings_config('sirsoft-ecommerce');
return $result;
return $this->saveSettings(['order_settings' => $currentSettings]);
}
/**
@@ -825,13 +886,27 @@ class EcommerceSettingsService implements ModuleSettingsInterface
/**
* 현재 선택된 현금영수증 발급 프로바이더 ID를 반환합니다.
*
* @return string|null 프로바이더 ID (미설정 시 null)
* 저장값이 남아 있어도 그 프로바이더를 제공하는 확장이 없으면 **미설정으로 해석**한다(A3).
* 플러그인을 제거해도 설정 문자열은 그대로 남는데, 그 값을 신뢰하면 체크아웃의 신청 폼과
* 마이페이지 발급 버튼이 계속 렌더되고, 신청하면 구독자 없는 훅을 호출해 발급 실패로만
* 조용히 기록된다.
*
* @return string|null 프로바이더 ID (미설정이거나 제공 확장 부재 시 null)
*/
public function getCashReceiptProvider(): ?string
{
$provider = $this->getSetting('order_settings.cash_receipt_provider');
return is_string($provider) && $provider !== '' ? $provider : null;
if (! is_string($provider) || $provider === '') {
return null;
}
$registeredIds = array_map(
fn ($entry) => $entry['id'] ?? null,
$this->getRegisteredCashReceiptProviders()
);
return in_array($provider, $registeredIds, true) ? $provider : null;
}
/**
@@ -946,13 +1021,65 @@ class EcommerceSettingsService implements ModuleSettingsInterface
* 기본/플러그인 정의와 사용자 저장 설정을 병합합니다.
*
* @param array $savedMethods 사용자 저장 결제수단 배열
* @param string|null $defaultPgProvider 기본 PG 제공자 (죽은 PG 판정용, 미전달 시 판정 생략)
* @return array 병합된 결제수단 배열
*/
public function getMergedPaymentMethods(array $savedMethods = []): array
public function getMergedPaymentMethods(array $savedMethods = [], ?string $defaultPgProvider = null): array
{
$available = $this->getAvailablePaymentMethods();
return $this->mergePaymentMethodSettings($available, $savedMethods);
return $this->mergePaymentMethodSettings($available, $savedMethods, $defaultPgProvider);
}
/**
* 결제수단에 지정된 PG 가 현재 레지스트리에 없는지 판정합니다. (A2)
*
* 고아 판정(`_orphaned`)은 결제수단 ID 만 본다. builtin 수단에 특정 PG 를 지정한 뒤 그
* PG 플러그인을 제거하면 수단 자체는 카탈로그에 남아 있어 그 필터를 통과하고, 체크아웃에
* 선택 가능한 수단으로 노출된다. 주문하면 PG 라우팅이 매칭에 실패해 결제창 없이
* 주문완료로 넘어간다.
*
* 유효 PG 의 폴백 규칙은 런타임(`OrderProcessingService::determinePgProvider()`)과
* 동일하게 맞춘다 — 수단의 지정값이 **null 일 때만** 기본 PG 로 내려간다. 지정값이 죽은
* 문자열이면 기본 PG 가 살아 있어도 폴백하지 않으므로, 그 경우도 차단 대상이다.
*
* @param bool $needsPg PG 결제창이 필요한 수단인지
* @param string|null $ownProvider 수단에 지정된 PG
* @param string|null $defaultPgProvider 기본 PG
* @param array<int, string> $registeredIds 현재 등록된 PG provider ID 목록
* @return bool 죽은 PG 지정 여부
*/
private function hasOrphanedPgProvider(
bool $needsPg,
?string $ownProvider,
?string $defaultPgProvider,
array $registeredIds
): bool {
if (! $needsPg) {
return false;
}
$effective = $ownProvider ?? $defaultPgProvider;
// 양쪽 미설정은 기존 계약('none' → non-PG 강하) — 고아가 아니다
if (! is_string($effective) || $effective === '') {
return false;
}
return ! in_array($effective, $registeredIds, true);
}
/**
* 현재 등록된 PG provider ID 목록을 반환합니다.
*
* @return array<int, string> provider ID 목록
*/
private function registeredPgProviderIds(): array
{
return array_values(array_filter(
array_map(fn ($provider) => $provider['id'] ?? null, $this->getRegisteredPgProviders()),
fn ($id) => is_string($id) && $id !== ''
));
}
/**
@@ -960,13 +1087,17 @@ class EcommerceSettingsService implements ModuleSettingsInterface
*
* @param array $available 사용 가능한 결제수단 정의 배열
* @param array $saved 사용자 저장 설정 배열
* @param string|null $defaultPgProvider 기본 PG 제공자 (죽은 PG 판정용, null 이면 판정 생략)
* @return array 병합된 결제수단 배열
*/
private function mergePaymentMethodSettings(array $available, array $saved): array
private function mergePaymentMethodSettings(array $available, array $saved, ?string $defaultPgProvider = null): array
{
$availableById = collect($available)->keyBy('id');
$savedById = collect($saved)->keyBy('id');
// 레지스트리는 루프 밖에서 1회만 조회한다 (수단 수만큼 훅이 돌지 않도록)
$registeredPgIds = $this->registeredPgProviderIds();
$merged = [];
// 1. 사용 가능한 결제수단: 저장된 설정과 병합
@@ -1037,6 +1168,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface
$entry['core_payment_method'] = $definition['defaults']['core_payment_method'];
}
// 지정된 PG 가 현재 레지스트리에 없으면 런타임 전용 플래그를 단다 (A2).
// 관리자 화면은 이 플래그로 배지를 띄우고, 공개 응답은 이 항목을 제거한다.
if ($this->hasOrphanedPgProvider($needsPg, $entry['pg_provider'] ?? null, $defaultPgProvider, $registeredPgIds)) {
$entry['_orphaned_pg'] = true;
}
$merged[] = $entry;
}
@@ -1094,8 +1231,8 @@ class EcommerceSettingsService implements ModuleSettingsInterface
}
// 고아 항목은 기존 _cached_* 유지
// _orphaned 플래그는 저장하지 않음 (런타임 전용)
unset($savedMethods[$index]['_orphaned']);
// _orphaned / _orphaned_pg 플래그는 저장하지 않음 (런타임 전용)
unset($savedMethods[$index]['_orphaned'], $savedMethods[$index]['_orphaned_pg']);
}
return $savedMethods;
@@ -168,6 +168,31 @@ class PaymentMethodResolver
)));
}
/**
* 지금 이 결제수단으로 주문을 받을 수 있는지 판정합니다. (A2 / 공개 #111 서버 대칭)
*
* 두 가지를 막는다:
* - `_orphaned` — 저장값은 남았지만 공급 확장이 그 수단 제공을 중단한 상태
* - `_orphaned_pg` — 수단은 살아 있으나 지정된 PG 가 레지스트리에서 사라진 상태
*
* 카탈로그에 아예 없는 ID 는 **막지 않는다**. 능력 질의 전반의 폴백 계약(카탈로그 →
* enum → 기본값)과 같은 방향이며, 여기서 조이면 카탈로그 구성 시점에 따라 정상 주문이
* 거부될 수 있다. 화이트리스트 자체는 `allValidIds()` 가 이미 담당한다.
*
* @param string $methodId 결제수단 ID
* @return bool 주문 가능 여부
*/
public function isOrderable(string $methodId): bool
{
$entry = $this->catalog()[$methodId] ?? null;
if ($entry === null) {
return true;
}
return ! ($entry['_orphaned'] ?? false) && ! ($entry['_orphaned_pg'] ?? false);
}
/**
* 카탈로그에서 특정 결제수단의 특정 키 값을 조회합니다.
*
@@ -44,6 +44,18 @@ class ShippingPolicyResolver
return $this->defaultPolicyCache;
}
/**
* 캐시된 기본 배송정책을 비웁니다.
*
* 이 해석기는 싱글톤이라 요청 하나가 배송정책을 바꾼 뒤에도 같은 요청 안에서는
* 변경 전 정책을 계속 돌려준다. 배송정책을 바꾸는 모든 지점(생성/수정/삭제/기본지정/
* 사용여부)이 이 메서드를 거쳐 캐시를 비운다.
*/
public function flushCache(): void
{
$this->defaultPolicyCache = false;
}
/**
* 상품에 실제로 적용될 배송정책을 해석합니다.
*
@@ -862,6 +862,7 @@ return [
// Order validation messages (backward compatibility - order.* format)
'order' => [
'payment_method_unavailable' => 'This payment method is currently unavailable. Please choose another one.',
'ids' => [
'required' => 'Please select orders to update.',
'array' => 'Order IDs must be an array.',
@@ -862,6 +862,7 @@ return [
// 주문 검증 메시지 (하위 호환성 - order.* 형식)
'order' => [
'payment_method_unavailable' => '현재 사용할 수 없는 결제수단입니다. 다른 결제수단을 선택해주세요.',
'ids' => [
'required' => '변경할 주문을 선택해주세요.',
'array' => '주문 ID는 배열 형태여야 합니다.',
@@ -0,0 +1,37 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Concerns;
use App\Extension\HookManager;
/**
* 테스트용 현금영수증 발급 프로바이더 등록 헬퍼
*
* 현금영수증 프로바이더 선택값은 저장만으로 유효해지지 않는다 — 그 프로바이더를 실제로
* 제공하는 확장이 `sirsoft-ecommerce.cash_receipt.registered_providers` 훅에 자신을
* 등록해야 한다(A3). 그래서 설정값만 넣고 확장을 등록하지 않으면 "제공자 없음" 상태가 되며,
* 이는 운영에서 플러그인을 제거한 상태와 같다.
*
* 프로바이더가 살아 있는 상황을 검증하려는 테스트는 이 트레이트를 사용해 레지스트리에도
* 등록한다. 등록은 `HookManager` 정적 상태에 남지만 `ModuleTestCase` 가 테스트마다
* 스냅샷/복원하므로 누수되지 않는다.
*/
trait RegistersTestCashReceiptProvider
{
/**
* 현금영수증 발급 프로바이더를 레지스트리에 등록합니다.
*
* @param string $providerId 프로바이더 ID
*/
protected function registerCashReceiptProvider(string $providerId): void
{
HookManager::addFilter(
'sirsoft-ecommerce.cash_receipt.registered_providers',
function (array $providers) use ($providerId) {
$providers[] = ['id' => $providerId, 'name' => strtoupper($providerId)];
return $providers;
}
);
}
}
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
@@ -23,6 +24,8 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
*/
class EcommerceSettingsCashReceiptTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
private User $adminUser;
@@ -46,6 +49,9 @@ class EcommerceSettingsCashReceiptTest extends ModuleTestCase
public function test_현금영수증_프로바이더가_저장된다(): void
{
// 저장값 해석은 레지스트리 대조를 거치므로 제공 확장을 함께 등록한다 (A3)
$this->registerCashReceiptProvider('tosspayments');
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => 'tosspayments'],
@@ -124,4 +130,78 @@ class EcommerceSettingsCashReceiptTest extends ModuleTestCase
->assertOk()
->assertJsonStructure(['data' => ['available_cash_receipt_providers']]);
}
/**
* 저장값이 남아 있어도 그 프로바이더를 제공하는 확장이 없으면 미설정으로 본다. (A3, 실패-먼저)
*
* 플러그인을 제거해도 `order_settings.cash_receipt_provider` 문자열은 그대로 남는다.
* 그 값을 그대로 신뢰하면 체크아웃의 현금영수증 신청 폼과 마이페이지 발급 버튼이 계속
* 렌더되고, 신청하면 구독자 없는 훅을 호출해 발급 실패로만 기록된다.
*
* @scenario provider_state=dead
*
* @effects dead_cash_receipt_provider_treated_as_unset
*/
public function test_등록되지_않은_프로바이더는_미설정으로_취급된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => 'ghost_provider'],
])->assertOk();
// 저장값 자체는 남는다 (관리자가 확인하고 고칠 수 있어야 하므로)
$this->assertSame('ghost_provider', $this->settings()->getSetting('order_settings.cash_receipt_provider'));
// 해석 결과는 미설정
$this->assertNull(
$this->settings()->getCashReceiptProvider(),
'제공 확장이 없는 프로바이더가 유효한 것으로 해석되었습니다.'
);
}
/**
* 공개 결제 설정에서도 죽은 프로바이더가 미설정으로 정규화된다. (실패-먼저)
*
* 체크아웃 신청 폼은 카테고리 raw 값을 그대로 읽으므로, 공개 응답에서 정규화하지 않으면
* 폼이 계속 렌더된다.
*
* @scenario provider_state=dead
*
* @effects dead_cash_receipt_provider_normalized_in_public
*/
public function test_공개_결제설정에서_죽은_프로바이더가_정규화된다(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => 'ghost_provider'],
])->assertOk();
$public = $this->settings()->getPublicPaymentSettings();
$this->assertArrayHasKey('cash_receipt_provider', $public);
$this->assertNull(
$public['cash_receipt_provider'],
'죽은 현금영수증 프로바이더가 공개 응답에 그대로 노출되었습니다.'
);
}
/**
* 등록된 프로바이더는 그대로 해석된다. (비회귀 pin)
*
* @scenario provider_state=live
*
* @effects live_cash_receipt_provider_resolved
*/
public function test_등록된_프로바이더는_그대로_해석된다(): void
{
$this->registerCashReceiptProvider('tosspayments');
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['cash_receipt_provider' => 'tosspayments'],
])->assertOk();
$this->assertSame('tosspayments', $this->settings()->getCashReceiptProvider());
$this->assertSame('tosspayments', $this->settings()->getPublicPaymentSettings()['cash_receipt_provider'] ?? null);
}
}
@@ -0,0 +1,305 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Extension\HookManager;
use App\Listeners\CoreActivityLogListener;
use App\Models\User;
use App\Seo\Contracts\SeoCacheManagerInterface;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\EcommerceSettingsController;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateSettingRequest;
use Modules\Sirsoft\Ecommerce\Listeners\SeoSettingsCacheListener;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 모듈 설정 저장 시 `core.module_settings.after_save` 발화 (B-5)
*
* 이 훅을 구독하는 리스너가 셋(이커머스 SEO 캐시 / 게시판 SEO 캐시 / 코어 활동로그) 있는데,
* 정작 발화 지점인 `ModuleSettingsService::save()` 는 프로덕션에서 호출되지 않는다.
* 모듈 설정은 각 모듈의 관리자 컨트롤러가 자기 SettingsService 로 직접 저장하기 때문이다.
* 그 결과 모듈 환경설정에서 SEO 메타 템플릿을 바꿔도 SEO 캐시가 무효화되지 않았다.
*
* 발화 지점을 관리자 컨트롤러로 둔다 — 서비스에 두면 테스트 fixture 의 모든 저장이
* 활동로그·SEO 무효화를 유발하고, 훅 의미도 "관리자가 설정을 저장했다" 이다.
*/
class EcommerceSettingsSeoCacheInvalidationTest extends ModuleTestCase
{
private string $apiBase = '/api/modules/sirsoft-ecommerce/admin/settings';
private User $adminUser;
/**
* 훅 수신 기록 [[identifier, settings, result], ...]
*
* @var array<int, array>
*/
private array $received = [];
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.settings.read',
'sirsoft-ecommerce.settings.update',
]);
$this->received = [];
HookManager::addAction('core.module_settings.after_save', function (...$args) {
$this->received[] = $args;
}, 5);
}
/**
* 설정 저장 API 가 모듈 설정 저장 훅을 발화한다. (실패-먼저)
*
* @scenario actor=admin, save_path=bulk
*
* @effects module_settings_after_save_hook_fired
*/
public function test_settings_save_fires_module_settings_after_save_hook(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'seo',
'seo' => ['meta_product_title' => '{product_name} | shop'],
])->assertOk();
$this->assertCount(1, $this->received, '모듈 설정 저장 훅이 발화되지 않았습니다.');
[$identifier, $settings, $result] = $this->received[0];
$this->assertSame('sirsoft-ecommerce', $identifier);
$this->assertArrayHasKey('seo', $settings, '훅 payload 가 저장된 카테고리를 담지 않았습니다.');
$this->assertTrue($result);
}
/**
* 은행 목록 저장도 같은 훅을 발화한다. (실패-먼저)
*
* @scenario actor=admin, save_path=save_banks
*
* @effects module_settings_after_save_hook_fired
*/
public function test_bank_save_fires_module_settings_after_save_hook(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase.'/banks', [
'banks' => [['code' => '004', 'name' => ['ko' => '국민은행', 'en' => 'Kookmin Bank']]],
])->assertOk();
$this->assertCount(1, $this->received, '은행 저장이 모듈 설정 저장 훅을 발화하지 않았습니다.');
[$identifier, $settings] = $this->received[0];
$this->assertSame('sirsoft-ecommerce', $identifier);
$this->assertArrayHasKey('order_settings', $settings);
}
/**
* 저장 실패(검증 탈락) 시에는 훅이 발화되지 않는다.
*
* @scenario actor=admin, save_path=bulk, outcome=validation_failed
*
* @effects module_settings_after_save_hook_not_fired_on_failure
*/
public function test_hook_is_not_fired_when_validation_fails(): void
{
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['shipping_fee_tax_policy' => 'no_such_policy'],
])->assertStatus(422);
$this->assertSame([], $this->received, '검증 실패인데 저장 훅이 발화되었습니다.');
}
/**
* 이커머스 SEO 캐시 리스너가 이 훅으로 실제 무효화를 수행한다.
*
* @scenario actor=admin, save_path=bulk, listener=seo_cache
*
* @effects seo_cache_invalidated_on_module_settings_save
*/
public function test_seo_cache_listener_receives_the_hook(): void
{
$listener = new SeoSettingsCacheListener;
$hooks = $listener::getSubscribedHooks();
$this->assertArrayHasKey('core.module_settings.after_save', $hooks);
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'seo',
'seo' => ['meta_product_title' => '{product_name}'],
])->assertOk();
$this->assertNotEmpty($this->received);
// 리스너가 SEO 키 존재로 무효화를 판정하므로 payload 에 seo 카테고리가 실려야 한다
$this->assertArrayHasKey('meta_product_title', $this->received[0][1]['seo'] ?? []);
}
// ─── 실제 무효화 수행 ──────────────────────────────────────
/**
* SEO 탭 저장이 상품 상세 레이아웃 캐시를 실제로 지운다. (훅 발화가 아닌 결과 검증)
*
* 훅 발화만 고정하면 리스너가 조용히 아무것도 하지 않게 되어도 green 이다.
* 캐시 매니저를 mock 해 호출 자체를 단언한다.
*
* @scenario actor=admin, save_path=bulk, listener=seo_cache
*
* @effects seo_cache_invalidated_on_module_settings_save
*/
public function test_seo_tab_save_actually_invalidates_layout_cache(): void
{
$cache = Mockery::mock(SeoCacheManagerInterface::class);
$cache->shouldReceive('invalidateByLayout')->with('shop/show')->atLeast()->once();
$cache->shouldReceive('invalidateByLayout')->andReturnNull();
$this->app->instance(SeoCacheManagerInterface::class, $cache);
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'seo',
'seo' => ['meta_product_title' => '{product_name} | shop'],
])->assertOk();
$this->addToAssertionCount(1);
}
/**
* SEO 와 무관한 탭 저장은 SEO 캐시를 건드리지 않는다.
*
* @scenario actor=admin, save_path=bulk, listener=seo_cache, tab=non_seo
*
* @effects seo_cache_untouched_for_non_seo_tab
*/
public function test_non_seo_tab_save_does_not_invalidate_layout_cache(): void
{
$cache = Mockery::mock(SeoCacheManagerInterface::class);
$cache->shouldNotReceive('invalidateByLayout');
$this->app->instance(SeoCacheManagerInterface::class, $cache);
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => 5],
])->assertOk();
$this->addToAssertionCount(1);
}
/**
* 관리자 단건 저장(`updateSetting`) 경로도 같은 훅을 발화한다.
*
* 훅 발화는 서비스가 아니라 관리자 컨트롤러가 한다 — 서비스에 두면 테스트 fixture 나
* 내부 호출까지 활동로그·SEO 무효화를 유발하기 때문이다. 그래서 이 경로도 컨트롤러가
* 직접 발화해야 하며, 그것을 여기서 고정한다.
*
* 이 메서드에는 아직 라우트가 없어(설정 화면은 벌크 저장만 사용) HTTP 로 도달할 수
* 없다. 그래서 컨트롤러 메서드를 직접 호출한다 — 라우트가 붙는 시점에 발화 계약이
* 이미 고정되어 있게 한다.
*
* @scenario actor=admin, save_path=single_key
*
* @effects module_settings_after_save_hook_fired
*/
public function test_admin_single_key_save_fires_module_settings_after_save_hook(): void
{
$this->actingAs($this->adminUser);
$request = UpdateSettingRequest::create('/', 'PUT', [
'key' => 'seo.meta_product_title',
'value' => '{product_name}',
]);
$request->setContainer($this->app)->setRedirector($this->app['redirect']);
$request->validateResolved();
app(EcommerceSettingsController::class)->updateSetting($request)->getData();
$this->assertCount(1, $this->received, '관리자 단건 저장이 모듈 설정 저장 훅을 발화하지 않았습니다.');
[$identifier, $settings] = $this->received[0];
$this->assertSame('sirsoft-ecommerce', $identifier);
$this->assertArrayHasKey('seo', $settings, '단건 저장 payload 가 벌크와 같은 카테고리 형태가 아닙니다.');
$this->assertArrayHasKey('meta_product_title', $settings['seo']);
}
/**
* 서비스 직접 호출은 훅을 발화하지 않는다. (설계 고정)
*
* 발화 지점을 서비스로 내리면 내부 호출·시더·테스트 fixture 의 모든 저장이 활동로그와
* SEO 무효화를 유발한다. 그래서 서비스는 조용해야 한다 — 이 비발화를 명시 고정한다.
*
* @scenario actor=system, save_path=single_key
*
* @effects service_level_save_stays_silent
*/
public function test_service_level_save_does_not_fire_the_hook(): void
{
app(EcommerceSettingsService::class)->setSetting('seo.meta_product_title', '{product_name}');
$this->assertSame([], $this->received, '서비스 직접 저장이 관리자 저장 훅을 발화했습니다.');
}
/**
* 서비스의 벌크 저장도 훅을 발화하지 않는다. (설계 고정)
*
* 단건만 조용하고 벌크는 발화하면, 시더·업그레이드 스텝의 대량 저장이 운영자 감사
* 기록을 오염시킨다. 침묵은 저장 경로 전부에 걸린 계약이다.
*
* @scenario actor=system, save_path=bulk
*
* @effects service_level_save_stays_silent
*/
public function test_service_level_bulk_save_does_not_fire_the_hook(): void
{
app(EcommerceSettingsService::class)->saveSettings([
'seo' => ['meta_product_title' => '{product_name} | shop'],
]);
$this->assertSame([], $this->received, '서비스 벌크 저장이 관리자 저장 훅을 발화했습니다.');
}
/**
* 서비스의 은행 목록 저장도 훅을 발화하지 않는다. (설계 고정)
*
* @scenario actor=system, save_path=save_banks
*
* @effects service_level_save_stays_silent
*/
public function test_service_level_bank_save_does_not_fire_the_hook(): void
{
app(EcommerceSettingsService::class)->saveBanks([
['code' => '004', 'name' => ['ko' => '국민은행', 'en' => 'Kookmin Bank']],
]);
$this->assertSame([], $this->received, '서비스 은행 저장이 관리자 저장 훅을 발화했습니다.');
}
/**
* 저장이 코어 활동로그에 기록된다.
*
* 코어 활동로그 리스너가 같은 훅을 구독하므로, 훅이 죽으면 SEO 캐시와 함께
* 설정 변경 감사 기록도 조용히 사라진다.
*
* @scenario actor=admin, save_path=bulk, listener=activity_log
*
* @effects module_settings_save_recorded_in_activity_log
*/
public function test_settings_save_is_recorded_in_activity_log(): void
{
$hooks = CoreActivityLogListener::getSubscribedHooks();
$this->assertArrayHasKey('core.module_settings.after_save', $hooks);
$this->assertSame('handleModuleSettingsAfterSave', $hooks['core.module_settings.after_save']['method']);
$before = DB::table('activity_logs')->where('action', 'module_settings.save')->count();
$this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'seo',
'seo' => ['meta_product_title' => '{product_name}'],
])->assertOk();
$this->assertGreaterThan(
$before,
DB::table('activity_logs')->where('action', 'module_settings.save')->count(),
'설정 저장이 활동로그에 기록되지 않았습니다.'
);
}
}
@@ -0,0 +1,385 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Extension\HookManager;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 죽은 PG 를 지정한 결제수단의 공개 노출 차단 (A2, 공개 #111 동종)
*
* 고아 판정(`_orphaned`)은 결제수단 ID 에만 계산된다. 그래서 builtin 수단(card 등)에
* 특정 PG 를 지정한 뒤 그 PG 플러그인을 제거하면, 수단 자체는 카탈로그에 남아 있으므로
* 고아 필터를 그대로 통과한다. 결과적으로 체크아웃에 선택 가능한 결제수단으로 노출되고,
* 주문 시 PG 라우팅이 매칭에 실패해 **결제창 없이 주문완료로 넘어간다**.
*
* 판정식은 런타임 폴백과 일치시킨다:
* effective = method.pg_provider ?? default_pg_provider // null 일 때만 폴백
* _orphaned_pg = needs_pg && effective 가 비지 않았고 && 레지스트리에 없음
*
* own 이 죽었고 default 가 살아 있어도 폴백하지 않는다 — `determinePgProvider()` 의 실제
* 동작이 그렇다(죽은 own 을 그대로 반환). 레지스트리 인식 폴백으로 바꾸는 것은 결제 라우팅
* 계약 변경이므로 하지 않고, 차단만 한다.
*/
class PaymentSettingsOrphanedPgProviderTest 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 = app(EcommerceSettingsService::class);
$this->service->clearCache();
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 살아 있는 PG provider 를 레지스트리에 등록합니다.
*
* @param array<int, string> $ids provider ID 목록
*/
private function registerPgProviders(array $ids): void
{
HookManager::addFilter(
'sirsoft-ecommerce.payment.registered_pg_providers',
function (array $providers) use ($ids) {
foreach ($ids as $id) {
$providers[] = ['id' => $id, 'name' => strtoupper($id)];
}
return $providers;
},
10
);
}
/**
* PG 고정(`pg_locked`) 확장 결제수단을 카탈로그에 등록합니다.
*
* @param string $pgProvider 이 수단이 고정으로 쓰는 PG
*/
private function registerLockedExtensionMethod(string $pgProvider): void
{
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => 'locked_easypay',
'name' => ['ko' => '고정PG 간편결제', 'en' => 'Locked Easy Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:test-locked',
'defaults' => [
'pg_provider' => $pgProvider,
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => true,
'min_order_amount' => 0,
],
]]),
10
);
}
/**
* order_settings 저장 파일을 직접 구성합니다.
*
* @param string|null $cardPgProvider card 수단에 지정할 PG
* @param string|null $defaultPgProvider 기본 PG
*/
private function seedOrderSettings(?string $cardPgProvider, ?string $defaultPgProvider): void
{
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode([
'default_pg_provider' => $defaultPgProvider,
'payment_methods' => [
['id' => 'card', 'is_active' => true, 'sort_order' => 1, 'pg_provider' => $cardPgProvider],
['id' => 'dbank', 'is_active' => true, 'sort_order' => 2],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->service->clearCache();
}
/**
* 관리자 응답에서 특정 결제수단 항목을 찾습니다.
*
* @param string $id 결제수단 ID
* @return array|null 항목
*/
private function adminMethod(string $id): ?array
{
$methods = $this->service->getSettings('order_settings')['payment_methods'] ?? [];
return collect($methods)->firstWhere('id', $id);
}
/**
* 공개 응답의 결제수단 ID 목록을 반환합니다.
*
* @return array<int, string> 결제수단 ID 목록
*/
private function publicMethodIds(): array
{
$methods = $this->service->getPublicPaymentSettings()['payment_methods'] ?? [];
return array_values(array_map(fn ($m) => $m['id'] ?? null, $methods));
}
/**
* 수단에 지정된 PG 가 레지스트리에 없으면 공개 응답에서 제거된다. (실패-먼저)
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_method_hidden_from_checkout, dead_pg_method_flagged_for_admin
*/
public function test_dead_own_pg_provider_is_hidden_from_public(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: 'ghost_pg', defaultPgProvider: 'kginicis');
$this->assertTrue(
(bool) ($this->adminMethod('card')['_orphaned_pg'] ?? false),
'관리자 응답에 죽은 PG 표시가 없습니다.'
);
$this->assertNotContains('card', $this->publicMethodIds(), '죽은 PG 를 지정한 수단이 공개 응답에 남았습니다.');
$this->assertContains('dbank', $this->publicMethodIds(), '정상 수단까지 제거되었습니다.');
}
/**
* own 이 죽었으면 default 가 살아 있어도 차단한다. (런타임 폴백과 동일 판정)
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_method_hidden_from_checkout
*/
public function test_live_default_does_not_rescue_dead_own_provider(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: 'ghost_pg', defaultPgProvider: 'kginicis');
$this->assertNotContains('card', $this->publicMethodIds());
}
/**
* 수단에 PG 지정이 없으면 기본 PG 로 폴백하며, 그 기본 PG 가 죽었으면 차단한다. (실패-먼저)
*
* @scenario pg_provider_state=dead_default
*
* @effects dead_pg_method_hidden_from_checkout, dead_default_pg_normalized_in_public
*/
public function test_dead_default_pg_provider_blocks_method_without_own(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: null, defaultPgProvider: 'ghost_pg');
$this->assertTrue((bool) ($this->adminMethod('card')['_orphaned_pg'] ?? false));
$this->assertNotContains('card', $this->publicMethodIds());
// `??` 는 null 을 부재로 접으므로 키 존재 여부와 값을 나눠 확인한다
$public = $this->service->getPublicPaymentSettings();
$this->assertArrayHasKey('default_pg_provider', $public);
$this->assertNull(
$public['default_pg_provider'],
'죽은 기본 PG 가 공개 응답에 그대로 노출되었습니다.'
);
}
/**
* 양쪽 모두 미설정이면 기존 계약(non-PG 강하)을 유지한다. (비회귀 pin)
*
* @scenario pg_provider_state=none
*
* @effects unconfigured_pg_keeps_legacy_contract
*/
public function test_unset_pg_provider_keeps_method_visible(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: null, defaultPgProvider: null);
$this->assertFalse(
(bool) ($this->adminMethod('card')['_orphaned_pg'] ?? false),
'PG 미설정 상태가 고아로 판정되었습니다 (기존 계약 축소).'
);
$this->assertContains('card', $this->publicMethodIds());
}
/**
* 살아 있는 PG 지정은 그대로 노출된다. (비회귀 pin)
*
* @scenario pg_provider_state=live
*
* @effects live_pg_method_remains_visible
*/
public function test_live_pg_provider_stays_visible(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: 'kginicis', defaultPgProvider: 'kginicis');
$this->assertFalse((bool) ($this->adminMethod('card')['_orphaned_pg'] ?? false));
$this->assertContains('card', $this->publicMethodIds());
$this->assertSame('kginicis', $this->service->getPublicPaymentSettings()['default_pg_provider'] ?? null);
}
/**
* PG 불필요 수단(dbank)은 PG 레지스트리와 무관하게 노출된다.
*
* @scenario pg_provider_state=dead_default
*
* @effects non_pg_method_unaffected_by_registry
*/
public function test_non_pg_method_is_not_affected(): void
{
$this->registerPgProviders([]);
$this->seedOrderSettings(cardPgProvider: null, defaultPgProvider: 'ghost_pg');
$this->assertFalse((bool) ($this->adminMethod('dbank')['_orphaned_pg'] ?? false));
$this->assertContains('dbank', $this->publicMethodIds());
}
/**
* PG 고정(`pg_locked`) 수단도 그 PG 가 사라지면 똑같이 차단된다. (비회귀 pin)
*
* 판정식에 `pg_locked` 특례를 두면 안 된다 — 고정이라는 선언은 "운영자가 PG 를 바꿀 수
* 없다" 는 뜻일 뿐, 그 PG 가 살아 있다는 보증이 아니다. 특례를 두는 순간 PG 고정 수단만
* 죽은 PG 를 달고 주문서에 남아, 결제창 없이 주문완료로 넘어가는 원래 결함이 되살아난다.
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_method_hidden_from_checkout, dead_pg_method_flagged_for_admin
*/
public function test_pg_locked_method_has_no_orphan_exemption(): void
{
// 수단은 등록하되 그 수단이 고정으로 쓰는 PG 는 레지스트리에 없다 (PG 플러그인 삭제)
$this->registerLockedExtensionMethod('ghost_pg');
$this->registerPgProviders(['kginicis']);
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode([
'default_pg_provider' => 'kginicis',
'payment_methods' => [
['id' => 'locked_easypay', 'is_active' => true, 'sort_order' => 1],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->service->clearCache();
$method = $this->adminMethod('locked_easypay');
$this->assertNotNull($method, '고정 PG 수단이 카탈로그에서 사라졌습니다.');
$this->assertTrue((bool) ($method['pg_locked'] ?? false), '테스트 전제(pg_locked)가 깨졌습니다.');
$this->assertTrue(
(bool) ($method['_orphaned_pg'] ?? false),
'pg_locked 수단이 고아 판정에서 면제되었습니다.'
);
$this->assertNotContains('locked_easypay', $this->publicMethodIds());
}
/**
* PG 고정 수단의 PG 가 살아 있으면 그대로 노출된다. (비회귀 pin)
*
* @scenario pg_provider_state=live
*
* @effects live_pg_method_remains_visible
*/
public function test_pg_locked_method_with_live_provider_stays_visible(): void
{
$this->registerLockedExtensionMethod('kginicis');
$this->registerPgProviders(['kginicis']);
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode([
'default_pg_provider' => 'kginicis',
'payment_methods' => [
['id' => 'locked_easypay', 'is_active' => true, 'sort_order' => 1],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->service->clearCache();
$this->assertFalse((bool) ($this->adminMethod('locked_easypay')['_orphaned_pg'] ?? false));
$this->assertContains('locked_easypay', $this->publicMethodIds());
}
/**
* 런타임 전용 플래그는 저장 파일에 박제되지 않는다.
*
* @scenario pg_provider_state=dead_own
*
* @effects runtime_only_flag_not_persisted
*/
public function test_orphaned_pg_flag_is_not_persisted(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: 'ghost_pg', defaultPgProvider: 'kginicis');
// 관리자 화면이 읽은 값을 그대로 되저장하는 상황
$methods = $this->service->getSettings('order_settings')['payment_methods'];
$this->service->saveSettings(['order_settings' => ['payment_methods' => $methods]]);
$saved = json_decode(File::get($this->storagePath.'/order_settings.json'), true);
foreach ($saved['payment_methods'] as $method) {
$this->assertArrayNotHasKey('_orphaned_pg', $method, '런타임 전용 플래그가 저장되었습니다.');
}
}
/**
* 체크아웃이 실제로 호출하는 엔드포인트도 같은 목록을 내려준다.
*
* 위 테스트들은 서비스 메서드를 직접 부른다 — 필터가 응답 조립 경로에 실제로 걸려
* 있는지는 말해 주지 않는다. 컨트롤러가 raw `getSettings()` 를 쓰면 서비스는 정상이고
* 그 엔드포인트만 조용히 뚫린다.
*
* 항목 제거 후 인덱스 재정렬(`array_values`)도 여기서 함께 본다 — 비연속 키는 JSON 에서
* 배열이 아니라 객체로 직렬화되어, 화면의 반복 렌더가 아무것도 그리지 않는다.
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_method_hidden_from_checkout, dead_default_pg_normalized_in_public
*/
public function test_checkout_endpoint_returns_the_same_filtered_list(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings(cardPgProvider: 'ghost_pg', defaultPgProvider: 'kginicis');
// 같은 데이터를 내보내는 공개 엔드포인트가 둘이다 — 한쪽이 raw 게터를 쓰면 그 경로만 뚫린다.
foreach (['checkout', 'payment'] as $endpoint) {
$methods = $this->getJson('/api/modules/sirsoft-ecommerce/settings/'.$endpoint)
->assertOk()
->json('data.order_settings.payment_methods');
$this->assertIsArray($methods, "[{$endpoint}] 결제수단이 배열로 직렬화되지 않았습니다.");
$this->assertSame(
range(0, count($methods) - 1),
array_keys($methods),
"[{$endpoint}] 결제수단 배열의 키가 비연속입니다 — JSON 객체로 직렬화되어 화면 반복이 깨집니다."
);
$ids = array_column($methods, 'id');
$this->assertNotContains('card', $ids, "[{$endpoint}] 죽은 PG 수단이 공개 응답에 남았습니다.");
$this->assertContains('dbank', $ids, "[{$endpoint}] 정상 수단까지 응답에서 사라졌습니다.");
$this->assertSame(
$this->publicMethodIds(),
$ids,
"[{$endpoint}] 공개 게터와 다른 목록을 내려줍니다."
);
}
}
}
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -27,6 +28,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class CashReceiptControllerTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -47,6 +50,7 @@ class CashReceiptControllerTest extends ModuleTestCase
$this->receiptSequence = 0;
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -0,0 +1,274 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Requests;
use App\Extension\HookManager;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Validator;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 죽은 PG 를 지정한 결제수단의 주문 제출 차단 (A2 서버 대칭 가드)
*
* 공개 응답에서 감추는 것만으로는 부족하다 — 프론트를 우회해 직접 제출하면 그대로 통과해
* 결제창 없는 주문이 만들어진다. 검증 계층에서도 같은 판정을 적용한다.
*
* 이 가드는 `_orphaned`(수단 자체 고아)도 함께 막는다. `allValidIds()` 는 카탈로그 키를
* 그대로 쓰므로 고아 수단이 통과하는 공백이 있었다 — 공개 #111 의 서버측 대칭이 여기서 완성된다.
* `allValidIds()` 자체는 건드리지 않는다(과거 주문 목록 필터가 같은 목록을 쓴다 — 조이면
* 예전 수단으로 결제한 주문을 조회할 수 없게 된다).
*/
class CreateOrderDeadPgGuardTest extends ModuleTestCase
{
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);
}
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 살아 있는 PG provider 를 레지스트리에 등록합니다.
*
* @param array<int, string> $ids provider ID 목록
*/
private function registerPgProviders(array $ids): void
{
HookManager::addFilter(
'sirsoft-ecommerce.payment.registered_pg_providers',
function (array $providers) use ($ids) {
foreach ($ids as $id) {
$providers[] = ['id' => $id, 'name' => strtoupper($id)];
}
return $providers;
}
);
}
/**
* order_settings 저장 파일을 구성합니다.
*
* @param array $paymentMethods 결제수단 배열
* @param string|null $defaultPgProvider 기본 PG
*/
private function seedOrderSettings(array $paymentMethods, ?string $defaultPgProvider = null): void
{
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode([
'default_pg_provider' => $defaultPgProvider,
'payment_methods' => $paymentMethods,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
app(EcommerceSettingsService::class)->clearCache();
app(PaymentMethodResolver::class)->flushCache();
}
/**
* payment_method 규칙만 떼어 검증합니다.
*
* @param string $methodId 결제수단 ID
* @return \Illuminate\Contracts\Validation\Validator 검증기
*/
private function validatePaymentMethod(string $methodId)
{
$rules = (new CreateOrderRequest)->rules();
return Validator::make(
['payment_method' => $methodId],
['payment_method' => $rules['payment_method']]
);
}
/**
* 죽은 PG 를 지정한 수단의 주문 제출은 거부된다. (실패-먼저)
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_order_submission_rejected_422
*/
public function test_dead_pg_method_submission_is_rejected(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'card', 'is_active' => true, 'pg_provider' => 'ghost_pg'],
], 'kginicis');
$validator = $this->validatePaymentMethod('card');
$this->assertTrue($validator->fails(), '죽은 PG 를 지정한 결제수단이 검증을 통과했습니다.');
$message = $validator->errors()->first('payment_method');
$this->assertNotSame(
'sirsoft-ecommerce::validation.order.payment_method_unavailable',
$message,
'다국어 키가 원문 그대로 노출되었습니다.'
);
$this->assertNotEmpty($message);
}
/**
* 카탈로그에서 사라진 고아 수단의 제출도 거부된다. (공개 #111 서버 대칭)
*
* @scenario pg_provider_state=live
*
* @effects orphaned_method_order_submission_rejected_422
*/
public function test_orphaned_method_submission_is_rejected(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'zombie_pay', 'is_active' => true, 'pg_provider' => 'kginicis'],
], 'kginicis');
$this->assertTrue(
$this->validatePaymentMethod('zombie_pay')->fails(),
'카탈로그에 없는 고아 결제수단이 검증을 통과했습니다.'
);
}
/**
* 살아 있는 PG 를 지정한 수단은 그대로 통과한다. (비회귀 pin)
*
* @scenario pg_provider_state=live
*
* @effects live_pg_order_submission_accepted
*/
public function test_live_pg_method_submission_passes(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'card', 'is_active' => true, 'pg_provider' => 'kginicis'],
], 'kginicis');
$this->assertFalse(
$this->validatePaymentMethod('card')->fails(),
'정상 결제수단이 거부되었습니다.'
);
}
/**
* PG 미설정 수단(non-PG)은 그대로 통과한다. (비회귀 pin)
*
* @scenario pg_provider_state=none
*
* @effects live_pg_order_submission_accepted
*/
public function test_non_pg_method_submission_passes(): void
{
$this->registerPgProviders([]);
$this->seedOrderSettings([
['id' => 'dbank', 'is_active' => true],
]);
$this->assertFalse($this->validatePaymentMethod('dbank')->fails());
}
/**
* 죽은 PG 수단은 실제 주문 생성 엔드포인트에서도 422 로 거부된다.
*
* 위 테스트들은 `rules()` 배열을 떼어 검증한다 — 규칙 자체는 고정되지만, 그 규칙이
* 라우트에 실제로 걸려 있는지는 말해 주지 않는다. FormRequest 를 컨트롤러가 타입힌트하지
* 않으면 규칙은 살아 있고 요청만 그대로 통과한다. 그 연결을 여기서 고정한다.
*
* @scenario pg_provider_state=dead_own
*
* @effects dead_pg_order_submission_rejected_422
*/
public function test_dead_pg_method_submission_is_rejected_over_http(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'card', 'is_active' => true, 'pg_provider' => 'ghost_pg'],
], 'kginicis');
$this->postJson('/api/modules/sirsoft-ecommerce/user/orders', $this->orderPayload('card'))
->assertStatus(422)
->assertJsonValidationErrors(['payment_method']);
}
/**
* 살아 있는 PG 수단은 결제수단 검증에서 걸리지 않는다. (거짓 양성 차단)
*
* 다른 필드(주소·상품)로 422 가 날 수는 있으나 `payment_method` 는 그 목록에 없어야 한다 —
* 그래야 위 테스트의 422 가 결제수단 때문임이 증명된다.
*
* @scenario pg_provider_state=live
*
* @effects live_pg_order_submission_accepted
*/
public function test_live_pg_method_is_not_flagged_over_http(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'card', 'is_active' => true, 'pg_provider' => 'kginicis'],
], 'kginicis');
$response = $this->postJson('/api/modules/sirsoft-ecommerce/user/orders', $this->orderPayload('card'));
$this->assertArrayNotHasKey(
'payment_method',
(array) ($response->json('errors') ?? []),
'정상 PG 를 지정한 결제수단이 HTTP 검증에서 거부되었습니다.'
);
}
/**
* 주문 생성 요청 본문을 만듭니다.
*
* 결제수단 축만 보므로 나머지 필드는 형식만 갖춘다 — 다른 필드의 검증 실패는
* `payment_method` 오류의 유무 판정에 영향을 주지 않는다.
*
* @param string $methodId 결제수단 ID
* @return array<string, mixed> 요청 본문
*/
private function orderPayload(string $methodId): array
{
return [
'payment_method' => $methodId,
'items' => [['product_id' => 1, 'quantity' => 1]],
'receiver_name' => '홍길동',
'receiver_phone' => '01012345678',
'address' => '서울시 강남구',
'address_detail' => '101호',
'postcode' => '06234',
];
}
/**
* 리졸버의 주문 가능 판정은 카탈로그 밖 ID 를 막지 않는다. (기존 계약 유지)
*
* @scenario pg_provider_state=live
*
* @effects unknown_method_id_keeps_legacy_contract
*/
public function test_resolver_allows_ids_outside_catalog(): void
{
$this->registerPgProviders(['kginicis']);
$this->seedOrderSettings([
['id' => 'card', 'is_active' => true, 'pg_provider' => 'kginicis'],
], 'kginicis');
$this->assertTrue(
app(PaymentMethodResolver::class)->isOrderable('some_unknown_id'),
'카탈로그 밖 ID 판정이 기존 계약보다 좁아졌습니다.'
);
}
}
@@ -551,4 +551,99 @@ class ExtensionPaymentMethodRegressionTest extends ModuleTestCase
$this->assertTrue($payment->needsPgProvider());
$this->assertFalse($payment->isCardPayment());
}
// ─────────────────────────────────────────────────────────────
// 죽은 PG 차단(A2) 과의 비회귀 — 살아있는 확장 수단은 걸리지 않아야 한다
// ─────────────────────────────────────────────────────────────
/**
* 살아있는 확장 결제수단은 죽은-PG 판정에 걸리지 않는다. (비회귀 pin)
*
* 죽은 PG 를 가리키는 결제수단을 주문서에서 걷어내는 가드가 들어왔다. 그 판정이
* 헐거우면 정상 등록된 간편결제 수단까지 함께 사라지는데, 이때 오류도 경고도
* 남지 않는다 — 결제수단 목록에서 조용히 빠질 뿐이다. 이 파일이 지키는
* "확장 결제수단 1급 시민" 계약이 그 방향으로 깨지는 것을 막는다.
*
* @scenario method_kind=extension, pg_provider_state=live
*
* @effects live_pg_method_remains_visible, extension_method_survives_dead_pg_guard
*/
#[Test]
public function live_extension_payment_method_is_not_flagged_as_dead_pg(): void
{
$settingsService = app(EcommerceSettingsService::class);
$methods = $settingsService->getAllSettings()['order_settings']['payment_methods'] ?? [];
$extensionMethod = collect($methods)->firstWhere('id', self::EXT_METHOD);
$this->assertNotNull($extensionMethod, '확장 결제수단이 병합 결과에 없습니다.');
$this->assertArrayNotHasKey(
'_orphaned_pg',
$extensionMethod,
'살아있는 PG 를 가리키는 확장 결제수단이 죽은-PG 로 표시됐습니다.'
);
$publicMethods = $settingsService->getPublicPaymentSettings()['payment_methods'] ?? [];
$this->assertContains(
self::EXT_METHOD,
array_column($publicMethods, 'id'),
'살아있는 확장 결제수단이 공개 응답에서 사라졌습니다 — 주문서에서 결제수단이 통째로 빠집니다.'
);
}
/**
* `pg_locked` 수단도 죽은-PG 판정에 특례가 없다. (비회귀 pin)
*
* `pg_locked` 는 저장값이 카탈로그 선언을 덮지 못한다는 뜻이지, 그 선언이 가리키는
* PG 가 사라져도 결제 가능하다는 뜻이 아니다. 특례를 두면 그 수단은 결제창 없이
* 주문완료로 넘어간다.
*
* @scenario method_kind=extension, pg_provider_state=dead_own
*
* @effects dead_pg_method_flagged_for_admin, dead_pg_method_hidden_from_checkout, pg_locked_method_has_no_dead_pg_exemption
*/
#[Test]
public function pg_locked_extension_method_has_no_dead_pg_exemption(): void
{
// 수단 카탈로그는 남아 있으나 그 수단이 지목한 PG 만 레지스트리에서 사라진 상태
HookManager::resetAll();
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
fn (array $methods) => array_merge($methods, [[
'id' => self::EXT_METHOD,
'name' => ['ko' => '네이버페이', 'en' => 'Naver Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin:sirsoft-pay_nhnkcp',
'defaults' => [
'pg_provider' => self::EXT_PG,
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'mileage_deduction_timing' => 'payment_complete',
],
]])
);
$settingsService = app(EcommerceSettingsService::class);
$methods = $settingsService->getAllSettings()['order_settings']['payment_methods'] ?? [];
$extensionMethod = collect($methods)->firstWhere('id', self::EXT_METHOD);
$this->assertNotNull($extensionMethod, '확장 결제수단이 병합 결과에 없습니다.');
$this->assertTrue(
$extensionMethod['_orphaned_pg'] ?? false,
'pg_locked 수단이 죽은 PG 를 가리키는데 관리자 화면에 표시되지 않습니다.'
);
$publicMethods = $settingsService->getPublicPaymentSettings()['payment_methods'] ?? [];
$this->assertNotContains(
self::EXT_METHOD,
array_column($publicMethods, 'id'),
'pg_locked 수단이 죽은 PG 를 가리키는데 주문서에 남았습니다 — 결제창 없이 주문완료로 넘어갑니다.'
);
}
}
@@ -0,0 +1,199 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Services;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
use Modules\Sirsoft\Ecommerce\Listeners\ShippingPolicyCacheListener;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyResolver;
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 배송정책 변경 후 해석기 캐시 무효화 검증
*
* `ShippingPolicyResolver` 는 싱글톤이라 기본 배송정책을 요청 내 1회만 조회한다.
* 무효화 경로가 없으면 배송정책을 바꾼 요청이 같은 요청 안에서 변경 전 정책을 계속 쓴다
* — 예외도 오류도 남지 않고 배송비만 이전 값으로 계산된다.
*/
class ShippingPolicyResolverCacheTest extends ModuleTestCase
{
/**
* 배송정책을 생성합니다.
*
* @param bool $isDefault 기본 배송정책 여부
* @param int $baseFee 기본 배송비
* @return ShippingPolicy 생성된 배송정책
*/
private function makePolicy(bool $isDefault, int $baseFee = 600): ShippingPolicy
{
$policy = ShippingPolicy::create([
'name' => ['ko' => '배송정책 '.$baseFee, 'en' => 'Policy '.$baseFee],
'is_default' => $isDefault,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FIXED,
'base_fee' => $baseFee,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* 기본 배송정책은 요청 내 1회만 조회됩니다 (캐시가 살아 있는지 확인).
*
* @scenario mutation=none
*
* @effects default_policy_cached_within_request
*/
public function test_default_policy_is_cached_within_request(): void
{
$policy = $this->makePolicy(isDefault: true);
$resolver = app(ShippingPolicyResolver::class);
$first = $resolver->getDefaultPolicy();
$this->assertNotNull($first);
$this->assertSame($policy->id, $first->id);
// 캐시가 없으면 이 단언이 새 기본정책을 집어 캐시 부재를 드러낸다
$this->makePolicy(isDefault: false);
$this->assertSame($first, $resolver->getDefaultPolicy(), '요청 내 재조회가 발생했습니다.');
}
/**
* `flushCache()` 후에는 저장소를 다시 조회합니다. (실패-먼저)
*
* @scenario mutation=manual_flush
*
* @effects resolver_cache_flushed
*/
public function test_flush_cache_forces_refetch(): void
{
$old = $this->makePolicy(isDefault: true, baseFee: 600);
$resolver = app(ShippingPolicyResolver::class);
$this->assertSame($old->id, $resolver->getDefaultPolicy()?->id);
$old->update(['is_default' => false]);
$new = $this->makePolicy(isDefault: true, baseFee: 900);
$resolver->flushCache();
$this->assertSame($new->id, $resolver->getDefaultPolicy()?->id, 'flushCache() 후에도 이전 정책이 반환됩니다.');
}
/**
* 기본 배송정책 지정을 바꾸면 같은 요청 안에서 해석기가 새 정책을 반환합니다. (실패-먼저)
*
* @scenario mutation=set_default
*
* @effects resolver_cache_flushed
*/
public function test_set_default_invalidates_resolver_cache(): void
{
$old = $this->makePolicy(isDefault: true, baseFee: 600);
$candidate = $this->makePolicy(isDefault: false, baseFee: 900);
$resolver = app(ShippingPolicyResolver::class);
$this->assertSame($old->id, $resolver->getDefaultPolicy()?->id);
app(ShippingPolicyService::class)->setDefault($candidate);
$this->assertSame(
$candidate->id,
$resolver->getDefaultPolicy()?->id,
'기본 배송정책을 바꿨는데 해석기가 이전 정책을 반환합니다.'
);
}
/**
* 기본 배송정책 수정도 같은 요청 안에서 반영됩니다. (실패-먼저)
*
* @scenario mutation=update
*
* @effects resolver_cache_flushed
*/
public function test_update_invalidates_resolver_cache(): void
{
$policy = $this->makePolicy(isDefault: true, baseFee: 600);
$resolver = app(ShippingPolicyResolver::class);
$this->assertSame(
600,
(int) $resolver->getDefaultPolicy()?->getCountrySetting('KR')?->base_fee
);
app(ShippingPolicyService::class)->update($policy, [
'name' => ['ko' => '배송정책', 'en' => 'Policy'],
'is_default' => true,
'is_active' => true,
'country_settings' => [[
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => ChargePolicyEnum::FIXED->value,
'base_fee' => 1500,
'is_active' => true,
]],
]);
$this->assertSame(
1500,
(int) $resolver->getDefaultPolicy()?->getCountrySetting('KR')?->base_fee,
'배송정책을 수정했는데 해석기가 이전 배송비를 반환합니다.'
);
}
/**
* 기본 배송정책 삭제도 같은 요청 안에서 반영됩니다. (실패-먼저)
*
* @scenario mutation=delete
*
* @effects resolver_cache_flushed
*/
public function test_delete_invalidates_resolver_cache(): void
{
$policy = $this->makePolicy(isDefault: true);
$resolver = app(ShippingPolicyResolver::class);
$this->assertNotNull($resolver->getDefaultPolicy());
app(ShippingPolicyService::class)->delete($policy);
$this->assertNull($resolver->getDefaultPolicy(), '삭제된 배송정책이 계속 반환됩니다.');
}
/**
* 리스너가 배송정책 변경 훅 전부를 구독합니다.
*
* 변경 경로가 늘어나도 같은 훅만 발화하면 무효화가 따라오도록, 구독 목록을 고정한다.
*
* @scenario mutation=hook_subscription
*
* @effects resolver_cache_flushed
*/
public function test_listener_subscribes_every_mutation_hook(): void
{
$hooks = array_keys(ShippingPolicyCacheListener::getSubscribedHooks());
foreach ([
'after_create',
'after_update',
'after_delete',
'after_bulk_delete',
'after_toggle_active',
'after_bulk_toggle_active',
'after_set_default',
] as $event) {
$this->assertContains('sirsoft-ecommerce.shipping_policy.'.$event, $hooks);
}
}
}
@@ -0,0 +1,180 @@
/**
* 주문설정 결제수단 — 죽은 PG 지정 표시·차단 (A2).
*
* 배경: 고아 판정(`_orphaned`)은 결제수단 ID 에만 계산된다. builtin 수단(카드 등)에 특정 PG 를
* 지정한 뒤 그 PG 플러그인을 제거하면, 수단 자체는 카탈로그에 남아 있어 고아 필터를 그대로
* 통과한다. 체크아웃에 선택 가능한 결제수단으로 노출되고, 주문하면 PG 라우팅이 매칭에 실패해
* **결제창 없이 주문완료로 넘어간다**.
*
* 측정 규율 (이 spec 을 고칠 사람에게):
* - 결제수단 ID·PG명을 하드코딩하지 않는다. 검증 대상은 카탈로그에서 `needs_pg && !pg_locked`
* 조건으로 런타임 선별한다.
* - 죽은 PG 상태는 환경에 자연 발생하지 않으므로 **관리자 API 로 주입**하고 `finally` 에서
* 반드시 원복한다. 주입 없이 skip 하면 그 skip 이 거짓 통과가 된다.
* - `_orphaned` 와 달리 행 편집 컨트롤은 막지 않는다 — 살아있는 PG 로 바꿔 복구하는 경로가
* 남아야 하므로 PG 선택 셀렉트는 계속 보여야 한다.
*
* @scenario pg_provider_state=dead_own
* @effects dead_pg_method_flagged_for_admin,
* dead_pg_method_hidden_from_checkout,
* dead_pg_method_keeps_recovery_control
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { APIRequestContext, Page } from '@playwright/test';
const ORDER_SETTINGS_URL = '/admin/ecommerce/settings?tab=order_settings';
const ADMIN_SETTINGS_API = '/api/modules/sirsoft-ecommerce/admin/settings';
const PUBLIC_PAYMENT_API = '/api/modules/sirsoft-ecommerce/settings/payment';
const DEAD_PG = 'ghost_pg_e2e';
interface CatalogMethod {
id: string;
pg_provider: string | null;
pg_locked?: boolean;
needs_pg?: boolean;
_orphaned?: boolean;
_orphaned_pg?: boolean;
}
/** 폼에 시드된 결제수단 카탈로그 (화면이 실제로 그리는 원본). */
async function catalogMethods(page: Page): Promise<CatalogMethod[]> {
return page.evaluate(() => {
const form = (window as any).G7Core?.state?.getLocal?.()?.form;
return (form?.order_settings?.payment_methods ?? []) as CatalogMethod[];
});
}
async function gotoOrderSettings(page: Page, token: string): Promise<void> {
await authenticatePage(page, token);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(ORDER_SETTINGS_URL);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect.poll(async () => (await catalogMethods(page)).length, { timeout: 30_000 }).toBeGreaterThan(0);
}
/** 관리자 API 로 저장된 결제수단 배열을 읽는다 (런타임 전용 플래그 포함). */
async function readSavedMethods(request: APIRequestContext, token: string): Promise<CatalogMethod[]> {
const response = await request.get(ADMIN_SETTINGS_API, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
expect(response.ok(), '관리자 설정 조회 실패').toBeTruthy();
const body = await response.json();
return body?.data?.order_settings?.payment_methods ?? [];
}
/** 결제수단 배열을 저장한다 (런타임 전용 플래그는 서버가 제거한다). */
async function saveMethods(
request: APIRequestContext,
token: string,
methods: CatalogMethod[],
): Promise<void> {
const response = await request.put(ADMIN_SETTINGS_API, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
data: { _tab: 'order_settings', order_settings: { payment_methods: methods } },
});
expect(response.ok(), `결제수단 저장 실패 (${response.status()})`).toBeTruthy();
}
test.describe('@sirsoft-ecommerce 주문설정 결제수단 — 죽은 PG 표시·차단', () => {
// 이 그룹은 상점 설정(전역 상태)을 주입·원복한다. 병렬로 돌리면 다른 테스트가 주입 구간의
// 상태를 읽어 거짓 실패를 낸다.
test.describe.configure({ mode: 'serial' });
// @scenario pg_provider_state=dead_own
// @effects dead_pg_method_flagged_for_admin, dead_pg_method_hidden_from_checkout, dead_pg_method_keeps_recovery_control
test('지정 PG 가 사라진 결제수단은 관리자에 배지가 뜨고 공개 응답에서는 제거된다', async ({
page,
request,
settingsToken,
}) => {
const original = await readSavedMethods(request, settingsToken);
expect(original.length, '결제수단 카탈로그가 비어 있어 판별 불가').toBeGreaterThan(0);
const target = original.find((m) => m.needs_pg === true && !m.pg_locked);
test.skip(!target, 'PG 를 관리자가 지정할 수 있는 결제수단이 없는 환경 — 판별 불가');
const targetId = target!.id;
// 주입 전에 이미 죽은 PG 가 지정돼 있으면 원복 기준(original)이 오염된 상태다.
// 그대로 진행하면 "원복" 이 오염 상태를 되돌려 놓으므로 여기서 멈추고 드러낸다.
expect(
target!._orphaned_pg ?? false,
`${targetId} 에 이미 죽은 PG 가 지정돼 있다 — 운영자 조치가 필요하거나 이전 실행이 원복에 실패했다`,
).toBeFalsy();
try {
// 죽은 PG 주입 — 플러그인 제거 후 저장값만 남은 상태를 등가 재현한다
await saveMethods(
request,
settingsToken,
original.map((m) => (m.id === targetId ? { ...m, pg_provider: DEAD_PG, is_active: true } : m)),
);
// ① 관리자 응답: 플래그가 서고 배지가 뜬다
const flagged = (await readSavedMethods(request, settingsToken)).find((m) => m.id === targetId);
expect(
flagged?._orphaned_pg,
`${targetId} 에 죽은 PG 가 지정됐으므로 관리자 응답에 _orphaned_pg 가 서야 한다`,
).toBe(true);
await gotoOrderSettings(page, settingsToken);
const badge = page.getByTestId(`orphaned-pg-badge-${targetId}`);
await expect(badge, `${targetId} 에 죽은 PG 배지가 떠야 한다`).toBeVisible({ timeout: 15_000 });
// ② 복구 경로 유지 — PG 선택 셀렉트를 막지 않는다
await expect(
page.getByTestId(`pg-select-${targetId}`),
`${targetId} 는 살아있는 PG 로 바꿔 복구할 수 있어야 하므로 PG 선택 셀렉트가 남아야 한다`,
).toBeVisible();
// ③ 공개 응답: 해당 수단이 제거된다 (체크아웃에 노출되면 결제창 없는 주문이 만들어진다)
const publicResponse = await request.get(PUBLIC_PAYMENT_API, { headers: { Accept: 'application/json' } });
expect(publicResponse.ok(), '공개 결제설정 조회 실패').toBeTruthy();
const publicBody = await publicResponse.json();
const publicIds = (publicBody?.data?.payment_methods ?? []).map((m: CatalogMethod) => m.id);
expect(
publicIds,
`${targetId} 는 죽은 PG 를 지정했으므로 공개 응답에서 제거돼야 한다`,
).not.toContain(targetId);
// 런타임 전용 플래그는 공개 응답에도 남지 않는다
for (const method of publicBody?.data?.payment_methods ?? []) {
expect(method._orphaned_pg ?? false, '공개 응답에 런타임 플래그가 남았다').toBeFalsy();
}
} finally {
// 원복 — 실패하더라도 환경을 저장 전 상태로 되돌린다
await saveMethods(request, settingsToken, original);
}
// 원복 확인: 배지가 사라지고 공개 응답에 다시 실린다
const restored = (await readSavedMethods(request, settingsToken)).find((m) => m.id === targetId);
expect(restored?._orphaned_pg ?? false, '원복 후에도 죽은 PG 플래그가 남았다').toBeFalsy();
expect(restored?.pg_provider ?? null, '원복 후 PG 지정이 주입값 그대로다').not.toBe(DEAD_PG);
});
// 살아 있는 PG 만 있는 상태를 확인하는 테스트다 — dead_own 축이 아니다.
// @scenario pg_provider_state=live
// @effects live_pg_method_remains_visible
test('정상 환경에서는 죽은 PG 배지가 어디에도 뜨지 않는다 (거짓 양성 차단)', async ({
page,
settingsToken,
}) => {
await gotoOrderSettings(page, settingsToken);
const methods = await catalogMethods(page);
// 부재를 단언하기 전에 카탈로그가 실제로 렌더됐는지부터 확정한다
expect(methods.length, '결제수단 카탈로그가 비어 있다 — 부재 단언이 무의미해진다').toBeGreaterThan(0);
const flagged = methods.filter((m) => m._orphaned_pg === true);
expect(
flagged.map((m) => m.id),
'죽은 PG 를 지정한 결제수단이 남아 있다 (운영자 조치 필요) — 또는 판정식이 정상 PG 를 오판한다',
).toEqual([]);
await expect(page.locator('[data-testid^="orphaned-pg-badge-"]')).toHaveCount(0);
});
});
@@ -679,4 +679,50 @@ test.describe('현금영수증 전주기 (신청 → 입금확인 → 자동발
'거부된 조합인데 영수증이 발급되었다'
).toBeNull();
});
// 발급사를 제공하는 확장이 없는 상태(미설정 또는 플러그인 제거)에서의 계약.
// 위 테스트들과 정확히 반대 조건이라 서로 배타적으로 skip 된다.
// @scenario provider_state=dead
// @effects issue_blocked_when_provider_unregistered, dead_provider_nulled_in_resource
test('발급사가 없으면 신규 발급이 차단되고 발급사 필드가 null 로 내려간다', async ({
page,
orderManageToken,
}) => {
await authenticatePage(page, orderManageToken);
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
await page.goto('/shop');
await page.waitForLoadState('domcontentloaded');
const provider = await cashReceiptProvider(page);
test.skip(provider !== null, '현금영수증 발급사가 설정된 환경 — 이 계약은 미설정 상태 전용');
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
const orderId = await resolveOrderId(page, orderNumber);
// 주문 상세가 발급사를 null 로 내려야 화면이 발급 버튼을 켜지 않는다
const detail = await api(page, `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}`);
expect(detail.status, `주문 상세 조회 실패: ${detail.body}`).toBe(200);
const payment = JSON.parse(detail.body)?.data?.payment ?? {};
expect(
payment,
'주문 상세 응답에 결제 정보가 없다 — 이후 단언이 무의미해진다'
).toHaveProperty('cash_receipt_provider');
expect(
payment.cash_receipt_provider ?? null,
'발급사가 없는데 발급사 필드가 값을 갖고 있다'
).toBeNull();
// 발급 시도는 거부되고 발급 이력도 남지 않아야 한다
const attempt = await api(page, `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}/cash-receipt`, {
method: 'POST',
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01012345678' },
});
expect(attempt.status, `발급사가 없는데 발급이 수락됐다: ${attempt.body}`).not.toBe(200);
expect(
(await readReceiptState(page, orderId)).active,
'발급사가 없는데 활성 영수증이 생겼다'
).toBeNull();
});
});
@@ -255,6 +255,39 @@ test.describe('체크아웃 현금영수증 신청 폼 (무통장 슬롯 주입)
await expect(page.locator(SLOT)).toHaveCount(0);
});
// A3 — 저장값은 남아 있는데 그 프로바이더를 제공하는 확장이 없으면 미설정과 동일하게 다뤄야 한다.
// 공개 응답이 raw 값을 그대로 내보내면 신청 폼이 계속 렌더되고, 신청하면 구독자 없는 훅을
// 호출해 발급 실패로만 조용히 기록된다.
// @scenario cash-receipt-ui-and-refund-bank provider_state=dead
// @effects dead_cash_receipt_provider_normalized_in_public, checkout_slot_hidden_when_provider_unset
test('공개 결제설정의 현금영수증 프로바이더는 등록된 확장이 있을 때만 실린다', async ({ page }) => {
const settings = await page.evaluate(async () => {
const response = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
headers: { Accept: 'application/json' },
});
return response.json();
});
const provider = settings?.data?.cash_receipt_provider ?? null;
// 화면 렌더와 공개 응답이 같은 판정을 공유해야 한다 — 한쪽만 살아 있으면 폼은 뜨는데
// 신청은 실패하는 상태가 된다.
//
// selectDbank() 헬퍼는 슬롯이 보이는 것을 전제하므로 여기서는 쓰지 않는다 —
// 이 테스트는 "슬롯이 없어야 하는 경우" 도 판정 대상이다.
await gotoCheckout(page);
await paymentMethod(page, 'dbank').click();
await expect(page.locator(SLOT)).toHaveCount(provider ? 1 : 0);
if (provider === null) {
test.info().annotations.push({
type: 'coverage-note',
description: '현금영수증 프로바이더 미설정 환경 — 폼 미렌더 방향만 검증',
});
}
});
test('신청 체크 전에는 입력 필드가 없고, 체크하면 마운트된다', async ({ page }) => {
await gotoCheckout(page);
await selectDbank(page);
@@ -18,6 +18,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -29,6 +30,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class IssueCashReceiptOnDepositListenerTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -48,6 +51,7 @@ class IssueCashReceiptOnDepositListenerTest extends ModuleTestCase
$this->receiptSequence = 0;
$this->issueCalls = [];
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -13,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -23,6 +24,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class PurgeCashReceiptIdentifierListenerTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -31,6 +34,7 @@ class PurgeCashReceiptIdentifierListenerTest extends ModuleTestCase
{
parent::setUp();
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -28,6 +29,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class CashReceiptResourceTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -43,6 +46,7 @@ class CashReceiptResourceTest extends ModuleTestCase
$this->issueShouldFail = false;
$this->receiptSequence = 0;
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -284,6 +288,61 @@ class CashReceiptResourceTest extends ModuleTestCase
$this->assertNotEmpty($failed['occurred_at_formatted'], '실패 이력도 발생 시각을 표시할 수 있어야 한다');
}
/**
* 발급사 플러그인이 제거되면 발급사 필드가 null 로 내려간다. (비회귀 pin)
*
* 화면은 이 필드로 신규 발급 버튼을 켠다. 죽은 발급사 ID 를 그대로 내려보내면
* 버튼이 살아 있고, 눌러도 실패 이력만 쌓인다.
*
* @scenario provider_state=dead
*
* @effects dead_provider_nulled_in_resource
*/
#[Test]
public function 발급사가_제거되면_발급사_필드가_null_이_된다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
// 살아 있는 동안은 그대로 내려간다 (존재를 먼저 확정)
$this->assertSame(self::PROVIDER, $this->resourceArray($order)['payment']['cash_receipt_provider'] ?? null);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
$payment = $this->resourceArray($order->fresh())['payment'];
$this->assertArrayHasKey('cash_receipt_provider', $payment);
$this->assertNull($payment['cash_receipt_provider'], '레지스트리에 없는 발급사가 그대로 노출되었습니다.');
}
/**
* 발급사가 제거돼도 이미 발급한 이력은 계속 노출된다. (비회귀 pin)
*
* 발급 이력과 영수증 링크는 구매자·운영자의 증빙이다. 발급사 유무로 감추면
* 이미 발급된 영수증의 확인 경로가 사라진다.
*
* @scenario provider_state=dead
*
* @effects existing_receipt_history_survives_dead_provider
*/
#[Test]
public function 발급사가_제거돼도_기존_발급_이력은_계속_노출된다(): void
{
$this->registerProvider();
$order = $this->makeOrder();
app(CashReceiptService::class)->issue(
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
$array = $this->resourceArray($order->fresh());
$this->assertNotEmpty($array['cash_receipts'], '발급사 제거로 발급 이력이 사라졌습니다.');
$this->assertNotNull($array['cash_receipt'], '활성 영수증이 사라졌습니다.');
$this->assertSame(self::PROVIDER, $array['cash_receipts'][0]['provider'], '이력의 발급사 스냅샷이 바뀌었습니다.');
}
#[Test]
public function 리소스는_프로바이더_원응답을_노출하지_않는다(): void
{
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -31,6 +32,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class CashReceiptCancellationSyncTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -46,6 +49,7 @@ class CashReceiptCancellationSyncTest extends ModuleTestCase
$this->issueShouldFail = false;
$this->receiptSequence = 0;
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -19,6 +19,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderCashReceiptRepositoryI
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderService;
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -30,6 +31,8 @@ use PHPUnit\Framework\Attributes\Test;
*/
class CashReceiptServiceTest extends ModuleTestCase
{
use RegistersTestCashReceiptProvider;
private const PROVIDER = 'tosspayments';
private const IDENTIFIER = '01012345678';
@@ -54,6 +57,7 @@ class CashReceiptServiceTest extends ModuleTestCase
$this->receiptSequence = 0;
$this->issueShouldFail = false;
$this->registerCashReceiptProvider(self::PROVIDER);
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
}
@@ -716,6 +720,70 @@ class CashReceiptServiceTest extends ModuleTestCase
$this->assertSame(2, $this->issueCalls[1]['payload']['issue_sequence']);
}
// ─────────────────────────────────────────────────────────────
// 발급사 플러그인 제거 후 동작 (A3)
// ─────────────────────────────────────────────────────────────
/**
* 발급사 플러그인이 제거되면 신규 발급이 차단된다. (비회귀 pin)
*
* 저장값은 남아 있어도 그 발급사를 제공하는 확장이 없으면 발급 요청이 어디에도
* 도달하지 못한다. 그 상태에서 발급을 허용하면 구매자에게는 성공처럼 보이고
* 실제로는 실패 이력만 쌓인다.
*
* @scenario provider_state=dead
*
* @effects issue_blocked_when_provider_unregistered
*/
#[Test]
public function 발급사_플러그인이_제거되면_신규_발급이_차단된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
// 레지스트리에 없는 발급사로 저장값만 바꾼다 (= 플러그인 제거 상태)
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
$receipt = $this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertSame(CashReceiptIssueStatus::FAILED, $receipt->issue_status);
$this->assertSame('PROVIDER_NOT_CONFIGURED', $receipt->error_code);
$this->assertSame([], $this->issueCalls, '발급사가 없는데 발급 훅이 호출되었습니다.');
}
/**
* 이미 발급된 영수증의 취소는 영수증에 박제된 발급사로 수행된다. (비회귀 pin)
*
* 취소를 현재 설정값으로 판정하면, 발급사 플러그인을 제거한 뒤에는 과거 영수증을
* 취소할 수 없게 된다. 구매자는 환불받았는데 현금영수증만 살아 있는 상태가 남는다.
*
* @scenario provider_state=dead
*
* @effects cancel_uses_receipt_snapshot_provider
*/
#[Test]
public function 발급사_플러그인이_제거돼도_기존_영수증_취소는_스냅샷_발급사로_수행된다(): void
{
$this->registerProvider();
$order = $this->makeOrder(11000, 11000, 0);
$this->service()->issue($order, CashReceiptType::INCOME, self::IDENTIFIER);
$this->assertNotNull($this->activeReceipt($order->fresh()), '취소 대상 영수증이 없습니다.');
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
$result = $this->service()->cancelAll($order->fresh(), '전액취소');
$this->assertTrue($result, '발급사 제거 후 기존 영수증 취소가 실패했습니다.');
$this->assertNotSame([], $this->cancelCalls, '취소 훅이 호출되지 않았습니다.');
$this->assertSame(
self::PROVIDER,
$this->cancelCalls[0]['provider'],
'취소가 영수증 스냅샷이 아닌 현재 설정값을 사용했습니다.'
);
$this->assertNull($this->activeReceipt($order->fresh()), '취소 후에도 활성 영수증이 남았습니다.');
}
#[Test]
public function 주문_삭제_시_현금영수증_이력도_명시적으로_삭제된다(): void
{
@@ -569,8 +569,105 @@ class EcommerceSettingsOrderSettingsTest extends ModuleTestCase
$this->assertEquals('mobile', $kakaopay['_cached_icon']);
$this->assertEquals('plugin:sirsoft-kakaopay', $kakaopay['_cached_source']);
// _orphaned 플래그는 저장되지 않아야 함
// 런타임 전용 플래그는 저장되지 않아야 함
$this->assertArrayNotHasKey('_orphaned', $kakaopay);
$this->assertArrayNotHasKey('_orphaned_pg', $kakaopay);
// 저장 파일 전체에 런타임 플래그가 하나도 없어야 한다 (항목별 누락 방지)
foreach ($saved['payment_methods'] as $method) {
$this->assertArrayNotHasKey('_orphaned', $method);
$this->assertArrayNotHasKey('_orphaned_pg', $method);
}
}
// ──────────────────────────────────────────────
// 병합 4분면 — (카탈로그 등록) × (저장값 존재)
// ──────────────────────────────────────────────
/**
* 카탈로그 등록 여부와 저장값 존재 여부의 네 조합을 한 번에 고정합니다.
*
* 이 네 칸이 각각 다른 결과를 내야 한다 — 하나라도 뒤섞이면 삭제한 플러그인의
* 결제수단이 정상 수단처럼 노출되거나, 반대로 살아 있는 수단이 사라진다.
*
* @scenario catalog_state=registered, saved_state=present
*
* @effects merge_quadrant_pinned
*/
public function test_merge_quadrants_of_catalog_and_saved_state(): void
{
// 카탈로그에는 kakaopay 만 등록한다 (ghostpay 는 미등록 = 플러그인 삭제 상태)
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'kakaopay',
'name' => ['ko' => '카카오페이', 'en' => 'Kakao Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'mobile',
'source' => 'plugin:sirsoft-kakaopay',
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
];
return $methods;
});
// 저장값에는 kakaopay(등록 O) 와 ghostpay(등록 X) 만 둔다.
// builtin card 는 저장값 없이 카탈로그에만 있는 칸(등록 O + 저장 X)을 담당한다.
$this->saveOrderSettings([
'payment_methods' => [
['id' => 'kakaopay', 'sort_order' => 1, 'is_active' => true],
['id' => 'ghostpay', 'sort_order' => 2, 'is_active' => true, '_cached_name' => ['ko' => '유령페이', 'en' => 'Ghost Pay']],
],
]);
$this->service->clearCache();
$methods = collect($this->service->getSettings('order_settings')['payment_methods'] ?? [])->keyBy('id');
// ① 등록 O + 저장 O → 정상 병합
$this->assertTrue($methods->has('kakaopay'), '등록·저장된 수단이 사라졌습니다.');
$this->assertFalse((bool) ($methods['kakaopay']['_orphaned'] ?? false));
// ② 등록 O + 저장 X → 카탈로그 기본값으로 포함 (builtin)
$this->assertTrue($methods->has('card'), '저장값 없는 카탈로그 수단이 누락되었습니다.');
$this->assertFalse((bool) ($methods['card']['_orphaned'] ?? false));
// ③ 등록 X + 저장 O → 고아로 표시하되 관리자 응답에는 남긴다
$this->assertTrue($methods->has('ghostpay'), '고아 수단이 관리자 응답에서 사라졌습니다.');
$this->assertTrue(
(bool) ($methods['ghostpay']['_orphaned'] ?? false),
'공급 플러그인이 없는 수단이 고아로 표시되지 않았습니다.'
);
// ④ 등록 X + 저장 X → 애초에 존재하지 않는다
$this->assertFalse($methods->has('nowherepay'));
}
/**
* 고아 수단은 공개 응답에서만 제거되고 관리자 응답에는 남습니다.
*
* 관리자는 그 항목을 보고 지워야 하므로 양쪽 응답의 처리가 달라야 한다.
*
* @scenario catalog_state=unregistered, saved_state=present
*
* @effects merge_quadrant_pinned
*/
public function test_orphaned_method_is_admin_only(): void
{
$this->saveOrderSettings([
'payment_methods' => [
['id' => 'card', 'sort_order' => 1, 'is_active' => true],
['id' => 'ghostpay', 'sort_order' => 2, 'is_active' => true],
],
]);
$this->service->clearCache();
$adminIds = collect($this->service->getSettings('order_settings')['payment_methods'] ?? [])
->pluck('id')->all();
$publicIds = collect($this->service->getPublicPaymentSettings()['payment_methods'] ?? [])
->pluck('id')->all();
$this->assertContains('ghostpay', $adminIds);
$this->assertNotContains('ghostpay', $publicIds);
$this->assertContains('card', $publicIds, '정상 수단까지 제거되었습니다.');
}
// ──────────────────────────────────────────────
@@ -0,0 +1,311 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 단건 설정 저장(setSetting)·은행 저장(saveBanks)의 정규화 파이프라인 경유 테스트 (공개 #114)
*
* 두 경로는 `Arr::set` 후 카테고리 파일을 통째로 덮어써서 벌크 저장(saveSettings)의
* 정규화 단계(분리 입력 필드 병합 / 기본 통화 동기화 / defaults 스키마 정규화 /
* 결제수단 메타데이터 스냅샷 / 삭제 통화 기록 / 통화 캐시 무효화)를 전부 건너뛰었다.
* 저장 파일이 서로 어긋난 상태로 남고(예: default_currency 는 USD 인데 통화 목록의
* is_default 는 KRW), 읽기 경로의 재동기화가 그 어긋남을 가려 왔다.
*
* 위임 payload 는 반드시 **저장본**(loadCategorySettings) 기준이어야 한다. 읽기 결과
* (getAllSettings)를 넘기면 조회 시점 보강분(통화 symbol/flag, 결제수단 병합 메타)이
* 영속화되고, 삭제 통화 기록(tombstone, 공개 #91)이 재계산으로 지워져 관리자가 삭제한
* 통화가 부활한다.
*/
class EcommerceSettingsSetSettingPipelineTest 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();
}
// ──────────────────────────────────────────────
// 헬퍼
// ──────────────────────────────────────────────
/**
* 카테고리 저장 파일을 직접 기록합니다. (저장 이전 상태 구성용)
*
* @param string $category 카테고리명
* @param array $data 파일에 기록할 데이터
*/
private function seedFile(string $category, array $data): void
{
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/'.$category.'.json',
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->service->clearCache();
}
/**
* 카테고리 저장 파일을 그대로 읽습니다.
*
* @param string $category 카테고리명
* @return array 저장 파일의 디코드 결과 (파일 부재 시 빈 배열)
*/
private function savedFile(string $category): array
{
$path = $this->storagePath.'/'.$category.'.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 통화 항목 배열을 만듭니다.
*
* @param array<int, string> $codes 통화 코드 목록
* @param string $defaultCode 기본 통화 코드
* @return array 통화 항목 배열
*/
private function currencies(array $codes, string $defaultCode = 'KRW'): array
{
return array_map(fn (string $code) => [
'code' => $code,
'name' => ['ko' => $code, 'en' => $code],
'exchange_rate' => $code === $defaultCode ? null : 1.5,
'base_unit' => 1,
'rounding_unit' => '0.01',
'rounding_method' => 'round',
'decimal_places' => 2,
'is_default' => $code === $defaultCode,
], $codes);
}
// ──────────────────────────────────────────────
// ① 저장본 기준 위임
// ──────────────────────────────────────────────
/**
* 단건 저장은 조회 시점 보강분을 영속화하지 않고 타 카테고리 파일도 건드리지 않는다.
*
* @scenario pipeline_stage=stored_file_basis
*
* @effects read_time_enrichment_not_persisted, other_category_file_untouched
*/
public function test_single_key_save_persists_stored_basis_only(): void
{
$this->seedFile('language_currency', [
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
// 조회 보강(symbol/flag 주입)이 일어난 뒤에 저장해도 그 보강분이 파일에 남으면 안 된다
$this->service->getAllSettings();
$this->service->setSetting('language_currency.default_currency', 'USD');
$saved = $this->savedFile('language_currency');
$this->assertCount(2, $saved['currencies'] ?? [], '조회 병합으로 보충된 통화가 저장본에 영속화되었습니다.');
foreach ($saved['currencies'] as $currency) {
$this->assertArrayNotHasKey('flag', $currency, '표시 전용 메타(flag)가 저장본에 영속화되었습니다.');
}
$this->assertSame([], $this->savedFile('basic_info'), '단건 저장이 다른 카테고리 파일을 생성/변경했습니다.');
$this->assertSame([], $this->savedFile('order_settings'), '단건 저장이 다른 카테고리 파일을 생성/변경했습니다.');
}
/**
* 단건 저장이 관리자의 통화 삭제 기록(tombstone)을 지우지 않는다.
*
* @scenario pipeline_stage=stored_file_basis
*
* @effects removed_currency_tombstone_preserved
*/
public function test_single_key_save_preserves_removed_currency_tombstone(): void
{
$this->seedFile('language_currency', [
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
'removed_default_currencies' => ['JPY', 'CNY', 'EUR'],
]);
$this->service->setSetting('language_currency.default_currency', 'USD');
$saved = $this->savedFile('language_currency');
$this->assertSame(
['JPY', 'CNY', 'EUR'],
$saved['removed_default_currencies'] ?? [],
'단건 저장이 삭제 통화 기록을 훼손했습니다.'
);
$this->service->clearCache();
$codes = array_map(fn ($c) => $c['code'] ?? null, $this->service->getSettings('language_currency')['currencies'] ?? []);
$this->assertNotContains('JPY', $codes, '관리자가 삭제한 통화가 단건 저장으로 부활했습니다.');
}
// ──────────────────────────────────────────────
// ② 정규화 파이프라인 경유 (실패-먼저)
// ──────────────────────────────────────────────
/**
* 기본 통화를 단건으로 바꾸면 통화 목록의 is_default 도 함께 동기화된다. (실패-먼저)
*
* @scenario pipeline_stage=currency_default_sync
*
* @effects saved_file_default_currency_matches_is_default
*/
public function test_single_key_save_syncs_currency_is_default(): void
{
$this->seedFile('language_currency', [
'default_currency' => 'KRW',
'currencies' => $this->currencies(['KRW', 'USD']),
]);
$this->service->setSetting('language_currency.default_currency', 'USD');
$saved = $this->savedFile('language_currency');
$byCode = collect($saved['currencies'] ?? [])->keyBy('code');
$this->assertSame('USD', $saved['default_currency'] ?? null);
$this->assertTrue(
(bool) ($byCode['USD']['is_default'] ?? false),
'저장 파일의 default_currency 와 is_default 가 어긋났습니다 (정규화 파이프라인 미경유).'
);
$this->assertFalse(
(bool) ($byCode['KRW']['is_default'] ?? false),
'이전 기본 통화의 is_default 가 해제되지 않았습니다.'
);
}
/**
* 카테고리 통째 단건 저장도 분리 입력 필드 병합을 거친다. (실패-먼저)
*
* @scenario pipeline_stage=split_fields
*
* @effects split_input_fields_merged_on_single_key_save
*/
public function test_single_key_save_merges_split_fields(): void
{
$this->service->setSetting('basic_info', [
'phone_1' => '02',
'phone_2' => '1234',
'phone_3' => '5678',
]);
$saved = $this->savedFile('basic_info');
$this->assertSame('02-1234-5678', $saved['phone'] ?? null, '분리 입력 필드가 병합되지 않았습니다.');
$this->assertArrayNotHasKey('phone_1', $saved, '분리 입력 필드 원본이 저장본에 남았습니다.');
}
/**
* 결제수단 단건 저장도 _cached_* 스냅샷을 남기고 런타임 전용 플래그를 제거한다. (실패-먼저)
*
* @scenario pipeline_stage=payment_method_snapshot
*
* @effects payment_method_metadata_snapshotted, runtime_only_flag_not_persisted
*/
public function test_single_key_save_snapshots_payment_method_metadata(): void
{
$this->service->setSetting('order_settings.payment_methods', [
['id' => 'card', 'is_active' => true, 'sort_order' => 1],
['id' => 'zombie_pay', 'is_active' => true, 'sort_order' => 2, '_orphaned' => true],
]);
$saved = $this->savedFile('order_settings');
$byId = collect($saved['payment_methods'] ?? [])->keyBy('id');
$this->assertArrayHasKey(
'_cached_name',
$byId['card'] ?? [],
'결제수단 메타데이터 스냅샷이 남지 않았습니다.'
);
$this->assertArrayNotHasKey(
'_orphaned',
$byId['zombie_pay'] ?? ['_orphaned' => true],
'런타임 전용 플래그(_orphaned)가 저장본에 박제되었습니다.'
);
}
/**
* 단건 저장 후 요청 단위 통화 캐시가 비워져 같은 요청에서 신값이 읽힌다. (실패-먼저)
*
* @scenario pipeline_stage=currency_cache_flush
*
* @effects currency_request_cache_cleared_on_single_key_save
*/
public function test_single_key_save_clears_currency_request_cache(): void
{
// 캐시 선점 (저장 전 통화 구성)
$before = array_map(fn ($c) => $c['code'] ?? null, CurrencySettingsCache::currencies());
$this->assertNotEmpty($before);
$this->service->setSetting('language_currency.currencies', $this->currencies(['KRW']));
$after = array_map(fn ($c) => $c['code'] ?? null, CurrencySettingsCache::currencies());
$this->assertSame(
['KRW'],
array_values($after),
'단건 저장 후에도 요청 단위 통화 캐시가 저장 전 구성을 유지했습니다.'
);
}
// ──────────────────────────────────────────────
// ③ saveBanks
// ──────────────────────────────────────────────
/**
* 은행 목록 저장도 정규화를 거치고 order_settings 의 다른 키를 보존한다. (실패-먼저)
*
* @scenario pipeline_stage=normalize_category_data
*
* @effects bank_name_normalized_to_multilingual, sibling_keys_preserved_on_bank_save
*/
public function test_save_banks_normalizes_and_preserves_siblings(): void
{
$this->seedFile('order_settings', [
'cash_receipt_provider' => 'keep_me',
'banks' => [['code' => '004', 'name' => ['ko' => '국민은행', 'en' => 'Kookmin Bank']]],
]);
$this->service->saveBanks([
['code' => '999', 'name' => '테스트은행'],
]);
$saved = $this->savedFile('order_settings');
$this->assertSame('keep_me', $saved['cash_receipt_provider'] ?? null, '은행 저장이 형제 키를 지웠습니다.');
$this->assertIsArray(
$saved['banks'][0]['name'] ?? null,
'은행명이 다국어 배열로 정규화되지 않았습니다 (정규화 파이프라인 미경유).'
);
$this->assertSame('테스트은행', $saved['banks'][0]['name']['ko'] ?? null);
}
}
@@ -0,0 +1,161 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 부팅 중 만들어진 병합 결과를 요청 전체가 물고 가지 않는다 (공개 #116 파생)
*
* 설정 조회 결과에는 훅 카탈로그와 병합된 값이 섞여 있다 — 확장이 등록한 결제수단, 그리고
* 그 수단·PG 의 생사 판정(`_orphaned` / `_orphaned_pg`)이다.
*
* 그런데 코어는 부팅 중(`CoreServiceProvider::boot`)에 config 미러를 채우려고 이 설정을 한 번
* 읽는다. 그 시점은 플러그인이 자기 훅을 등록하기 **전**이라 카탈로그가 비어 있다. 서비스가
* 비-싱글톤이던 때는 요청 처리 단계에서 새 인스턴스가 다시 읽어 문제가 드러나지 않았지만,
* 공유 인스턴스가 되면 그 빈 카탈로그 기준 판정이 요청 내내 남는다 — 살아 있는 PG 를 지정한
* 결제수단이 "PG 없음" 으로 판정되어 주문서에서 통째로 사라진다.
*/
class SettingsCacheBootOrderTest extends ModuleTestCase
{
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);
}
}
protected function tearDown(): void
{
$this->setBooted(true);
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 컨테이너의 부팅 완료 플래그를 조작합니다. (부팅 순서 재현용)
*
* @param bool $booted 부팅 완료 여부
*/
private function setBooted(bool $booted): void
{
$prop = (new \ReflectionClass($this->app))->getProperty('booted');
$prop->setAccessible(true);
$prop->setValue($this->app, $booted);
}
/**
* 결제수단 카탈로그의 ID 목록을 반환합니다.
*
* @param EcommerceSettingsService $service 설정 서비스
* @return array<int, string> 결제수단 ID 목록
*/
private function methodIds(EcommerceSettingsService $service): array
{
$methods = $service->getSettings('order_settings')['payment_methods'] ?? [];
return array_values(array_map(fn ($m) => $m['id'] ?? null, $methods));
}
/**
* 부팅 중 읽은 결과가 캐시로 굳지 않는다. (실패-먼저)
*
* @scenario pg_provider_state=live
*
* @effects boot_time_read_not_cached
*/
public function test_settings_read_during_boot_is_not_cached(): void
{
$service = app(EcommerceSettingsService::class);
// 부팅 중 — 플러그인 훅 등록 이전 상태에서 코어가 config 미러를 채우려고 한 번 읽는다
$this->setBooted(false);
$this->assertNotContains('plugin_pay', $this->methodIds($service));
// 부팅 완료 — 이제 플러그인이 자기 결제수단을 등록한 상태다
$this->setBooted(true);
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
function (array $methods) {
$methods[] = [
'id' => 'plugin_pay',
'name' => ['ko' => '플러그인 결제', 'en' => 'Plugin Pay'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'credit-card',
'source' => 'plugin',
'defaults' => ['needs_pg' => true, 'pg_provider' => 'plugin_pg', 'is_active' => true],
];
return $methods;
}
);
$this->assertContains(
'plugin_pay',
$this->methodIds($service),
'부팅 중 만들어진 카탈로그가 캐시로 굳어 확장 결제수단이 요청 내내 사라졌습니다.'
);
}
/**
* 살아 있는 PG 를 지정한 결제수단이 부팅 순서 때문에 고아로 오판되지 않는다. (실패-먼저)
*
* @scenario pg_provider_state=live
*
* @effects live_pg_method_remains_visible
*/
public function test_live_pg_method_is_not_flagged_due_to_boot_order(): void
{
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode([
'payment_methods' => [
['id' => 'card', 'is_active' => true, 'sort_order' => 1, 'pg_provider' => 'late_pg'],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$service = app(EcommerceSettingsService::class);
// 부팅 중 조회 — PG 레지스트리가 아직 비어 있다.
// 캐시 비우기도 부팅 구간 안에서 수행한다(부팅 완료 상태에서 비우면 그 시점의
// "훅 미등록" 상태가 정상적으로 캐시되어 재현 대상이 아닌 상태가 된다).
$this->setBooted(false);
$service->clearCache();
$service->getSettings('order_settings');
// 부팅 완료 후 PG 플러그인이 자기 PG 를 등록한다
$this->setBooted(true);
HookManager::addFilter(
'sirsoft-ecommerce.payment.registered_pg_providers',
function (array $providers) {
$providers[] = ['id' => 'late_pg', 'name' => 'Late PG'];
return $providers;
}
);
$card = collect($service->getSettings('order_settings')['payment_methods'] ?? [])->firstWhere('id', 'card');
$this->assertFalse(
(bool) ($card['_orphaned_pg'] ?? false),
'살아 있는 PG 인데 부팅 순서 때문에 죽은 PG 로 판정되었습니다.'
);
$this->assertContains(
'card',
array_map(fn ($m) => $m['id'], $service->getPublicPaymentSettings()['payment_methods'] ?? []),
'정상 결제수단이 공개 응답에서 제거되었습니다.'
);
}
}
@@ -0,0 +1,259 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 설정 서비스 공유 인스턴스화 + 저장 시 리졸버 캐시 무효화 (공개 #116)
*
* 형제 서비스 3종(CurrencyConversionService / ShippingPolicyResolver / PaymentMethodResolver)은
* 싱글톤으로 등록되어 있는데 `EcommerceSettingsService` 만 미등록이라, 주입 지점마다 별개
* 인스턴스가 만들어지고 각자 자기 설정 캐시를 들고 있었다. 싱글톤 리졸버가 비-싱글톤 설정
* 서비스를 captive 로 보유하는 비대칭도 함께 생긴다.
*
* 싱글톤화만으로는 부족하다 — 리졸버들은 자기 캐시를 따로 들고 있어서, 같은 요청 안에서
* 설정을 저장해도 이미 해석된 리졸버는 저장 전 카탈로그를 계속 답한다. 저장 지점에서
* (이미 해석된 경우에만) 무효화를 연동한다.
*/
class SettingsSaveFlushesResolversTest extends ModuleTestCase
{
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);
}
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 카드 결제수단을 지정 상태로 만드는 저장 payload 를 만듭니다.
*
* @param bool $cardActive card 결제수단 활성 여부
* @return array order_settings.payment_methods payload
*/
private function paymentMethods(bool $cardActive): array
{
return [
['id' => 'card', 'is_active' => $cardActive, 'sort_order' => 1],
['id' => 'dbank', 'is_active' => true, 'sort_order' => 2],
];
}
/**
* 설정 서비스는 컨테이너에서 공유 인스턴스로 해석된다. (실패-먼저)
*
* @scenario resolution=container
*
* @effects settings_service_shared_instance
*/
public function test_settings_service_is_resolved_as_shared_instance(): void
{
$this->assertTrue(
$this->app->bound(EcommerceSettingsService::class),
'설정 서비스가 컨테이너에 등록되어 있지 않습니다 (형제 3종은 싱글톤 등록).'
);
$first = $this->app->make(EcommerceSettingsService::class);
$second = $this->app->make(EcommerceSettingsService::class);
$this->assertSame($first, $second, '설정 서비스가 해석할 때마다 새 인스턴스로 만들어집니다.');
}
/**
* 싱글톤 리졸버가 붙든 설정 서비스도 같은 공유 인스턴스다.
*
* @scenario resolution=captive_dependency
*
* @effects settings_service_shared_instance
*/
public function test_resolver_holds_the_same_shared_settings_instance(): void
{
$resolver = $this->app->make(PaymentMethodResolver::class);
$reflection = new \ReflectionClass($resolver);
$prop = $reflection->getProperty('settingsService');
$prop->setAccessible(true);
$this->assertSame(
$this->app->make(EcommerceSettingsService::class),
$prop->getValue($resolver),
'싱글톤 리졸버가 별개의 설정 서비스 인스턴스를 붙들고 있습니다.'
);
}
/**
* 저장 직후 같은 요청에서 결제수단 리졸버가 신값을 답한다. (실패-먼저)
*
* @scenario resolution=container
*
* @effects payment_resolver_reflects_saved_catalog
*/
public function test_bulk_save_flushes_payment_method_resolver(): void
{
$settings = $this->app->make(EcommerceSettingsService::class);
$settings->saveSettings(['order_settings' => ['payment_methods' => $this->paymentMethods(false)]]);
// 저장 전 카탈로그를 리졸버가 선점하도록 강제
$resolver = $this->app->make(PaymentMethodResolver::class);
$this->assertFalse($this->isActive($resolver, 'card'));
$settings->saveSettings(['order_settings' => ['payment_methods' => $this->paymentMethods(true)]]);
$this->assertTrue(
$this->isActive($resolver, 'card'),
'저장 후에도 리졸버가 저장 전 카탈로그를 답했습니다 (캐시 무효화 미연동).'
);
}
/**
* 단건 저장(setSetting) 경로도 같은 무효화 지점을 지난다. (실패-먼저)
*
* @scenario resolution=container
*
* @effects payment_resolver_reflects_saved_catalog
*/
public function test_single_key_save_flushes_payment_method_resolver(): void
{
$settings = $this->app->make(EcommerceSettingsService::class);
$settings->setSetting('order_settings.payment_methods', $this->paymentMethods(false));
$resolver = $this->app->make(PaymentMethodResolver::class);
$this->assertFalse($this->isActive($resolver, 'card'));
$settings->setSetting('order_settings.payment_methods', $this->paymentMethods(true));
$this->assertTrue(
$this->isActive($resolver, 'card'),
'단건 저장 후에도 리졸버가 저장 전 카탈로그를 답했습니다.'
);
}
/**
* 저장 직후 같은 요청에서 통화 서비스도 신값을 답한다. (실패-먼저)
*
* 결제수단 리졸버와 함께 무효화 목록에 있으나, 종전에는 "해석 가드가 있는가" 만
* 확인하고 실제로 신값이 나오는지는 검증하지 않았다.
*
* @scenario resolution=container
*
* @effects currency_service_reflects_saved_settings
*/
public function test_bulk_save_flushes_currency_conversion_service(): void
{
$settings = $this->app->make(EcommerceSettingsService::class);
$settings->saveSettings(['language_currency' => $this->currencySettings('KRW')]);
// 저장 전 설정을 통화 서비스가 선점하도록 강제
$currency = $this->app->make(CurrencyConversionService::class);
$this->assertSame('KRW', $currency->getDefaultCurrency());
$settings->saveSettings(['language_currency' => $this->currencySettings('USD')]);
$this->assertSame(
'USD',
$currency->getDefaultCurrency(),
'저장 후에도 통화 서비스가 저장 전 기본 통화를 답했습니다 (캐시 무효화 미연동).'
);
}
/**
* 단건 저장 경로도 통화 서비스 캐시를 비운다. (실패-먼저)
*
* @scenario resolution=container
*
* @effects currency_service_reflects_saved_settings
*/
public function test_single_key_save_flushes_currency_conversion_service(): void
{
$settings = $this->app->make(EcommerceSettingsService::class);
$settings->saveSettings(['language_currency' => $this->currencySettings('KRW')]);
$currency = $this->app->make(CurrencyConversionService::class);
$this->assertSame('KRW', $currency->getDefaultCurrency());
$settings->setSetting('language_currency.default_currency', 'USD');
$this->assertSame(
'USD',
$currency->getDefaultCurrency(),
'단건 저장 후에도 통화 서비스가 저장 전 기본 통화를 답했습니다.'
);
}
/**
* 지정한 기본 통화로 language_currency payload 를 만듭니다.
*
* @param string $defaultCurrency 기본 통화 코드
* @return array language_currency payload
*/
private function currencySettings(string $defaultCurrency): array
{
return [
'default_currency' => $defaultCurrency,
'currencies' => [
['code' => 'KRW', 'symbol' => '₩', 'decimal_places' => 0, 'exchange_rate' => 1, 'is_active' => true, 'is_default' => $defaultCurrency === 'KRW'],
['code' => 'USD', 'symbol' => '$', 'decimal_places' => 2, 'exchange_rate' => 0.00075, 'is_active' => true, 'is_default' => $defaultCurrency === 'USD'],
],
];
}
/**
* 아직 해석되지 않은 리졸버는 저장이 강제로 인스턴스화하지 않는다.
*
* @scenario resolution=not_resolved
*
* @effects unresolved_services_not_instantiated_on_save
*/
public function test_save_does_not_instantiate_unresolved_resolvers(): void
{
$this->app->forgetInstance(PaymentMethodResolver::class);
$this->app->forgetInstance(CurrencyConversionService::class);
$this->app->make(EcommerceSettingsService::class)
->saveSettings(['order_settings' => ['payment_methods' => $this->paymentMethods(true)]]);
$this->assertFalse(
$this->app->resolved(PaymentMethodResolver::class),
'저장이 미해석 리졸버를 강제로 인스턴스화했습니다.'
);
$this->assertFalse(
$this->app->resolved(CurrencyConversionService::class),
'저장이 미해석 통화 서비스를 강제로 인스턴스화했습니다.'
);
}
/**
* 리졸버가 보는 카탈로그에서 결제수단의 활성 여부를 읽습니다.
*
* @param PaymentMethodResolver $resolver 결제수단 리졸버
* @param string $methodId 결제수단 ID
* @return bool 활성 여부
*/
private function isActive(PaymentMethodResolver $resolver, string $methodId): bool
{
$reflection = new \ReflectionClass($resolver);
$method = $reflection->getMethod('catalogValue');
$method->setAccessible(true);
return (bool) $method->invoke($resolver, $methodId, 'is_active');
}
}
@@ -0,0 +1,40 @@
feature: 죽은 현금영수증 발급사 차단 (A3)
description: |
발급사(`order_settings.cash_receipt_provider`)는 플러그인이 훅으로 등록한 카탈로그에서 고른다.
운영자가 고른 뒤 그 플러그인을 제거하면 저장값만 남는다 — 카탈로그에는 없는 문자열이다.
그 상태에서 발급을 허용하면 요청이 어디에도 도달하지 못한다. 구매자 화면에는 신청 폼이
그대로 뜨고, 발급을 눌러도 실패 이력만 쌓인다. 예외는 나지 않는다.
판정은 하나다 — 저장값이 현재 레지스트리에 없으면 **미설정과 동일하게** 취급한다.
단, 저장값 자체는 지우지 않는다. 관리자 응답에는 남겨 운영자가 무엇을 골랐었는지 보고
고칠 수 있게 하고, 공개 응답·리소스에서만 null 로 정규화한다.
이미 발급된 영수증은 별개다. 취소는 현재 설정값이 아니라 **영수증에 박제된 발급사**로
수행한다 — 그러지 않으면 플러그인을 제거한 뒤 과거 영수증을 취소할 수 없게 되어,
구매자는 환불받았는데 현금영수증만 살아 있는 상태가 남는다.
axis_notes:
provider_state: |
surface(관리자 설정 / 공개 응답 / 주문 리소스 / 발급 서비스)는 축이 아니라 effects 로
구분한다 — 네 표면이 같은 판정을 공유하는 것이 이 기능의 요구사항이기 때문이다.
죽은 PG 를 다루는 형제 매니페스트(payment-method-dead-pg.yaml)와 같은 구조다.
axes:
provider_state: [live, dead]
effects:
- live_cash_receipt_provider_resolved # 등록된 발급사는 그대로 해석 (거짓 양성 차단)
- dead_cash_receipt_provider_treated_as_unset # 해석 결과가 null (저장값은 관리자 확인용으로 보존)
- dead_cash_receipt_provider_normalized_in_public # 공개 응답에서 null → 체크아웃 신청 폼 미렌더
- dead_provider_nulled_in_resource # 주문 리소스의 발급사 필드도 null → 발급 버튼 미노출
- existing_receipt_history_survives_dead_provider # 이미 발급/시도된 이력은 계속 표시 (영수증 URL 도달 경로 보존)
- issue_blocked_when_provider_unregistered # 신규 발급은 훅 호출 없이 차단 (실패 이력만 쌓이는 것 방지)
- cancel_uses_receipt_snapshot_provider # 취소는 영수증에 박제된 발급사로 수행
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsCashReceiptTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/CashReceiptResourceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CashReceiptServiceTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/cash-receipt-lifecycle.spec.ts
@@ -57,7 +57,8 @@ axes:
- free
provider_state: # 발급 프로바이더 설정 상태
- configured # 슬롯/카드 렌더
- configured # 설정값이 있고 그 프로바이더를 제공하는 확장도 살아 있음 → 슬롯/카드 렌더
- dead # 설정값은 남았으나 제공 확장이 사라짐 → 미설정과 동일 취급 (A3)
- not_configured # 슬롯/카드 전체 미렌더 (DOM 카운트 0)
receipt_type:
@@ -157,6 +158,10 @@ effects:
- checkout_refund_bank_empty_allowed
- checkout_refund_bank_name_resolved_from_code # 은행코드 → 은행명 조회 저장
# 죽은 프로바이더(A3)의 판정·응답 계약은 cash-receipt-dead-provider.yaml 이 소유한다.
# 이 매니페스트는 cross product 상한 초과로 effects 검사가 면제되어 있어, 그곳에 두면
# 검증되지 않는다. provider_state=dead 축은 UI 렌더 조건으로서 여기 남는다.
# 유저 주문상세 카드 (W-2)
- mypage_card_hidden_when_not_dbank_or_no_provider
- mypage_card_state_machine_five_states
@@ -0,0 +1,35 @@
feature: 설정 저장이 리졸버 캐시에 도달하는 경로 (B-6)
description: |
설정을 저장한 뒤 같은 요청 안에서 그 값을 읽는 코드가 있다. 그런데 SettingsService 가
컨테이너에 singleton 으로 묶여 있지 않으면, 저장한 인스턴스와 읽는 인스턴스가 서로 다른
객체가 된다 — 저장은 성공하고, 바로 뒤의 조회만 옛 값을 돌려준다.
더 조용한 형태가 captive dependency 다. 생성자에서 SettingsService 를 받아 둔 서비스는
컨테이너를 다시 거치지 않으므로, singleton 으로 바꾼 뒤에도 그 서비스만 자기 사본을
붙들고 있을 수 있다. 그래서 "컨테이너에서 두 번 꺼내 같은 객체인가" 와 "생성자로 받아 둔
객체도 같은가" 를 따로 본다.
반대 방향의 위험도 있다. 저장 시점에 리졸버를 무조건 깨우면(`app(X)->flush()`), 그 요청에서
쓰지도 않을 서비스가 인스턴스화되어 부팅 비용이 늘고 부작용이 생긴다. 아직 해석되지 않은
서비스는 건드리지 않아야 한다.
axis_notes:
resolution: |
저장 시점에 대상 서비스가 어떤 상태였는가. container = 컨테이너에서 해석된 상태.
captive_dependency = 다른 서비스의 생성자에 붙들린 사본. not_resolved = 아직 해석되지 않음
(건드리지 않아야 하는 케이스).
저장 경로(벌크/단건)와 대상 서비스(결제수단/통화)는 조합이 아니라 같은 계약의 반복이라
effects 로 판정한다 — 축으로 두면 전개만 늘고 검증 내용은 같아진다.
axes:
resolution: [container, captive_dependency, not_resolved]
effects:
- settings_service_shared_instance # 컨테이너 해석분과 주입된 사본이 동일 객체
- payment_resolver_reflects_saved_catalog # 저장 직후 결제수단 리졸버가 새 카탈로그를 본다
- currency_service_reflects_saved_settings # 저장 직후 통화 서비스가 새 설정을 본다
- unresolved_services_not_instantiated_on_save # 해석 안 된 서비스는 저장이 깨우지 않는다
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/SettingsSaveFlushesResolversTest.php
@@ -0,0 +1,37 @@
feature: 모듈 설정 저장 훅의 발화 지점 (B-5)
description: |
`core.module_settings.after_save` 를 구독하는 리스너가 셋(이커머스 SEO 캐시 / 게시판 SEO
캐시 / 코어 활동로그) 있는데, 정작 발화 지점인 `ModuleSettingsService::save()` 는
프로덕션에서 호출되지 않았다. 모듈 설정은 각 모듈의 관리자 컨트롤러가 자기
SettingsService 로 직접 저장하기 때문이다.
결과: 모듈 환경설정에서 SEO 메타 템플릿을 바꿔도 SEO 캐시가 무효화되지 않고, 설정 변경이
감사 기록에도 남지 않았다. 예외도 경고도 없이 구독자만 굶었다.
발화 지점은 관리자 컨트롤러다. 서비스에 두면 시더·업그레이드 스텝·테스트 픽스처의 모든
저장이 활동로그와 SEO 무효화를 유발하고, 훅의 의미도 "관리자가 설정을 저장했다" 이다.
axis_notes:
actor: |
admin = 관리자 HTTP 요청(컨트롤러 경유). system = 서비스 직접 호출(시더·내부 로직).
이 축이 곧 발화 여부를 가르는 설계 결정이라, 두 값 모두에 대해 저장 경로 전부를 본다 —
한쪽 경로만 조용하면 나머지 경로로 감사 기록이 오염된다.
save_path: |
single_key 는 아직 라우트가 없다(설정 화면은 벌크 저장만 사용). 컨트롤러 메서드를 직접
호출해 계약을 고정한다 — 라우트가 붙는 시점에 발화 계약이 이미 서 있게 한다.
axes:
actor: [admin, system]
save_path: [bulk, single_key, save_banks]
effects:
- module_settings_after_save_hook_fired # 관리자 저장 → 발화 (저장 카테고리 payload 동반)
- module_settings_after_save_hook_not_fired_on_failure # 검증 탈락 시 미발화
- service_level_save_stays_silent # 서비스 직접 호출은 전 경로에서 침묵
- seo_cache_invalidated_on_module_settings_save # 구독자 ① SEO 프리렌더 캐시
- seo_cache_untouched_for_non_seo_tab # SEO 무관 탭 저장은 캐시를 건드리지 않는다
- module_settings_save_recorded_in_activity_log # 구독자 ② 코어 활동로그
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsSeoCacheInvalidationTest.php
@@ -0,0 +1,42 @@
feature: 이커머스 단건 설정 저장의 파이프라인 통과 (B-1)
description: |
이커머스 설정은 저장 전에 여러 단계를 거친다 — 분할 입력 필드 병합, 통화 기본값 동기화,
결제수단 메타데이터 스냅샷, 은행명 다국어 정규화. 벌크 저장(`saveSettings`)은 그 파이프라인을
전부 통과하는데, 단건 저장(`setSetting`)은 값 하나를 파일에 바로 써 넣었다.
그래서 같은 값을 어느 화면에서 저장했느냐에 따라 결과가 달라졌다. 오류는 나지 않는다 —
저장은 성공하고, 파생 값만 갱신되지 않은 채 남는다.
단건 저장이 딛는 기준(payload_basis)도 문제였다. 조회 결과에는 읽기 시점 보강값(카탈로그
라벨 등)이 섞여 있고, 삭제된 통화의 tombstone 은 조회에서 걸러진다. 조회 결과를 기준으로
덮어쓰면 보강값이 파일에 눌어붙고 tombstone 이 사라진다 — 저장된 파일이 기준이어야 한다.
axis_notes:
pipeline_stage: |
벌크 경로가 이미 수행하던 단계를 단건 경로에도 통과시킨다. 각 단계는 서로 다른 입력을
보므로 조합이 아니라 독립 축이다 — 한 단계를 통과시켜도 다른 단계가 빠지면 그 축만
조용히 어긋난다.
axes:
pipeline_stage:
- stored_file_basis # 저장 파일 기준 병합 (조회 결과 기준 금지)
- currency_default_sync # is_default 통화 ↔ default_currency 일치
- split_fields # 화면의 분할 입력 필드를 한 키로 병합
- payment_method_snapshot # 결제수단 카탈로그 메타데이터 박제
- currency_cache_flush # 요청 캐시 무효화
- normalize_category_data # 카테고리 단위 정규화 (은행명 다국어)
effects:
- read_time_enrichment_not_persisted # 읽기 시점 보강값이 파일에 눌어붙지 않는다
- other_category_file_untouched # 단건 저장이 다른 카테고리 파일을 건드리지 않는다
- removed_currency_tombstone_preserved # 삭제 통화 tombstone 보존
- saved_file_default_currency_matches_is_default
- split_input_fields_merged_on_single_key_save
- payment_method_metadata_snapshotted
- currency_request_cache_cleared_on_single_key_save
- bank_name_normalized_to_multilingual
- sibling_keys_preserved_on_bank_save # 은행 저장이 같은 카테고리의 형제 키를 지우지 않는다
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsSetSettingPipelineTest.php
@@ -34,6 +34,8 @@ axes:
capability_declared: [declared, undeclared]
# 능력별 판정 표면
capability: [needs_pg, label, refund_method, pg_locked, brand_mark]
# 지정 PG 의 생사(pg_provider_state)는 능력 해석과 직교하는 별개 관심사다 —
# payment-method-dead-pg.yaml 이 그 축을 소유한다.
exclusions:
- method_kind: builtin
@@ -0,0 +1,50 @@
feature: 죽은 PG 를 지정한 결제수단 차단 (A2)
description: |
고아 판정(`_orphaned`)은 결제수단 ID 에만 계산된다. 그래서 builtin 수단(카드 등)에 특정 PG 를
지정한 뒤 그 PG 플러그인을 제거하면, 수단 자체는 카탈로그에 남아 있어 고아 필터를 그대로
통과한다. 체크아웃에 선택 가능한 결제수단으로 노출되고, 주문하면 PG 라우팅이 매칭에 실패해
**결제창 없이 주문완료로 넘어간다**.
판정식은 런타임 폴백(`OrderProcessingService::determinePgProvider()`)과 일치시킨다.
수단의 지정값이 **null 일 때만** 기본 PG 로 내려가므로, 지정값이 죽은 문자열이면 기본 PG 가
살아 있어도 폴백하지 않는다 — 그 경우도 차단 대상이다. 레지스트리를 인식하는 폴백으로
바꾸는 것은 결제 라우팅 계약 변경이라 하지 않는다.
관리자 응답은 필터하지 않고 `_orphaned_pg` 플래그를 그대로 실어 운영자가 고칠 수 있게 한다.
`_orphaned` 와 달리 행 편집 컨트롤(PG 선택)도 막지 않는다 — 살아 있는 PG 로 바꾸는 복구
경로가 남아야 한다.
axes:
# 유효 PG 의 상태. surface(관리자/공개/주문제출)는 축이 아니라 effects 로 구분한다 —
# 세 표면이 같은 판정을 공유하는 것이 이 기능의 요구사항이기 때문이다.
pg_provider_state: [live, dead_own, dead_default, none]
effects:
- dead_pg_method_flagged_for_admin # 관리자 응답·화면에 _orphaned_pg 플래그와 배지
- dead_pg_method_hidden_from_checkout # 공개 응답에서 제거 — 결제창 없는 주문완료 차단
- dead_pg_method_keeps_recovery_control # 행 편집(PG 선택)은 막지 않음 — 복구 경로 유지
- dead_default_pg_normalized_in_public # 죽은 기본 PG 는 공개 응답에서 null 로 정규화
- dead_pg_order_submission_rejected_422 # 프론트 우회 직접 제출도 검증에서 거부
- orphaned_method_order_submission_rejected_422 # 고아 수단 제출도 함께 차단 (공개 #111 서버 대칭)
- unconfigured_pg_keeps_legacy_contract # 양쪽 미설정은 기존 계약(non-PG 강하) 유지
- live_pg_method_remains_visible # 살아있는 PG 지정은 그대로 노출 (거짓 양성 차단)
- live_pg_order_submission_accepted # 정상 수단 제출은 통과
- non_pg_method_unaffected_by_registry # PG 불필요 수단은 레지스트리와 무관
- unknown_method_id_keeps_legacy_contract # 카탈로그 밖 ID 판정은 종전대로 통과
- runtime_only_flag_not_persisted # _orphaned_pg 는 저장 파일에 박제되지 않음
- boot_time_read_not_cached # 부팅 중(훅 등록 전) 판정이 요청 전체에 남지 않음
- extension_method_survives_dead_pg_guard # 플러그인 등록 수단이 가드에 휩쓸리지 않음 (#475 계약 보존)
- pg_locked_method_has_no_dead_pg_exemption # pg_locked 는 저장값 고정일 뿐 PG 사멸 면제가 아님
- merge_quadrant_pinned # 카탈로그 등재 × 저장값 존재 4분면 — 고아 판정의 토대
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/PaymentSettingsOrphanedPgProviderTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Requests/CreateOrderDeadPgGuardTest.php
# 확장 결제수단 1급화(#475) 스위트에 심은 죽은-PG 비회귀 pin — 그 스위트만 돌려도 걸리도록
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Payment/ExtensionPaymentMethodRegressionTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/SettingsCacheBootOrderTest.php
# 카탈로그 × 저장값 4분면 (고아 판정이 딛는 병합 규칙)
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/payment-method-orphaned-pg-badge.spec.ts
@@ -0,0 +1,29 @@
feature: 기본 배송정책 리졸버의 요청 내 캐시 무효화
description: |
`ShippingPolicyResolver` 는 기본 배송정책(`is_default = true`) 행을 요청 안에서 한 번만
조회해 들고 있다. 그 캐시를 비우는 지점이 없으면, 같은 요청에서 기본 정책을 바꾼 뒤의
계산이 옛 정책으로 수행된다 — 배송비만 틀린 주문이 조용히 생긴다.
무효화 트리거는 **설정 저장이 아니라 배송정책 CRUD** 다. 이 리졸버가 캐시하는 것은 설정
값이 아니라 DB 행이기 때문이다. 설정 저장에 걸어 두면 정작 정책을 바꿨을 때 캐시가 남고,
설정만 바꿨을 때는 쓸데없이 비워진다.
무효화는 화면·서비스마다 복제하지 않고 리스너 하나가 관련 훅 전부를 구독해 수행한다.
구독 목록에서 훅 하나가 빠지면 그 경로에서만 옛 정책이 살아남는다.
axis_notes:
mutation: |
기본 정책이 바뀔 수 있는 경로 전부. hook_subscription 은 개별 경로가 아니라
"리스너가 구독한 훅 집합이 정책 변경 훅 전부를 덮는가" 를 본다 — 경로마다 테스트를
복제하는 대신 구독 목록 자체를 고정한다.
axes:
mutation: [none, manual_flush, set_default, update, delete, hook_subscription]
effects:
- default_policy_cached_within_request # 변경이 없으면 요청 내 재조회 없음
- resolver_cache_flushed # 정책이 바뀌면 다음 조회가 새 행을 본다
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/ShippingPolicyResolverCacheTest.php
@@ -15,6 +15,8 @@
### Changed
- 환경설정 > 드라이버 탭의 검색엔진 선택 목록이 서버가 제공하는 목록을 그대로 사용하도록 바뀌었습니다 — 검색엔진 플러그인을 설치하면 그 엔진이 목록에 자동으로 나타납니다. 이전에는 기본 제공 엔진 하나만 고를 수 있었습니다.
- 드라이버·발송 방식 선택란에 저장된 값이 현재 사용할 수 없는 값이면(해당 플러그인을 삭제한 경우 등) 선택란 아래에 그 값과 함께 안내가 표시됩니다. 이전에는 선택란이 빈 칸으로만 보여 무엇이 저장돼 있었는지 알 수 없었습니다.
- S3 리전이 5개 목록 선택에서 자유 입력으로 바뀌었습니다 — 새로 생기는 AWS 리전이나 R2 의 `auto` 값을 그대로 입력할 수 있습니다.
- S3 URL 항목의 이름과 설명을 "공개 URL(CDN)" 용도로 명확히 했습니다 — API 요청 주소는 새로 생긴 엔드포인트 URL 칸에 입력합니다.
- 웹소켓 연결 테스트가 서버 발송용 주소까지 함께 검사하도록 테스트 요청 항목이 확장되었습니다.
@@ -0,0 +1,269 @@
/**
* @file admin-settings-drivers-catalog-binding.test.tsx
* @description 드라이버 셀렉트의 카탈로그 바인딩 + 죽은 저장값 안내
*
* 검색엔진 셀렉트만 옵션이 레이아웃에 박혀 있어, 플러그인이 검색엔진을 등록해도
* 화면에서 고를 수 없었다. 그리고 저장값이 카탈로그에 없으면(공급 플러그인 제거)
* 셀렉트는 빈 칸으로만 보여, 운영자가 "설정이 비어 있다" 로 오해하고 실제 저장값이
* 무엇인지 알 방법이 없었다.
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const PARTIAL_DIR = resolve(__dirname, '../../layouts/partials/admin_settings');
const driversPartial = JSON.parse(readFileSync(resolve(PARTIAL_DIR, '_tab_drivers.json'), 'utf-8'));
const mailPartial = JSON.parse(readFileSync(resolve(PARTIAL_DIR, '_tab_mail.json'), 'utf-8'));
// ---------------------------------------------------------------------------
// 테스트용 컴포넌트
// ---------------------------------------------------------------------------
const TestDiv: React.FC<any> = ({ className, children }) => <div className={className}>{children}</div>;
const TestSelect: React.FC<any> = ({ name, options }) => (
<select name={name} data-testid={`select-${name}`}>
{(options ?? []).map((o: any) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
const TestSpan: React.FC<any> = ({ children, text, className }) => <span className={className}>{children || text}</span>;
const TestP: React.FC<any> = ({ children, text, className, ...rest }) => (
<p className={className} data-testid={rest['data-testid']}>
{children || text}
</p>
);
const TestLabel: React.FC<any> = ({ children, text }) => <label>{children || text}</label>;
const TestInput: React.FC<any> = ({ name }) => <input name={name} data-testid={name} />;
const TestButton: React.FC<any> = ({ children, text }) => <button type="button">{children || text}</button>;
const TestIcon: React.FC<any> = ({ name }) => <i data-icon={name} />;
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
/**
* 테스트용 컴포넌트 레지스트리를 구성합니다.
*
* @returns 구성된 레지스트리
*/
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* id 로 노드를 깊이 우선 탐색합니다.
*
* @param node 탐색 시작 노드
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findNodeById(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const child of node.children ?? []) {
const found = findNodeById(child, id);
if (found) return found;
}
return null;
}
/**
* partial 루트들에서 id 노드를 찾습니다.
*
* @param partial 대상 partial JSON
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findInPartial(partial: any, id: string): any {
for (const root of partial.components ?? [partial]) {
const found = findNodeById(root, id);
if (found) return found;
}
return null;
}
/**
* 노드 트리를 순회하며 조건을 만족하는 노드를 모읍니다.
*
* @param node 탐색 시작 노드
* @param predicate 판정 함수
* @param acc 누적 배열
* @returns 조건을 만족하는 노드 목록
*/
function collect(node: any, predicate: (n: any) => boolean, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (Array.isArray(node)) {
for (const item of node) collect(item, predicate, acc);
return acc;
}
if (predicate(node)) acc.push(node);
for (const key of Object.keys(node)) collect(node[key], predicate, acc);
return acc;
}
/**
* 검색엔진 필드 블록을 주어진 상태로 렌더합니다.
*
* @param form 폼 상태 (_local.form)
* @returns 레이아웃 테스트 유틸
*/
function renderSearchField(form: Record<string, unknown>) {
const field = findInPartial(driversPartial, 'field_search_engine_driver');
expect(field).not.toBeNull();
return createLayoutTest(
{
version: '1.0.0',
layout_name: 'test_drivers_catalog_binding',
components: [field],
} as any,
{ initialState: { _local: { form, errors: {} } } }
);
}
const CORE_SEARCH_CATALOG = [{ id: 'mysql-fulltext', label: { ko: 'MySQL 전문검색', en: 'MySQL Full-Text' } }];
// @scenario saved_value_state=live
// @effects driver_select_options_built_from_catalog, live_saved_value_shows_no_notice
describe('드라이버 셀렉트 카탈로그 바인딩', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
it('검색엔진 옵션을 카탈로그에서 만든다 — 플러그인 등록 드라이버가 화면에 뜬다', async () => {
const testUtils = renderSearchField({
drivers: { search_engine_driver: 'meilisearch' },
available_drivers: {
search: [
...CORE_SEARCH_CATALOG,
{ id: 'meilisearch', label: { ko: 'Meilisearch', en: 'Meilisearch' } },
],
},
});
await testUtils.render();
const select = screen.getByTestId('select-drivers.search_engine_driver');
const values = Array.from(select.querySelectorAll('option')).map((o) => o.getAttribute('value'));
expect(values).toContain('mysql-fulltext');
expect(values).toContain('meilisearch');
testUtils.cleanup();
});
it('저장값이 카탈로그에 있으면 안내를 띄우지 않는다', async () => {
const testUtils = renderSearchField({
drivers: { search_engine_driver: 'mysql-fulltext' },
available_drivers: { search: CORE_SEARCH_CATALOG },
});
await testUtils.render();
// 존재를 먼저 확정한 뒤 부재를 단언한다 (부재 단독 단언 금지 규율)
expect(screen.getByTestId('select-drivers.search_engine_driver')).toBeInTheDocument();
expect(screen.queryByTestId('driver-unavailable-search')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('공급 플러그인이 사라진 저장값은 안내와 함께 값 자체를 드러낸다', async () => {
const testUtils = renderSearchField({
drivers: { search_engine_driver: 'elasticsearch' },
available_drivers: { search: CORE_SEARCH_CATALOG },
});
await testUtils.render();
const notice = screen.getByTestId('driver-unavailable-search');
expect(notice).toBeInTheDocument();
expect(notice.textContent).toContain('elasticsearch');
testUtils.cleanup();
});
it('저장값이 비어 있으면 안내를 띄우지 않는다', async () => {
const present = renderSearchField({
drivers: { search_engine_driver: 'elasticsearch' },
available_drivers: { search: CORE_SEARCH_CATALOG },
});
await present.render();
expect(screen.getByTestId('driver-unavailable-search')).toBeInTheDocument();
present.cleanup();
const testUtils = renderSearchField({
drivers: { search_engine_driver: '' },
available_drivers: { search: CORE_SEARCH_CATALOG },
});
await testUtils.render();
expect(screen.queryByTestId('driver-unavailable-search')).not.toBeInTheDocument();
testUtils.cleanup();
});
});
// @scenario saved_value_state=dead
// @effects dead_saved_value_notice_reveals_the_value, every_catalog_bound_select_has_dead_value_notice
describe('카탈로그 바인딩 셀렉트 전수', () => {
const CATALOG_RE = /_local\.form\?\.available_drivers\?\.([a-z_]+)\s*\?\?/;
it('카탈로그 바인딩 셀렉트마다 죽은 저장값 안내가 붙어 있다', () => {
for (const [name, partial] of [
['_tab_drivers.json', driversPartial],
['_tab_mail.json', mailPartial],
] as const) {
const selects = collect(
partial,
(n) => n.name === 'Select' && typeof n.props?.options === 'string' && CATALOG_RE.test(n.props.options)
);
expect(selects.length, `${name} 에서 카탈로그 바인딩 셀렉트를 찾지 못했습니다.`).toBeGreaterThan(0);
const notices = collect(partial, (n) => typeof n.id === 'string' && n.id.startsWith('unavailable_driver_notice_'));
const selectCategories = selects.map((s) => s.props.options.match(CATALOG_RE)![1]).sort();
const noticeCategories = notices.map((n) => n.id.replace('unavailable_driver_notice_', '')).sort();
expect(noticeCategories, `${name} 의 안내 노드가 셀렉트 카테고리와 일치하지 않습니다.`).toEqual(
selectCategories
);
}
});
it('안내 조건식이 저장값과 카탈로그를 함께 본다', () => {
const notices = collect(
driversPartial,
(n) => typeof n.id === 'string' && n.id.startsWith('unavailable_driver_notice_')
);
expect(notices.length).toBeGreaterThan(0);
for (const notice of notices) {
expect(notice.if).toMatch(/available_drivers\?\.[a-z_]+ \?\? \[\]\)\.some\(/);
expect(notice.if).toMatch(/^\{\{!!_local\.form\?\./);
}
});
});
@@ -1624,7 +1624,9 @@
},
"drivers": {
"common": {
"optional": "Optional"
"optional": "Optional",
"unavailable_saved_value_prefix": "The saved value ",
"unavailable_saved_value_suffix": " is no longer available. Choose an available option and save."
},
"storage": {
"title": "File Storage",
@@ -1743,9 +1745,6 @@
"desc": "Configure the search engine driver for integrated and admin search.",
"driver": "Search Engine Driver",
"driver_desc": "The built-in MySQL FULLTEXT (ngram) engine supports Korean search without additional configuration.",
"options": {
"mysql_fulltext": "MySQL FULLTEXT (ngram)"
},
"plugin_notice": "Additional drivers will appear when search engine plugins (Meilisearch, Elasticsearch, etc.) are installed."
},
"test_connection": "Test Connection",
@@ -1628,7 +1628,9 @@
},
"drivers": {
"common": {
"optional": "선택사항"
"optional": "선택사항",
"unavailable_saved_value_prefix": "저장된 값 ",
"unavailable_saved_value_suffix": " 은(는) 현재 사용할 수 없습니다. 사용 가능한 값을 선택해 저장하세요."
},
"storage": {
"title": "파일 스토리지",
@@ -1747,9 +1749,6 @@
"desc": "통합검색 및 관리자 검색에 사용할 검색엔진 드라이버를 설정합니다.",
"driver": "검색엔진 드라이버",
"driver_desc": "기본 제공되는 MySQL FULLTEXT(ngram) 엔진은 별도 설정 없이 한국어 검색을 지원합니다.",
"options": {
"mysql_fulltext": "MySQL FULLTEXT (ngram)"
},
"plugin_notice": "검색엔진 플러그인(Meilisearch, Elasticsearch 등)을 설치하면 추가 드라이버가 표시됩니다."
},
"test_connection": "연결 테스트",
@@ -81,6 +81,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.storage_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_storage",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.storage_driver && !(_local.form?.available_drivers?.storage ?? []).some(d => d.id === _local.form?.drivers?.storage_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-storage"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.storage_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
}
@@ -407,7 +437,7 @@
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || (_local.testingDriver === 's3')}}",
"className": "px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-blue-400 dark:disabled:bg-blue-800 text-white dark:text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
},
"children": [
{
@@ -631,6 +661,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_public_asset",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.public_asset_disk && !(_local.form?.available_drivers?.public_asset ?? []).some(d => d.id === _local.form?.drivers?.public_asset_disk)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-public_asset"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.public_asset_disk ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
}
@@ -723,6 +783,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.cache_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_cache",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.cache_driver && !(_local.form?.available_drivers?.cache ?? []).some(d => d.id === _local.form?.drivers?.cache_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-cache"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.cache_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
}
@@ -921,7 +1011,7 @@
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || (_local.testingDriver === 'redis')}}",
"className": "px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-blue-400 dark:disabled:bg-blue-800 text-white dark:text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
},
"children": [
{
@@ -1173,7 +1263,7 @@
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || (_local.testingDriver === 'memcached')}}",
"className": "px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-blue-400 dark:disabled:bg-blue-800 text-white dark:text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
},
"children": [
{
@@ -1381,12 +1471,7 @@
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.search_engine_driver']?.[0] ?? ''}}",
"options": [
{
"value": "mysql-fulltext",
"label": "$t:admin.settings.drivers.search_engine.options.mysql_fulltext"
}
]
"options": "{{(_local.form?.available_drivers?.search ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
}
},
{
@@ -1397,6 +1482,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.search_engine_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_search",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.search_engine_driver && !(_local.form?.available_drivers?.search ?? []).some(d => d.id === _local.form?.drivers?.search_engine_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-search"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.search_engine_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
},
@@ -1512,6 +1627,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.session_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_session",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.session_driver && !(_local.form?.available_drivers?.session ?? []).some(d => d.id === _local.form?.drivers?.session_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-session"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.session_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
},
@@ -1671,6 +1816,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.queue_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_queue",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.queue_driver && !(_local.form?.available_drivers?.queue ?? []).some(d => d.id === _local.form?.drivers?.queue_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-queue"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.queue_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
},
@@ -1825,6 +2000,36 @@
"className": "form-error"
},
"text": "{{_local.errors?.['drivers.log_driver']?.[0] ?? ''}}"
},
{
"id": "unavailable_driver_notice_log",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.drivers?.log_driver && !(_local.form?.available_drivers?.log ?? []).some(d => d.id === _local.form?.drivers?.log_driver)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-log"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.drivers?.log_driver ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
}
@@ -2659,7 +2864,7 @@
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || (_local.testingDriver === 'websocket')}}",
"className": "px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-blue-400 dark:disabled:bg-blue-800 text-white dark:text-white text-sm font-medium rounded-lg flex-center gap-2 transition-colors"
},
"children": [
{
@@ -85,6 +85,36 @@
"options": "{{(_local.form?.available_drivers?.mail ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}",
"error": "{{_local.errors?.['mail.mailer']?.[0] ?? ''}}"
}
},
{
"id": "unavailable_driver_notice_mail",
"type": "basic",
"name": "P",
"if": "{{!!_local.form?.mail?.mailer && !(_local.form?.available_drivers?.mail ?? []).some(d => d.id === _local.form?.mail?.mailer)}}",
"props": {
"className": "text-warning-soft mt-2",
"data-testid": "driver-unavailable-mail"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_prefix"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-mono"
},
"text": "{{_local.form?.mail?.mailer ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.common.unavailable_saved_value_suffix"
}
]
}
]
}
@@ -813,7 +843,7 @@
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSendingTest}}",
"className": "flex-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
"className": "flex-center justify-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white dark:text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
},
"actions": [
{
@@ -915,7 +945,7 @@
"if": "{{!_local.isSendingTest}}",
"props": {
"name": "paper-plane",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -0,0 +1,34 @@
feature: 드라이버 셀렉트의 카탈로그 바인딩과 죽은 저장값 안내 (A5b / E4)
description: |
환경설정의 드라이버 셀렉트는 8곳이다 — 파일 스토리지 · 공개 자산 스토리지 · 캐시 ·
검색엔진 · 세션 · 큐 · 로그 · 메일. 이 목록은 서버가 카탈로그(`available_drivers`)로
내려주는데, 검색엔진만 레이아웃에 옵션이 리터럴로 박혀 있었다. 플러그인이 엔진을 등록해도
화면 셀렉트에는 뜨지 않아 운영자가 고를 방법이 없었다.
두 번째 문제는 그 반대 방향이다. 운영자가 고른 드라이버를 제공하던 확장이 사라지면
저장값만 남는다. 셀렉트는 카탈로그에서 옵션을 만드므로 그 값에 해당하는 option 이 없고,
브라우저는 첫 옵션을 선택된 것처럼 그린다 — 화면은 "정상" 으로 보이고, 실제로 동작 중인
드라이버는 폴백된 기본값이다. 무엇을 골랐었는지도, 그것이 지금 없다는 사실도 화면에
나타나지 않는다.
그래서 저장값이 카탈로그에 없을 때만 셀렉트 아래에 안내를 띄우고 **저장된 값 문자열
자체를 드러낸다**. 값을 감추면 운영자가 무엇을 복구해야 하는지 알 수 없다.
axis_notes:
saved_value_state: |
카탈로그 8곳은 축이 아니라 effects 로 판정한다 — 여덟 셀렉트가 같은 규칙을 공유하는
것이 이 기능의 요구사항이라, 한 곳만 검사하면 나머지 일곱은 조용히 빠질 수 있다.
`every_catalog_bound_select_has_dead_value_notice` 가 레이아웃 파일을 순회해 전수를 센다.
axes:
saved_value_state: [live, dead]
effects:
- driver_select_options_built_from_catalog # 리터럴 옵션 금지 — 플러그인 등록분이 화면에 뜬다
- live_saved_value_shows_no_notice # 카탈로그에 있는 값에는 안내를 띄우지 않는다
- dead_saved_value_notice_reveals_the_value # 안내가 저장된 값 문자열을 드러낸다
- every_catalog_bound_select_has_dead_value_notice # 카탈로그 바인딩 셀렉트 8곳 전수
test_files:
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-drivers-catalog-binding.test.tsx
@@ -3,6 +3,7 @@
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Extension\HookManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
@@ -130,6 +131,10 @@ class SettingsControllerDriverOptionsTest extends TestCase
/**
* available_drivers의 각 드라이버에 id와 label 필드가 있는지 검증합니다.
*
* @scenario engine_source=core
*
* @effects admin_driver_options_include_search_category
*/
#[Test]
public function available_drivers_have_correct_structure(): void
@@ -140,8 +145,8 @@ class SettingsControllerDriverOptionsTest extends TestCase
$data = $response->json('data.available_drivers');
// 8개 카테고리 존재
$this->assertCount(8, $data);
// 9개 카테고리 존재 (search 는 폴백 가드 편입으로 추가 — A5b)
$this->assertCount(9, $data);
// 각 카테고리에 최소 1개 이상의 드라이버 존재
foreach ($data as $category => $drivers) {
@@ -186,6 +191,55 @@ class SettingsControllerDriverOptionsTest extends TestCase
$this->assertContains('ses', $ids);
}
/**
* search 카테고리에 코어 검색엔진이 포함되는지 검증합니다.
*
* 카테고리 개수만 세면 어떤 카테고리가 늘었는지 알 수 없다 — search 가 빠진 채
* 다른 카테고리가 하나 늘어도 개수 단언은 통과한다.
*
* @scenario engine_source=core
*
* @effects admin_driver_options_include_search_category
*/
#[Test]
public function search_drivers_include_core_engine(): void
{
$response = $this->authRequest()->getJson('/api/admin/settings');
$response->assertStatus(200);
$drivers = $response->json('data.available_drivers.search');
$this->assertIsArray($drivers, 'search 카테고리가 응답에 없습니다.');
$this->assertContains('mysql-fulltext', array_column($drivers, 'id'));
}
/**
* 플러그인이 Scout 엔진 등록 훅으로 추가한 검색엔진이 카탈로그에 나타납니다.
*
* 관리자 화면 셀렉트가 이 카탈로그를 그대로 바인딩하므로, 여기 없으면 플러그인이
* 등록한 검색엔진을 운영자가 고를 수 없다.
*
* @scenario engine_source=plugin
*
* @effects admin_driver_options_include_search_category
*/
#[Test]
public function search_drivers_include_plugin_registered_engine(): void
{
HookManager::addFilter(
'core.search.engine_drivers',
fn (array $drivers) => array_merge($drivers, ['meilisearch' => \stdClass::class])
);
$response = $this->authRequest()->getJson('/api/admin/settings');
$ids = array_column($response->json('data.available_drivers.search'), 'id');
$this->assertContains('meilisearch', $ids, '플러그인이 등록한 검색엔진이 카탈로그에 없습니다.');
$this->assertContains('mysql-fulltext', $ids, '코어 검색엔진이 사라졌습니다.');
}
/**
* public_asset 카테고리에 코어 3종(none/public/s3)이 포함되는지 검증합니다.
*
@@ -0,0 +1,378 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Repositories\JsonConfigRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 단건 설정 저장(`PUT /api/admin/settings/{key}`)의 값 형태 검증
*
* 종전 규칙은 `required|string|max:1000` 이었다. 그래서 두 가지가 깨졌다.
* - 한 번 입력한 값을 빈 칸으로 되돌릴 수 없다 (`required` 가 빈 문자열을 거부해 422).
* - 폼 전송의 boolean/정수가 문자열로 저장되어, 값을 읽는 쪽의 타입 비교가 어긋난다.
*
* 값의 타입은 `config/settings/defaults.json` 의 기본값이 SSoT 다.
*/
class SettingsSingleValueUpdateTest extends TestCase
{
use RefreshDatabase;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->token = $this->createAdminUser()->createToken('test-token')->plainTextToken;
}
/**
* 관리자 사용자 생성
*/
private function createAdminUser(): User
{
$user = User::factory()->create(['password' => Hash::make('password123')]);
$permissionIds = [];
foreach (['core.settings.read', 'core.settings.update'] as $identifier) {
$permissionIds[] = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
)->id;
}
$scopedRole = Role::create([
'identifier' => 'admin_test_'.uniqid(),
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']),
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'is_active' => true,
]);
$scopedRole->permissions()->sync($permissionIds);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($scopedRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
/**
* 단건 저장 요청을 보냅니다.
*
* @param string $key 설정 키
* @param array<string, mixed> $payload 요청 본문
*/
private function putSetting(string $key, array $payload): TestResponse
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
])->putJson('/api/admin/settings/'.$key, $payload);
}
/**
* 저장된 값을 저장소에서 직접 읽습니다.
*
* @param string $key 설정 키
* @return mixed 저장값
*/
private function stored(string $key): mixed
{
return (new JsonConfigRepository)->get($key);
}
// ─── 빈 값 되돌리기 ──────────────────────────────────────
/**
* 문자열 설정을 빈 값으로 되돌릴 수 있습니다. (실패-먼저)
*
* @scenario declared_type=string, value_disposition=cleared
*
* @effects setting_cleared_to_empty_string
*/
#[Test]
public function string_setting_can_be_cleared_to_empty(): void
{
$this->putSetting('seo.meta_keywords', ['value' => 'g7, cms'])->assertOk();
$this->assertSame('g7, cms', $this->stored('seo.meta_keywords'));
$this->putSetting('seo.meta_keywords', ['value' => ''])->assertOk();
$this->assertSame('', $this->stored('seo.meta_keywords'), '빈 값으로 되돌릴 수 없습니다.');
}
/**
* null 도 빈 값으로 받아들이며, 문자열 설정에는 빈 문자열로 남깁니다.
*
* `ConvertEmptyStringsToNull` 미들웨어가 `''` 를 null 로 바꾸므로 두 입력은
* 요청 시점에 이미 같다. 저장 형태는 선언 타입(기본값 `''`)에 맞춘다.
*
* @scenario declared_type=string, value_disposition=cleared
*
* @effects setting_cleared_to_empty_string
*/
#[Test]
public function null_value_is_accepted(): void
{
$this->putSetting('seo.meta_keywords', ['value' => null])->assertOk();
$this->assertSame('', $this->stored('seo.meta_keywords'));
}
/**
* `value` 키 자체가 빠진 요청은 거부합니다.
*
* "값을 비운다" 와 "값을 안 보냈다" 는 다르다 — 후자는 잘못된 payload 다.
*
* @scenario declared_type=string, value_disposition=rejected
*
* @effects missing_value_key_rejected_with_422
*/
#[Test]
public function missing_value_key_is_rejected(): void
{
$this->putSetting('seo.meta_keywords', [])
->assertStatus(422)
->assertJsonValidationErrors(['value']);
}
/**
* 문자열 설정은 값이 숫자·불리언처럼 보여도 문자열 그대로 저장합니다.
*
* 정규화는 선언 타입이 boolean·정수일 때만 캐스팅해야 한다. 문자열 설정까지 캐스팅하면
* 앞자리 0 이 사라지거나(`"0123"` → 123) 문구가 boolean 이 되어, 운영자가 넣은 값이
* 조용히 다른 것이 된다.
*
* 앞뒤 공백은 프레임워크의 `TrimStrings` 미들웨어가 요청 시점에 이미 제거하므로
* 이 경로의 판정 대상이 아니다.
*
* @scenario declared_type=string, value_disposition=accepted
*
* @effects string_setting_stored_verbatim
*/
#[Test]
public function string_setting_is_stored_verbatim(): void
{
foreach (['0123', 'false', '42'] as $value) {
$this->putSetting('seo.meta_keywords', ['value' => $value])->assertOk();
$stored = $this->stored('seo.meta_keywords');
$this->assertIsString($stored, "'{$value}' 가 문자열이 아닌 타입으로 캐스팅되었습니다.");
$this->assertSame($value, $stored, '문자열 값이 변형되었습니다.');
}
}
// ─── 타입 보존 ──────────────────────────────────────
/**
* boolean 설정에 문자열 "true" 를 보내도 boolean 으로 저장됩니다. (실패-먼저)
*
* @scenario declared_type=boolean, value_disposition=accepted
*
* @effects boolean_setting_stored_as_boolean
*/
#[Test]
public function boolean_setting_stores_boolean_not_string(): void
{
$this->putSetting('seo.sitemap_enabled', ['value' => 'false'])->assertOk();
$stored = $this->stored('seo.sitemap_enabled');
$this->assertIsBool($stored, '문자열이 그대로 저장되었습니다.');
$this->assertFalse($stored);
$this->putSetting('seo.sitemap_enabled', ['value' => '1'])->assertOk();
$this->assertTrue($this->stored('seo.sitemap_enabled'));
}
/**
* JSON 본문의 진짜 boolean 도 그대로 저장됩니다.
*
* @scenario declared_type=boolean, value_disposition=accepted
*
* @effects boolean_setting_stored_as_boolean
*/
#[Test]
public function native_boolean_is_preserved(): void
{
$this->putSetting('general.maintenance_mode', ['value' => true])->assertOk();
$this->assertTrue($this->stored('general.maintenance_mode'));
}
/**
* boolean 으로 해석할 수 없는 값은 거부합니다.
*
* 조용히 false 로 캐스팅하면 오타 입력이 "정상 저장" 으로 통과한다.
*
* @scenario declared_type=boolean, value_disposition=rejected
*
* @effects uninterpretable_value_rejected_with_422
*/
#[Test]
public function uninterpretable_boolean_is_rejected(): void
{
$this->putSetting('seo.sitemap_enabled', ['value' => 'maybe'])
->assertStatus(422)
->assertJsonValidationErrors(['value']);
}
/**
* 정수 설정에 숫자 문자열을 보내면 정수로 저장됩니다. (실패-먼저)
*
* @scenario declared_type=integer, value_disposition=accepted
*
* @effects integer_setting_stored_as_integer
*/
#[Test]
public function integer_setting_stores_integer_not_string(): void
{
$this->putSetting('upload.max_file_size', ['value' => '25'])->assertOk();
$stored = $this->stored('upload.max_file_size');
$this->assertIsInt($stored, '문자열이 그대로 저장되었습니다.');
$this->assertSame(25, $stored);
}
/**
* 정수로 해석할 수 없는 값은 거부합니다.
*
* @scenario declared_type=integer, value_disposition=rejected
*
* @effects uninterpretable_value_rejected_with_422
*/
#[Test]
public function uninterpretable_integer_is_rejected(): void
{
$this->putSetting('upload.max_file_size', ['value' => 'twenty'])
->assertStatus(422)
->assertJsonValidationErrors(['value']);
}
/**
* 비-문자열 설정에 빈 문자열을 보내면 null 로 비웁니다.
*
* @scenario declared_type=integer, value_disposition=cleared
*
* @effects setting_cleared_to_null
*/
#[Test]
public function empty_string_clears_non_string_setting(): void
{
$this->putSetting('upload.max_file_size', ['value' => '25'])->assertOk();
$this->putSetting('upload.max_file_size', ['value' => ''])->assertOk();
$this->assertNull($this->stored('upload.max_file_size'));
}
/**
* 배열 값도 저장할 수 있습니다.
*
* @scenario declared_type=array, value_disposition=accepted
*
* @effects array_setting_stored_as_array
*/
#[Test]
public function array_value_is_accepted(): void
{
$this->putSetting('seo.bot_user_agents', ['value' => ['Googlebot', 'Bingbot']])->assertOk();
$this->assertSame(['Googlebot', 'Bingbot'], $this->stored('seo.bot_user_agents'));
}
/**
* boolean 설정도 빈 값으로 되돌릴 수 있습니다.
*
* @scenario declared_type=boolean, value_disposition=cleared
*
* @effects setting_cleared_to_null
*/
#[Test]
public function boolean_setting_can_be_cleared(): void
{
$this->putSetting('seo.sitemap_enabled', ['value' => 'true'])->assertOk();
$this->putSetting('seo.sitemap_enabled', ['value' => ''])->assertOk();
$this->assertNull($this->stored('seo.sitemap_enabled'), 'boolean 설정을 비울 수 없습니다.');
}
/**
* 배열 설정도 빈 값으로 되돌릴 수 있습니다.
*
* @scenario declared_type=array, value_disposition=cleared
*
* @effects setting_cleared_to_null
*/
#[Test]
public function array_setting_can_be_cleared(): void
{
$this->putSetting('seo.bot_user_agents', ['value' => ['Googlebot']])->assertOk();
$this->putSetting('seo.bot_user_agents', ['value' => ''])->assertOk();
$this->assertNull($this->stored('seo.bot_user_agents'), '배열 설정을 비울 수 없습니다.');
}
// ─── 길이 상한 ──────────────────────────────────────
/**
* 문자열 상한(1000자)은 그대로 유지됩니다.
*
* @scenario declared_type=string, value_disposition=rejected
*
* @effects oversized_value_rejected_with_422
*/
#[Test]
public function oversized_string_is_rejected(): void
{
$this->putSetting('seo.meta_keywords', ['value' => str_repeat('a', 1001)])
->assertStatus(422)
->assertJsonValidationErrors(['value']);
}
/**
* 배열 값의 JSON 직렬화 상한(5000자)도 거부합니다.
*
* 문자열 상한만 있으면 배열 축으로 얼마든지 큰 값이 들어온다 — 설정 JSON 파일이
* 요청 하나로 무한정 커진다.
*
* @scenario declared_type=array, value_disposition=rejected
*
* @effects oversized_array_rejected_with_422
*/
#[Test]
public function oversized_array_is_rejected(): void
{
// 항목당 12자 남짓 × 500 → JSON 직렬화 6000자 초과
$agents = array_map(static fn (int $i): string => 'Bot'.str_pad((string) $i, 8, '0', STR_PAD_LEFT), range(1, 500));
$this->assertGreaterThan(5000, mb_strlen((string) json_encode($agents)), '픽스처가 상한을 넘지 못합니다.');
$this->putSetting('seo.bot_user_agents', ['value' => $agents])
->assertStatus(422)
->assertJsonValidationErrors(['value']);
}
}
@@ -155,6 +155,7 @@ class TemplateControllerTest extends TestCase
// 기본 동작 설정
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn([]);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$mock->shouldReceive('getTemplateInfo')->andReturn(null);
@@ -205,6 +206,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
@@ -260,6 +262,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
@@ -301,6 +304,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
@@ -429,6 +433,7 @@ class TemplateControllerTest extends TestCase
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
@@ -520,6 +525,7 @@ class TemplateControllerTest extends TestCase
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
@@ -554,6 +560,7 @@ class TemplateControllerTest extends TestCase
// Mock 설정 — externals 에 정규화 대상(HTTP URL, 중복) 포함
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
@@ -611,6 +618,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
@@ -647,6 +655,7 @@ class TemplateControllerTest extends TestCase
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
@@ -712,6 +721,7 @@ class TemplateControllerTest extends TestCase
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')->andThrow(
ValidationException::withMessages([
'template' => ['No active template found'],
@@ -773,6 +783,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $templateIdentifier,
'vendor' => 'test',
@@ -822,6 +833,7 @@ class TemplateControllerTest extends TestCase
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn(null);
$mock->shouldReceive('installTemplate')->andThrow(
ValidationException::withMessages([
@@ -1160,6 +1172,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
@@ -1245,6 +1258,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
@@ -1299,6 +1313,7 @@ class TemplateControllerTest extends TestCase
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('deactivateTemplate')->andThrow(
ValidationException::withMessages([
'template' => ['No active template found'],
@@ -1327,6 +1342,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
@@ -1391,6 +1407,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, false)
->andReturn([
@@ -1446,6 +1463,7 @@ class TemplateControllerTest extends TestCase
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, true)
->andReturnUsing(function () use ($template) {
@@ -1496,6 +1514,7 @@ class TemplateControllerTest extends TestCase
// template.json: {"modules": {"sirsoft-board": ">=1.0.0", "sirsoft-ecommerce": ">=1.0.0"}}
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, false)
->andReturn([
@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Api\Identity;
use App\Extension\IdentityVerification\Providers\MailIdentityProvider;
use App\Models\IdentityPolicy;
use App\Services\IdentityPolicyService;
use Tests\TestCase;
/**
* 정책 프리페치 엔드포인트의 provider_id 해석 (A6a)
*
* `GET /api/identity/policies/resolve` 는 저장된 `provider_id` 를 그대로 내보냈다.
* 바로 옆의 428 강제 경로(`IdentityPolicyService::resolveProviderId()`)는 같은 값을
* 레지스트리와 대조하고 미등록이면 purpose 기반 폴백으로 대체하는데, 이쪽만 raw 였다 —
* 같은 데이터에 두 개의 게터가 서로 다른 답을 주는 안티패턴이고, 제거된 플러그인의
* provider ID 가 공개 응답(optional.sanctum)에 그대로 실린다.
*/
class ResolvePolicyEndpointTest extends TestCase
{
private string $endpoint = '/api/identity/policies/resolve';
/**
* 정책 행을 만듭니다.
*
* @param string|null $providerId 저장할 provider ID
* @return IdentityPolicy 생성된 정책
*/
private function makePolicy(?string $providerId): IdentityPolicy
{
return IdentityPolicy::updateOrCreate([
'key' => 'test.resolve.policy',
], [
'scope' => 'route',
'target' => 'api.test.resolve',
'purpose' => 'sensitive_action',
'provider_id' => $providerId,
'enabled' => true,
'grace_minutes' => 10,
'applies_to' => 'both',
'fail_mode' => 'block',
'source_type' => 'core',
]);
}
/**
* 미등록 provider 는 해석된 값(폴백)으로 대체되어 나간다. (실패-먼저)
*/
public function test_unregistered_provider_is_resolved_not_echoed(): void
{
$this->makePolicy('ghost_provider');
$response = $this->getJson($this->endpoint.'?scope=route&target=api.test.resolve')
->assertOk();
$this->assertNotSame(
'ghost_provider',
$response->json('data.provider_id'),
'제거된 플러그인의 provider ID 가 공개 응답에 그대로 실렸습니다.'
);
// 428 강제 경로와 같은 게터를 경유해야 두 경로의 답이 갈리지 않는다
$policy = IdentityPolicy::where('key', 'test.resolve.policy')->firstOrFail();
$this->assertSame(
app(IdentityPolicyService::class)->resolveProviderId($policy),
$response->json('data.provider_id'),
'프리페치 응답과 428 강제 경로의 provider 해석이 어긋납니다.'
);
}
/**
* 등록된 provider 는 그대로 유지된다. (비회귀 pin)
*/
public function test_registered_provider_is_returned_as_is(): void
{
// 코어 메일 provider 는 항상 등록되어 있다 (ID 는 provider 자신이 선언한 값)
$registeredId = app(MailIdentityProvider::class)->getId();
$this->makePolicy($registeredId);
$this->getJson($this->endpoint.'?scope=route&target=api.test.resolve')
->assertOk()
->assertJsonPath('data.provider_id', $registeredId);
}
/**
* 매칭 정책이 없으면 null 을 반환한다. (기존 계약 pin)
*/
public function test_no_matching_policy_returns_null(): void
{
$this->getJson($this->endpoint.'?scope=route&target=api.no.such.target')
->assertOk()
->assertJsonPath('data', null);
}
}

Some files were not shown because too many files have changed in this diff Show More