Files
Gnuboard7/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php
T
chym1217 4e77d25926 feat(message_bizppurio): 검수 모드 라벨 통일 + 발송 payload 저장 + 알림톡 URL 프로토콜 중복 방지
발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일하고, 검수 모드 발송 건은
상태 라벨 대신 "검수" 라벨로 표시한다. 설정 미완료 상태에서도 검수 모드 안내가
함께 표시되도록 배너 노출 조건을 수정한다.

발송 요청 payload가 어디에도 저장되지 않아 실패 원인을 사후 재구성할 수 없던 문제를
해결한다. bizppurio_dispatches.request_payload 컬럼을 신설해 발송 시점(성공/실패
무관)에 실제 비즈뿌리오 전송 payload를 저장하되, 다른 컬럼과 중복되는 개인식별
정보(수신번호·치환된 본문 텍스트)는 제외하고 저장한다.

카카오 알림톡 버튼 URL과 완전한 URL 변수(action_url 등, 항상 프로토콜 포함)가
결합될 때 프로토콜이 중복(https://https://...)되어 잘못된 링크로 발송되던 문제를
방지한다.

플러그인 제거 후 재설치 시 sms·alimtalk 채널 템플릿이 재생성되지 않던 문제를
해결한다. 리스너 등록 이력 캐시가 재활성화 후에도 남아있어 재등록이 조용히
스킵되던 것으로, 코어 수정 없이 플러그인 내부에서 리플렉션으로 우회한다.
2026-07-28 12:40:15 +09:00

192 lines
7.0 KiB
PHP

<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* 비즈뿌리오 메시징 플러그인 테스트 베이스 클래스
*
* 모든 플러그인 테스트는 이 클래스를 상속받아야 합니다.
* 코어 + 번들 확장 마이그레이션을 자동으로 처리합니다.
*/
abstract class PluginTestCase extends TestCase
{
use RefreshDatabase;
/**
* HookManager static state 스냅샷 — tearDown 에서 복원하여 테스트 간 훅 격리 보장.
*
* @var array{hooks: array, filters: array, dispatching: array}|null
*/
private ?array $hookSnapshot = null;
/**
* HookListenerRegistrar::$registered 스냅샷 — tearDown 에서 복원.
*
* HookManager 의 hooks/filters 는 스냅샷 복원되지만, HookListenerRegistrar 는
* "source::listenerClass" 키로 별도의 static 등록 여부 캐시를 갖는다(동일 PHP
* process 내 중복 등록 방지용, idempotent 보장). 이 캐시를 복원하지 않으면,
* 어떤 테스트에서 activate() 로 리스너를 한 번 등록한 뒤 HookManager 상태만
* 복원되고 이 캐시는 "등록됨"으로 남아, 다음 테스트에서 같은 리스너를 다시
* register() 해도 이미 등록된 것으로 판단해 스킵된다 — 실제로는 훅이 비어있는데
* 등록 로직만 건너뛰어 필터가 전혀 발동하지 않는 조용한 실패로 이어진다.
*
* @var array<string, bool>|null
*/
private ?array $hookListenerRegistrarSnapshot = null;
/** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */
private ?string $isolatedPluginsRoot = null;
/**
* 테스트 환경 설정
*/
protected function setUp(): void
{
parent::setUp();
$this->isolatePluginStorage();
$this->snapshotHookManager();
$this->snapshotHookListenerRegistrar();
}
/**
* tearDown 에 HookManager 상태 + plugins 스토리지 복원.
*/
protected function tearDown(): void
{
$this->restoreHookManager();
$this->restoreHookListenerRegistrar();
$this->restorePluginStorage();
parent::tearDown();
}
/**
* 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다.
*
* 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크
* (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면
* 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다
* (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를
* 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못
* 건드리게 한다. (회귀 배경: #458)
*/
private function isolatePluginStorage(): void
{
// Laravel 이 테스트용 쓰기 공간으로 보장하는 storage/framework/testing 하위를 사용한다.
// sys_get_temp_dir() 은 CI/컨테이너/open_basedir 제약 환경에서 위치가 다르거나
// 쓰기 불가일 수 있어 프로젝트 내부의 격리 경로를 쓴다 (Laravel 규약 준수).
$this->isolatedPluginsRoot = storage_path(
'framework/testing/plugin-storage-'.uniqid('', true)
);
File::ensureDirectoryExists($this->isolatedPluginsRoot);
config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]);
// 이미 resolve 된 'plugins' 디스크 인스턴스를 폐기해 새 root 로 재생성되게 한다.
Storage::forgetDisk('plugins');
}
/**
* 격리 임시 디렉토리를 제거한다 (테스트 간 잔여 파일 격리).
*/
private function restorePluginStorage(): void
{
if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) {
File::deleteDirectory($this->isolatedPluginsRoot);
}
$this->isolatedPluginsRoot = null;
Storage::forgetDisk('plugins');
}
/**
* HookManager static $hooks / $filters / $dispatching 를 스냅샷.
*/
private function snapshotHookManager(): void
{
$ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(),
'dispatching' => $ref->getProperty('dispatching')->getValue(),
];
}
/**
* 스냅샷 시점으로 HookManager 복원.
*/
private function restoreHookManager(): void
{
if ($this->hookSnapshot === null) {
return;
}
$ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
$this->hookSnapshot = null;
}
/**
* HookListenerRegistrar static $registered 를 스냅샷.
*/
private function snapshotHookListenerRegistrar(): void
{
$ref = new \ReflectionClass(HookListenerRegistrar::class);
$prop = $ref->getProperty('registered');
$this->hookListenerRegistrarSnapshot = $prop->getValue();
}
/**
* 스냅샷 시점으로 HookListenerRegistrar::$registered 를 복원.
*/
private function restoreHookListenerRegistrar(): void
{
if ($this->hookListenerRegistrarSnapshot === null) {
return;
}
$ref = new \ReflectionClass(HookListenerRegistrar::class);
$ref->getProperty('registered')->setValue(null, $this->hookListenerRegistrarSnapshot);
$this->hookListenerRegistrarSnapshot = null;
}
/**
* 마이그레이션 경로를 반환합니다.
*
* RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다.
*
* @return array<string, mixed>
*/
protected function migrateFreshUsing(): array
{
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => $paths,
];
}
}