Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsSeoCacheInvalidationTest.php
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

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

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

181 lines
6.4 KiB
PHP

<?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);
}
}