Files
Gnuboard7/tests/Unit/Services/NotificationTemplateServiceTest.php
T
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00

263 lines
9.3 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Models\NotificationDefinition;
use App\Models\NotificationTemplate;
use App\Services\NotificationTemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* NotificationTemplateService 테스트
*
* 알림 템플릿 조회, 캐싱, 수정, 미리보기 동작을 검증합니다.
*/
class NotificationTemplateServiceTest extends TestCase
{
use RefreshDatabase;
private NotificationTemplateService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(NotificationTemplateService::class);
}
/**
* resolve()가 활성 템플릿을 반환하는지 확인
*/
public function test_resolve_returns_active_template(): void
{
$definition = NotificationDefinition::create([
'type' => 'test_resolve',
'hook_prefix' => 'core.test',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '테스트'],
'variables' => [],
'channels' => ['mail'],
'hooks' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationTemplate::create([
'definition_id' => $definition->id,
'channel' => 'mail',
'subject' => ['ko' => '제목', 'en' => 'Subject'],
'body' => ['ko' => '본문', 'en' => 'Body'],
'is_active' => true,
'is_default' => true,
]);
$this->service->invalidateCache('test_resolve', 'mail');
$result = $this->service->resolve('test_resolve', 'mail');
$this->assertNotNull($result);
$this->assertEquals('mail', $result->channel);
}
/**
* resolve()가 비활성 템플릿을 반환하지 않는지 확인
*/
public function test_resolve_returns_null_for_inactive_template(): void
{
$definition = NotificationDefinition::create([
'type' => 'test_inactive',
'hook_prefix' => 'core.test',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '테스트'],
'variables' => [],
'channels' => ['mail'],
'hooks' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationTemplate::create([
'definition_id' => $definition->id,
'channel' => 'mail',
'subject' => ['ko' => '제목'],
'body' => ['ko' => '본문'],
'is_active' => false,
'is_default' => true,
]);
$this->service->invalidateCache('test_inactive', 'mail');
$result = $this->service->resolve('test_inactive', 'mail');
$this->assertNull($result);
}
/**
* updateTemplate()이 템플릿을 수정하는지 확인
*/
public function test_update_template(): void
{
$definition = NotificationDefinition::create([
'type' => 'test_update',
'hook_prefix' => 'core.test',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '테스트'],
'variables' => [],
'channels' => ['mail'],
'hooks' => [],
'is_active' => true,
'is_default' => true,
]);
$template = NotificationTemplate::create([
'definition_id' => $definition->id,
'channel' => 'mail',
'subject' => ['ko' => '원본 제목'],
'body' => ['ko' => '원본 본문'],
'is_active' => true,
'is_default' => true,
]);
$updated = $this->service->updateTemplate($template, [
'subject' => ['ko' => '수정된 제목'],
'body' => ['ko' => '수정된 본문'],
]);
$this->assertEquals(['ko' => '수정된 제목'], $updated->subject);
$this->assertEquals(['ko' => '수정된 본문'], $updated->body);
}
/**
* toggleActive()가 활성 상태를 반전하는지 확인
*/
public function test_toggle_active(): void
{
$definition = NotificationDefinition::create([
'type' => 'test_toggle',
'hook_prefix' => 'core.test',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '테스트'],
'variables' => [],
'channels' => ['mail'],
'hooks' => [],
'is_active' => true,
'is_default' => true,
]);
$template = NotificationTemplate::create([
'definition_id' => $definition->id,
'channel' => 'mail',
'subject' => ['ko' => '제목'],
'body' => ['ko' => '본문'],
'is_active' => true,
'is_default' => true,
]);
$result = $this->service->toggleActive($template);
$this->assertFalse($result->is_active);
}
/**
* getDefaultTemplateData()가 click_url과 recipients를 포함하는지 확인 (복원 누락 회귀)
*
* config/core.php 의 welcome/database 채널은 recipients 와 click_url='/mypage' 를 가진다.
* 복원 데이터에 두 필드가 누락되면 관리자 수정값이 잔존하므로 추출 화이트리스트를 검증한다.
*/
public function test_get_default_template_data_includes_click_url_and_recipients(): void
{
$data = $this->service->getDefaultTemplateData('welcome', 'database');
$this->assertArrayHasKey('click_url', $data);
$this->assertArrayHasKey('recipients', $data);
$this->assertEquals('/mypage', $data['click_url']);
$this->assertEquals([['type' => 'trigger_user']], $data['recipients']);
}
/**
* config에 click_url이 없는 채널은 null로 복원되는지 확인 (정상값 비파괴)
*
* welcome/mail 채널은 recipients 만 있고 click_url 이 없으므로 null 이어야 한다.
*/
public function test_get_default_template_data_null_click_url_when_absent_in_config(): void
{
$data = $this->service->getDefaultTemplateData('welcome', 'mail');
$this->assertArrayHasKey('click_url', $data);
$this->assertNull($data['click_url']);
$this->assertEquals([['type' => 'trigger_user']], $data['recipients']);
}
/**
* resetToDefault()가 관리자 수정한 click_url/recipients를 기본값으로 복원하는지 확인 (회귀)
*
* 관리자가 click_url/recipients 를 수정한 뒤 복원하면 config 기본값으로 돌아가야 한다.
* 수정 전에는 subject/body 만 복원되고 두 필드는 수정본이 잔존하는 결함이 있었다.
*/
public function test_reset_to_default_restores_click_url_and_recipients(): void
{
// welcome 은 코어 알림 정의라 config/core.php 를 SSoT 로 부팅 시 이미 동기화되어 있다.
// 이 테스트는 그 정의에 템플릿을 매달아 복원 동작을 보므로, 있으면 그대로 쓴다.
$definition = NotificationDefinition::firstOrCreate(
['type' => 'welcome'],
[
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '회원가입 환영'],
'variables' => [],
'channels' => ['database'],
'hooks' => [],
'is_active' => true,
'is_default' => true,
]
);
// 관리자가 click_url/recipients 를 임의 값으로 수정한 상태.
// 코어 템플릿이 이미 동기화되어 있으면 그 행을 수정본으로 만든다 (실제 시나리오와 동일).
$template = NotificationTemplate::updateOrCreate(
['definition_id' => $definition->id, 'channel' => 'database'],
[
'subject' => ['ko' => '수정된 제목'],
'body' => ['ko' => '수정된 본문'],
'click_url' => '/custom-edited-url',
'recipients' => [['type' => 'admin']],
'is_active' => true,
'is_default' => false,
'user_overrides' => ['click_url' => true, 'recipients' => true],
]
);
$defaultData = $this->service->getDefaultTemplateData('welcome', 'database');
$restored = $this->service->resetToDefault($template, $defaultData);
$this->assertEquals('/mypage', $restored->click_url);
$this->assertEquals([['type' => 'trigger_user']], $restored->recipients);
$this->assertNull($restored->user_overrides);
$this->assertTrue($restored->is_default);
}
/**
* getPreview()가 변수를 치환하는지 확인
*/
public function test_get_preview_replaces_variables(): void
{
$result = $this->service->getPreview([
'subject' => ['ko' => '{name}님 환영합니다'],
'body' => ['ko' => '<p>{app_name}입니다</p>'],
'locale' => 'ko',
'variables' => [
'name' => '홍길동',
'app_name' => 'G7',
],
]);
$this->assertEquals('홍길동님 환영합니다', $result['subject']);
$this->assertEquals('<p>G7입니다</p>', $result['body']);
}
}