v7.0.0-beta.2 release
This commit is contained in:
1 parent
6595fd0eb5
commit
fa8eba7a0d
1165 files changed
+107118
-762127
No files matched your search
@@ -63,6 +63,74 @@ class AbstractModuleTest extends TestCase
|
||||
$this->assertNotEmpty($vendor);
|
||||
}
|
||||
|
||||
/**
|
||||
* getVendor()가 module.json 의 vendor 필드를 우선 사용하는지 테스트
|
||||
*
|
||||
* 이슈: https://github.com/gnuboard/g7/issues/9
|
||||
*/
|
||||
public function test_get_vendor_prefers_manifest_vendor_field(): void
|
||||
{
|
||||
$module = $this->makeModuleWithManifest(
|
||||
manifest: ['vendor' => 'Glitter.kr'],
|
||||
moduleDir: '/tmp/glitter-reservation'
|
||||
);
|
||||
|
||||
$this->assertSame('Glitter.kr', $module->getVendor());
|
||||
}
|
||||
|
||||
/**
|
||||
* getVendor()가 manifest 에 vendor 필드가 없으면 디렉토리 prefix 로 폴백하는지 테스트
|
||||
*/
|
||||
public function test_get_vendor_falls_back_to_identifier_prefix_when_manifest_missing(): void
|
||||
{
|
||||
$module = $this->makeModuleWithManifest(
|
||||
manifest: [],
|
||||
moduleDir: '/tmp/sirsoft-sample'
|
||||
);
|
||||
|
||||
$this->assertSame('sirsoft', $module->getVendor());
|
||||
}
|
||||
|
||||
/**
|
||||
* getVendor()가 manifest vendor 값이 빈 문자열이면 디렉토리 prefix 로 폴백하는지 테스트
|
||||
*/
|
||||
public function test_get_vendor_falls_back_when_manifest_vendor_is_empty_string(): void
|
||||
{
|
||||
$module = $this->makeModuleWithManifest(
|
||||
manifest: ['vendor' => ''],
|
||||
moduleDir: '/tmp/acme-widget'
|
||||
);
|
||||
|
||||
$this->assertSame('acme', $module->getVendor());
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 와 디렉토리 경로를 주입 가능한 테스트용 AbstractModule 인스턴스를 생성합니다.
|
||||
*
|
||||
* AbstractModule 의 loadManifest() 와 getModulePath() 를 오버라이드하여
|
||||
* 파일 시스템에 접근하지 않고도 vendor 로직을 검증할 수 있습니다.
|
||||
*
|
||||
* @param array<string, mixed> $manifest module.json 내용을 시뮬레이션할 배열
|
||||
* @param string $moduleDir 모듈 디렉토리 경로 (basename 이 식별자로 사용됨)
|
||||
*/
|
||||
private function makeModuleWithManifest(array $manifest, string $moduleDir): AbstractModule
|
||||
{
|
||||
return new class($manifest, $moduleDir) extends AbstractModule
|
||||
{
|
||||
public function __construct(private array $fakeManifest, private string $fakeModulePath) {}
|
||||
|
||||
protected function loadManifest(): array
|
||||
{
|
||||
return $this->fakeManifest;
|
||||
}
|
||||
|
||||
protected function getModulePath(): string
|
||||
{
|
||||
return $this->fakeModulePath;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* install()이 기본적으로 true를 반환하는지 테스트
|
||||
*/
|
||||
|
||||
@@ -2,36 +2,30 @@
|
||||
|
||||
namespace Tests\Unit\ActivityLog\Drivers;
|
||||
|
||||
use App\ActivityLog\Drivers\DatabaseActivityLogDriver;
|
||||
use App\Enums\ActivityLogType;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DatabaseActivityLogDriver 테스트
|
||||
* Database 활동 로그 동작 테스트 (Monolog 'activity' 채널 기반)
|
||||
*
|
||||
* 데이터베이스 드라이버의 로그 저장 기능을 검증합니다.
|
||||
* 구버전 DatabaseActivityLogDriver 클래스는 폐기되었고,
|
||||
* 현재는 Log::channel('activity')->info() → ActivityLogHandler → DB 저장 흐름을 사용합니다.
|
||||
* 본 테스트는 그 흐름이 정상적으로 모든 필드를 저장하는지 검증합니다.
|
||||
*/
|
||||
class DatabaseActivityLogDriverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private DatabaseActivityLogDriver $driver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->driver = new DatabaseActivityLogDriver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 이름이 'database'인지 확인
|
||||
* 채널 이름이 'activity'로 설정되어 있는지 확인
|
||||
*/
|
||||
public function test_driver_name_is_database(): void
|
||||
public function test_channel_name_is_activity(): void
|
||||
{
|
||||
$this->assertEquals('database', $this->driver->getName());
|
||||
$this->assertEquals('activity', config('activity_log.channel'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,18 +33,15 @@ class DatabaseActivityLogDriverTest extends TestCase
|
||||
*/
|
||||
public function test_log_saves_to_database(): void
|
||||
{
|
||||
$log = $this->driver->log(
|
||||
ActivityLogType::Admin,
|
||||
'test.action',
|
||||
'Test description'
|
||||
);
|
||||
Log::channel('activity')->info('test.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'description_key' => 'test.description',
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(ActivityLog::class, $log);
|
||||
$this->assertDatabaseHas('activity_logs', [
|
||||
'id' => $log->id,
|
||||
'log_type' => ActivityLogType::Admin->value,
|
||||
'action' => 'test.action',
|
||||
'description' => 'Test description',
|
||||
'description_key' => 'test.description',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,20 +52,21 @@ class DatabaseActivityLogDriverTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$log = $this->driver->log(
|
||||
ActivityLogType::User,
|
||||
'user.login',
|
||||
'User logged in',
|
||||
$user,
|
||||
['browser' => 'Chrome'],
|
||||
$user->id,
|
||||
'192.168.1.1',
|
||||
'Mozilla/5.0'
|
||||
);
|
||||
Log::channel('activity')->info('user.login', [
|
||||
'log_type' => ActivityLogType::User,
|
||||
'description_key' => 'user.login.description',
|
||||
'loggable' => $user,
|
||||
'properties' => ['browser' => 'Chrome'],
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '192.168.1.1',
|
||||
'user_agent' => 'Mozilla/5.0',
|
||||
]);
|
||||
|
||||
$log = ActivityLog::where('action', 'user.login')->firstOrFail();
|
||||
|
||||
$this->assertEquals(ActivityLogType::User, $log->log_type);
|
||||
$this->assertEquals('user.login', $log->action);
|
||||
$this->assertEquals('User logged in', $log->description);
|
||||
$this->assertEquals('user.login.description', $log->description_key);
|
||||
$this->assertEquals($user->getMorphClass(), $log->loggable_type);
|
||||
$this->assertEquals($user->id, $log->loggable_id);
|
||||
$this->assertEquals(['browser' => 'Chrome'], $log->properties);
|
||||
@@ -90,41 +82,34 @@ class DatabaseActivityLogDriverTest extends TestCase
|
||||
{
|
||||
$longUserAgent = str_repeat('a', 600);
|
||||
|
||||
$log = $this->driver->log(
|
||||
ActivityLogType::Admin,
|
||||
'test.action',
|
||||
'Test',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$longUserAgent
|
||||
);
|
||||
Log::channel('activity')->info('test.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'user_agent' => $longUserAgent,
|
||||
]);
|
||||
|
||||
$log = ActivityLog::where('action', 'test.action')->firstOrFail();
|
||||
|
||||
$this->assertEquals(500, mb_strlen($log->user_agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* null 값들이 정상적으로 처리되는지 확인
|
||||
* 선택적 필드(loggable, properties)가 미지정 시 null로 저장되는지 확인
|
||||
*
|
||||
* 주의: ip_address/user_agent/user_id는 ActivityLogProcessor가
|
||||
* Auth/Request에서 자동 주입하므로, 본 테스트는 그 외 nullable 필드만 검증합니다.
|
||||
*/
|
||||
public function test_nullable_fields_are_handled(): void
|
||||
{
|
||||
$log = $this->driver->log(
|
||||
ActivityLogType::System,
|
||||
'system.task',
|
||||
'System task completed',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
Log::channel('activity')->info('system.task', [
|
||||
'log_type' => ActivityLogType::System,
|
||||
]);
|
||||
|
||||
$log = ActivityLog::where('action', 'system.task')->firstOrFail();
|
||||
|
||||
$this->assertNull($log->loggable_type);
|
||||
$this->assertNull($log->loggable_id);
|
||||
$this->assertNull($log->properties);
|
||||
$this->assertNull($log->user_id);
|
||||
$this->assertNull($log->ip_address);
|
||||
$this->assertNull($log->user_agent);
|
||||
$this->assertNull($log->changes);
|
||||
$this->assertNull($log->description_key);
|
||||
}
|
||||
}
|
||||
@@ -2,69 +2,78 @@
|
||||
|
||||
namespace Tests\Unit\ActivityLog\Drivers;
|
||||
|
||||
use App\ActivityLog\Drivers\NullActivityLogDriver;
|
||||
use App\Enums\ActivityLogType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NullActivityLogDriver 테스트
|
||||
* Null 활동 로그 동작 테스트 (config('activity_log.enabled') = false 기반)
|
||||
*
|
||||
* Null 드라이버가 아무 동작도 하지 않는지 검증합니다.
|
||||
* 구버전 NullActivityLogDriver 클래스는 폐기되었고,
|
||||
* 현재는 config('activity_log.enabled')가 false이면 ActivityLogHandler::write()가
|
||||
* DB 저장을 건너뛰는 방식으로 비활성화 동작이 제공됩니다.
|
||||
* 본 테스트는 비활성화 시 어떤 로그도 DB에 저장되지 않는지 검증합니다.
|
||||
*/
|
||||
class NullActivityLogDriverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NullActivityLogDriver $driver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->driver = new NullActivityLogDriver;
|
||||
config(['activity_log.enabled' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 이름이 'null'인지 확인
|
||||
* 비활성화 시 로그가 데이터베이스에 저장되지 않는지 확인
|
||||
*/
|
||||
public function test_driver_name_is_null(): void
|
||||
public function test_disabled_log_does_not_save_to_database(): void
|
||||
{
|
||||
$this->assertEquals('null', $this->driver->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* log()가 null을 반환하는지 확인
|
||||
*/
|
||||
public function test_log_returns_null(): void
|
||||
{
|
||||
$result = $this->driver->log(
|
||||
ActivityLogType::Admin,
|
||||
'test.action',
|
||||
'Test description'
|
||||
);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* log()가 데이터베이스에 아무것도 저장하지 않는지 확인
|
||||
*/
|
||||
public function test_log_does_not_save_to_database(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->driver->log(
|
||||
ActivityLogType::Admin,
|
||||
'test.action',
|
||||
'Test description',
|
||||
$user,
|
||||
['key' => 'value'],
|
||||
$user->id,
|
||||
'127.0.0.1',
|
||||
'Test Agent'
|
||||
);
|
||||
Log::channel('activity')->info('test.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'description_key' => 'test.description',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('activity_logs', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성화 시 모든 필드가 전달되어도 아무것도 저장되지 않는지 확인
|
||||
*/
|
||||
public function test_disabled_log_ignores_all_fields(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Log::channel('activity')->info('test.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'description_key' => 'test.description',
|
||||
'loggable' => $user,
|
||||
'properties' => ['key' => 'value'],
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Agent',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('activity_logs', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성화 → 활성화 토글이 정상 동작하는지 확인
|
||||
*/
|
||||
public function test_enabling_after_disabled_resumes_logging(): void
|
||||
{
|
||||
Log::channel('activity')->info('disabled.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
]);
|
||||
$this->assertDatabaseCount('activity_logs', 0);
|
||||
|
||||
config(['activity_log.enabled' => true]);
|
||||
|
||||
Log::channel('activity')->info('enabled.action', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
]);
|
||||
$this->assertDatabaseHas('activity_logs', ['action' => 'enabled.action']);
|
||||
}
|
||||
}
|
||||
@@ -186,17 +186,19 @@ class MonologIntegrationTest extends TestCase
|
||||
$authUser = User::factory()->create();
|
||||
$this->actingAs($authUser);
|
||||
|
||||
$explicitUserId = 99999;
|
||||
// 명시 user_id는 인증 사용자와 다른 실제 User여야 함 (FK 제약)
|
||||
$explicitUser = User::factory()->create();
|
||||
|
||||
Log::channel('activity')->info('system.impersonate', [
|
||||
'log_type' => ActivityLogType::System,
|
||||
'user_id' => $explicitUserId,
|
||||
'user_id' => $explicitUser->id,
|
||||
'ip_address' => '172.16.0.1',
|
||||
'user_agent' => 'CLI/1.0',
|
||||
]);
|
||||
|
||||
$log = ActivityLog::first();
|
||||
$this->assertEquals($explicitUserId, $log->user_id);
|
||||
$this->assertEquals($explicitUser->id, $log->user_id);
|
||||
$this->assertNotEquals($authUser->id, $log->user_id);
|
||||
$this->assertEquals('172.16.0.1', $log->ip_address);
|
||||
$this->assertEquals('CLI/1.0', $log->user_agent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache;
|
||||
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Cache\ModuleCacheDriver;
|
||||
use App\Extension\Cache\PluginCacheDriver;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* AbstractCacheDriver (CoreCacheDriver 기반) 단위 테스트
|
||||
*
|
||||
* 공통 캐시 연산(CRUD, remember, flush, 태그, withStore)을 검증합니다.
|
||||
*/
|
||||
class AbstractCacheDriverTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $driver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->driver = new CoreCacheDriver('array');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// === 키 접두사 ===
|
||||
|
||||
#[Test]
|
||||
public function core_driver_resolves_key_with_correct_prefix(): void
|
||||
{
|
||||
$this->assertEquals('g7:core:test_key', $this->driver->resolveKey('test_key'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function module_driver_resolves_key_with_module_prefix(): void
|
||||
{
|
||||
$driver = new ModuleCacheDriver('sirsoft-ecommerce', 'array');
|
||||
$this->assertEquals(
|
||||
'g7:module.sirsoft-ecommerce:products',
|
||||
$driver->resolveKey('products')
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function plugin_driver_resolves_key_with_plugin_prefix(): void
|
||||
{
|
||||
$driver = new PluginCacheDriver('sirsoft-payment', 'array');
|
||||
$this->assertEquals(
|
||||
'g7:plugin.sirsoft-payment:gateways',
|
||||
$driver->resolveKey('gateways')
|
||||
);
|
||||
}
|
||||
|
||||
// === 기본 CRUD ===
|
||||
|
||||
#[Test]
|
||||
public function put_and_get_stores_and_retrieves_value(): void
|
||||
{
|
||||
$this->driver->put('test_key', 'test_value', 3600);
|
||||
|
||||
$this->assertEquals('test_value', $this->driver->get('test_key'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_returns_default_for_missing_key(): void
|
||||
{
|
||||
$this->assertNull($this->driver->get('nonexistent'));
|
||||
$this->assertEquals('fallback', $this->driver->get('nonexistent', 'fallback'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function has_returns_true_for_existing_key(): void
|
||||
{
|
||||
$this->driver->put('exists', 'value');
|
||||
|
||||
$this->assertTrue($this->driver->has('exists'));
|
||||
$this->assertFalse($this->driver->has('not_exists'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function forget_removes_key(): void
|
||||
{
|
||||
$this->driver->put('to_delete', 'value');
|
||||
$this->assertTrue($this->driver->has('to_delete'));
|
||||
|
||||
$this->driver->forget('to_delete');
|
||||
$this->assertFalse($this->driver->has('to_delete'));
|
||||
}
|
||||
|
||||
// === Remember 패턴 ===
|
||||
|
||||
#[Test]
|
||||
public function remember_caches_callback_result(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return 'computed_value';
|
||||
};
|
||||
|
||||
// 첫 번째 호출: 콜백 실행
|
||||
$result1 = $this->driver->remember('key', $callback, 3600);
|
||||
$this->assertEquals('computed_value', $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
// 두 번째 호출: 캐시 히트 (콜백 미실행)
|
||||
$result2 = $this->driver->remember('key', $callback, 3600);
|
||||
$this->assertEquals('computed_value', $result2);
|
||||
$this->assertEquals(1, $callCount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function remember_query_uses_query_prefix(): void
|
||||
{
|
||||
$result = $this->driver->rememberQuery('abc123', fn () => ['data'], 3600);
|
||||
|
||||
$this->assertEquals(['data'], $result);
|
||||
$this->assertTrue($this->driver->has('query:abc123'));
|
||||
}
|
||||
|
||||
// === 벌크 연산 ===
|
||||
|
||||
#[Test]
|
||||
public function put_many_and_many_bulk_operations(): void
|
||||
{
|
||||
$this->driver->putMany([
|
||||
'key1' => 'value1',
|
||||
'key2' => 'value2',
|
||||
'key3' => 'value3',
|
||||
], 3600);
|
||||
|
||||
$results = $this->driver->many(['key1', 'key2', 'key3']);
|
||||
$this->assertEquals('value1', $results['key1']);
|
||||
$this->assertEquals('value2', $results['key2']);
|
||||
$this->assertEquals('value3', $results['key3']);
|
||||
}
|
||||
|
||||
// === Refresh ===
|
||||
|
||||
#[Test]
|
||||
public function refresh_replaces_cached_value(): void
|
||||
{
|
||||
$this->driver->put('refresh_key', 'old_value');
|
||||
|
||||
$result = $this->driver->refresh('refresh_key', fn () => 'new_value', 3600);
|
||||
|
||||
$this->assertEquals('new_value', $result);
|
||||
$this->assertEquals('new_value', $this->driver->get('refresh_key'));
|
||||
}
|
||||
|
||||
// === withStore 불변 복제 ===
|
||||
|
||||
#[Test]
|
||||
public function with_store_creates_new_instance(): void
|
||||
{
|
||||
$original = new CoreCacheDriver('array');
|
||||
$clone = $original->withStore('file');
|
||||
|
||||
$this->assertEquals('array', $original->getStore());
|
||||
$this->assertEquals('file', $clone->getStore());
|
||||
$this->assertNotSame($original, $clone);
|
||||
}
|
||||
|
||||
// === Flush (태그 미지원 드라이버 — array 기반 인덱스 전략) ===
|
||||
|
||||
#[Test]
|
||||
public function flush_removes_all_driver_keys_via_index(): void
|
||||
{
|
||||
// array 드라이버는 태그를 지원하지 않으므로 인덱스 기반 flush
|
||||
$this->driver->remember('key1', fn () => 'v1', 3600);
|
||||
$this->driver->remember('key2', fn () => 'v2', 3600);
|
||||
|
||||
$this->assertTrue($this->driver->has('key1'));
|
||||
$this->assertTrue($this->driver->has('key2'));
|
||||
|
||||
$this->driver->flush();
|
||||
|
||||
$this->assertFalse($this->driver->has('key1'));
|
||||
$this->assertFalse($this->driver->has('key2'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function flush_tags_removes_only_tagged_keys(): void
|
||||
{
|
||||
$this->driver->remember('tagged', fn () => 'v1', 3600, ['group_a']);
|
||||
$this->driver->remember('other', fn () => 'v2', 3600, ['group_b']);
|
||||
|
||||
$this->driver->flushTags(['group_a']);
|
||||
|
||||
$this->assertFalse($this->driver->has('tagged'));
|
||||
// group_b 키는 유지
|
||||
$this->assertTrue($this->driver->has('other'));
|
||||
}
|
||||
|
||||
// === 드라이버 격리 ===
|
||||
|
||||
#[Test]
|
||||
public function different_drivers_have_isolated_keys(): void
|
||||
{
|
||||
$coreDriver = new CoreCacheDriver('array');
|
||||
$moduleDriver = new ModuleCacheDriver('sirsoft-ecommerce', 'array');
|
||||
|
||||
$coreDriver->put('shared_name', 'core_value');
|
||||
$moduleDriver->put('shared_name', 'module_value');
|
||||
|
||||
$this->assertEquals('core_value', $coreDriver->get('shared_name'));
|
||||
$this->assertEquals('module_value', $moduleDriver->get('shared_name'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function module_flush_does_not_affect_core(): void
|
||||
{
|
||||
$coreDriver = new CoreCacheDriver('array');
|
||||
$moduleDriver = new ModuleCacheDriver('sirsoft-ecommerce', 'array');
|
||||
|
||||
$coreDriver->remember('core_key', fn () => 'core_val', 3600);
|
||||
$moduleDriver->remember('mod_key', fn () => 'mod_val', 3600);
|
||||
|
||||
$moduleDriver->flush();
|
||||
|
||||
$this->assertFalse($moduleDriver->has('mod_key'));
|
||||
$this->assertTrue($coreDriver->has('core_key'));
|
||||
}
|
||||
|
||||
// === 메타 ===
|
||||
|
||||
#[Test]
|
||||
public function get_store_returns_current_store_name(): void
|
||||
{
|
||||
$this->assertEquals('array', $this->driver->getStore());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function supports_tags_returns_boolean(): void
|
||||
{
|
||||
// array 드라이버는 태그를 지원하지 않음
|
||||
$this->assertIsBool($this->driver->supportsTags());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Traits\CacheInvalidatable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CacheInvalidatable 트레이트 단위 테스트
|
||||
*
|
||||
* 모델 saved/deleted 이벤트 시 캐시 자동 무효화를 검증합니다.
|
||||
*/
|
||||
class CacheInvalidatableTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cacheDriver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
$this->cacheDriver = new CoreCacheDriver('array');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidate_related_cache_flushes_tags(): void
|
||||
{
|
||||
// 태그가 있는 캐시 생성
|
||||
$this->cacheDriver->remember('products_list', fn () => ['p1', 'p2'], 3600, ['products']);
|
||||
$this->cacheDriver->remember('product_1', fn () => ['id' => 1], 3600, ['products', 'product:1']);
|
||||
$this->cacheDriver->remember('settings', fn () => ['s1'], 3600, ['config']);
|
||||
|
||||
$this->assertTrue($this->cacheDriver->has('products_list'));
|
||||
$this->assertTrue($this->cacheDriver->has('product_1'));
|
||||
$this->assertTrue($this->cacheDriver->has('settings'));
|
||||
|
||||
// products 태그 무효화
|
||||
$this->cacheDriver->flushTags(['products']);
|
||||
|
||||
$this->assertFalse($this->cacheDriver->has('products_list'));
|
||||
$this->assertFalse($this->cacheDriver->has('product_1'));
|
||||
// config 태그는 영향 없음
|
||||
$this->assertTrue($this->cacheDriver->has('settings'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function null_cache_driver_skips_invalidation(): void
|
||||
{
|
||||
// getCacheDriver()가 null을 반환하면 예외 없이 건너뜀
|
||||
$model = new class extends Model
|
||||
{
|
||||
use CacheInvalidatable;
|
||||
|
||||
protected $table = 'test_table';
|
||||
|
||||
protected function getCacheInvalidationTags(): array
|
||||
{
|
||||
return ['products'];
|
||||
}
|
||||
|
||||
protected function getCacheDriver(): ?CacheInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function testInvalidate(): void
|
||||
{
|
||||
$this->invalidateRelatedCache();
|
||||
}
|
||||
};
|
||||
|
||||
// 예외 없이 실행됨
|
||||
$model->testInvalidate();
|
||||
$this->assertTrue(true); // 예외 미발생 확인
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function empty_tags_skips_invalidation(): void
|
||||
{
|
||||
$model = new class extends Model
|
||||
{
|
||||
use CacheInvalidatable;
|
||||
|
||||
protected $table = 'test_table';
|
||||
|
||||
protected function getCacheInvalidationTags(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getCacheDriver(): ?CacheInterface
|
||||
{
|
||||
return new CoreCacheDriver('array');
|
||||
}
|
||||
|
||||
public function testInvalidate(): void
|
||||
{
|
||||
$this->invalidateRelatedCache();
|
||||
}
|
||||
};
|
||||
|
||||
// 빈 태그 배열이면 flushTags 호출하지 않음
|
||||
$model->testInvalidate();
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 G 이관 검증 테스트 (Artisan 캐시 클리어 명령어)
|
||||
*
|
||||
* 계획서 §13 G-1 의 4개 테스트 케이스를 검증합니다.
|
||||
* - module:cache-clear / plugin:cache-clear / template:cache-clear 명령어가
|
||||
* 새 CacheInterface 를 통해 캐시를 삭제하는지 검증.
|
||||
*
|
||||
* 실제 명령어 실행은 모듈 manager / plugin manager 의존성이 크므로,
|
||||
* 명령어가 사용하는 키 형식과 forget 동작을 키 차원에서 검증합니다.
|
||||
*/
|
||||
class ArtisanCacheClearTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* G-1-1: module:cache-clear 명령어 — 모듈 관련 캐시 삭제 검증.
|
||||
*
|
||||
* ClearModuleCacheCommand::clearModuleCache() 가 사용하는 키 패턴을 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function g_1_1_module_cache_clear_removes_per_module_keys(): void
|
||||
{
|
||||
$identifier = 'sirsoft-ecommerce';
|
||||
$keys = [
|
||||
"module.config.{$identifier}",
|
||||
"module.info.{$identifier}",
|
||||
"module.routes.{$identifier}",
|
||||
"module.permissions.{$identifier}",
|
||||
"module.menus.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->put($k, 'data');
|
||||
}
|
||||
|
||||
// 명령어 핸들러 시뮬레이션 — CacheInterface.forget 호출
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->assertFalse($this->cache->has($k), "Key {$k} should be cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* G-1-2: plugin:cache-clear 명령어 — 플러그인 캐시 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function g_1_2_plugin_cache_clear_removes_per_plugin_keys(): void
|
||||
{
|
||||
$identifier = 'sirsoft-payment';
|
||||
$keys = [
|
||||
"plugin.config.{$identifier}",
|
||||
"plugin.info.{$identifier}",
|
||||
"plugin.routes.{$identifier}",
|
||||
"plugin.permissions.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->put($k, 'data');
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->assertFalse($this->cache->has($k));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* G-1-3: template:cache-clear 명령어 — 템플릿 + 레이아웃 + routes + 다국어 캐시 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function g_1_3_template_cache_clear_removes_template_layout_routes(): void
|
||||
{
|
||||
$identifier = 'sirsoft-admin_basic';
|
||||
|
||||
$keys = [
|
||||
"layout.{$identifier}.dashboard",
|
||||
"layout.{$identifier}.users",
|
||||
"template.routes.{$identifier}",
|
||||
"template.language.{$identifier}.ko",
|
||||
"template.language.{$identifier}.en",
|
||||
'templates.active.admin',
|
||||
'templates.active.user',
|
||||
];
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->put($k, 'data');
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->assertFalse($this->cache->has($k));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* G-1-4: 인수 없이 호출 시 모든 모듈 캐시 + 전역 키 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function g_1_4_clear_without_args_clears_all(): void
|
||||
{
|
||||
// 전역 키
|
||||
$this->cache->put('modules.all', 'global');
|
||||
$this->cache->put('modules.active', 'global');
|
||||
$this->cache->put('modules.installed', 'global');
|
||||
|
||||
// 개별 모듈 키
|
||||
foreach (['mod-a', 'mod-b'] as $id) {
|
||||
$this->cache->put("module.config.{$id}", 'data');
|
||||
$this->cache->put("module.info.{$id}", 'data');
|
||||
}
|
||||
|
||||
// 명령어 시뮬레이션 — 전역 + 개별 모두 삭제
|
||||
$globalKeys = ['modules.all', 'modules.active', 'modules.installed'];
|
||||
foreach ($globalKeys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
foreach (['mod-a', 'mod-b'] as $id) {
|
||||
$this->cache->forget("module.config.{$id}");
|
||||
$this->cache->forget("module.info.{$id}");
|
||||
}
|
||||
|
||||
foreach ($globalKeys as $k) {
|
||||
$this->assertFalse($this->cache->has($k));
|
||||
}
|
||||
foreach (['mod-a', 'mod-b'] as $id) {
|
||||
$this->assertFalse($this->cache->has("module.config.{$id}"));
|
||||
$this->assertFalse($this->cache->has("module.info.{$id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Cache\ModuleCacheDriver;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 크로스 그룹 통합 검증 (계획서 §13 X-1 ~ X-5)
|
||||
*
|
||||
* 여러 그룹(A/B/C/D/E/F/G)이 함께 동작하는 시나리오와
|
||||
* CacheService 완전 제거 검증을 수행합니다.
|
||||
*/
|
||||
class CacheCrossGroupTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* X-1: 모듈 설치 → 전체 캐시 카스케이드 무효화.
|
||||
*
|
||||
* 그룹 A(상태) + B(레이아웃) + C(SEO) 캐시가 한 번에 무효화되고,
|
||||
* ext.cache_version 이 증가하는 시나리오를 키 차원에서 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function x_1_module_install_cascades_all_groups(): void
|
||||
{
|
||||
// 초기 상태: 모든 그룹에 캐시 존재
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a']); // A
|
||||
$this->cache->put('ext.cache_version', 1000); // A
|
||||
$this->cache->put('template.1.layout.admin/dashboard', 'merged'); // B
|
||||
$this->cache->put('seo.page.abc123', '<html/>'); // C
|
||||
$this->cache->put('seo.config.sirsoft-admin_basic', ['v' => 1]); // C
|
||||
$this->cache->put('seo.sitemap', '<xml/>'); // C
|
||||
|
||||
// 모듈 설치 시 카스케이드 무효화 시뮬레이션
|
||||
$this->cache->forget('ext.modules.active_identifiers'); // A: 상태 캐시
|
||||
$this->cache->put('ext.cache_version', 2000); // A: 버전 증가
|
||||
$this->cache->forget('template.1.layout.admin/dashboard'); // B: 레이아웃
|
||||
$this->cache->forget('seo.page.abc123'); // C: SEO 페이지
|
||||
$this->cache->forget('seo.config.sirsoft-admin_basic'); // C: SEO config
|
||||
$this->cache->forget('seo.sitemap'); // C: Sitemap
|
||||
|
||||
// 모든 그룹 캐시 무효화 확인
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
$this->assertSame(2000, $this->cache->get('ext.cache_version'));
|
||||
$this->assertFalse($this->cache->has('template.1.layout.admin/dashboard'));
|
||||
$this->assertFalse($this->cache->has('seo.page.abc123'));
|
||||
$this->assertFalse($this->cache->has('seo.config.sirsoft-admin_basic'));
|
||||
$this->assertFalse($this->cache->has('seo.sitemap'));
|
||||
}
|
||||
|
||||
/**
|
||||
* X-2: 설정 저장(SEO 탭) → 설정 + SEO 캐시 카스케이드.
|
||||
*/
|
||||
#[Test]
|
||||
public function x_2_seo_settings_save_cascades_settings_and_seo(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['cached' => true]); // E
|
||||
$this->cache->put('seo.page.abc', '<html/>'); // C
|
||||
$this->cache->put('seo.sitemap', '<xml/>'); // C
|
||||
|
||||
// SEO 탭 저장 시 카스케이드
|
||||
$this->cache->forget('settings.system'); // E: 설정
|
||||
$this->cache->forget('seo.page.abc'); // C: SEO 페이지
|
||||
$this->cache->forget('seo.sitemap'); // C: sitemap
|
||||
|
||||
$this->assertFalse($this->cache->has('settings.system'));
|
||||
$this->assertFalse($this->cache->has('seo.page.abc'));
|
||||
$this->assertFalse($this->cache->has('seo.sitemap'));
|
||||
}
|
||||
|
||||
/**
|
||||
* X-3: 모듈 비활성화 → 코어 상태/레이아웃 + 모듈 자체(ModuleCacheDriver) 캐시 모두 정리.
|
||||
*/
|
||||
#[Test]
|
||||
public function x_3_module_deactivation_clears_core_and_module_own_cache(): void
|
||||
{
|
||||
// 코어 캐시
|
||||
$this->cache->put('ext.modules.active_identifiers', ['sirsoft-ecommerce']);
|
||||
$this->cache->put('template.1.layout.sirsoft-ecommerce.admin_products', 'merged');
|
||||
|
||||
// 모듈 자체 캐시 (ModuleCacheDriver) — remember 로 저장하여 인덱스 등록
|
||||
$moduleCache = new ModuleCacheDriver('sirsoft-ecommerce', 'array');
|
||||
$moduleCache->remember('products_count', fn () => 100);
|
||||
$moduleCache->remember('orders_today', fn () => 50);
|
||||
|
||||
$this->assertTrue($moduleCache->has('products_count'));
|
||||
|
||||
// 비활성화 시뮬레이션 — 코어 정리
|
||||
$this->cache->forget('ext.modules.active_identifiers');
|
||||
$this->cache->forget('template.1.layout.sirsoft-ecommerce.admin_products');
|
||||
// ModuleManager.flushModuleCache 호출 시뮬레이션
|
||||
$moduleCache->flush();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
$this->assertFalse($this->cache->has('template.1.layout.sirsoft-ecommerce.admin_products'));
|
||||
$this->assertFalse($moduleCache->has('products_count'));
|
||||
$this->assertFalse($moduleCache->has('orders_today'));
|
||||
}
|
||||
|
||||
/**
|
||||
* X-4: 캐시 드라이버 전환 후 정상 동작 (withStore).
|
||||
*/
|
||||
#[Test]
|
||||
public function x_4_driver_switch_isolates_data(): void
|
||||
{
|
||||
$arrayDriver = new CoreCacheDriver('array');
|
||||
$arrayDriver->put('switch.test', 'array-value');
|
||||
|
||||
$this->assertSame('array-value', $arrayDriver->get('switch.test'));
|
||||
|
||||
// withStore 로 다른 스토어 인스턴스 — 동일 array 풀 공유
|
||||
// (file 드라이버 테스트는 부수효과 회피를 위해 array 인스턴스 두 개로 검증)
|
||||
$cloned = $arrayDriver->withStore('array');
|
||||
$this->assertSame('array', $cloned->getStore());
|
||||
|
||||
// 새 인스턴스도 동일 키 접두사 사용
|
||||
$this->assertSame(
|
||||
$arrayDriver->resolveKey('switch.test'),
|
||||
$cloned->resolveKey('switch.test')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* X-5: CacheService 완전 제거 검증.
|
||||
*
|
||||
* CacheService 클래스 파일 삭제 + 어디에도 참조되지 않는지 grep 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function x_5_cache_service_class_removed(): void
|
||||
{
|
||||
// 파일 삭제 확인
|
||||
$this->assertFalse(
|
||||
file_exists(base_path('app/Services/CacheService.php')),
|
||||
'app/Services/CacheService.php 가 삭제되어야 합니다'
|
||||
);
|
||||
|
||||
// 클래스 미존재 확인 (오토로드 차단 — 파일이 없으면 false)
|
||||
$this->assertFalse(
|
||||
class_exists('App\\Services\\CacheService', false),
|
||||
'CacheService 클래스가 로드되지 않아야 합니다'
|
||||
);
|
||||
|
||||
// 모든 코어 + 마이그레이션 테스트가 통과한다는 사실 자체가
|
||||
// CacheService 미참조의 증거이다.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* TTL 설정 중앙 관리 검증 (계획서 §13 S-1 ~ S-14)
|
||||
*
|
||||
* 모든 캐시 서비스가 g7_core_settings('cache.*_ttl') 또는 'cache.*_enabled' 를
|
||||
* 추종하는지 검증합니다.
|
||||
*/
|
||||
class CacheSettingsTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
// 설정 초기화
|
||||
Config::set('g7_settings.core.cache', []);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* S-1: AbstractCacheDriver 기본 TTL 이 g7_core_settings('cache.default_ttl') 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_1_default_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.default_ttl', 7200);
|
||||
|
||||
$reflection = new \ReflectionMethod(CoreCacheDriver::class, 'getDefaultTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
$this->assertSame(7200, $reflection->invoke($this->cache));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-2: g7_core_settings 변경 시 TTL 즉시 반영.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_2_settings_change_reflects_immediately(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.default_ttl', 86400);
|
||||
|
||||
$reflection = new \ReflectionMethod(CoreCacheDriver::class, 'getDefaultTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
$this->assertSame(86400, $reflection->invoke($this->cache));
|
||||
|
||||
// 설정 변경
|
||||
Config::set('g7_settings.core.cache.default_ttl', 3600);
|
||||
|
||||
$this->assertSame(3600, $reflection->invoke($this->cache));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-3: 레이아웃 캐시 TTL 이 cache.layout_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_3_layout_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.layout_ttl', 1800);
|
||||
|
||||
$service = app(\App\Services\LayoutService::class);
|
||||
$reflection = new \ReflectionMethod($service, 'getCacheTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
$this->assertSame(1800, $reflection->invoke($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-4: SEO 캐시 TTL 이 cache.seo_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_4_seo_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.seo_ttl', 3600);
|
||||
|
||||
$manager = new \App\Seo\SeoCacheManager($this->cache);
|
||||
$reflection = new \ReflectionMethod($manager, 'getCacheTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
$this->assertSame(3600, $reflection->invoke($manager));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-5: 알림 캐시 TTL 이 cache.notification_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_5_notification_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.notification_ttl', 7200);
|
||||
|
||||
$service = app(\App\Services\NotificationDefinitionService::class);
|
||||
$reflection = new \ReflectionMethod($service, 'getCacheTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
$this->assertSame(7200, $reflection->invoke($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-6: GeoIP 캐시 TTL 이 cache.geoip_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_6_geoip_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.geoip_ttl', 43200);
|
||||
Config::set('geoip.enabled', true);
|
||||
Config::set('geoip.cache.enabled', true);
|
||||
Config::set('geoip.database_path', '/nonexistent');
|
||||
|
||||
$service = app(\App\Services\GeoIpService::class);
|
||||
$service->getTimezoneByIp('1.2.3.4', ['Asia/Seoul']);
|
||||
|
||||
// 빈 문자열이 캐시되었는지 + TTL 설정 값 반영 (간접 검증)
|
||||
$this->assertTrue($this->cache->has('geoip.timezone.1.2.3.4'));
|
||||
$this->assertSame(
|
||||
43200,
|
||||
(int) g7_core_settings('cache.geoip_ttl', 86400)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* S-7: 버전 검증 캐시 TTL 이 cache.version_check_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_7_version_check_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.version_check_ttl', 7200);
|
||||
|
||||
$this->assertSame(7200, CoreVersionChecker::getCacheTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* S-8: 확장 상태 캐시 TTL 이 cache.extension_status_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_8_extension_status_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.extension_status_ttl', 43200);
|
||||
|
||||
$this->assertSame(
|
||||
43200,
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* S-9: Sitemap 캐시 TTL 이 cache.seo_sitemap_ttl 추종.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_9_sitemap_ttl_follows_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.seo_sitemap_ttl', 172800);
|
||||
|
||||
$this->assertSame(
|
||||
172800,
|
||||
(int) g7_core_settings('cache.seo_sitemap_ttl', 86400)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* S-10: SEO 캐시 비활성화 시 저장 안 함.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_10_seo_disable_does_not_store(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.seo_enabled', false);
|
||||
|
||||
$manager = new \App\Seo\SeoCacheManager($this->cache);
|
||||
$manager->put('/page', 'ko', '<html/>');
|
||||
|
||||
$this->assertNull($manager->get('/page', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-11: GeoIP 캐시 비활성화 시 저장 안 함.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_11_geoip_disable_does_not_store(): void
|
||||
{
|
||||
Config::set('geoip.enabled', true);
|
||||
Config::set('g7_settings.core.cache.geoip_enabled', false);
|
||||
Config::set('geoip.database_path', '/nonexistent');
|
||||
|
||||
$service = app(\App\Services\GeoIpService::class);
|
||||
$service->getTimezoneByIp('5.6.7.8', ['Asia/Seoul']);
|
||||
|
||||
$this->assertFalse($this->cache->has('geoip.timezone.5.6.7.8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-12: 레이아웃 캐시 비활성화 시 캐시 미사용.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_12_layout_disable_skips_cache(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.layout_enabled', false);
|
||||
|
||||
// LayoutService 의 loadAndMergeLayout 은 cacheEnabled=false 인 경우
|
||||
// 캐시 키를 사용하지 않고 매번 병합한다 (단위 테스트로는 통합 검증 불가).
|
||||
// 대신 설정이 false 로 인식되는지만 검증.
|
||||
$this->assertFalse((bool) g7_core_settings('cache.layout_enabled', true));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-13: remember() 명시적 TTL 이 g7_core_settings 기본값보다 우선.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_13_explicit_ttl_overrides_settings(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.default_ttl', 86400);
|
||||
|
||||
// 300 초로 명시 — 기본값이 무시되어야 함
|
||||
$this->cache->put('explicit.ttl.test', 'value', 300);
|
||||
|
||||
// put 은 storage 에 저장만 하므로 TTL 값을 직접 검증할 수는 없으나,
|
||||
// 명시적 TTL 인자를 받았을 때 default 가 사용되지 않음은 코드로 보장됨.
|
||||
$this->assertTrue($this->cache->has('explicit.ttl.test'));
|
||||
}
|
||||
|
||||
/**
|
||||
* S-14: defaults.json 미설정 시 하드코딩 fallback 사용.
|
||||
*/
|
||||
#[Test]
|
||||
public function s_14_missing_setting_uses_hardcoded_fallback(): void
|
||||
{
|
||||
// 명시적으로 미설정
|
||||
Config::set('g7_settings.core.cache.notification_ttl', null);
|
||||
|
||||
$service = app(\App\Services\NotificationDefinitionService::class);
|
||||
$reflection = new \ReflectionMethod($service, 'getCacheTtl');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
// null 일 때 fallback 3600 사용
|
||||
$this->assertSame(3600, $reflection->invoke($service));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 F 이관 검증 테스트 (CoreVersionChecker)
|
||||
*
|
||||
* 계획서 §13 F-1 의 4개 테스트 케이스를 검증합니다.
|
||||
* - 호환성 검사 캐시 키가 새 접두사(`g7:core:ext.version_check.{type}.{version}`)로 이관됨
|
||||
* - clearCache() 가 3종 타입(modules/plugins/templates) 키를 모두 무효화함
|
||||
* - 코어 버전 변경 시 캐시 키가 자동 분리됨
|
||||
*/
|
||||
class CoreVersionCheckerCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
private ?string $originalEnvVersion = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
|
||||
// CoreVersionChecker::getCoreVersion() 은 env APP_VERSION 을 우선 읽으므로,
|
||||
// config('app.version') 로만 버전을 시뮬레이션하려면 env 를 먼저 비워야 한다.
|
||||
$this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null;
|
||||
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
|
||||
putenv('APP_VERSION');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
if ($this->originalEnvVersion !== null) {
|
||||
$_ENV['APP_VERSION'] = $this->originalEnvVersion;
|
||||
$_SERVER['APP_VERSION'] = $this->originalEnvVersion;
|
||||
putenv('APP_VERSION='.$this->originalEnvVersion);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* F-1-1: 호환성 검사 캐시 키 형식 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function f_1_1_cache_key_uses_new_prefix(): void
|
||||
{
|
||||
config()->set('app.version', '7.0.1');
|
||||
|
||||
$key = CoreVersionChecker::getCacheKey('modules');
|
||||
$this->assertSame('ext.version_check.modules.7.0.1', $key);
|
||||
|
||||
$resolved = $this->cache->resolveKey($key);
|
||||
$this->assertSame('g7:core:ext.version_check.modules.7.0.1', $resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* F-1-2: 호환성 검사 결과 저장 → 같은 키로 조회 시 캐시 히트.
|
||||
*/
|
||||
#[Test]
|
||||
public function f_1_2_compatibility_cache_hit(): void
|
||||
{
|
||||
config()->set('app.version', '7.0.1');
|
||||
|
||||
$key = CoreVersionChecker::getCacheKey('modules');
|
||||
$this->cache->put($key, true, CoreVersionChecker::getCacheTtl());
|
||||
|
||||
$this->assertTrue($this->cache->has($key));
|
||||
$this->assertTrue($this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* F-1-3: clearCache() 호출 시 modules/plugins/templates 3개 키 모두 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function f_1_3_clear_cache_invalidates_all_three_types(): void
|
||||
{
|
||||
config()->set('app.version', '7.0.1');
|
||||
|
||||
$this->cache->put(CoreVersionChecker::getCacheKey('modules'), true);
|
||||
$this->cache->put(CoreVersionChecker::getCacheKey('plugins'), true);
|
||||
$this->cache->put(CoreVersionChecker::getCacheKey('templates'), true);
|
||||
|
||||
CoreVersionChecker::clearCache();
|
||||
|
||||
$this->assertFalse($this->cache->has(CoreVersionChecker::getCacheKey('modules')));
|
||||
$this->assertFalse($this->cache->has(CoreVersionChecker::getCacheKey('plugins')));
|
||||
$this->assertFalse($this->cache->has(CoreVersionChecker::getCacheKey('templates')));
|
||||
}
|
||||
|
||||
/**
|
||||
* F-1-4: 코어 버전 변경 시 새 키로 분리 — 기존 키와 충돌 없음.
|
||||
*/
|
||||
#[Test]
|
||||
public function f_1_4_version_change_separates_keys(): void
|
||||
{
|
||||
config()->set('app.version', '7.0.0');
|
||||
$oldKey = CoreVersionChecker::getCacheKey('modules');
|
||||
$this->cache->put($oldKey, true);
|
||||
|
||||
config()->set('app.version', '7.0.1');
|
||||
$newKey = CoreVersionChecker::getCacheKey('modules');
|
||||
|
||||
$this->assertNotSame($oldKey, $newKey);
|
||||
$this->assertTrue($this->cache->has($oldKey));
|
||||
$this->assertFalse($this->cache->has($newKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Traits\CachesModuleStatus;
|
||||
use App\Extension\Traits\CachesPluginStatus;
|
||||
use App\Extension\Traits\CachesTemplateStatus;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 A 이관 검증 테스트 (확장 상태 캐시 Traits 4종)
|
||||
*
|
||||
* 계획서 §13 A-1 ~ A-4 의 확장 상태/버전 캐시 키 이관을 검증합니다.
|
||||
* - CachesModuleStatus / CachesPluginStatus / CachesTemplateStatus
|
||||
* - ClearsTemplateCaches (extension cache version)
|
||||
*
|
||||
* 각 trait 의 정적 메서드가 새 키 접두사(`g7:core:ext.*`)를 사용하는지,
|
||||
* lazy resolveCache() 헬퍼가 컨테이너 바인딩을 사용하는지 검증합니다.
|
||||
*
|
||||
* 모델 모킹은 복잡하므로 keys/cache 동작 검증에 집중하고,
|
||||
* DB 통합 검증은 기존 ModuleManagerLayoutTest / TemplateManagerOverrideTest 에 의존합니다.
|
||||
*/
|
||||
class ExtensionStatusCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-1. CachesModuleStatus
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-1-1: 모듈 활성/설치 캐시 키 형식 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_1_module_cache_key_uses_new_prefix(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a', 'mod-b']);
|
||||
$this->cache->put('ext.modules.installed_identifiers', ['mod-a', 'mod-b']);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:ext.modules.active_identifiers',
|
||||
$this->cache->resolveKey('ext.modules.active_identifiers')
|
||||
);
|
||||
$this->assertSame(
|
||||
['mod-a', 'mod-b'],
|
||||
$this->cache->get('ext.modules.active_identifiers')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A-1-3 ~ A-1-6: invalidateModuleStatusCache() 가 두 키 모두 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_3_invalidate_module_status_clears_both_keys(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a']);
|
||||
$this->cache->put('ext.modules.installed_identifiers', ['mod-a']);
|
||||
|
||||
$stub = new class {
|
||||
use CachesModuleStatus;
|
||||
};
|
||||
$stub::invalidateModuleStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
$this->assertFalse($this->cache->has('ext.modules.installed_identifiers'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-2. CachesPluginStatus
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-2-1: 플러그인 캐시 키 형식 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_2_1_plugin_cache_key_uses_new_prefix(): void
|
||||
{
|
||||
$this->cache->put('ext.plugins.active_identifiers', ['plugin-a']);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:ext.plugins.active_identifiers',
|
||||
$this->cache->resolveKey('ext.plugins.active_identifiers')
|
||||
);
|
||||
$this->assertTrue($this->cache->has('ext.plugins.active_identifiers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A-2-3: invalidatePluginStatusCache() 가 두 키 모두 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_2_3_invalidate_plugin_status_clears_both_keys(): void
|
||||
{
|
||||
$this->cache->put('ext.plugins.active_identifiers', ['plugin-a']);
|
||||
$this->cache->put('ext.plugins.installed_identifiers', ['plugin-a']);
|
||||
|
||||
$stub = new class {
|
||||
use CachesPluginStatus;
|
||||
};
|
||||
$stub::invalidatePluginStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.plugins.active_identifiers'));
|
||||
$this->assertFalse($this->cache->has('ext.plugins.installed_identifiers'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-3. CachesTemplateStatus
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-3-1: 템플릿 캐시 키 형식 검증 (4종).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_3_1_template_cache_keys_use_new_prefix(): void
|
||||
{
|
||||
$this->cache->put('ext.templates.active_identifiers', ['t-a']);
|
||||
$this->cache->put('ext.templates.active_identifiers_admin', ['t-admin']);
|
||||
$this->cache->put('ext.templates.active_identifiers_user', ['t-user']);
|
||||
$this->cache->put('ext.templates.installed_identifiers', ['t-a']);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:ext.templates.active_identifiers',
|
||||
$this->cache->resolveKey('ext.templates.active_identifiers')
|
||||
);
|
||||
$this->assertTrue($this->cache->has('ext.templates.active_identifiers_admin'));
|
||||
$this->assertTrue($this->cache->has('ext.templates.active_identifiers_user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A-3-3: invalidateTemplateStatusCache() 가 4개 키 모두 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_3_3_invalidate_template_status_clears_all_four_keys(): void
|
||||
{
|
||||
$this->cache->put('ext.templates.active_identifiers', ['t-a']);
|
||||
$this->cache->put('ext.templates.active_identifiers_admin', ['t-admin']);
|
||||
$this->cache->put('ext.templates.active_identifiers_user', ['t-user']);
|
||||
$this->cache->put('ext.templates.installed_identifiers', ['t-a']);
|
||||
|
||||
$stub = new class {
|
||||
use CachesTemplateStatus;
|
||||
};
|
||||
$stub::invalidateTemplateStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.templates.active_identifiers'));
|
||||
$this->assertFalse($this->cache->has('ext.templates.active_identifiers_admin'));
|
||||
$this->assertFalse($this->cache->has('ext.templates.active_identifiers_user'));
|
||||
$this->assertFalse($this->cache->has('ext.templates.installed_identifiers'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-4. ClearsTemplateCaches (extension cache version)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-4-1: 확장 캐시 버전 키 형식 + 증가 동작 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_4_1_increment_extension_cache_version(): void
|
||||
{
|
||||
$this->cache->put('ext.cache_version', 1000);
|
||||
|
||||
$stub = new class {
|
||||
use ClearsTemplateCaches {
|
||||
incrementExtensionCacheVersion as public callIncrement;
|
||||
}
|
||||
};
|
||||
$stub->callIncrement();
|
||||
|
||||
$newVersion = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$this->assertGreaterThan(1000, $newVersion);
|
||||
$this->assertSame(
|
||||
'g7:core:ext.cache_version',
|
||||
$this->cache->resolveKey('ext.cache_version')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A-4-3: 미설정 시 0 반환.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_4_3_get_extension_cache_version_returns_zero_when_unset(): void
|
||||
{
|
||||
$this->cache->forget('ext.cache_version');
|
||||
|
||||
$this->assertSame(0, ClearsTemplateCaches::getExtensionCacheVersion());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-1 추가: 모듈 상태 캐시 라이프사이클 시나리오
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-1-1: 모듈 활성 목록 캐시 생성 — put 후 has=true.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_create_active_modules_cache(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a', 'mod-b', 'mod-c']);
|
||||
|
||||
$this->assertSame(
|
||||
['mod-a', 'mod-b', 'mod-c'],
|
||||
$this->cache->get('ext.modules.active_identifiers')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A-1-2: 모듈 활성 목록 캐시 히트 — 두 번째 read 동일.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_active_modules_cache_hit(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a']);
|
||||
|
||||
$first = $this->cache->get('ext.modules.active_identifiers');
|
||||
$second = $this->cache->get('ext.modules.active_identifiers');
|
||||
|
||||
$this->assertSame($first, $second);
|
||||
}
|
||||
|
||||
/**
|
||||
* A-1-4: 모듈 활성화 시 캐시 무효화 — invalidate 후 has=false.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_4_module_activate_invalidates_cache(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a', 'mod-b']);
|
||||
|
||||
$stub = new class {
|
||||
use CachesModuleStatus;
|
||||
};
|
||||
$stub::invalidateModuleStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A-1-5: 모듈 비활성화 시 캐시 무효화 — installed 키도 함께 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_5_module_deactivate_invalidates_both_keys(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', ['mod-a']);
|
||||
$this->cache->put('ext.modules.installed_identifiers', ['mod-a', 'mod-b']);
|
||||
|
||||
$stub = new class {
|
||||
use CachesModuleStatus;
|
||||
};
|
||||
$stub::invalidateModuleStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
$this->assertFalse($this->cache->has('ext.modules.installed_identifiers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A-1-7: 빈 배열 처리 — 빈 배열도 정상 캐시 가능 (무효화로 보장).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_1_7_empty_array_can_be_cached_and_invalidated(): void
|
||||
{
|
||||
$this->cache->put('ext.modules.active_identifiers', []);
|
||||
|
||||
$this->assertTrue($this->cache->has('ext.modules.active_identifiers'));
|
||||
$this->assertSame([], $this->cache->get('ext.modules.active_identifiers'));
|
||||
|
||||
// 모듈 설치/활성화 시 invalidateModuleStatusCache 호출로 확실히 갱신
|
||||
$stub = new class {
|
||||
use CachesModuleStatus;
|
||||
};
|
||||
$stub::invalidateModuleStatusCache();
|
||||
|
||||
$this->assertFalse($this->cache->has('ext.modules.active_identifiers'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-2 추가: 플러그인 상태 캐시 라이프사이클
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-2-2: 플러그인 활성 목록 캐시 히트.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_2_2_active_plugins_cache_hit(): void
|
||||
{
|
||||
$this->cache->put('ext.plugins.active_identifiers', ['plugin-a']);
|
||||
|
||||
$this->assertSame(['plugin-a'], $this->cache->get('ext.plugins.active_identifiers'));
|
||||
$this->assertSame(['plugin-a'], $this->cache->get('ext.plugins.active_identifiers'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A-2-4: 플러그인 활성화/비활성화/삭제 시 캐시 무효화 (라이프사이클).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_2_4_plugin_lifecycle_invalidates_cache(): void
|
||||
{
|
||||
$stub = new class {
|
||||
use CachesPluginStatus;
|
||||
};
|
||||
|
||||
// 활성화 시점
|
||||
$this->cache->put('ext.plugins.active_identifiers', ['plugin-a']);
|
||||
$stub::invalidatePluginStatusCache();
|
||||
$this->assertFalse($this->cache->has('ext.plugins.active_identifiers'));
|
||||
|
||||
// 비활성화 시점
|
||||
$this->cache->put('ext.plugins.active_identifiers', ['plugin-b']);
|
||||
$stub::invalidatePluginStatusCache();
|
||||
$this->assertFalse($this->cache->has('ext.plugins.active_identifiers'));
|
||||
|
||||
// 삭제 시점
|
||||
$this->cache->put('ext.plugins.installed_identifiers', ['plugin-c']);
|
||||
$stub::invalidatePluginStatusCache();
|
||||
$this->assertFalse($this->cache->has('ext.plugins.installed_identifiers'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-3 추가: 템플릿 타입별 캐시
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-3-2: 템플릿 타입별 캐시 히트 (admin/user 분리).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_3_2_template_type_cache_hit(): void
|
||||
{
|
||||
$this->cache->put('ext.templates.active_identifiers_admin', ['t-admin-1']);
|
||||
$this->cache->put('ext.templates.active_identifiers_user', ['t-user-1']);
|
||||
|
||||
$this->assertSame(['t-admin-1'], $this->cache->get('ext.templates.active_identifiers_admin'));
|
||||
$this->assertSame(['t-user-1'], $this->cache->get('ext.templates.active_identifiers_user'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// A-4 추가: ClearsTemplateCaches 확장 라이프사이클
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* A-4-2: 모듈 설치 시 캐시 버전 증가 (T1 → T2 시뮬레이션).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_4_2_install_increments_cache_version(): void
|
||||
{
|
||||
$this->cache->put('ext.cache_version', 1000);
|
||||
|
||||
$stub = new class {
|
||||
use ClearsTemplateCaches {
|
||||
incrementExtensionCacheVersion as public callIncrement;
|
||||
}
|
||||
};
|
||||
|
||||
sleep(1); // time() 기반 증가 보장
|
||||
$stub->callIncrement();
|
||||
|
||||
$this->assertGreaterThan(1000, ClearsTemplateCaches::getExtensionCacheVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* A-4-3: 라이프사이클 단계마다 버전 증가 (T0 < T1 < T2 < T3).
|
||||
*/
|
||||
#[Test]
|
||||
public function a_4_3_lifecycle_increments_each_step(): void
|
||||
{
|
||||
$stub = new class {
|
||||
use ClearsTemplateCaches {
|
||||
incrementExtensionCacheVersion as public callIncrement;
|
||||
}
|
||||
};
|
||||
|
||||
$stub->callIncrement();
|
||||
$t1 = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
sleep(1);
|
||||
$stub->callIncrement();
|
||||
$t2 = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
sleep(1);
|
||||
$stub->callIncrement();
|
||||
$t3 = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
$this->assertGreaterThan(0, $t1);
|
||||
$this->assertGreaterThanOrEqual($t1, $t2);
|
||||
$this->assertGreaterThanOrEqual($t2, $t3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 B 이관 검증 테스트 (Layout 캐시)
|
||||
*
|
||||
* 계획서 §13 B-1, B-2, B-3, B-4 의 16개 테스트 케이스를 검증합니다.
|
||||
*
|
||||
* - B-1: LayoutService 내부 캐시 (병합 결과)
|
||||
* - B-2: PublicLayoutController 서빙 캐시
|
||||
* - B-3: LayoutResolverService 해석 캐시
|
||||
* - B-4: InvalidatesLayoutCache Trait 카스케이드 무효화
|
||||
*
|
||||
* 모델/Repository 통합 시나리오는 LayoutServiceCachingTest /
|
||||
* ModuleManagerLayoutTest / TemplateManagerOverrideTest 에서 검증되며,
|
||||
* 본 파일은 새 키 형식과 키 일관성에 집중합니다.
|
||||
*/
|
||||
class LayoutCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B-1. LayoutService 내부 캐시
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* B-1-1: 레이아웃 병합 결과 캐시 키 형식 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_1_merged_layout_cache_key_format(): void
|
||||
{
|
||||
$key = 'template.1.layout.admin/dashboard';
|
||||
$this->cache->put($key, ['merged' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:template.1.layout.admin/dashboard',
|
||||
$this->cache->resolveKey($key)
|
||||
);
|
||||
$this->assertTrue($this->cache->has($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-1-2: 레이아웃 캐시 히트 — 동일 키 재조회 시 동일 값.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_2_merged_layout_cache_hit(): void
|
||||
{
|
||||
$key = 'template.1.layout.admin/dashboard';
|
||||
$this->cache->put($key, ['v' => 1]);
|
||||
|
||||
$this->assertSame(['v' => 1], $this->cache->get($key));
|
||||
$this->assertSame(['v' => 1], $this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-1-3: 레이아웃 편집 저장 시 캐시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_3_layout_edit_invalidates_merged_cache(): void
|
||||
{
|
||||
$key = 'template.1.layout.admin/dashboard';
|
||||
$this->cache->put($key, 'old');
|
||||
|
||||
$this->cache->forget($key);
|
||||
|
||||
$this->assertFalse($this->cache->has($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-1-4: 레이아웃 이름 변경 시 이전 키 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_4_layout_rename_invalidates_old_key(): void
|
||||
{
|
||||
$oldKey = 'template.1.layout.admin/old_name';
|
||||
$newKey = 'template.1.layout.admin/new_name';
|
||||
|
||||
$this->cache->put($oldKey, ['old' => true]);
|
||||
$this->cache->forget($oldKey);
|
||||
$this->cache->put($newKey, ['new' => true]);
|
||||
|
||||
$this->assertFalse($this->cache->has($oldKey));
|
||||
$this->assertTrue($this->cache->has($newKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-1-5: 모듈 레이아웃 sourceHash 포함 키 형식.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_5_module_layout_source_hash_key(): void
|
||||
{
|
||||
$sourceHash = md5('module'.'sirsoft-ecommerce');
|
||||
$key = "template.1.layout.admin/products.{$sourceHash}";
|
||||
|
||||
$this->cache->put($key, ['module' => true]);
|
||||
|
||||
$this->assertTrue($this->cache->has($key));
|
||||
$this->assertSame(
|
||||
"g7:core:template.1.layout.admin/products.{$sourceHash}",
|
||||
$this->cache->resolveKey($key)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-1-6: 부모-자식 extends 캐시 카스케이드 무효화.
|
||||
*
|
||||
* InvalidatesLayoutCache Trait 또는 LayoutService.clearLayoutCache 가
|
||||
* 자식 레이아웃 캐시도 함께 삭제하는 동작을 키 차원에서 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_1_6_extends_cascade_invalidation(): void
|
||||
{
|
||||
$baseKey = 'template.1.layout._admin_base';
|
||||
$childKey = 'template.1.layout.admin/dashboard';
|
||||
|
||||
$this->cache->put($baseKey, ['base' => true]);
|
||||
$this->cache->put($childKey, ['child' => true]);
|
||||
|
||||
// 부모 변경 → 부모 + 자식 모두 삭제
|
||||
$this->cache->forget($baseKey);
|
||||
$this->cache->forget($childKey);
|
||||
|
||||
$this->assertFalse($this->cache->has($baseKey));
|
||||
$this->assertFalse($this->cache->has($childKey));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B-2. PublicLayoutController 서빙 캐시
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* B-2-1: 서빙 캐시 키 형식 — `layout.{identifier}.{name}.v{version}`
|
||||
*/
|
||||
#[Test]
|
||||
public function b_2_1_serving_cache_key_format(): void
|
||||
{
|
||||
$version = 1000;
|
||||
$key = "layout.sirsoft-admin_basic.admin/dashboard.v{$version}";
|
||||
|
||||
$this->cache->put($key, ['layout' => 'data']);
|
||||
|
||||
$this->assertSame(
|
||||
"g7:core:layout.sirsoft-admin_basic.admin/dashboard.v{$version}",
|
||||
$this->cache->resolveKey($key)
|
||||
);
|
||||
$this->assertSame(['layout' => 'data'], $this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-2-2: 서빙 캐시 히트 — 두 번째 요청은 동일 결과 반환.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_2_2_serving_cache_hit(): void
|
||||
{
|
||||
$key = 'layout.sirsoft-admin_basic.admin/dashboard.v1000';
|
||||
$this->cache->put($key, ['cached' => 'response']);
|
||||
|
||||
$this->assertSame(['cached' => 'response'], $this->cache->get($key));
|
||||
$this->assertSame(['cached' => 'response'], $this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-2-3: 레이아웃 편집 시 현재 버전 서빙 캐시 무효화.
|
||||
*
|
||||
* InvalidatesLayoutCache::forgetLayoutCacheKeys() 가 동일 키를 삭제하는
|
||||
* 패턴을 키 형식 차원에서 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_2_3_layout_edit_clears_current_version_serving_cache(): void
|
||||
{
|
||||
$version = 1000;
|
||||
$this->cache->put('ext.cache_version', $version);
|
||||
$key = "layout.sirsoft-admin_basic.admin/dashboard.v{$version}";
|
||||
$this->cache->put($key, ['old' => 'content']);
|
||||
|
||||
// 편집 시 현재 버전 키만 능동 삭제
|
||||
$current = (int) $this->cache->get('ext.cache_version', 0);
|
||||
$this->cache->forget("layout.sirsoft-admin_basic.admin/dashboard.v{$current}");
|
||||
|
||||
$this->assertFalse($this->cache->has($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-2-4: 확장 변경 시 ext.cache_version 증가로 자동 무효화.
|
||||
*
|
||||
* 이전 버전 캐시는 능동 삭제하지 않고 TTL 만료에 의존.
|
||||
* 새 버전 키로 새 캐시 생성됨.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_2_4_extension_change_creates_new_version_key(): void
|
||||
{
|
||||
$oldVersion = 1000;
|
||||
$newVersion = 2000;
|
||||
|
||||
$oldKey = "layout.sirsoft-admin_basic.admin/dashboard.v{$oldVersion}";
|
||||
$newKey = "layout.sirsoft-admin_basic.admin/dashboard.v{$newVersion}";
|
||||
|
||||
$this->cache->put($oldKey, ['old' => true]);
|
||||
$this->cache->put('ext.cache_version', $newVersion);
|
||||
$this->cache->put($newKey, ['new' => true]);
|
||||
|
||||
// 이전 키는 잔존 (능동 삭제 안 함)
|
||||
$this->assertTrue($this->cache->has($oldKey));
|
||||
// 새 키는 별도 키로 생성됨
|
||||
$this->assertTrue($this->cache->has($newKey));
|
||||
$this->assertNotSame($this->cache->get($oldKey), $this->cache->get($newKey));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B-3. LayoutResolverService 해석 캐시
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* B-3-1: 모듈 레이아웃 해석 캐시 키 형식.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_3_1_resolver_cache_key_format(): void
|
||||
{
|
||||
$key = 'layout_resolver.1.admin/products';
|
||||
$this->cache->put($key, 42);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:layout_resolver.1.admin/products',
|
||||
$this->cache->resolveKey($key)
|
||||
);
|
||||
$this->assertSame(42, $this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-2: 해석 캐시 히트 — 재조회 시 동일 ID 반환.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_3_2_resolver_cache_hit(): void
|
||||
{
|
||||
$key = 'layout_resolver.1.admin/products';
|
||||
$this->cache->put($key, 42);
|
||||
|
||||
$this->assertSame(42, $this->cache->get($key));
|
||||
$this->assertSame(42, $this->cache->get($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-3: 레이아웃 편집 시 해석 캐시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_3_3_layout_edit_invalidates_resolver_cache(): void
|
||||
{
|
||||
$key = 'layout_resolver.1.admin/products';
|
||||
$this->cache->put($key, 42);
|
||||
|
||||
$this->cache->forget($key);
|
||||
|
||||
$this->assertFalse($this->cache->has($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-4: 모듈 비활성화 시 해석 캐시 무효화 — 모듈의 모든 레이아웃 키 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_3_4_module_deactivate_invalidates_all_resolver_keys(): void
|
||||
{
|
||||
$keys = [
|
||||
'layout_resolver.1.sirsoft-ecommerce.admin_products',
|
||||
'layout_resolver.1.sirsoft-ecommerce.admin_categories',
|
||||
'layout_resolver.1.sirsoft-ecommerce.admin_orders',
|
||||
];
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->put($k, 1);
|
||||
}
|
||||
|
||||
// 모듈 비활성화 시뮬레이션 — 모든 키 삭제
|
||||
foreach ($keys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
|
||||
foreach ($keys as $k) {
|
||||
$this->assertFalse($this->cache->has($k));
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B-4. InvalidatesLayoutCache Trait
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* B-4-1: 모듈 활성화 시 관련 레이아웃 캐시 전체 삭제 (3 레이아웃 × 3 종류 = 9개).
|
||||
*/
|
||||
#[Test]
|
||||
public function b_4_1_module_activation_clears_all_layout_caches(): void
|
||||
{
|
||||
// 3 레이아웃 × 3 종류 캐시 = 9개 키
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->cache->put("template.1.layout.admin/page_{$i}", 'merged');
|
||||
$this->cache->put("layout.sirsoft-admin_basic.admin/page_{$i}.v1000", 'served');
|
||||
$this->cache->put("layout_resolver.1.admin/page_{$i}", $i);
|
||||
}
|
||||
|
||||
// 모듈 활성화 → 모든 키 삭제 시뮬레이션
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->cache->forget("template.1.layout.admin/page_{$i}");
|
||||
$this->cache->forget("layout.sirsoft-admin_basic.admin/page_{$i}.v1000");
|
||||
$this->cache->forget("layout_resolver.1.admin/page_{$i}");
|
||||
}
|
||||
|
||||
// 9개 모두 삭제 확인
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->assertFalse($this->cache->has("template.1.layout.admin/page_{$i}"));
|
||||
$this->assertFalse($this->cache->has("layout.sirsoft-admin_basic.admin/page_{$i}.v1000"));
|
||||
$this->assertFalse($this->cache->has("layout_resolver.1.admin/page_{$i}"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B-4-2: 템플릿 비활성화 시 해당 템플릿의 모든 레이아웃 캐시 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function b_4_2_template_deactivation_clears_template_layouts(): void
|
||||
{
|
||||
$templateKeys = [
|
||||
'template.1.layout.admin/dashboard',
|
||||
'template.1.layout.admin/users',
|
||||
'template.1.layout.admin/settings',
|
||||
];
|
||||
|
||||
foreach ($templateKeys as $k) {
|
||||
$this->cache->put($k, 'data');
|
||||
}
|
||||
|
||||
// 템플릿 비활성화 → 해당 템플릿 키 모두 삭제
|
||||
foreach ($templateKeys as $k) {
|
||||
$this->cache->forget($k);
|
||||
}
|
||||
|
||||
foreach ($templateKeys as $k) {
|
||||
$this->assertFalse($this->cache->has($k));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
||||
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 D 이관 검증 테스트 (NotificationDefinitionService, NotificationTemplateService)
|
||||
*
|
||||
* 계획서 §13 D-1, D-2 의 10개 테스트 케이스를 검증합니다.
|
||||
* - NotificationDefinitionService: 정의 캐시 생성/히트/무효화/all_active (5건)
|
||||
* - NotificationTemplateService: 템플릿 캐시 생성/히트/수정/토글/복원 (5건)
|
||||
*
|
||||
* 실제 CoreCacheDriver(array store)를 사용하며, Repository는 mock 처리합니다.
|
||||
*/
|
||||
class NotificationCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
// 훅 리스너가 큐 직렬화를 거쳐 model을 잃어버리는 것을 방지
|
||||
Bus::fake();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// D-1. NotificationDefinitionService
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* D-1-1: 알림 정의 캐시 생성 — resolve() 호출 시 새 키에 저장.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_1_1_definition_cache_stored_under_new_key(): void
|
||||
{
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
|
||||
$repo = $this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
$repo->shouldReceive('getActiveByType')->with('order.created')->once()->andReturn($definition);
|
||||
|
||||
$service = app(NotificationDefinitionService::class);
|
||||
$service->resolve('order.created');
|
||||
|
||||
$this->assertTrue($this->cache->has('notification.definition.order.created'));
|
||||
$this->assertSame(
|
||||
'g7:core:notification.definition.order.created',
|
||||
$this->cache->resolveKey('notification.definition.order.created')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* D-1-2: 알림 정의 캐시 히트 — 두 번째 호출은 Repository 미호출.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_1_2_definition_cache_hit_skips_repository(): void
|
||||
{
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
|
||||
$repo = $this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
$repo->shouldReceive('getActiveByType')->with('order.created')->once()->andReturn($definition);
|
||||
|
||||
$service = app(NotificationDefinitionService::class);
|
||||
$service->resolve('order.created');
|
||||
$service->resolve('order.created');
|
||||
}
|
||||
|
||||
/**
|
||||
* D-1-3: 정의 수정 시 type 키 + all_active 키 모두 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_1_3_update_definition_invalidates_both_keys(): void
|
||||
{
|
||||
$this->cache->put('notification.definition.order.created', 'cached');
|
||||
$this->cache->put('notification.definition.all_active', 'cached');
|
||||
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
|
||||
$repo = $this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
$repo->shouldReceive('update')->andReturn($definition);
|
||||
|
||||
$service = app(NotificationDefinitionService::class);
|
||||
$service->updateDefinition($definition, ['title' => 'new']);
|
||||
|
||||
$this->assertFalse($this->cache->has('notification.definition.order.created'));
|
||||
$this->assertFalse($this->cache->has('notification.definition.all_active'));
|
||||
}
|
||||
|
||||
/**
|
||||
* D-1-4: 활성/비활성 토글 시 캐시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_1_4_toggle_active_invalidates_cache(): void
|
||||
{
|
||||
$this->cache->put('notification.definition.order.created', 'cached');
|
||||
$this->cache->put('notification.definition.all_active', 'cached');
|
||||
|
||||
$definition = new NotificationDefinition(['type' => 'order.created', 'is_active' => true]);
|
||||
|
||||
$repo = $this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
$repo->shouldReceive('update')->andReturn($definition);
|
||||
|
||||
$service = app(NotificationDefinitionService::class);
|
||||
$service->toggleActive($definition);
|
||||
|
||||
$this->assertFalse($this->cache->has('notification.definition.order.created'));
|
||||
$this->assertFalse($this->cache->has('notification.definition.all_active'));
|
||||
}
|
||||
|
||||
/**
|
||||
* D-1-5: 전체 활성 정의 캐시 생성 + 히트.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_1_5_all_active_cache_created_and_hit(): void
|
||||
{
|
||||
$definitions = new EloquentCollection([
|
||||
new NotificationDefinition(['type' => 'a']),
|
||||
new NotificationDefinition(['type' => 'b']),
|
||||
]);
|
||||
|
||||
$repo = $this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
$repo->shouldReceive('getAllActive')->once()->andReturn($definitions);
|
||||
|
||||
$service = app(NotificationDefinitionService::class);
|
||||
$service->getAllActive();
|
||||
|
||||
$this->assertTrue($this->cache->has('notification.definition.all_active'));
|
||||
|
||||
// 히트 — Repository 추가 호출 없음 (mock once())
|
||||
$service->getAllActive();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// D-2. NotificationTemplateService
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* D-2-1: 알림 템플릿 캐시 생성 — 새 키 형식.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_2_1_template_cache_stored_under_new_key(): void
|
||||
{
|
||||
$template = new NotificationTemplate(['channel' => 'database']);
|
||||
|
||||
$templateRepo = $this->mock(NotificationTemplateRepositoryInterface::class);
|
||||
$templateRepo->shouldReceive('getActiveByTypeAndChannel')
|
||||
->with('order.created', 'database')
|
||||
->once()
|
||||
->andReturn($template);
|
||||
$this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
|
||||
$service = app(NotificationTemplateService::class);
|
||||
$service->resolve('order.created', 'database');
|
||||
|
||||
$this->assertTrue($this->cache->has('notification.template.order.created.database'));
|
||||
$this->assertSame(
|
||||
'g7:core:notification.template.order.created.database',
|
||||
$this->cache->resolveKey('notification.template.order.created.database')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* D-2-2: 알림 템플릿 캐시 히트 — 재호출 시 Repository 미호출.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_2_2_template_cache_hit_skips_repository(): void
|
||||
{
|
||||
$template = new NotificationTemplate(['channel' => 'database']);
|
||||
|
||||
$templateRepo = $this->mock(NotificationTemplateRepositoryInterface::class);
|
||||
$templateRepo->shouldReceive('getActiveByTypeAndChannel')->once()->andReturn($template);
|
||||
$this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
|
||||
$service = app(NotificationTemplateService::class);
|
||||
$service->resolve('order.created', 'database');
|
||||
$service->resolve('order.created', 'database');
|
||||
}
|
||||
|
||||
/**
|
||||
* D-2-3: 템플릿 수정 시 캐시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_2_3_update_template_invalidates_cache(): void
|
||||
{
|
||||
$this->cache->put('notification.template.order.created.database', 'cached');
|
||||
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
$template = $this->makeTemplateWithDefinition($definition, 'database');
|
||||
|
||||
$templateRepo = $this->mock(NotificationTemplateRepositoryInterface::class);
|
||||
$templateRepo->shouldReceive('update')->andReturn($template);
|
||||
$this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
|
||||
$service = app(NotificationTemplateService::class);
|
||||
$service->updateTemplate($template, ['subject' => 'new']);
|
||||
|
||||
$this->assertFalse($this->cache->has('notification.template.order.created.database'));
|
||||
}
|
||||
|
||||
/**
|
||||
* D-2-4: 템플릿 활성/비활성 토글 시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_2_4_toggle_template_active_invalidates_cache(): void
|
||||
{
|
||||
$this->cache->put('notification.template.order.created.database', 'cached');
|
||||
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
$template = $this->makeTemplateWithDefinition($definition, 'database');
|
||||
$template->is_active = true;
|
||||
|
||||
$templateRepo = $this->mock(NotificationTemplateRepositoryInterface::class);
|
||||
$templateRepo->shouldReceive('update')->andReturn($template);
|
||||
$this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
|
||||
$service = app(NotificationTemplateService::class);
|
||||
$service->toggleActive($template);
|
||||
|
||||
$this->assertFalse($this->cache->has('notification.template.order.created.database'));
|
||||
}
|
||||
|
||||
/**
|
||||
* D-2-5: 기본값 복원 시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function d_2_5_reset_to_default_invalidates_cache(): void
|
||||
{
|
||||
$this->cache->put('notification.template.order.created.database', 'cached');
|
||||
|
||||
$definition = new NotificationDefinition(['type' => 'order.created']);
|
||||
$template = $this->makeTemplateWithDefinition($definition, 'database');
|
||||
|
||||
$templateRepo = $this->mock(NotificationTemplateRepositoryInterface::class);
|
||||
$templateRepo->shouldReceive('update')->andReturn($template);
|
||||
$this->mock(NotificationDefinitionRepositoryInterface::class);
|
||||
|
||||
$service = app(NotificationTemplateService::class);
|
||||
$service->resetToDefault($template, ['subject' => 'default', 'body' => 'default']);
|
||||
|
||||
$this->assertFalse($this->cache->has('notification.template.order.created.database'));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 헬퍼
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* NotificationTemplate 인스턴스에 definition 관계를 직접 주입합니다.
|
||||
* (Eloquent setRelation 패턴)
|
||||
*/
|
||||
private function makeTemplateWithDefinition(NotificationDefinition $definition, string $channel): NotificationTemplate
|
||||
{
|
||||
$template = new NotificationTemplate(['channel' => $channel]);
|
||||
$template->setRelation('definition', $definition);
|
||||
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Seo\SeoCacheManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 C 이관 검증 테스트 (SEO 캐시)
|
||||
*
|
||||
* 계획서 §13 C-1, C-2, C-3 의 SEO 캐시 키 이관을 검증합니다.
|
||||
* - SeoCacheManager: 페이지/인덱스/clearAll/패턴 무효화
|
||||
* - SeoConfigMerger: 병합 캐시 키 형식
|
||||
* - Sitemap: GenerateSitemapJob, SitemapController 키 형식
|
||||
*
|
||||
* 모듈/플러그인 의존성이 큰 SeoConfigMerger와 Sitemap 통합 테스트는
|
||||
* 별도 SeoConfigMergerTest / SitemapControllerTest 에 의존합니다.
|
||||
*/
|
||||
class SeoCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
private SeoCacheManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
|
||||
// SEO 캐시 활성화
|
||||
$this->app['config']->set('g7_settings.core.seo.cache_enabled', true);
|
||||
$this->app['config']->set('g7_settings.core.seo.cache_ttl', 7200);
|
||||
|
||||
$this->manager = new SeoCacheManager($this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// C-1. SeoCacheManager
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* C-1-1: SEO 페이지 캐시 생성 — put() 호출 시 새 키 형식.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_1_seo_page_cache_uses_new_prefix(): void
|
||||
{
|
||||
$this->manager->put('/board/notice/123', 'ko', '<html>Hello</html>');
|
||||
|
||||
$expectedKey = 'seo.page.'.md5('/board/notice/123|ko');
|
||||
$this->assertSame('<html>Hello</html>', $this->cache->get($expectedKey));
|
||||
$this->assertSame(
|
||||
'g7:core:'.$expectedKey,
|
||||
$this->cache->resolveKey($expectedKey)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-2: SEO 페이지 캐시 히트.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_2_seo_page_cache_hit(): void
|
||||
{
|
||||
$this->manager->put('/page', 'ko', '<html>Cached</html>');
|
||||
|
||||
$this->assertSame('<html>Cached</html>', $this->manager->get('/page', 'ko'));
|
||||
$this->assertSame('<html>Cached</html>', $this->manager->get('/page', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-3: URL 패턴 기반 무효화 (와일드카드).
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_3_invalidate_by_url_pattern_wildcards(): void
|
||||
{
|
||||
$this->manager->put('/board/notice/1', 'ko', '<html>1</html>');
|
||||
$this->manager->put('/board/notice/2', 'ko', '<html>2</html>');
|
||||
$this->manager->put('/board/free/1', 'ko', '<html>free</html>');
|
||||
|
||||
$count = $this->manager->invalidateByUrl('/board/notice/*');
|
||||
|
||||
$this->assertSame(2, $count);
|
||||
$this->assertNull($this->manager->get('/board/notice/1', 'ko'));
|
||||
$this->assertNull($this->manager->get('/board/notice/2', 'ko'));
|
||||
$this->assertSame('<html>free</html>', $this->manager->get('/board/free/1', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-5: clearAll() — 모든 SEO 페이지 캐시 + 인덱스 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_5_clear_all_removes_pages_and_index(): void
|
||||
{
|
||||
$this->manager->put('/page1', 'ko', '<a/>');
|
||||
$this->manager->put('/page2', 'ko', '<b/>');
|
||||
|
||||
$this->assertNotEmpty($this->manager->getCachedUrls());
|
||||
|
||||
$this->manager->clearAll();
|
||||
|
||||
$this->assertEmpty($this->manager->getCachedUrls());
|
||||
$this->assertNull($this->manager->get('/page1', 'ko'));
|
||||
$this->assertNull($this->manager->get('/page2', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-7: SEO 캐시 비활성 시 저장 안 함.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_7_disabled_cache_does_not_store(): void
|
||||
{
|
||||
$this->app['config']->set('g7_settings.core.cache.seo_enabled', false);
|
||||
$this->app['config']->set('g7_settings.core.seo.cache_enabled', false);
|
||||
|
||||
$this->manager->put('/page', 'ko', '<html/>');
|
||||
|
||||
$this->assertNull($this->manager->get('/page', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-extra: invalidateByLayout — 레이아웃 정보 매칭 삭제.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_extra_invalidate_by_layout(): void
|
||||
{
|
||||
$this->manager->putWithLayout('/board/list', 'ko', '<list/>', 'board.list');
|
||||
$this->manager->putWithLayout('/board/show/1', 'ko', '<show/>', 'board.show');
|
||||
|
||||
$count = $this->manager->invalidateByLayout('board.list');
|
||||
|
||||
$this->assertSame(1, $count);
|
||||
$this->assertNull($this->manager->get('/board/list', 'ko'));
|
||||
$this->assertSame('<show/>', $this->manager->get('/board/show/1', 'ko'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// C-3. Sitemap key format (GenerateSitemapJob, SitemapController)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* C-3-1: Sitemap 캐시 키는 'seo.sitemap' 으로 통일.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_3_1_sitemap_cache_uses_new_key(): void
|
||||
{
|
||||
$this->cache->put('seo.sitemap', '<xml/>', 86400);
|
||||
|
||||
$this->assertSame('<xml/>', $this->cache->get('seo.sitemap'));
|
||||
$this->assertSame(
|
||||
'g7:core:seo.sitemap',
|
||||
$this->cache->resolveKey('seo.sitemap')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-4: 게시글 삭제 시 URL 패턴 + 목록 페이지 동시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_4_post_delete_invalidates_detail_and_list(): void
|
||||
{
|
||||
$this->manager->put('/board/notice/123', 'ko', '<detail/>');
|
||||
$this->manager->put('/board/notice', 'ko', '<list/>');
|
||||
|
||||
// 패턴 무효화: /board/notice* 모두 삭제
|
||||
$count = $this->manager->invalidateByUrl('/board/notice*');
|
||||
|
||||
$this->assertSame(2, $count);
|
||||
$this->assertNull($this->manager->get('/board/notice/123', 'ko'));
|
||||
$this->assertNull($this->manager->get('/board/notice', 'ko'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1-6: 모듈 설치 시 SeoCacheManager.clearAll() 동작 검증.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_1_6_module_install_clears_all(): void
|
||||
{
|
||||
$this->manager->put('/page1', 'ko', 'a');
|
||||
$this->manager->put('/page2', 'ko', 'b');
|
||||
$this->manager->put('/page3', 'ko', 'c');
|
||||
|
||||
// 모듈 설치 → SeoExtensionCacheListener 가 clearAll 호출
|
||||
$this->manager->clearAll();
|
||||
|
||||
$this->assertNull($this->manager->get('/page1', 'ko'));
|
||||
$this->assertNull($this->manager->get('/page2', 'ko'));
|
||||
$this->assertNull($this->manager->get('/page3', 'ko'));
|
||||
$this->assertEmpty($this->manager->getCachedUrls());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// C-2. SeoConfigMerger 병합 캐시 키 형식
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* C-2-1: SEO config 병합 캐시 키 형식 — 'seo.config.{templateId}'.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_2_1_seo_config_cache_key_format(): void
|
||||
{
|
||||
$this->cache->put('seo.config.sirsoft-admin_basic', ['merged' => true]);
|
||||
|
||||
$this->assertSame(
|
||||
'g7:core:seo.config.sirsoft-admin_basic',
|
||||
$this->cache->resolveKey('seo.config.sirsoft-admin_basic')
|
||||
);
|
||||
$this->assertTrue($this->cache->has('seo.config.sirsoft-admin_basic'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-2-2: SEO config 캐시 히트.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_2_2_seo_config_cache_hit(): void
|
||||
{
|
||||
$this->cache->put('seo.config.sirsoft-basic', ['v' => 1]);
|
||||
|
||||
$this->assertSame(['v' => 1], $this->cache->get('seo.config.sirsoft-basic'));
|
||||
$this->assertSame(['v' => 1], $this->cache->get('seo.config.sirsoft-basic'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-2-3: 확장 변경 시 config 캐시 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function c_2_3_extension_change_invalidates_config_cache(): void
|
||||
{
|
||||
$this->cache->put('seo.config.sirsoft-basic', ['old' => true]);
|
||||
|
||||
$this->cache->forget('seo.config.sirsoft-basic');
|
||||
|
||||
$this->assertFalse($this->cache->has('seo.config.sirsoft-basic'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-3-3: 확장 변경 시 Sitemap 캐시 무효화 (SeoExtensionCacheListener 동작).
|
||||
*/
|
||||
#[Test]
|
||||
public function c_3_3_extension_change_invalidates_sitemap(): void
|
||||
{
|
||||
$this->cache->put('seo.sitemap', '<old/>');
|
||||
|
||||
$this->cache->forget('seo.sitemap');
|
||||
|
||||
$this->assertFalse($this->cache->has('seo.sitemap'));
|
||||
}
|
||||
|
||||
/**
|
||||
* C-3-4: SEO 설정 변경 시 Sitemap 캐시 무효화 (SeoSettingsCacheListener 동작).
|
||||
*/
|
||||
#[Test]
|
||||
public function c_3_4_seo_settings_change_invalidates_sitemap(): void
|
||||
{
|
||||
$this->cache->put('seo.sitemap', '<old/>');
|
||||
|
||||
// SeoSettingsCacheListener 동작 시뮬레이션
|
||||
$this->cache->forget('seo.sitemap');
|
||||
|
||||
$this->assertFalse($this->cache->has('seo.sitemap'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Cache\Migration;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Services\GeoIpService;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 그룹 E 이관 검증 테스트 (SettingsService, GeoIpService)
|
||||
*
|
||||
* 계획서 §13 E-1, E-2 의 11개 테스트 케이스를 검증합니다.
|
||||
* - SettingsService: system 설정 캐시 생성/히트/무효화 (6건)
|
||||
* - GeoIpService: timezone 캐시 생성/히트/실패 캐싱/비활성/TTL (5건)
|
||||
*
|
||||
* 실제 CoreCacheDriver(array store)를 사용하여 키 접두사(`g7:core:`)까지
|
||||
* 포함한 동작을 end-to-end로 검증합니다.
|
||||
*/
|
||||
class SettingsCacheTest extends TestCase
|
||||
{
|
||||
private CoreCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
|
||||
$this->cache = new CoreCacheDriver('array');
|
||||
$this->app->instance(CacheInterface::class, $this->cache);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// E-1. SettingsService
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* E-1-1: 시스템 설정 캐시 생성 — 외부 컴포넌트가 설정값을 직접 캐싱한 뒤
|
||||
* SettingsService 가 'settings.system' 키를 읽을 수 있는지 검증한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_1_system_settings_cache_can_be_stored_under_new_key(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['general' => ['site_name' => 'G7']]);
|
||||
|
||||
$this->assertTrue($this->cache->has('settings.system'));
|
||||
$this->assertSame(
|
||||
'g7:core:settings.system',
|
||||
$this->cache->resolveKey('settings.system')
|
||||
);
|
||||
$this->assertSame(
|
||||
['general' => ['site_name' => 'G7']],
|
||||
$this->cache->get('settings.system')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E-1-2: 시스템 설정 캐시 히트 — 동일 키 재조회 시 동일 값 반환.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_2_system_settings_cache_hit_returns_same_value(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['v' => 1]);
|
||||
|
||||
$this->assertSame(['v' => 1], $this->cache->get('settings.system'));
|
||||
$this->assertSame(['v' => 1], $this->cache->get('settings.system'));
|
||||
}
|
||||
|
||||
/**
|
||||
* E-1-3: 일반 탭 저장 시 settings.system 무효화 + config:clear 호출.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_3_general_tab_save_invalidates_settings_cache(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['cached' => true]);
|
||||
|
||||
Artisan::shouldReceive('call')->with('config:clear')->once();
|
||||
|
||||
$configRepo = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepo->shouldReceive('getCategory')->with('mail')->andReturn([]);
|
||||
$configRepo->shouldReceive('saveCategory')
|
||||
->with('mail', \Mockery::type('array'))
|
||||
->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$result = $service->saveSettings([
|
||||
'_tab' => 'mail',
|
||||
'mail' => ['mailer' => 'smtp'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($this->cache->has('settings.system'));
|
||||
}
|
||||
|
||||
/**
|
||||
* E-1-4: advanced 탭(cache 카테고리) 저장 시 settings.system 무효화.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_4_advanced_tab_save_invalidates_settings_cache(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['cached' => true]);
|
||||
|
||||
Artisan::shouldReceive('call')->with('config:clear')->once();
|
||||
|
||||
$configRepo = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepo->shouldReceive('getCategory')->with('cache')->andReturn([]);
|
||||
$configRepo->shouldReceive('saveCategory')
|
||||
->with('cache', \Mockery::type('array'))
|
||||
->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$result = $service->saveSettings([
|
||||
'_tab' => 'advanced',
|
||||
'advanced' => ['enabled' => true],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($this->cache->has('settings.system'));
|
||||
}
|
||||
|
||||
/**
|
||||
* E-1-5: SEO 탭 저장 시 설정 캐시 무효화 + after_save 훅 발행.
|
||||
*
|
||||
* SeoSettingsCacheListener 의 SEO 캐시 삭제 동작은 그룹 C 이관 시 검증한다.
|
||||
* 여기서는 설정 캐시만 삭제되는지 확인한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_5_seo_tab_save_invalidates_settings_cache_and_fires_hook(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['cached' => true]);
|
||||
|
||||
Artisan::shouldReceive('call')->with('config:clear')->once();
|
||||
|
||||
$configRepo = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepo->shouldReceive('getCategory')->with('seo')->andReturn([]);
|
||||
$configRepo->shouldReceive('saveCategory')
|
||||
->with('seo', \Mockery::type('array'))
|
||||
->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$result = $service->saveSettings([
|
||||
'_tab' => 'seo',
|
||||
'seo' => ['title' => 'G7'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($this->cache->has('settings.system'));
|
||||
}
|
||||
|
||||
/**
|
||||
* E-1-6: drivers 탭 저장 시 queue:restart 추가 호출.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_1_6_drivers_tab_save_triggers_queue_restart(): void
|
||||
{
|
||||
$this->cache->put('settings.system', ['cached' => true]);
|
||||
|
||||
Artisan::shouldReceive('call')->with('config:clear')->once();
|
||||
Artisan::shouldReceive('call')->with('queue:restart')->once();
|
||||
|
||||
$configRepo = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepo->shouldReceive('getCategory')->with('drivers')->andReturn([]);
|
||||
$configRepo->shouldReceive('saveCategory')
|
||||
->with('drivers', \Mockery::type('array'))
|
||||
->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$result = $service->saveSettings([
|
||||
'_tab' => 'drivers',
|
||||
'drivers' => ['queue' => 'sync'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertFalse($this->cache->has('settings.system'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// E-2. GeoIpService
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* E-2-1: GeoIP 캐시 생성 — 지원 타임존 목록에 있는 경우 캐시에 저장.
|
||||
*
|
||||
* MaxMind DB 가 실제로 존재하지 않으므로 lookupTimezone() 은 null 반환,
|
||||
* 결과적으로 빈 문자열이 캐시된다. (E-2-3 과 동일 동작)
|
||||
*
|
||||
* 여기서는 캐시 저장 키가 새 접두사(`g7:core:geoip.timezone.{ip}`)로
|
||||
* 이관되었는지에 초점을 맞춘다.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_2_1_geoip_cache_uses_new_prefix(): void
|
||||
{
|
||||
config()->set('geoip.enabled', true);
|
||||
config()->set('geoip.cache.enabled', true);
|
||||
config()->set('geoip.database_path', '/nonexistent/path');
|
||||
|
||||
$service = app(GeoIpService::class);
|
||||
$service->getTimezoneByIp('1.2.3.4', ['Asia/Seoul']);
|
||||
|
||||
$this->assertTrue($this->cache->has('geoip.timezone.1.2.3.4'));
|
||||
$this->assertSame(
|
||||
'g7:core:geoip.timezone.1.2.3.4',
|
||||
$this->cache->resolveKey('geoip.timezone.1.2.3.4')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E-2-2: GeoIP 캐시 히트 — 저장된 타임존이 지원 목록에 있으면 반환.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_2_2_geoip_cache_hit_returns_cached_timezone(): void
|
||||
{
|
||||
config()->set('geoip.enabled', true);
|
||||
config()->set('geoip.cache.enabled', true);
|
||||
|
||||
$this->cache->put('geoip.timezone.1.2.3.4', 'Asia/Seoul');
|
||||
|
||||
$service = app(GeoIpService::class);
|
||||
$result = $service->getTimezoneByIp('1.2.3.4', ['Asia/Seoul']);
|
||||
|
||||
$this->assertSame('Asia/Seoul', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* E-2-3: 조회 실패 시 빈 문자열 캐싱 — 다음 조회에서 반복 실패 방지.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_2_3_geoip_failure_caches_empty_string(): void
|
||||
{
|
||||
config()->set('geoip.enabled', true);
|
||||
config()->set('geoip.cache.enabled', true);
|
||||
config()->set('geoip.database_path', '/nonexistent/path');
|
||||
|
||||
$service = app(GeoIpService::class);
|
||||
$first = $service->getTimezoneByIp('invalid-ip', ['Asia/Seoul']);
|
||||
|
||||
$this->assertNull($first);
|
||||
$this->assertSame('', $this->cache->get('geoip.timezone.invalid-ip'));
|
||||
|
||||
// 두 번째 호출은 캐시 히트(빈 문자열) → null 반환, 재조회 없음
|
||||
$second = $service->getTimezoneByIp('invalid-ip', ['Asia/Seoul']);
|
||||
$this->assertNull($second);
|
||||
}
|
||||
|
||||
/**
|
||||
* E-2-4: geoip.cache.enabled = false 시 캐시에 저장되지 않음.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_2_4_geoip_cache_disabled_does_not_store(): void
|
||||
{
|
||||
config()->set('geoip.enabled', true);
|
||||
config()->set('g7_settings.core.cache.geoip_enabled', false);
|
||||
config()->set('geoip.cache.enabled', false);
|
||||
config()->set('geoip.database_path', '/nonexistent/path');
|
||||
|
||||
$service = app(GeoIpService::class);
|
||||
$service->getTimezoneByIp('1.2.3.4', ['Asia/Seoul']);
|
||||
|
||||
$this->assertFalse($this->cache->has('geoip.timezone.1.2.3.4'));
|
||||
}
|
||||
|
||||
/**
|
||||
* E-2-5: TTL 만료 시 캐시 미스로 재조회 — array store 의 경우 TTL 검증은
|
||||
* forget 으로 간접 시뮬레이션한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function e_2_5_geoip_cache_miss_after_forget_triggers_relookup(): void
|
||||
{
|
||||
config()->set('geoip.enabled', true);
|
||||
config()->set('geoip.cache.enabled', true);
|
||||
config()->set('geoip.database_path', '/nonexistent/path');
|
||||
|
||||
$this->cache->put('geoip.timezone.1.2.3.4', 'Asia/Seoul');
|
||||
$this->assertTrue($this->cache->has('geoip.timezone.1.2.3.4'));
|
||||
|
||||
// TTL 만료 시뮬레이션
|
||||
$this->cache->forget('geoip.timezone.1.2.3.4');
|
||||
$this->assertFalse($this->cache->has('geoip.timezone.1.2.3.4'));
|
||||
|
||||
// 재조회 → DB 없음 → 빈 문자열 재저장
|
||||
$service = app(GeoIpService::class);
|
||||
$result = $service->getTimezoneByIp('1.2.3.4', ['Asia/Seoul']);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertSame('', $this->cache->get('geoip.timezone.1.2.3.4'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Console;
|
||||
|
||||
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* BundledExtensionUpdatePrompt trait 단위 테스트.
|
||||
*
|
||||
* collectBundledUpdates() 가 CoreUpdateService::collectBundledExtensionUpdates() 를
|
||||
* 통해 _bundled 버전 직접 비교 결과를 반환하는지 검증.
|
||||
*/
|
||||
class BundledExtensionUpdatePromptTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* collectBundledUpdates 는 CoreUpdateService 의 결과를 그대로 반환한다.
|
||||
*/
|
||||
public function test_collect_bundled_updates_delegates_to_service(): void
|
||||
{
|
||||
$expected = [
|
||||
'modules' => [
|
||||
['identifier' => 'sirsoft-ecommerce', 'current_version' => '1.0.0', 'latest_version' => '1.1.0', 'update_source' => 'bundled'],
|
||||
],
|
||||
'plugins' => [
|
||||
['identifier' => 'sirsoft-payment', 'current_version' => '1.0.0', 'latest_version' => '1.0.1', 'update_source' => 'bundled'],
|
||||
],
|
||||
'templates' => [],
|
||||
];
|
||||
|
||||
$serviceMock = Mockery::mock(CoreUpdateService::class);
|
||||
$serviceMock->shouldReceive('collectBundledExtensionUpdates')
|
||||
->once()
|
||||
->andReturn($expected);
|
||||
$this->app->instance(CoreUpdateService::class, $serviceMock);
|
||||
|
||||
$harness = $this->makeTraitHarness();
|
||||
$result = $harness->callCollect(
|
||||
Mockery::mock(ModuleManager::class),
|
||||
Mockery::mock(PluginManager::class),
|
||||
Mockery::mock(TemplateManager::class),
|
||||
);
|
||||
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 가 빈 결과를 반환하면 trait 도 빈 결과를 반환한다.
|
||||
*/
|
||||
public function test_collect_bundled_updates_returns_empty_when_service_returns_empty(): void
|
||||
{
|
||||
$serviceMock = Mockery::mock(CoreUpdateService::class);
|
||||
$serviceMock->shouldReceive('collectBundledExtensionUpdates')
|
||||
->once()
|
||||
->andReturn(['modules' => [], 'plugins' => [], 'templates' => []]);
|
||||
$this->app->instance(CoreUpdateService::class, $serviceMock);
|
||||
|
||||
$harness = $this->makeTraitHarness();
|
||||
$result = $harness->callCollect(
|
||||
Mockery::mock(ModuleManager::class),
|
||||
Mockery::mock(PluginManager::class),
|
||||
Mockery::mock(TemplateManager::class),
|
||||
);
|
||||
|
||||
$this->assertEmpty($result['modules']);
|
||||
$this->assertEmpty($result['plugins']);
|
||||
$this->assertEmpty($result['templates']);
|
||||
}
|
||||
|
||||
/**
|
||||
* trait 을 사용하는 anonymous 테스트 harness.
|
||||
*/
|
||||
private function makeTraitHarness(): object
|
||||
{
|
||||
return new class
|
||||
{
|
||||
use BundledExtensionUpdatePrompt;
|
||||
|
||||
public function callCollect(ModuleManager $m, PluginManager $p, TemplateManager $t): array
|
||||
{
|
||||
return $this->collectBundledUpdates($m, $p, $t);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Console\Commands;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Services\DashboardService;
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
@@ -21,43 +20,39 @@ class BroadcastDashboardResourcesTest extends TestCase
|
||||
|
||||
public function test_command_broadcasts_resources_update(): void
|
||||
{
|
||||
Event::fake([DashboardUpdated::class]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
// 커맨드 실행
|
||||
$this->artisan('dashboard:broadcast-resources')
|
||||
->expectsOutput('시스템 리소스 정보가 브로드캐스트되었습니다.')
|
||||
->assertExitCode(0);
|
||||
|
||||
// 이벤트 디스패치 확인
|
||||
Event::assertDispatched(DashboardUpdated::class, function ($event) {
|
||||
return $event->type === 'resources';
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
return $event->channel === 'core.admin.dashboard'
|
||||
&& $event->eventName === 'dashboard.resources.updated'
|
||||
&& $event->payload['type'] === 'resources';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_command_sends_correct_resource_data(): void
|
||||
{
|
||||
Event::fake([DashboardUpdated::class]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
// 커맨드 실행
|
||||
$this->artisan('dashboard:broadcast-resources')
|
||||
->assertExitCode(0);
|
||||
|
||||
// 이벤트에 올바른 데이터가 포함되어 있는지 확인
|
||||
Event::assertDispatched(DashboardUpdated::class, function ($event) {
|
||||
// resources 타입인지 확인
|
||||
if ($event->type !== 'resources') {
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
if ($event->payload['type'] !== 'resources') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// data가 배열인지 확인
|
||||
if (! is_array($event->data)) {
|
||||
$data = $event->payload['data'] ?? null;
|
||||
if (! is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 필수 필드가 있는지 확인
|
||||
return isset($event->data['cpu']) &&
|
||||
isset($event->data['memory']) &&
|
||||
isset($event->data['disk']);
|
||||
return isset($data['cpu'])
|
||||
&& isset($data['memory'])
|
||||
&& isset($data['disk']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Database\Seeders;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\PermissionType;
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* RolePermissionSeeder 권한 type 정합성 테스트
|
||||
*
|
||||
* 회귀 방지: config/core.php에 정의된 `type` 필드가 시더를 통해 DB에 반영되어야 한다.
|
||||
* 과거 시더는 type을 PermissionType::Admin으로 하드코딩하여 사용자용 권한
|
||||
* (core.user-notifications.*)이 admin으로 저장되는 문제가 있었다.
|
||||
*/
|
||||
class RolePermissionSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->artisan('db:seed', ['--class' => 'RolePermissionSeeder']);
|
||||
}
|
||||
|
||||
/**
|
||||
* core.user-notifications 카테고리의 type이 user여야 합니다.
|
||||
*/
|
||||
public function test_user_notifications_category_type_is_user(): void
|
||||
{
|
||||
$category = Permission::where('identifier', 'core.user-notifications')
|
||||
->where('extension_type', ExtensionOwnerType::Core)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($category, 'core.user-notifications 카테고리가 존재해야 합니다.');
|
||||
$this->assertEquals(
|
||||
PermissionType::User,
|
||||
$category->type,
|
||||
'config에서 type=user로 정의된 카테고리는 DB에도 user로 저장되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* core.user-notifications 하위 권한 3건 모두 type이 user여야 합니다.
|
||||
*/
|
||||
public function test_user_notifications_child_permissions_type_is_user(): void
|
||||
{
|
||||
$identifiers = [
|
||||
'core.user-notifications.read',
|
||||
'core.user-notifications.update',
|
||||
'core.user-notifications.delete',
|
||||
];
|
||||
|
||||
foreach ($identifiers as $identifier) {
|
||||
$permission = Permission::where('identifier', $identifier)
|
||||
->where('extension_type', ExtensionOwnerType::Core)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($permission, "{$identifier} 권한이 존재해야 합니다.");
|
||||
$this->assertEquals(
|
||||
PermissionType::User,
|
||||
$permission->type,
|
||||
"{$identifier} 권한 type은 user여야 합니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* admin 전용 카테고리는 type이 admin으로 유지되어야 합니다 (회귀 방지).
|
||||
*/
|
||||
public function test_admin_categories_remain_admin_type(): void
|
||||
{
|
||||
$adminCategories = [
|
||||
'core.users',
|
||||
'core.menus',
|
||||
'core.modules',
|
||||
'core.plugins',
|
||||
'core.templates',
|
||||
'core.permissions',
|
||||
'core.notification-logs',
|
||||
'core.notifications',
|
||||
'core.settings',
|
||||
];
|
||||
|
||||
foreach ($adminCategories as $identifier) {
|
||||
$category = Permission::where('identifier', $identifier)
|
||||
->where('extension_type', ExtensionOwnerType::Core)
|
||||
->first();
|
||||
|
||||
if ($category === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertEquals(
|
||||
PermissionType::Admin,
|
||||
$category->type,
|
||||
"{$identifier} 카테고리 type은 admin이어야 합니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 권한의 type은 config 값과 일치해야 합니다.
|
||||
*/
|
||||
public function test_individual_permission_type_matches_config(): void
|
||||
{
|
||||
$categories = config('core.permissions.categories');
|
||||
|
||||
foreach ($categories as $categoryData) {
|
||||
foreach ($categoryData['permissions'] as $permData) {
|
||||
$expectedType = isset($permData['type'])
|
||||
? PermissionType::from($permData['type'])
|
||||
: PermissionType::Admin;
|
||||
|
||||
$permission = Permission::where('identifier', $permData['identifier'])
|
||||
->where('extension_type', ExtensionOwnerType::Core)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($permission, "{$permData['identifier']} 권한이 존재해야 합니다.");
|
||||
$this->assertEquals(
|
||||
$expectedType,
|
||||
$permission->type,
|
||||
"{$permData['identifier']} 권한의 type은 config 값과 일치해야 합니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Events\Dashboard;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DashboardUpdated 이벤트 테스트
|
||||
*/
|
||||
class DashboardUpdatedTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 이벤트가 private 채널로 브로드캐스트되는지 테스트합니다.
|
||||
*/
|
||||
public function test_event_broadcasts_on_private_channel(): void
|
||||
{
|
||||
$event = new DashboardUpdated('stats', ['count' => 100]);
|
||||
|
||||
$channels = $event->broadcastOn();
|
||||
|
||||
$this->assertCount(1, $channels);
|
||||
$this->assertInstanceOf(PrivateChannel::class, $channels[0]);
|
||||
$this->assertEquals('private-admin.dashboard', $channels[0]->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트가 올바른 브로드캐스트명을 가지는지 테스트합니다.
|
||||
*/
|
||||
public function test_event_has_correct_broadcast_name(): void
|
||||
{
|
||||
$event = new DashboardUpdated('stats', ['count' => 100]);
|
||||
|
||||
$this->assertEquals('dashboard.stats.updated', $event->broadcastAs());
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트에 타입과 데이터가 포함되는지 테스트합니다.
|
||||
*/
|
||||
public function test_event_contains_type_and_data(): void
|
||||
{
|
||||
$data = ['count' => 100, 'trend' => 'up'];
|
||||
$event = new DashboardUpdated('stats', $data);
|
||||
|
||||
$this->assertEquals('stats', $event->type);
|
||||
$this->assertEquals($data, $event->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다양한 타입의 이벤트가 올바른 브로드캐스트명을 생성하는지 테스트합니다.
|
||||
*/
|
||||
public function test_different_types_generate_correct_broadcast_names(): void
|
||||
{
|
||||
$types = ['stats', 'resources', 'activities', 'modules', 'alerts'];
|
||||
|
||||
foreach ($types as $type) {
|
||||
$event = new DashboardUpdated($type, []);
|
||||
$this->assertEquals("dashboard.{$type}.updated", $event->broadcastAs());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Events;
|
||||
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* GenericBroadcastEvent 단위 테스트
|
||||
*
|
||||
* HookManager 내부 전용 브로드캐스트 이벤트의 채널/이벤트명/페이로드 정확성을 검증합니다.
|
||||
*/
|
||||
class GenericBroadcastEventTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* broadcastOn()이 올바른 Private 채널을 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_on_returns_private_channel(): void
|
||||
{
|
||||
$event = new GenericBroadcastEvent('core.admin.dashboard', 'dashboard.stats.updated', []);
|
||||
|
||||
$channels = $event->broadcastOn();
|
||||
|
||||
$this->assertCount(1, $channels);
|
||||
$this->assertInstanceOf(PrivateChannel::class, $channels[0]);
|
||||
$this->assertEquals('private-core.admin.dashboard', $channels[0]->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* broadcastAs()가 지정한 이벤트명을 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_as_returns_event_name(): void
|
||||
{
|
||||
$event = new GenericBroadcastEvent('user.notifications.123', 'notification.received');
|
||||
|
||||
$this->assertEquals('notification.received', $event->broadcastAs());
|
||||
}
|
||||
|
||||
/**
|
||||
* broadcastWith()가 payload를 정확히 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_with_returns_payload(): void
|
||||
{
|
||||
$payload = ['type' => 'stats', 'data' => ['users' => 50]];
|
||||
$event = new GenericBroadcastEvent('core.admin.dashboard', 'dashboard.stats.updated', $payload);
|
||||
|
||||
$this->assertEquals($payload, $event->broadcastWith());
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 payload가 빈 배열로 반환되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_with_empty_payload(): void
|
||||
{
|
||||
$event = new GenericBroadcastEvent('test.channel', 'test.event');
|
||||
|
||||
$this->assertEquals([], $event->broadcastWith());
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
*/
|
||||
public function test_get_extension_cache_version_returns_zero_when_not_set(): void
|
||||
{
|
||||
Cache::forget('extension_cache_version');
|
||||
Cache::forget('g7:core:ext.cache_version');
|
||||
|
||||
$version = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
@@ -110,7 +110,7 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
public function test_get_extension_cache_version_returns_cached_value(): void
|
||||
{
|
||||
$expectedVersion = 1735000000;
|
||||
Cache::put('extension_cache_version', $expectedVersion);
|
||||
Cache::put('g7:core:ext.cache_version', $expectedVersion);
|
||||
|
||||
$version = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
@@ -118,11 +118,17 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 언어 캐시 무효화 테스트
|
||||
* clearAllTemplateLanguageCaches() 는 버전 기반 설계로 리팩토링되어 레거시 no-op.
|
||||
*
|
||||
* 2026-03-31 커밋 c86c6d3e5 에서 실제 캐시 삭제 로직 제거. 대신
|
||||
* incrementExtensionCacheVersion() 으로 버전 카운터가 증가하고,
|
||||
* 프론트엔드는 새 버전의 캐시 키로 API를 재요청하여 실질적 무효화가 이루어짐.
|
||||
* 이전 버전 캐시는 TTL 로 자연 만료.
|
||||
*
|
||||
* 본 테스트는 현재 동작(호출 호환성 유지, 버전 카운터 증가)을 검증한다.
|
||||
*/
|
||||
public function test_clear_all_template_language_caches(): void
|
||||
public function test_clear_all_template_language_caches_is_no_op_but_triggers_version_bump(): void
|
||||
{
|
||||
// 활성 템플릿 생성 - 고유 identifier 사용하여 트랜잭션 락 충돌 방지
|
||||
$identifier = 'test-template-'.uniqid();
|
||||
$template = Template::create([
|
||||
'identifier' => $identifier,
|
||||
@@ -134,30 +140,24 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
// 언어 캐시 설정
|
||||
Cache::put("template.language.{$template->identifier}.ko", ['key' => 'value']);
|
||||
Cache::put("template.language.{$template->identifier}.en", ['key' => 'value']);
|
||||
|
||||
// 캐시 존재 확인
|
||||
$this->assertTrue(Cache::has("template.language.{$template->identifier}.ko"));
|
||||
$this->assertTrue(Cache::has("template.language.{$template->identifier}.en"));
|
||||
|
||||
// 캐시 무효화
|
||||
// clearAllTemplateLanguageCaches 자체는 no-op (호출 호환성 유지용)
|
||||
// 예외 없이 호출 가능해야 함
|
||||
$this->traitUser->callClearAllTemplateLanguageCaches();
|
||||
|
||||
// 캐시가 삭제됨
|
||||
$this->assertFalse(Cache::has("template.language.{$template->identifier}.ko"));
|
||||
$this->assertFalse(Cache::has("template.language.{$template->identifier}.en"));
|
||||
// 기존 캐시는 그대로 유지 — 버전 기반 무효화는 프론트엔드가 새 버전 키로 요청하여 처리
|
||||
$this->assertTrue(
|
||||
Cache::has("template.language.{$template->identifier}.ko"),
|
||||
'clearAllTemplateLanguageCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 routes 캐시 무효화 테스트
|
||||
*/
|
||||
public function test_clear_all_template_routes_caches(): void
|
||||
public function test_clear_all_template_routes_caches_is_no_op(): void
|
||||
{
|
||||
// 활성 템플릿 생성 - 고유 identifier 사용하여 트랜잭션 락 충돌 방지
|
||||
$identifier = 'test-template-'.uniqid();
|
||||
$template = Template::create([
|
||||
Template::create([
|
||||
'identifier' => $identifier,
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '테스트', 'en' => 'Test'],
|
||||
@@ -167,26 +167,30 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
// routes 캐시 설정
|
||||
Cache::put("template.routes.{$template->identifier}", ['routes' => []]);
|
||||
Cache::put("template.routes.{$identifier}", ['routes' => []]);
|
||||
$this->assertTrue(Cache::has("template.routes.{$identifier}"));
|
||||
|
||||
// 캐시 존재 확인
|
||||
$this->assertTrue(Cache::has("template.routes.{$template->identifier}"));
|
||||
|
||||
// 캐시 무효화
|
||||
// no-op 호출 — 예외 없이 성공해야 함
|
||||
$this->traitUser->callClearAllTemplateRoutesCaches();
|
||||
|
||||
// 캐시가 삭제됨
|
||||
$this->assertFalse(Cache::has("template.routes.{$template->identifier}"));
|
||||
// 레거시 캐시는 유지됨
|
||||
$this->assertTrue(
|
||||
Cache::has("template.routes.{$identifier}"),
|
||||
'clearAllTemplateRoutesCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 템플릿의 캐시는 무효화하지 않음
|
||||
* incrementExtensionCacheVersion 호출 후 getExtensionCacheVersion 이 증가된 타임스탬프 반환.
|
||||
*
|
||||
* 활성/비활성 템플릿 구분은 더 이상 trait 수준에서 다루지 않으며
|
||||
* (레거시 테스트가 기대한 "활성만 삭제" 로직은 제거되었음),
|
||||
* 실제 무효화는 모든 프론트엔드 요청이 새 버전 파라미터를 사용하게 함으로써 이루어진다.
|
||||
*/
|
||||
public function test_clear_caches_only_affects_active_templates(): void
|
||||
public function test_increment_extension_cache_version_is_actual_invalidation_mechanism(): void
|
||||
{
|
||||
// 활성 템플릿
|
||||
$activeTemplate = Template::create([
|
||||
// 활성/비활성 템플릿 생성
|
||||
Template::create([
|
||||
'identifier' => 'active-template',
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '활성', 'en' => 'Active'],
|
||||
@@ -195,9 +199,7 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
// 비활성 템플릿
|
||||
$inactiveTemplate = Template::create([
|
||||
Template::create([
|
||||
'identifier' => 'inactive-template',
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '비활성', 'en' => 'Inactive'],
|
||||
@@ -207,16 +209,15 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
// 두 템플릿의 언어 캐시 설정
|
||||
Cache::put("template.language.{$activeTemplate->identifier}.ko", ['key' => 'active']);
|
||||
Cache::put("template.language.{$inactiveTemplate->identifier}.ko", ['key' => 'inactive']);
|
||||
// 초기 버전 0
|
||||
$this->assertEquals(0, \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion());
|
||||
|
||||
// 캐시 무효화
|
||||
$this->traitUser->callClearAllTemplateLanguageCaches();
|
||||
// 버전 증가
|
||||
$this->traitUser->callIncrementExtensionCacheVersion();
|
||||
|
||||
// 활성 템플릿 캐시만 삭제됨
|
||||
$this->assertFalse(Cache::has("template.language.{$activeTemplate->identifier}.ko"));
|
||||
// 비활성 템플릿 캐시는 유지됨
|
||||
$this->assertTrue(Cache::has("template.language.{$inactiveTemplate->identifier}.ko"));
|
||||
// 타임스탬프로 설정됨 (양수)
|
||||
$newVersion = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$this->assertGreaterThan(0, $newVersion);
|
||||
$this->assertLessThanOrEqual(time(), $newVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreVersionChecker::getCoreVersion() env 우선 읽기 회귀 테스트.
|
||||
*
|
||||
* 코어 업그레이드 중 spawn 프로세스(proc_open)가 `APP_VERSION={toVersion}` env 를 전달하지만
|
||||
* `bootstrap/cache/config.php` 가 존재하면 `LoadConfiguration` 이 캐시된 리터럴을 사용해
|
||||
* env 오버라이드가 반영되지 않는 회귀가 있었다. 본 테스트는 `getCoreVersion()` 이
|
||||
* env 를 우선 읽어 config cache 유무와 무관하게 spawn 이 전달한 버전을 신뢰함을 검증한다.
|
||||
*/
|
||||
class CoreVersionCheckerEnvPriorityTest extends TestCase
|
||||
{
|
||||
private ?string $originalEnvVersion = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
|
||||
if ($this->originalEnvVersion !== null) {
|
||||
$_ENV['APP_VERSION'] = $this->originalEnvVersion;
|
||||
$_SERVER['APP_VERSION'] = $this->originalEnvVersion;
|
||||
putenv('APP_VERSION='.$this->originalEnvVersion);
|
||||
} else {
|
||||
putenv('APP_VERSION');
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_env_app_version_overrides_config(): void
|
||||
{
|
||||
config()->set('app.version', '7.0.0-beta.1'); // 캐시된 stale 값 시뮬레이션
|
||||
$_ENV['APP_VERSION'] = '7.0.0-beta.2'; // spawn 이 전달한 toVersion
|
||||
|
||||
$this->assertSame(
|
||||
'7.0.0-beta.2',
|
||||
CoreVersionChecker::getCoreVersion(),
|
||||
'env APP_VERSION 이 config 보다 우선해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_config_when_env_missing(): void
|
||||
{
|
||||
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
|
||||
putenv('APP_VERSION');
|
||||
config()->set('app.version', '7.0.0-beta.1');
|
||||
|
||||
$this->assertSame(
|
||||
'7.0.0-beta.1',
|
||||
CoreVersionChecker::getCoreVersion(),
|
||||
'env 가 없으면 config 값을 반환해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_spawn_scenario_cached_config_with_env_override(): void
|
||||
{
|
||||
// spawn 환경 시뮬레이션: config 는 fromVersion 으로 캐시, env 는 toVersion
|
||||
config()->set('app.version', '7.0.0-beta.1');
|
||||
$_ENV['APP_VERSION'] = '7.0.0-beta.2';
|
||||
|
||||
// 확장이 >=7.0.0-beta.2 요구 — env 기반 판정이어야 호환성 통과
|
||||
$this->assertTrue(
|
||||
CoreVersionChecker::isCompatible('>=7.0.0-beta.2'),
|
||||
'spawn 에서 env 로 전달된 toVersion 기준으로 호환성이 평가되어 확장이 자동 비활성화되지 않아야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ExtensionManager 의 외부 ZIP 추출/manifest 검증 단위 테스트
|
||||
*
|
||||
* - extractFromZip(): 평탄 루트 / wrapper 디렉토리 구조 모두 지원
|
||||
* - prepareZipSource(): manifest 존재/해석/identifier/version 검증 경로
|
||||
*/
|
||||
class ExtensionManagerZipTest extends TestCase
|
||||
{
|
||||
private ExtensionManager $extensionManager;
|
||||
|
||||
/** @var array<string> 테스트 종료 시 정리할 임시 디렉토리 목록 */
|
||||
private array $tempDirs = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->extensionManager = app(ExtensionManager::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->tempDirs as $dir) {
|
||||
if (File::isDirectory($dir)) {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 레벨에 manifest 가 있는 평탄 ZIP 을 추출하고 그 디렉토리를 반환하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_from_zip_returns_extract_dir_for_flat_layout(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('flat');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'flat.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('module.json', '{"identifier":"sirsoft-example","version":"1.0.0"}');
|
||||
$zip->addFromString('composer.json', '{}');
|
||||
$zip->close();
|
||||
|
||||
$destDir = $this->makeTempDir('flat_dest');
|
||||
$extractedDir = $this->extensionManager->extractFromZip($zipPath, $destDir);
|
||||
|
||||
$this->assertSame($destDir.DIRECTORY_SEPARATOR.'extracted', $extractedDir);
|
||||
$this->assertTrue(File::exists($extractedDir.DIRECTORY_SEPARATOR.'module.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* owner-repo-hash/ 래퍼 디렉토리가 1개만 있는 ZIP 은 그 내부를 source 로 반환하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_from_zip_unwraps_single_wrapper_directory(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('wrap');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'wrap.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('sirsoft-example-abc123/module.json', '{"identifier":"sirsoft-example","version":"1.0.0"}');
|
||||
$zip->addFromString('sirsoft-example-abc123/README.md', '# example');
|
||||
$zip->close();
|
||||
|
||||
$destDir = $this->makeTempDir('wrap_dest');
|
||||
$extractedDir = $this->extensionManager->extractFromZip($zipPath, $destDir);
|
||||
|
||||
$expected = $destDir.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'sirsoft-example-abc123';
|
||||
$this->assertSame(realpath($expected), realpath($extractedDir));
|
||||
$this->assertTrue(File::exists($extractedDir.DIRECTORY_SEPARATOR.'module.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 파일이 존재하지 않으면 RuntimeException 이 발생하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_from_zip_throws_when_zip_missing(): void
|
||||
{
|
||||
$destDir = $this->makeTempDir('missing_dest');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->extensionManager->extractFromZip($destDir.DIRECTORY_SEPARATOR.'nonexistent.zip', $destDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* prepareZipSource 가 유효한 ZIP + manifest 조합에서 version/manifest 를 반환하는지 검증합니다.
|
||||
*/
|
||||
public function test_prepare_zip_source_returns_version_and_manifest(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('prep');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'ext.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('module.json', json_encode([
|
||||
'identifier' => 'sirsoft-example',
|
||||
'version' => '2.3.4',
|
||||
'name' => 'Example',
|
||||
]));
|
||||
$zip->close();
|
||||
|
||||
$result = $this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
|
||||
|
||||
$this->tempDirs[] = $result['temp_dir']; // 함수 내부에서 생성한 temp_dir 정리 등록
|
||||
|
||||
$this->assertSame('2.3.4', $result['to_version']);
|
||||
$this->assertSame('sirsoft-example', $result['manifest']['identifier']);
|
||||
$this->assertTrue(File::isDirectory($result['temp_dir']));
|
||||
$this->assertTrue(File::exists($result['extracted_dir'].DIRECTORY_SEPARATOR.'module.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 파일이 없는 경우 RuntimeException + 임시 디렉토리 자동 정리가 발생하는지 검증합니다.
|
||||
*/
|
||||
public function test_prepare_zip_source_throws_when_manifest_missing(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('no_manifest');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'no_manifest.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('README.md', '# no manifest');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* identifier 가 일치하지 않으면 RuntimeException 이 발생하는지 검증합니다.
|
||||
*/
|
||||
public function test_prepare_zip_source_throws_on_identifier_mismatch(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('id_mismatch');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'id_mismatch.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('module.json', json_encode([
|
||||
'identifier' => 'sirsoft-other',
|
||||
'version' => '1.0.0',
|
||||
]));
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 에 version 필드가 없으면 RuntimeException 이 발생하는지 검증합니다.
|
||||
*/
|
||||
public function test_prepare_zip_source_throws_when_version_missing(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$tempDir = $this->makeTempDir('no_version');
|
||||
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'no_version.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('module.json', json_encode([
|
||||
'identifier' => 'sirsoft-example',
|
||||
]));
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 디렉토리를 생성하고 tearDown 시 정리되도록 등록합니다.
|
||||
*/
|
||||
private function makeTempDir(string $suffix): string
|
||||
{
|
||||
$dir = storage_path('test_ext_zip_'.$suffix.'_'.uniqid());
|
||||
File::ensureDirectoryExists($dir);
|
||||
$this->tempDirs[] = $dir;
|
||||
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
@@ -120,31 +120,48 @@ class ExtensionPendingHelperTest extends TestCase
|
||||
|
||||
/**
|
||||
* JSON 없는 디렉토리를 건너뛰는지 확인합니다.
|
||||
*
|
||||
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
|
||||
* 무관하게 동작해야 한다.
|
||||
*/
|
||||
public function test_load_pending_extensions_skips_invalid_dirs(): void
|
||||
{
|
||||
// JSON 없는 디렉토리 생성
|
||||
$invalidPath = $this->modulesPath.'/_pending/invalid-mod';
|
||||
File::ensureDirectoryExists($invalidPath);
|
||||
$isolatedModulesPath = $this->tempBase.'/isolated-modules';
|
||||
$pendingDir = $isolatedModulesPath.'/_pending';
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($this->modulesPath, 'module.json');
|
||||
// 유효한 pending 모듈 1개
|
||||
File::ensureDirectoryExists($pendingDir.'/valid-mod');
|
||||
File::put($pendingDir.'/valid-mod/module.json', json_encode([
|
||||
'identifier' => 'valid-mod',
|
||||
'version' => '1.0.0',
|
||||
'name' => 'Valid Module',
|
||||
]));
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
// JSON 없는 invalid 디렉토리
|
||||
File::ensureDirectoryExists($pendingDir.'/invalid-mod');
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
|
||||
|
||||
$this->assertCount(1, $result, 'JSON 없는 디렉토리는 결과에 포함되지 않아야 함');
|
||||
$this->assertArrayHasKey('valid-mod', $result);
|
||||
$this->assertArrayNotHasKey('invalid-mod', $result);
|
||||
|
||||
// 정리
|
||||
File::deleteDirectory($invalidPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* _pending이 비어있으면 빈 배열을 반환하는지 확인합니다.
|
||||
*
|
||||
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
|
||||
* 무관하게 동작해야 한다.
|
||||
*/
|
||||
public function test_load_pending_extensions_returns_empty_when_no_pending(): void
|
||||
{
|
||||
// 기존 테스트 데이터 정리
|
||||
File::deleteDirectory($this->modulesPath.'/_pending/test-pending-mod');
|
||||
$isolatedModulesPath = $this->tempBase.'/isolated-modules-empty';
|
||||
$pendingDir = $isolatedModulesPath.'/_pending';
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($this->modulesPath, 'module.json');
|
||||
// _pending 디렉토리는 존재하지만 비어있음
|
||||
File::ensureDirectoryExists($pendingDir);
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
|
||||
|
||||
$this->assertEmpty($result);
|
||||
}
|
||||
@@ -160,6 +177,78 @@ class ExtensionPendingHelperTest extends TestCase
|
||||
$this->assertEmpty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리명과 manifest 의 identifier 가 일치하지 않는 디렉토리는 스킵되어야 합니다.
|
||||
*
|
||||
* 업데이트/백업 과정에서 남는 임시 디렉토리(예: sirsoft-admin_basic_20260402_081819,
|
||||
* sirsoft-admin_basic_updating_<uniq>, sirsoft-admin_basic_old_<uniq>)는 내부에 원본
|
||||
* manifest 를 그대로 가지고 있어 identifier 가 원본과 동일하다. 이를 등록하면 install
|
||||
* 시 `getPendingPath({identifier})` 가 존재하지 않는 표준 경로를 반환해 실패한다.
|
||||
*/
|
||||
public function test_load_extensions_skips_directories_with_mismatched_identifier(): void
|
||||
{
|
||||
$isolatedPath = $this->tempBase.'/isolated-mismatched';
|
||||
$pendingDir = $isolatedPath.'/_pending';
|
||||
|
||||
// 정상 _pending 디렉토리 (디렉토리명 == identifier)
|
||||
File::ensureDirectoryExists($pendingDir.'/sirsoft-board');
|
||||
File::put($pendingDir.'/sirsoft-board/module.json', json_encode([
|
||||
'identifier' => 'sirsoft-board',
|
||||
'version' => '1.0.0',
|
||||
'name' => 'Board',
|
||||
]));
|
||||
|
||||
// 업데이트 백업 임시 디렉토리 (디렉토리명에 타임스탬프 suffix, manifest 는 원본 그대로)
|
||||
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_20260402_081819');
|
||||
File::put($pendingDir.'/sirsoft-board_20260402_081819/module.json', json_encode([
|
||||
'identifier' => 'sirsoft-board',
|
||||
'version' => '1.0.0',
|
||||
'name' => 'Board',
|
||||
]));
|
||||
|
||||
// 업데이트 진행 중 임시 디렉토리
|
||||
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_updating_abc123');
|
||||
File::put($pendingDir.'/sirsoft-board_updating_abc123/module.json', json_encode([
|
||||
'identifier' => 'sirsoft-board',
|
||||
'version' => '1.0.0',
|
||||
'name' => 'Board',
|
||||
]));
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
|
||||
|
||||
$this->assertCount(1, $result, '정상 디렉토리 1개만 등록되어야 함');
|
||||
$this->assertArrayHasKey('sirsoft-board', $result);
|
||||
$this->assertEquals(
|
||||
'sirsoft-board',
|
||||
basename($result['sirsoft-board']['source_path']),
|
||||
'source_path 가 표준 디렉토리여야 함 (타임스탬프 suffix 가 붙은 임시 디렉토리가 아님)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 디렉토리가 없고 임시 디렉토리만 남은 경우 빈 배열을 반환해야 합니다.
|
||||
*
|
||||
* 이전 업데이트가 비정상 종료되어 원본이 삭제되고 임시 디렉토리만 남는 상황.
|
||||
* 이 경우 확장을 "pending" 으로 인식하면 install 이 잘못된 경로로 접근해 실패하므로,
|
||||
* 빈 배열을 반환하여 _bundled 폴백으로 넘어가게 해야 한다.
|
||||
*/
|
||||
public function test_load_extensions_returns_empty_when_only_temp_directories_exist(): void
|
||||
{
|
||||
$isolatedPath = $this->tempBase.'/isolated-temp-only';
|
||||
$pendingDir = $isolatedPath.'/_pending';
|
||||
|
||||
File::ensureDirectoryExists($pendingDir.'/orphan-mod_old_xyz');
|
||||
File::put($pendingDir.'/orphan-mod_old_xyz/module.json', json_encode([
|
||||
'identifier' => 'orphan-mod',
|
||||
'version' => '1.0.0',
|
||||
'name' => 'Orphan',
|
||||
]));
|
||||
|
||||
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
|
||||
|
||||
$this->assertEmpty($result, '임시 디렉토리만 있으면 pending 로 인식하지 않아야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* _pending에서 활성 디렉토리로 복사되는지 확인합니다.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\PermissionType;
|
||||
use App\Extension\Helpers\ExtensionMenuSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Phase C.1 (v6) 회귀 방지: helper `cleanupStale*` 가 user_overrides **무관** 으로 삭제하는지.
|
||||
*
|
||||
* PO 요구사항 재정의 (v6):
|
||||
* - config/확장 정의에 없는 row 는 user_overrides 유무 무관 삭제
|
||||
* - 필드 단위 보존은 upsert 시점(`syncMenu`/`syncRole`) 에서만 작동
|
||||
* - 예외: Role 에 `user_roles` 피벗 참조 사용자 있으면 삭제 차단 (안전 가드)
|
||||
*/
|
||||
class ExtensionSyncHelperUserOverridesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_cleanupStaleMenus_deletes_even_with_user_overrides(): void
|
||||
{
|
||||
Menu::create([
|
||||
'slug' => 'stale-with-overrides',
|
||||
'name' => ['ko' => 'user_overrides 있어도 삭제'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'url' => '/admin/stale-overrides',
|
||||
'user_overrides' => ['name', 'url'],
|
||||
'is_active' => true,
|
||||
'order' => 1,
|
||||
]);
|
||||
Menu::create([
|
||||
'slug' => 'stale-without-overrides',
|
||||
'name' => ['ko' => 'user_overrides 없는 삭제'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'url' => '/admin/stale',
|
||||
'user_overrides' => [],
|
||||
'is_active' => true,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$helper = app(ExtensionMenuSyncHelper::class);
|
||||
$deleted = $helper->cleanupStaleMenus(ExtensionOwnerType::Core, 'core', []);
|
||||
|
||||
$this->assertGreaterThanOrEqual(2, $deleted);
|
||||
$this->assertDatabaseMissing('menus', ['slug' => 'stale-with-overrides']);
|
||||
$this->assertDatabaseMissing('menus', ['slug' => 'stale-without-overrides']);
|
||||
}
|
||||
|
||||
public function test_cleanupStalePermissions_deletes_by_pure_diff(): void
|
||||
{
|
||||
Permission::create([
|
||||
'identifier' => 'core.stale.to_keep',
|
||||
'name' => ['ko' => '유지'],
|
||||
'description' => ['ko' => '유지'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => PermissionType::Admin,
|
||||
]);
|
||||
Permission::create([
|
||||
'identifier' => 'core.stale.to_delete',
|
||||
'name' => ['ko' => '삭제'],
|
||||
'description' => ['ko' => '삭제'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => PermissionType::Admin,
|
||||
]);
|
||||
|
||||
$helper = app(ExtensionRoleSyncHelper::class);
|
||||
$deleted = $helper->cleanupStalePermissions(
|
||||
ExtensionOwnerType::Core,
|
||||
'core',
|
||||
['core.stale.to_keep'],
|
||||
);
|
||||
|
||||
$this->assertGreaterThanOrEqual(1, $deleted);
|
||||
$this->assertDatabaseHas('permissions', ['identifier' => 'core.stale.to_keep']);
|
||||
$this->assertDatabaseMissing('permissions', ['identifier' => 'core.stale.to_delete']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleRoles_deletes_even_with_user_overrides(): void
|
||||
{
|
||||
Role::create([
|
||||
'identifier' => 'test-stale-with-overrides',
|
||||
'name' => ['ko' => 'user_overrides 있어도 삭제'],
|
||||
'description' => ['ko' => '삭제됨'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
'user_overrides' => ['name'],
|
||||
]);
|
||||
Role::create([
|
||||
'identifier' => 'test-stale-no-overrides',
|
||||
'name' => ['ko' => 'user_overrides 없이 삭제'],
|
||||
'description' => ['ko' => '삭제됨'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$helper = app(ExtensionRoleSyncHelper::class);
|
||||
$deleted = $helper->cleanupStaleRoles(ExtensionOwnerType::Core, 'core', []);
|
||||
|
||||
$this->assertGreaterThanOrEqual(2, $deleted);
|
||||
$this->assertDatabaseMissing('roles', ['identifier' => 'test-stale-with-overrides']);
|
||||
$this->assertDatabaseMissing('roles', ['identifier' => 'test-stale-no-overrides']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleRoles_blocks_deletion_when_users_reference(): void
|
||||
{
|
||||
$role = Role::create([
|
||||
'identifier' => 'test-blocked-role',
|
||||
'name' => ['ko' => '차단 역할'],
|
||||
'description' => ['ko' => '차단'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->roles()->attach($role->id);
|
||||
|
||||
$helper = app(ExtensionRoleSyncHelper::class);
|
||||
$helper->cleanupStaleRoles(ExtensionOwnerType::Core, 'core', []);
|
||||
|
||||
$this->assertDatabaseHas('roles', [
|
||||
'identifier' => 'test-blocked-role',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Helpers\GenericEntitySyncHelper;
|
||||
use App\Models\NotificationDefinition;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* GenericEntitySyncHelper 회귀 방지.
|
||||
*
|
||||
* 단일 테이블·단일 unique 키·선택적 scope 필터 범용 패턴 검증.
|
||||
*
|
||||
* 실제 소비처: ShippingType, BoardType, ClaimReason, (다음 버전) Schedule.
|
||||
* 본 테스트는 `NotificationDefinition` (HasUserOverrides 적용 + 스코프 컬럼 존재)
|
||||
* 을 대리 대상으로 사용하여 helper 동작을 검증합니다.
|
||||
*/
|
||||
class GenericEntitySyncHelperTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private GenericEntitySyncHelper $helper;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->helper = app(GenericEntitySyncHelper::class);
|
||||
}
|
||||
|
||||
public function test_sync_creates_new_entity(): void
|
||||
{
|
||||
$entity = $this->helper->sync(
|
||||
NotificationDefinition::class,
|
||||
['type' => 'generic-new'],
|
||||
[
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '신규'],
|
||||
'description' => ['ko' => '신규'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame('generic-new', $entity->type);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'generic-new']);
|
||||
}
|
||||
|
||||
public function test_sync_preserves_user_overrides(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'generic-override',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '사용자 수정됨'],
|
||||
'description' => ['ko' => '설명'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
'user_overrides' => ['name'],
|
||||
]);
|
||||
|
||||
$this->helper->sync(
|
||||
NotificationDefinition::class,
|
||||
['type' => 'generic-override'],
|
||||
[
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '시더 기본값'],
|
||||
'description' => ['ko' => '새 설명'],
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$entity = NotificationDefinition::where('type', 'generic-override')->first();
|
||||
|
||||
$this->assertSame(['ko' => '사용자 수정됨'], $entity->name, 'user_overrides 필드 name 은 보존');
|
||||
$this->assertSame(['ko' => '새 설명'], $entity->description, '추적 외 필드는 갱신');
|
||||
}
|
||||
|
||||
public function test_cleanupStale_with_scope_filter(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'keep',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'name' => ['ko' => '유지'],
|
||||
'description' => ['ko' => '유지'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationDefinition::create([
|
||||
'type' => 'remove',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'name' => ['ko' => '삭제'],
|
||||
'description' => ['ko' => '삭제'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationDefinition::create([
|
||||
'type' => 'other-module',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-board',
|
||||
'name' => ['ko' => '타 모듈'],
|
||||
'description' => ['ko' => '타 모듈'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStale(
|
||||
NotificationDefinition::class,
|
||||
['extension_type' => 'module', 'extension_identifier' => 'sirsoft-ecommerce'],
|
||||
'type',
|
||||
['keep'],
|
||||
);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'keep']);
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'remove']);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'other-module'], null, 'scope 외 데이터 보호');
|
||||
}
|
||||
|
||||
public function test_cleanupStale_deletes_even_with_user_overrides(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'stale-overridden',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'm1',
|
||||
'name' => ['ko' => '사용자 수정'],
|
||||
'description' => ['ko' => '삭제 대상'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
'user_overrides' => ['name'],
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStale(
|
||||
NotificationDefinition::class,
|
||||
['extension_type' => 'module', 'extension_identifier' => 'm1'],
|
||||
'type',
|
||||
[],
|
||||
);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'stale-overridden']);
|
||||
}
|
||||
|
||||
public function test_cleanupStale_empty_scope_affects_whole_table(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'a',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => 'a'],
|
||||
'description' => ['ko' => 'a'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationDefinition::create([
|
||||
'type' => 'b',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'm1',
|
||||
'name' => ['ko' => 'b'],
|
||||
'description' => ['ko' => 'b'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStale(
|
||||
NotificationDefinition::class,
|
||||
[],
|
||||
'type',
|
||||
['a'],
|
||||
);
|
||||
|
||||
$this->assertSame(1, $deleted, '빈 scope 는 전체 테이블 대상');
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'a']);
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'b']);
|
||||
}
|
||||
|
||||
public function test_cleanupStale_noop_when_all_current(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'single',
|
||||
'hook_prefix' => 'x',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => 'single'],
|
||||
'description' => ['ko' => 'single'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStale(
|
||||
NotificationDefinition::class,
|
||||
['extension_type' => 'core'],
|
||||
'type',
|
||||
['single'],
|
||||
);
|
||||
|
||||
$this->assertSame(0, $deleted);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'single']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Helpers;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\Helpers\DependencyEnricher;
|
||||
use App\Models\Module;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DependencyEnricherTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function enrich_빈_의존성은_빈_배열을_반환합니다(): void
|
||||
{
|
||||
$result = DependencyEnricher::enrich([]);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_modules_plugins_키가_없으면_빈_배열을_반환합니다(): void
|
||||
{
|
||||
$this->assertSame([], DependencyEnricher::enrich(['foo' => 'bar']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_미설치_모듈은_installed_version_null_과_is_met_false를_반환합니다(): void
|
||||
{
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn(null);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('sirsoft-ecommerce', $result[0]['identifier']);
|
||||
$this->assertSame('sirsoft-ecommerce', $result[0]['name']);
|
||||
$this->assertSame('module', $result[0]['type']);
|
||||
$this->assertSame('>=1.0.0', $result[0]['required_version']);
|
||||
$this->assertNull($result[0]['installed_version']);
|
||||
$this->assertFalse($result[0]['is_active']);
|
||||
$this->assertFalse($result[0]['is_met']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_설치_활성_모듈은_is_met_true를_반환합니다(): void
|
||||
{
|
||||
$module = new Module(['name' => ['ko' => '상점', 'en' => 'Shop'], 'version' => '1.2.0']);
|
||||
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn($module);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('1.2.0', $result[0]['installed_version']);
|
||||
$this->assertTrue($result[0]['is_active']);
|
||||
$this->assertTrue($result[0]['is_met']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_설치되었지만_비활성_모듈은_is_met_false를_반환합니다(): void
|
||||
{
|
||||
$module = new Module(['name' => ['ko' => '상점'], 'version' => '1.2.0']);
|
||||
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn($module);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
]);
|
||||
|
||||
$this->assertSame('1.2.0', $result[0]['installed_version']);
|
||||
$this->assertFalse($result[0]['is_active']);
|
||||
$this->assertFalse($result[0]['is_met']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_버전_미충족_모듈은_is_met_false를_반환합니다(): void
|
||||
{
|
||||
$module = new Module(['name' => ['ko' => '상점'], 'version' => '0.9.0']);
|
||||
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn($module);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
]);
|
||||
|
||||
$this->assertSame('0.9.0', $result[0]['installed_version']);
|
||||
$this->assertTrue($result[0]['is_active']);
|
||||
$this->assertFalse($result[0]['is_met']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_modules_와_plugins를_순서대로_반환합니다(): void
|
||||
{
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn(null);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$pluginRepo->method('findByIdentifier')->willReturn(null);
|
||||
$pluginRepo->method('findActiveByIdentifier')->willReturn(null);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-board' => '>=1.0.0'],
|
||||
'plugins' => ['sirsoft-tosspayments' => '>=0.1.0'],
|
||||
]);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertSame('module', $result[0]['type']);
|
||||
$this->assertSame('sirsoft-board', $result[0]['identifier']);
|
||||
$this->assertSame('plugin', $result[1]['type']);
|
||||
$this->assertSame('sirsoft-tosspayments', $result[1]['identifier']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrich_다국어_이름_배열에서_현재_로케일을_선택합니다(): void
|
||||
{
|
||||
app()->setLocale('ko');
|
||||
|
||||
$module = new Module(['name' => ['ko' => '게시판', 'en' => 'Board'], 'version' => '1.0.0']);
|
||||
|
||||
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
|
||||
$moduleRepo->method('findByIdentifier')->willReturn($module);
|
||||
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
|
||||
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
|
||||
|
||||
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
|
||||
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
|
||||
|
||||
$result = DependencyEnricher::enrich([
|
||||
'modules' => ['sirsoft-board' => '*'],
|
||||
]);
|
||||
|
||||
$this->assertSame('게시판', $result[0]['name']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
namespace Tests\Unit\Extension\Helpers;
|
||||
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -97,58 +101,6 @@ class GithubHelperTest extends TestCase
|
||||
$this->assertSame('Authorization: Bearer ghp_test_token', $headers[2]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// extractStatusCode
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function extractStatusCode_200_응답을_추출합니다(): void
|
||||
{
|
||||
$statusCode = GithubHelper::extractStatusCode(['HTTP/1.1 200 OK']);
|
||||
|
||||
$this->assertSame(200, $statusCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function extractStatusCode_404_응답을_추출합니다(): void
|
||||
{
|
||||
$statusCode = GithubHelper::extractStatusCode(['HTTP/1.1 404 Not Found']);
|
||||
|
||||
$this->assertSame(404, $statusCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function extractStatusCode_빈_헤더에서_0을_반환합니다(): void
|
||||
{
|
||||
$statusCode = GithubHelper::extractStatusCode([]);
|
||||
|
||||
$this->assertSame(0, $statusCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function extractStatusCode_리다이렉트_시_마지막_상태코드를_반환합니다(): void
|
||||
{
|
||||
$statusCode = GithubHelper::extractStatusCode([
|
||||
'HTTP/1.1 301 Moved Permanently',
|
||||
'Location: https://example.com',
|
||||
'HTTP/1.1 200 OK',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $statusCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function extractStatusCode_302_후_200_리다이렉트_체인을_처리합니다(): void
|
||||
{
|
||||
$statusCode = GithubHelper::extractStatusCode([
|
||||
'HTTP/1.1 302 Found',
|
||||
'HTTP/1.1 302 Found',
|
||||
'HTTP/1.1 200 OK',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $statusCode);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// parseUrl 경계 케이스
|
||||
// ──────────────────────────────────────────
|
||||
@@ -171,4 +123,321 @@ class GithubHelperTest extends TestCase
|
||||
$this->assertSame('owner', $owner);
|
||||
$this->assertSame('repo', $repo);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// checkRepoExists (Http::fake 기반)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function checkRepoExists_200_응답이면_true를_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/owner/repo' => Http::response(['full_name' => 'owner/repo'], 200),
|
||||
]);
|
||||
|
||||
$this->assertTrue(GithubHelper::checkRepoExists('owner', 'repo'));
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.github.com/repos/owner/repo'
|
||||
&& $request->hasHeader('User-Agent', 'G7')
|
||||
&& $request->hasHeader('Accept', 'application/vnd.github.v3+json');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkRepoExists_404_응답이면_false를_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/*' => Http::response([], 404),
|
||||
]);
|
||||
|
||||
$this->assertFalse(GithubHelper::checkRepoExists('owner', 'repo'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkRepoExists_연결_실패_시_false를_반환합니다(): void
|
||||
{
|
||||
Http::fake(function () {
|
||||
throw new ConnectionException('Connection refused');
|
||||
});
|
||||
|
||||
$this->assertFalse(GithubHelper::checkRepoExists('owner', 'repo'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkRepoExists_토큰이_있으면_Authorization_헤더를_포함합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
GithubHelper::checkRepoExists('owner', 'repo', 'ghp_test_token');
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->hasHeader('Authorization', 'Bearer ghp_test_token');
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// fetchLatestRelease (Http::fake 기반)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function fetchLatestRelease_200_응답이면_버전과_zipball_url을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/owner/repo/releases/latest' => Http::response([
|
||||
'tag_name' => 'v7.0.1',
|
||||
'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v7.0.1',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
|
||||
|
||||
$this->assertSame('7.0.1', $result['version']);
|
||||
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v7.0.1', $result['zipball_url']);
|
||||
$this->assertNull($result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchLatestRelease_v접두사_없는_tag_name을_정상_처리합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response(['tag_name' => '1.2.3', 'zipball_url' => 'https://example.com/z'], 200),
|
||||
]);
|
||||
|
||||
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
|
||||
|
||||
$this->assertSame('1.2.3', $result['version']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchLatestRelease_404_응답이면_version_null과_error_null을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response([], 404),
|
||||
]);
|
||||
|
||||
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
|
||||
|
||||
$this->assertNull($result['version']);
|
||||
$this->assertNull($result['zipball_url']);
|
||||
$this->assertNull($result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchLatestRelease_연결_실패_시_error_메시지를_반환합니다(): void
|
||||
{
|
||||
Http::fake(function () {
|
||||
throw new ConnectionException('Network unreachable');
|
||||
});
|
||||
|
||||
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
|
||||
|
||||
$this->assertNull($result['version']);
|
||||
$this->assertNull($result['zipball_url']);
|
||||
$this->assertNotNull($result['error']);
|
||||
$this->assertStringContainsString('GitHub', $result['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchLatestRelease_tag_name_누락이면_version_null을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response(['name' => 'release name only'], 200),
|
||||
]);
|
||||
|
||||
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
|
||||
|
||||
$this->assertNull($result['version']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// downloadArchive (sink 기반)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function downloadArchive_성공_시_파일을_생성합니다(): void
|
||||
{
|
||||
$savePath = sys_get_temp_dir().'/github_test_'.uniqid().'.zip';
|
||||
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('fake-zip-binary', 200),
|
||||
]);
|
||||
|
||||
try {
|
||||
GithubHelper::downloadArchive('https://example.com/archive.zip', $savePath);
|
||||
|
||||
$this->assertTrue(File::exists($savePath));
|
||||
$this->assertSame('fake-zip-binary', File::get($savePath));
|
||||
} finally {
|
||||
if (File::exists($savePath)) {
|
||||
File::delete($savePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadArchive_실패_응답이면_RuntimeException을_던집니다(): void
|
||||
{
|
||||
$savePath = sys_get_temp_dir().'/github_test_'.uniqid().'.zip';
|
||||
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('', 404),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->expectException(\RuntimeException::class);
|
||||
GithubHelper::downloadArchive('https://example.com/missing.zip', $savePath);
|
||||
} finally {
|
||||
if (File::exists($savePath)) {
|
||||
File::delete($savePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadArchive_연결_실패_시_RuntimeException을_던집니다(): void
|
||||
{
|
||||
Http::fake(function () {
|
||||
throw new ConnectionException('Timeout');
|
||||
});
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
GithubHelper::downloadArchive(
|
||||
'https://example.com/archive.zip',
|
||||
sys_get_temp_dir().'/github_test_'.uniqid().'.zip'
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// downloadArchiveContent (메모리 반환)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function downloadArchiveContent_성공_시_바이너리_내용을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('binary-content', 200),
|
||||
]);
|
||||
|
||||
$content = GithubHelper::downloadArchiveContent('https://example.com/archive.zip');
|
||||
|
||||
$this->assertSame('binary-content', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadArchiveContent_실패_응답이면_RuntimeException을_던집니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
GithubHelper::downloadArchiveContent('https://example.com/archive.zip');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// resolveArchiveUrl (HEAD 요청 폴백)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function resolveArchiveUrl_v접두사_태그_200이면_해당_URL을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
|
||||
|
||||
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v1.0.0', $url);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveArchiveUrl_v접두사_404이면_순수버전으로_폴백합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 404),
|
||||
'api.github.com/repos/owner/repo/zipball/1.0.0' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
|
||||
|
||||
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/1.0.0', $url);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveArchiveUrl_둘다_404이면_null을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response('', 404),
|
||||
]);
|
||||
|
||||
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
|
||||
|
||||
$this->assertNull($url);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveArchiveUrl_302이면_해당_URL을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 302),
|
||||
]);
|
||||
|
||||
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
|
||||
|
||||
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v1.0.0', $url);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// fetchRawFile (raw.githubusercontent.com)
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function fetchRawFile_원본_ref로_200이면_내용을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'raw.githubusercontent.com/owner/repo/main/CHANGELOG.md' => Http::response('# Changelog', 200),
|
||||
]);
|
||||
|
||||
$content = GithubHelper::fetchRawFile('owner', 'repo', 'main', 'CHANGELOG.md');
|
||||
|
||||
$this->assertSame('# Changelog', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchRawFile_v접두사_없는_태그는_v접두사로_폴백합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'raw.githubusercontent.com/owner/repo/1.0.0/README.md' => Http::response('', 404),
|
||||
'raw.githubusercontent.com/owner/repo/v1.0.0/README.md' => Http::response('readme body', 200),
|
||||
]);
|
||||
|
||||
$content = GithubHelper::fetchRawFile('owner', 'repo', '1.0.0', 'README.md');
|
||||
|
||||
$this->assertSame('readme body', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchRawFile_모두_404이면_null을_반환합니다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'raw.githubusercontent.com/*' => Http::response('', 404),
|
||||
]);
|
||||
|
||||
$this->assertNull(GithubHelper::fetchRawFile('owner', 'repo', 'main', 'missing.md'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fetchRawFile_연결_실패_시_null을_반환합니다(): void
|
||||
{
|
||||
Http::fake(function () {
|
||||
throw new ConnectionException('DNS failure');
|
||||
});
|
||||
|
||||
$this->assertNull(GithubHelper::fetchRawFile('owner', 'repo', 'main', 'CHANGELOG.md'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\HookArgumentSerializer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HookArgumentSerializer 테스트
|
||||
*
|
||||
* 훅 인자의 직렬화/역직렬화를 검증합니다.
|
||||
*/
|
||||
class HookArgumentSerializerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 스칼라 값이 그대로 직렬화/역직렬화되는지 검증합니다.
|
||||
*/
|
||||
public function test_scalar_values_pass_through(): void
|
||||
{
|
||||
$args = ['hello', 123, 45.6, true, null];
|
||||
$serialized = HookArgumentSerializer::serialize($args);
|
||||
$deserialized = HookArgumentSerializer::deserialize($serialized);
|
||||
|
||||
$this->assertEquals($args, $deserialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열이 재귀적으로 직렬화/역직렬화되는지 검증합니다.
|
||||
*/
|
||||
public function test_nested_arrays_are_serialized_recursively(): void
|
||||
{
|
||||
$args = [['key' => 'value', 'nested' => ['a' => 1, 'b' => 2]]];
|
||||
$serialized = HookArgumentSerializer::serialize($args);
|
||||
$deserialized = HookArgumentSerializer::deserialize($serialized);
|
||||
|
||||
$this->assertEquals($args, $deserialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eloquent Model이 클래스명 + PK로 직렬화되고 DB에서 복원되는지 검증합니다.
|
||||
*/
|
||||
public function test_eloquent_model_serialization(): void
|
||||
{
|
||||
$user = User::factory()->create(['name' => 'TestUser']);
|
||||
|
||||
$serialized = HookArgumentSerializer::serialize([$user]);
|
||||
|
||||
// 직렬화 결과에 마커와 클래스명, ID 포함
|
||||
$this->assertTrue($serialized[0]['__hook_model__']);
|
||||
$this->assertEquals(User::class, $serialized[0]['class']);
|
||||
$this->assertEquals($user->id, $serialized[0]['id']);
|
||||
|
||||
// 역직렬화 시 DB에서 조회하여 복원
|
||||
$deserialized = HookArgumentSerializer::deserialize($serialized);
|
||||
$this->assertInstanceOf(User::class, $deserialized[0]);
|
||||
$this->assertEquals($user->id, $deserialized[0]->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection이 직렬화/역직렬화되는지 검증합니다.
|
||||
*/
|
||||
public function test_collection_serialization(): void
|
||||
{
|
||||
$collection = collect(['a', 'b', 'c']);
|
||||
$serialized = HookArgumentSerializer::serialize([$collection]);
|
||||
|
||||
$this->assertTrue($serialized[0]['__hook_collection__']);
|
||||
|
||||
$deserialized = HookArgumentSerializer::deserialize($serialized);
|
||||
$this->assertInstanceOf(Collection::class, $deserialized[0]);
|
||||
$this->assertEquals(['a', 'b', 'c'], $deserialized[0]->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closure 등 직렬화 불가 객체가 null로 대체되는지 검증합니다.
|
||||
*/
|
||||
public function test_non_serializable_objects_become_null(): void
|
||||
{
|
||||
$closure = function () { return 'test'; };
|
||||
$serialized = HookArgumentSerializer::serialize([$closure]);
|
||||
|
||||
$this->assertNull($serialized[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 혼합 인자 배열이 올바르게 처리되는지 검증합니다.
|
||||
*/
|
||||
public function test_mixed_arguments(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$args = ['action_name', $user, ['extra' => 'data'], 42];
|
||||
$serialized = HookArgumentSerializer::serialize($args);
|
||||
$deserialized = HookArgumentSerializer::deserialize($serialized);
|
||||
|
||||
$this->assertEquals('action_name', $deserialized[0]);
|
||||
$this->assertInstanceOf(User::class, $deserialized[1]);
|
||||
$this->assertEquals($user->id, $deserialized[1]->id);
|
||||
$this->assertEquals(['extra' => 'data'], $deserialized[2]);
|
||||
$this->assertEquals(42, $deserialized[3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\HookContextCapture;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HookContextCapture 테스트
|
||||
*
|
||||
* 큐 워커에서 Auth/Request/Locale 컨텍스트를 캡처하고 복원하는 동작을 검증합니다.
|
||||
*/
|
||||
class HookContextCaptureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
HookManager::resetAll();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* capture()가 현재 Auth/Request/Locale을 스냅샷하는지 검증합니다.
|
||||
*/
|
||||
public function test_capture_returns_current_request_context(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
App::setLocale('ko');
|
||||
|
||||
$request = Request::create('/api/admin/users', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '203.0.113.10',
|
||||
'HTTP_USER_AGENT' => 'TestAgent/1.0',
|
||||
]);
|
||||
app()->instance('request', $request);
|
||||
|
||||
$context = HookContextCapture::capture();
|
||||
|
||||
$this->assertEquals($user->id, $context['user_id']);
|
||||
$this->assertEquals('203.0.113.10', $context['ip_address']);
|
||||
$this->assertEquals('TestAgent/1.0', $context['user_agent']);
|
||||
$this->assertEquals('ko', $context['locale']);
|
||||
$this->assertEquals('api/admin/users', $context['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미인증 상태에서도 capture()가 안전하게 동작하는지 검증합니다.
|
||||
*/
|
||||
public function test_capture_handles_unauthenticated_state(): void
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$context = HookContextCapture::capture();
|
||||
|
||||
$this->assertNull($context['user_id']);
|
||||
$this->assertArrayHasKey('locale', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* restore()가 Auth, Locale, Request를 복원하는지 검증합니다.
|
||||
*/
|
||||
public function test_restore_recovers_auth_locale_and_request(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 큐 워커처럼 빈 컨텍스트에서 시작
|
||||
Auth::logout();
|
||||
App::setLocale('en');
|
||||
|
||||
HookContextCapture::restore([
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '198.51.100.7',
|
||||
'user_agent' => 'WorkerAgent/2.0',
|
||||
'locale' => 'ko',
|
||||
'path' => 'api/admin/settings',
|
||||
]);
|
||||
|
||||
$this->assertEquals($user->id, Auth::id());
|
||||
$this->assertEquals('ko', App::getLocale());
|
||||
$this->assertEquals('198.51.100.7', request()->ip());
|
||||
$this->assertEquals('WorkerAgent/2.0', request()->userAgent());
|
||||
$this->assertEquals('api/admin/settings', request()->path());
|
||||
$this->assertTrue(request()->is('api/admin/*'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 컨텍스트(user_id null 등)에서도 restore()가 예외 없이 동작합니다.
|
||||
*/
|
||||
public function test_restore_handles_empty_context(): void
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
HookContextCapture::restore([
|
||||
'user_id' => null,
|
||||
'ip_address' => null,
|
||||
'user_agent' => null,
|
||||
'locale' => null,
|
||||
'path' => null,
|
||||
]);
|
||||
|
||||
$this->assertNull(Auth::id());
|
||||
}
|
||||
|
||||
/**
|
||||
* capture() 시 hook.context.capture 필터로 플러그인이 키를 추가할 수 있는지 검증합니다.
|
||||
*/
|
||||
public function test_capture_filter_allows_extension_to_add_keys(): void
|
||||
{
|
||||
HookManager::addFilter('hook.context.capture', function (array $context) {
|
||||
$context['tenant_id'] = 'tenant-42';
|
||||
|
||||
return $context;
|
||||
});
|
||||
|
||||
$context = HookContextCapture::capture();
|
||||
|
||||
$this->assertEquals('tenant-42', $context['tenant_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* restore() 시 hook.context.restore 액션으로 플러그인 복원 로직이 실행되는지 검증합니다.
|
||||
*/
|
||||
public function test_restore_action_fires_for_extension_recovery(): void
|
||||
{
|
||||
$received = null;
|
||||
HookManager::addAction('hook.context.restore', function (array $context) use (&$received) {
|
||||
$received = $context['tenant_id'] ?? null;
|
||||
});
|
||||
|
||||
HookContextCapture::restore([
|
||||
'user_id' => null,
|
||||
'tenant_id' => 'tenant-99',
|
||||
]);
|
||||
|
||||
$this->assertEquals('tenant-99', $received);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HookListenerRegistrar 테스트
|
||||
*
|
||||
* 리스너 등록 시 큐/동기 분기와 filter 등록을 검증합니다.
|
||||
*/
|
||||
class HookListenerRegistrarTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
HookManager::resetAll();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 Action 리스너가 큐 Job으로 디스패치되는지 검증합니다.
|
||||
*/
|
||||
public function test_action_listener_dispatches_job_by_default(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
HookListenerRegistrar::register(StubActionListener::class);
|
||||
HookManager::doAction('test.registrar.action', 'hello');
|
||||
|
||||
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) {
|
||||
return $job->listenerClass === StubActionListener::class
|
||||
&& $job->method === 'handleAction';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스패치된 Job이 현재 요청 컨텍스트(Auth/Locale/IP)를 함께 캡처하는지 검증합니다.
|
||||
*
|
||||
* 큐 워커는 별도 프로세스라 Auth가 사라지므로, 캡처 시점에 스냅샷을 함께
|
||||
* Job 페이로드로 전달해야 워커에서 복원 가능합니다.
|
||||
*/
|
||||
public function test_dispatched_job_captures_request_context(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = \App\Models\User::factory()->create();
|
||||
\Illuminate\Support\Facades\Auth::login($user);
|
||||
\Illuminate\Support\Facades\App::setLocale('ko');
|
||||
|
||||
$request = \Illuminate\Http\Request::create('/api/admin/users', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '203.0.113.10',
|
||||
'HTTP_USER_AGENT' => 'TestAgent/1.0',
|
||||
]);
|
||||
app()->instance('request', $request);
|
||||
|
||||
HookListenerRegistrar::register(StubActionListener::class);
|
||||
HookManager::doAction('test.registrar.action', 'hello');
|
||||
|
||||
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) use ($user) {
|
||||
return $job->context['user_id'] === $user->id
|
||||
&& $job->context['locale'] === 'ko'
|
||||
&& $job->context['ip_address'] === '203.0.113.10'
|
||||
&& $job->context['user_agent'] === 'TestAgent/1.0'
|
||||
&& $job->context['path'] === 'api/admin/users';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* sync: true 리스너가 동기로 실행되는지 검증합니다.
|
||||
*/
|
||||
public function test_sync_listener_executes_immediately(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
HookListenerRegistrar::register(StubSyncListener::class);
|
||||
HookManager::doAction('test.registrar.sync');
|
||||
|
||||
// Job이 디스패치되지 않아야 함
|
||||
Queue::assertNotPushed(DispatchHookListenerJob::class);
|
||||
// 동기 실행 확인
|
||||
$this->assertTrue(StubSyncListener::$executed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter 리스너가 동기로 실행되고 반환값이 정상 전달되는지 검증합니다.
|
||||
*/
|
||||
public function test_filter_listener_executes_synchronously(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
HookListenerRegistrar::register(StubFilterListener::class);
|
||||
$result = HookManager::applyFilters('test.registrar.filter', 'original');
|
||||
|
||||
$this->assertEquals('original_filtered', $result);
|
||||
Queue::assertNotPushed(DispatchHookListenerJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 우선순위가 HookManager에 정확히 전달되는지 검증합니다.
|
||||
*/
|
||||
public function test_priority_is_passed_to_hook_manager(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$order = [];
|
||||
|
||||
// 우선순위 5, 20인 두 리스너를 등록하여 순서 확인
|
||||
// StubPriorityListener는 sync: true로 실행 순서를 즉시 확인 가능
|
||||
HookListenerRegistrar::register(StubPriorityHighListener::class);
|
||||
HookListenerRegistrar::register(StubPriorityLowListener::class);
|
||||
|
||||
StubPriorityHighListener::$order = &$order;
|
||||
StubPriorityLowListener::$order = &$order;
|
||||
|
||||
HookManager::doAction('test.registrar.priority');
|
||||
|
||||
$this->assertEquals(['high', 'low'], $order);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 테스트용 스텁 클래스 ---
|
||||
|
||||
class StubActionListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.registrar.action' => ['method' => 'handleAction', 'priority' => 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleAction(string $value): void {}
|
||||
}
|
||||
|
||||
class StubSyncListener implements HookListenerInterface
|
||||
{
|
||||
public static bool $executed = false;
|
||||
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.registrar.sync' => ['method' => 'handleSync', 'priority' => 10, 'sync' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleSync(): void
|
||||
{
|
||||
self::$executed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class StubFilterListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.registrar.filter' => ['method' => 'handleFilter', 'priority' => 10, 'type' => 'filter'],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleFilter(string $value): string
|
||||
{
|
||||
return $value.'_filtered';
|
||||
}
|
||||
}
|
||||
|
||||
class StubPriorityHighListener implements HookListenerInterface
|
||||
{
|
||||
public static array $order = [];
|
||||
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.registrar.priority' => ['method' => 'handlePriority', 'priority' => 5, 'sync' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handlePriority(): void
|
||||
{
|
||||
self::$order[] = 'high';
|
||||
}
|
||||
}
|
||||
|
||||
class StubPriorityLowListener implements HookListenerInterface
|
||||
{
|
||||
public static array $order = [];
|
||||
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.registrar.priority' => ['method' => 'handlePriority', 'priority' => 20, 'sync' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handlePriority(): void
|
||||
{
|
||||
self::$order[] = 'low';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HookManager::broadcast() 테스트
|
||||
*
|
||||
* HookManager를 통한 WebSocket 브로드캐스트가 올바르게 동작하는지 검증합니다.
|
||||
*/
|
||||
class HookManagerBroadcastTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* broadcast() 호출 시 GenericBroadcastEvent가 dispatch되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_dispatches_generic_broadcast_event(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'reverb']);
|
||||
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
|
||||
'type' => 'stats',
|
||||
'data' => ['users' => 100],
|
||||
]);
|
||||
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
return $event->channel === 'core.admin.dashboard'
|
||||
&& $event->eventName === 'dashboard.stats.updated'
|
||||
&& $event->payload === ['type' => 'stats', 'data' => ['users' => 100]];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* broadcast()에 빈 payload가 허용되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_allows_empty_payload(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'reverb']);
|
||||
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
HookManager::broadcast('core.user.notifications.123', 'notification.received');
|
||||
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
return $event->channel === 'core.user.notifications.123'
|
||||
&& $event->eventName === 'notification.received'
|
||||
&& $event->payload === [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버가 null이면 브로드캐스트를 건너뛰는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_skips_when_driver_is_null(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'null']);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
|
||||
|
||||
Event::assertNotDispatched(GenericBroadcastEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버가 log이면 브로드캐스트를 건너뛰는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_skips_when_driver_is_log(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'log']);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
|
||||
|
||||
Event::assertNotDispatched(GenericBroadcastEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 호스트가 미설정이면 브로드캐스트를 건너뛰는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcast_skips_when_host_is_empty(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'reverb']);
|
||||
config(['broadcasting.connections.reverb.options.host' => null]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
|
||||
|
||||
Event::assertNotDispatched(GenericBroadcastEvent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* broadcast() 실패 시 예외가 전파되지 않고 로그만 기록되는지 테스트합니다.
|
||||
*
|
||||
* 테스트 환경에서는 reverb 자격증명이 비어 있어 Laravel BroadcastManager가
|
||||
* Pusher 인스턴스 생성 단계에서 예외를 던지며, HookManager의 catch 블록이
|
||||
* 이를 잡아 Log::warning으로 기록하는 동작을 검증합니다.
|
||||
*/
|
||||
public function test_broadcast_failure_does_not_propagate_exception(): void
|
||||
{
|
||||
config(['broadcasting.default' => 'reverb']);
|
||||
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
|
||||
// 자격증명은 비워 두어 Pusher 생성 시 예외가 발생하도록 함
|
||||
config(['broadcasting.connections.reverb.key' => null]);
|
||||
config(['broadcasting.connections.reverb.secret' => null]);
|
||||
config(['broadcasting.connections.reverb.app_id' => null]);
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function (string $message, array $context) {
|
||||
return $message === '브로드캐스트 실패 (Reverb 미실행 가능)'
|
||||
&& $context['channel'] === 'core.admin.dashboard'
|
||||
&& $context['event'] === 'dashboard.stats.updated'
|
||||
&& ! empty($context['error']);
|
||||
});
|
||||
|
||||
// 예외가 전파되지 않아야 함 — 전파되면 아래 assertion 없이 테스트가 예외로 종료됨
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
|
||||
'type' => 'stats',
|
||||
]);
|
||||
|
||||
$this->assertTrue(true, '예외가 호출자로 전파되지 않음');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `config('app.installer_completed')` 가드가 Schema::hasTable() 호출을
|
||||
* 건너뛰는지 검증합니다.
|
||||
*
|
||||
* 성능 회귀 방지 목적: 설치 완료 상태에서 매 요청 `information_schema.tables`
|
||||
* 쿼리가 실행되지 않아야 합니다.
|
||||
*/
|
||||
class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_module_trait_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_plugin_trait_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
PluginManager::getActivePluginIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
}
|
||||
|
||||
public function test_template_trait_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
TemplateManager::getActiveTemplateIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
}
|
||||
|
||||
public function test_module_trait_falls_back_to_has_table_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ModuleManager::cleanupStaleModuleEntries() 회귀 테스트.
|
||||
*
|
||||
* 회귀 방지 대상: 모듈 정의의 권한은 `action` 필드로 식별되고, DB 저장 시
|
||||
* `{moduleIdentifier}.{category.identifier}.{action}` 포맷으로 기록된다.
|
||||
* cleanupStaleModuleEntries 가 동일 포맷으로 expected 목록을 만들지 않으면
|
||||
* 모든 모듈 권한이 "stale" 로 오판되어 전수 삭제되는 치명적 회귀가 발생한다.
|
||||
*
|
||||
* (참고) beta.2 번들 일괄 업데이트 시 sirsoft-board / sirsoft-ecommerce 등
|
||||
* 모듈 권한이 대부분 삭제된 실제 프로덕션 증상을 재현·차단한다.
|
||||
*/
|
||||
class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ModuleManager $moduleManager;
|
||||
|
||||
private bool $ecommerceExistedBefore = false;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$modulesPath = base_path('modules');
|
||||
$activePath = $modulesPath.'/sirsoft-ecommerce';
|
||||
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
$this->ecommerceExistedBefore = File::isDirectory($activePath);
|
||||
|
||||
$this->moduleManager = app(ModuleManager::class);
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
Role::create([
|
||||
'identifier' => 'admin',
|
||||
'name' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
|
||||
'description' => ['ko' => '관리자', 'en' => 'Administrator'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
]);
|
||||
Role::create([
|
||||
'identifier' => 'manager',
|
||||
'name' => ['ko' => '매니저', 'en' => 'Manager'],
|
||||
'description' => ['ko' => '매니저', 'en' => 'Manager'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
User::factory()->create(['email' => 'test@test.com']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (! $this->ecommerceExistedBefore) {
|
||||
$activePath = base_path('modules/sirsoft-ecommerce');
|
||||
if (File::isDirectory($activePath)) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_cleanup_stale_module_entries_does_not_delete_live_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$this->assertNotNull($module);
|
||||
|
||||
// 설치 후 ecommerce 모듈 권한 수 측정
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(
|
||||
10,
|
||||
$countBefore,
|
||||
'ecommerce 모듈은 install 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
|
||||
);
|
||||
|
||||
// cleanupStaleModuleEntries 를 직접 실행 — 정의와 저장이 일치하는 상태라면 아무것도 삭제되면 안 됨
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
|
||||
$this->assertSame(
|
||||
$countBefore,
|
||||
$countAfter,
|
||||
'cleanupStaleModuleEntries 는 정의된 권한을 삭제하지 않아야 합니다. '
|
||||
."before={$countBefore}, after={$countAfter} (손실={$this->lossCount($countBefore, $countAfter)})"
|
||||
);
|
||||
}
|
||||
|
||||
public function test_cleanup_stale_module_entries_removes_only_genuinely_stale_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
|
||||
// 인위적으로 stale 권한 1건 삽입 (정의에 없는 identifier)
|
||||
Permission::create([
|
||||
'identifier' => 'sirsoft-ecommerce.removed-category.obsolete-action',
|
||||
'name' => ['ko' => '제거된 권한', 'en' => 'Removed permission'],
|
||||
'description' => ['ko' => '제거된 권한', 'en' => 'Removed permission'],
|
||||
'extension_type' => ExtensionOwnerType::Module,
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'type' => \App\Enums\PermissionType::Admin,
|
||||
'order' => 999,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$liveCountBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->where('identifier', '!=', 'sirsoft-ecommerce.removed-category.obsolete-action')
|
||||
->count();
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
|
||||
// stale 1건만 삭제되고 나머지는 보존되어야 함
|
||||
$this->assertNull(
|
||||
Permission::where('identifier', 'sirsoft-ecommerce.removed-category.obsolete-action')->first(),
|
||||
'stale 권한은 삭제되어야 합니다.'
|
||||
);
|
||||
|
||||
$liveCountAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
|
||||
$this->assertSame(
|
||||
$liveCountBefore,
|
||||
$liveCountAfter,
|
||||
'정의된 권한은 전부 보존되어야 합니다. '
|
||||
."before={$liveCountBefore}, after={$liveCountAfter}"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 권한(getDynamicPermissionIdentifiers override) 이 stale cleanup 대상에서 보존되는지 검증.
|
||||
*
|
||||
* 회귀 시나리오: sirsoft-board 처럼 런타임에 Permission::updateOrCreate 로 추가된 권한이
|
||||
* 모듈 업데이트 시점의 stale cleanup 에서 "정적 정의에 없다"는 이유로 전수 삭제되는 버그.
|
||||
*/
|
||||
public function test_cleanup_preserves_dynamic_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
|
||||
// 런타임 동적 권한 모사: 정적 정의에 없는 식별자를 삽입
|
||||
Permission::create([
|
||||
'identifier' => 'sirsoft-ecommerce.dynamic-scope.action-alpha',
|
||||
'name' => ['ko' => '동적', 'en' => 'Dynamic'],
|
||||
'description' => ['ko' => '동적', 'en' => 'Dynamic'],
|
||||
'extension_type' => ExtensionOwnerType::Module,
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'type' => \App\Enums\PermissionType::Admin,
|
||||
'order' => 900,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
|
||||
// Mockery partial mock: 실제 모듈 동작 유지 + getDynamicPermissionIdentifiers 만 override
|
||||
$wrapper = \Mockery::mock($module)->makePartial();
|
||||
$wrapper->shouldReceive('getDynamicPermissionIdentifiers')
|
||||
->andReturn(['sirsoft-ecommerce.dynamic-scope.action-alpha']);
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $wrapper);
|
||||
|
||||
$this->assertNotNull(
|
||||
Permission::where('identifier', 'sirsoft-ecommerce.dynamic-scope.action-alpha')->first(),
|
||||
'동적 권한은 getDynamicPermissionIdentifiers 반환 시 보존되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* uninstall(deleteData=false) 시 권한·메뉴·역할이 보존되어 재설치 경로를 비파괴적으로 만드는지 검증.
|
||||
* PO 정책: "동적 권한/메뉴는 데이터 삭제 옵션 체크 시에만 삭제".
|
||||
*/
|
||||
public function test_uninstall_without_delete_data_preserves_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(0, $countBefore);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: false);
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
|
||||
$this->assertSame(
|
||||
$countBefore,
|
||||
$countAfter,
|
||||
'deleteData=false 시 권한은 보존되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* uninstall(deleteData=true) 시 권한이 삭제되는지 (기존 동작 유지) 검증.
|
||||
*/
|
||||
public function test_uninstall_with_delete_data_removes_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
|
||||
$this->assertGreaterThan(
|
||||
0,
|
||||
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
|
||||
);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: true);
|
||||
|
||||
$this->assertSame(
|
||||
0,
|
||||
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
|
||||
'deleteData=true 시 권한은 전수 삭제되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateComposerAutoload 가 cleanupStaleModuleEntries 보다 먼저 호출되는 순서를 검증.
|
||||
*
|
||||
* 회귀 시나리오: 동적 hook(getDynamicPermissionIdentifiers 등)이 모듈 클래스(예: Board 모델)를
|
||||
* 참조할 때, 현재 프로세스의 PSR-4 매핑이 갱신되기 전에 cleanup 이 실행되면 "Class not found"
|
||||
* 가 발생한다. 본 테스트는 ModuleManager::updateModule 의 소스 구조적 순서를 검증한다.
|
||||
*/
|
||||
public function test_update_module_calls_autoload_before_cleanup(): void
|
||||
{
|
||||
$source = file_get_contents(base_path('app/Extension/ModuleManager.php'));
|
||||
|
||||
$start = strpos($source, 'public function updateModule(');
|
||||
$this->assertNotFalse($start, 'updateModule 메서드를 찾을 수 없습니다.');
|
||||
// 다음 메서드(visibility 무관) 시작 지점을 본문 종료로 사용
|
||||
$end = false;
|
||||
foreach (['public function ', 'protected function ', 'private function '] as $kw) {
|
||||
$pos = strpos($source, $kw, $start + 1);
|
||||
if ($pos !== false && ($end === false || $pos < $end)) {
|
||||
$end = $pos;
|
||||
}
|
||||
}
|
||||
$this->assertNotFalse($end, '다음 메서드를 찾지 못해 본문 범위 결정 실패');
|
||||
$body = substr($source, $start, $end - $start);
|
||||
|
||||
$autoloadPos = strpos($body, '$this->extensionManager->updateComposerAutoload();');
|
||||
$cleanupPos = strpos($body, '$this->cleanupStaleModuleEntries(');
|
||||
|
||||
$this->assertNotFalse($autoloadPos, 'updateComposerAutoload 호출을 찾을 수 없습니다.');
|
||||
$this->assertNotFalse($cleanupPos, 'cleanupStaleModuleEntries 호출을 찾을 수 없습니다.');
|
||||
$this->assertLessThan(
|
||||
$cleanupPos,
|
||||
$autoloadPos,
|
||||
'updateComposerAutoload 는 cleanupStaleModuleEntries 보다 먼저 호출되어야 한다. '
|
||||
.'(동적 hook 의 모듈 클래스 autoload 보장)'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_update_plugin_calls_autoload_before_cleanup(): void
|
||||
{
|
||||
$source = file_get_contents(base_path('app/Extension/PluginManager.php'));
|
||||
|
||||
$start = strpos($source, 'public function updatePlugin(');
|
||||
$this->assertNotFalse($start, 'updatePlugin 메서드를 찾을 수 없습니다.');
|
||||
// 다음 메서드(visibility 무관) 시작 지점을 본문 종료로 사용
|
||||
$end = false;
|
||||
foreach (['public function ', 'protected function ', 'private function '] as $kw) {
|
||||
$pos = strpos($source, $kw, $start + 1);
|
||||
if ($pos !== false && ($end === false || $pos < $end)) {
|
||||
$end = $pos;
|
||||
}
|
||||
}
|
||||
$this->assertNotFalse($end, '다음 메서드를 찾지 못해 본문 범위 결정 실패');
|
||||
$body = substr($source, $start, $end - $start);
|
||||
|
||||
$autoloadPos = strpos($body, '$this->extensionManager->updateComposerAutoload();');
|
||||
$cleanupPos = strpos($body, '$this->cleanupStalePluginEntries(');
|
||||
|
||||
$this->assertNotFalse($autoloadPos);
|
||||
$this->assertNotFalse($cleanupPos);
|
||||
$this->assertLessThan(
|
||||
$cleanupPos,
|
||||
$autoloadPos,
|
||||
'updateComposerAutoload 는 cleanupStalePluginEntries 보다 먼저 호출되어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
private function lossCount(int $before, int $after): int
|
||||
{
|
||||
return max(0, $before - $after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\AbstractModule;
|
||||
use App\Extension\ModuleManager;
|
||||
use Mockery;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* resolveForceUpdateSource() 회귀 테스트.
|
||||
*
|
||||
* PO 정책 (2026-04-14): --force 시 번들 우선, 번들 없으면 GitHub 폴백.
|
||||
*
|
||||
* 본 테스트는 ModuleManager 의 private 메서드를 Reflection으로 직접 검증한다.
|
||||
* AbstractModule 은 getIdentifier/getVendor 가 final 이므로 Mockery 로 모킹한다.
|
||||
*/
|
||||
class ModuleForceUpdateSourceTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function invokeResolve(ModuleManager $manager, string $identifier): ?string
|
||||
{
|
||||
$reflection = new ReflectionClass($manager);
|
||||
$method = $reflection->getMethod('resolveForceUpdateSource');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($manager, $identifier);
|
||||
}
|
||||
|
||||
private function setBundledModules(ModuleManager $manager, array $bundled): void
|
||||
{
|
||||
$reflection = new ReflectionClass($manager);
|
||||
$prop = $reflection->getProperty('bundledModules');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($manager, $bundled);
|
||||
}
|
||||
|
||||
private function setLoadedModules(ModuleManager $manager, array $modules): void
|
||||
{
|
||||
$reflection = new ReflectionClass($manager);
|
||||
$prop = $reflection->getProperty('modules');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($manager, $modules);
|
||||
}
|
||||
|
||||
private function makeFakeModule(?string $githubUrl): AbstractModule
|
||||
{
|
||||
$mock = Mockery::mock(AbstractModule::class);
|
||||
$mock->shouldReceive('getGithubUrl')->andReturn($githubUrl);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
public function test_resolves_bundled_when_in_memory_cache(): void
|
||||
{
|
||||
$manager = app(ModuleManager::class);
|
||||
$this->setBundledModules($manager, [
|
||||
'test-mod' => ['version' => '1.0.0'],
|
||||
]);
|
||||
|
||||
$this->assertSame('bundled', $this->invokeResolve($manager, 'test-mod'));
|
||||
}
|
||||
|
||||
public function test_resolves_github_when_no_bundle_but_github_url_present(): void
|
||||
{
|
||||
$manager = app(ModuleManager::class);
|
||||
$this->setBundledModules($manager, []);
|
||||
$this->setLoadedModules($manager, [
|
||||
'test-mod' => $this->makeFakeModule('https://github.com/test/repo'),
|
||||
]);
|
||||
|
||||
$this->assertSame('github', $this->invokeResolve($manager, 'test-mod'));
|
||||
}
|
||||
|
||||
public function test_returns_null_when_neither_bundle_nor_github_available(): void
|
||||
{
|
||||
$manager = app(ModuleManager::class);
|
||||
$this->setBundledModules($manager, []);
|
||||
$this->setLoadedModules($manager, [
|
||||
'test-mod' => $this->makeFakeModule(null),
|
||||
]);
|
||||
|
||||
$this->assertNull($this->invokeResolve($manager, 'test-mod'));
|
||||
}
|
||||
|
||||
public function test_prefers_bundled_over_github_when_both_exist(): void
|
||||
{
|
||||
$manager = app(ModuleManager::class);
|
||||
// 번들과 GitHub 모두 존재하는 시나리오 — PO 정책상 bundled 우선
|
||||
$this->setBundledModules($manager, [
|
||||
'test-mod' => ['version' => '1.0.0'],
|
||||
]);
|
||||
$this->setLoadedModules($manager, [
|
||||
'test-mod' => $this->makeFakeModule('https://github.com/test/repo'),
|
||||
]);
|
||||
|
||||
$this->assertSame('bundled', $this->invokeResolve($manager, 'test-mod'));
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
|
||||
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
|
||||
|
||||
@@ -68,7 +68,7 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
|
||||
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
|
||||
|
||||
@@ -92,7 +92,7 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
|
||||
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-payment' => '>=1.0.0']);
|
||||
->andReturn(['plugins' => ['sirsoft-payment' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
|
||||
|
||||
@@ -106,7 +106,7 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
{
|
||||
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getDependencies')
|
||||
->andReturn(['nonexistent-extension' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['nonexistent-extension' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
|
||||
|
||||
@@ -115,7 +115,11 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
|
||||
* enrichDependencies가 중첩 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
|
||||
*
|
||||
* manifest(module.json/plugin.json)의 dependencies 필드는 중첩 구조
|
||||
* ['modules' => [...], 'plugins' => [...]] 로 저장되므로 enrichDependencies는
|
||||
* 해당 구조를 그대로 파싱합니다 (커밋 72156e6f0, 2026-02-23).
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_module_identifier_not_version(): void
|
||||
{
|
||||
@@ -130,7 +134,9 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->moduleManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
@@ -140,7 +146,7 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
|
||||
* enrichDependencies가 중첩 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_plugin_identifier(): void
|
||||
{
|
||||
@@ -155,7 +161,9 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'plugins' => ['sirsoft-payment' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->moduleManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
@@ -164,17 +172,38 @@ class ModuleManagerDependencyTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 미등록 의존성을 unknown 타입으로 반환하는지 테스트합니다.
|
||||
* enrichDependencies가 미등록 의존성의 identifier를 그대로 반환하는지 테스트합니다.
|
||||
*
|
||||
* DB에 없는 의존성은 name 필드에 identifier를 사용하지만 type은 여전히
|
||||
* 선언된 구조(module/plugin)를 따릅니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_unknown_for_missing(): void
|
||||
public function test_enrich_dependencies_returns_identifier_for_missing(): void
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['nonexistent-extension' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'modules' => ['nonexistent-extension' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->moduleManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('nonexistent-extension', $result[0]['identifier']);
|
||||
$this->assertEquals('unknown', $result[0]['type']);
|
||||
$this->assertEquals('nonexistent-extension', $result[0]['name']);
|
||||
$this->assertEquals('module', $result[0]['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 중첩 구조가 아닌 경우 빈 배열을 반환하는지 테스트합니다.
|
||||
*
|
||||
* manifest 형식이 아닌 평면 배열 등은 파싱되지 않아야 합니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_empty_for_non_nested_structure(): void
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
|
||||
$result = $reflection->invoke($this->moduleManager, $dependencies);
|
||||
|
||||
$this->assertCount(0, $result);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ class ModuleManagerLayoutTest extends TestCase
|
||||
]);
|
||||
|
||||
// LayoutService와 동일한 캐시 키 패턴으로 설정: template.{templateId}.layout.{layoutName}
|
||||
$cacheKey = "template.{$this->adminTemplate->id}.layout.{$layoutName}";
|
||||
$cacheKey = "g7:core:template.{$this->adminTemplate->id}.layout.{$layoutName}";
|
||||
Cache::put($cacheKey, $layout->content, 3600);
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
@@ -519,13 +519,128 @@ class ModuleManagerLayoutTest extends TestCase
|
||||
$this->assertEquals('test-prefix-module.admin_sample_index', $layout->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerLayoutToTemplate 호출 시 original_content_hash / size 가 저장되는지 테스트.
|
||||
*
|
||||
* layout_strategy=keep 에서 사용자 수정 감지에 사용되므로 신규 등록 시 hash 가 채워져야 함.
|
||||
*/
|
||||
public function test_register_layout_stores_original_content_hash_and_size(): void
|
||||
{
|
||||
$moduleIdentifier = 'test-hash-module';
|
||||
|
||||
Module::create([
|
||||
'identifier' => $moduleIdentifier,
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '해시 테스트', 'en' => 'Hash Test'],
|
||||
'version' => '1.0.0',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
$layoutData = [
|
||||
'layout_name' => 'admin_home',
|
||||
'version' => '1.0.0',
|
||||
'slots' => ['content' => [['component' => 'Div']]],
|
||||
];
|
||||
|
||||
$reflection = new \ReflectionClass($this->moduleManager);
|
||||
$method = $reflection->getMethod('registerLayoutToTemplate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$method->invoke(
|
||||
$this->moduleManager,
|
||||
$this->adminTemplate,
|
||||
$moduleIdentifier.'.admin_home',
|
||||
$layoutData,
|
||||
$moduleIdentifier,
|
||||
);
|
||||
|
||||
$layout = TemplateLayout::where('template_id', $this->adminTemplate->id)
|
||||
->where('source_identifier', $moduleIdentifier)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($layout);
|
||||
$this->assertNotNull($layout->original_content_hash, 'hash 가 저장되어야 함');
|
||||
$this->assertEquals(64, strlen($layout->original_content_hash), 'SHA-256 hex length');
|
||||
$this->assertGreaterThan(0, $layout->original_content_size);
|
||||
|
||||
// hash 재계산이 동일한지
|
||||
$expectedHash = hash('sha256', json_encode($layoutData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$this->assertEquals($expectedHash, $layout->original_content_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasModifiedLayouts: 수정된 레이아웃이 없을 때 결과 검증.
|
||||
*/
|
||||
public function test_has_modified_layouts_returns_false_when_unchanged(): void
|
||||
{
|
||||
$moduleIdentifier = 'test-nomod-module';
|
||||
|
||||
Module::create([
|
||||
'identifier' => $moduleIdentifier,
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '미수정 테스트', 'en' => 'Unmodified Test'],
|
||||
'version' => '1.0.0',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
$layoutData = ['layout_name' => 'home', 'slots' => []];
|
||||
|
||||
$reflection = new \ReflectionClass($this->moduleManager);
|
||||
$method = $reflection->getMethod('registerLayoutToTemplate');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->moduleManager, $this->adminTemplate, $moduleIdentifier.'.home', $layoutData, $moduleIdentifier);
|
||||
|
||||
$result = $this->moduleManager->hasModifiedLayouts($moduleIdentifier);
|
||||
|
||||
$this->assertFalse($result['has_modified_layouts']);
|
||||
$this->assertEquals(0, $result['modified_count']);
|
||||
$this->assertEmpty($result['modified_layouts']);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasModifiedLayouts: 사용자가 content 를 수정한 레이아웃을 감지한다.
|
||||
*/
|
||||
public function test_has_modified_layouts_detects_user_modification(): void
|
||||
{
|
||||
$moduleIdentifier = 'test-modified-module';
|
||||
|
||||
Module::create([
|
||||
'identifier' => $moduleIdentifier,
|
||||
'vendor' => 'test',
|
||||
'name' => ['ko' => '수정 테스트', 'en' => 'Modified Test'],
|
||||
'version' => '1.0.0',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
|
||||
]);
|
||||
|
||||
$originalData = ['layout_name' => 'home', 'slots' => ['content' => []]];
|
||||
|
||||
$reflection = new \ReflectionClass($this->moduleManager);
|
||||
$method = $reflection->getMethod('registerLayoutToTemplate');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->moduleManager, $this->adminTemplate, $moduleIdentifier.'.home', $originalData, $moduleIdentifier);
|
||||
|
||||
// 사용자 UI 에서 content 만 수정 (hash 는 그대로 유지)
|
||||
TemplateLayout::where('source_identifier', $moduleIdentifier)
|
||||
->update(['content' => ['layout_name' => 'home', 'slots' => ['content' => [['component' => 'Div', 'props' => ['className' => 'user-added']]]]]]);
|
||||
|
||||
$result = $this->moduleManager->hasModifiedLayouts($moduleIdentifier);
|
||||
|
||||
$this->assertTrue($result['has_modified_layouts']);
|
||||
$this->assertEquals(1, $result['modified_count']);
|
||||
$this->assertCount(1, $result['modified_layouts']);
|
||||
$this->assertEquals($moduleIdentifier.'.home', $result['modified_layouts'][0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshModuleLayouts 호출 시 레이아웃이 변경되면 캐시 버전이 증가하는지 테스트
|
||||
*/
|
||||
public function test_refresh_module_layouts_increments_cache_version_on_change(): void
|
||||
{
|
||||
// 초기 캐시 버전 설정
|
||||
Cache::put('extension_cache_version', 1000);
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
// 테스트용 모듈 생성
|
||||
$module = Module::create([
|
||||
@@ -551,7 +666,7 @@ class ModuleManagerLayoutTest extends TestCase
|
||||
$method->invoke($this->moduleManager);
|
||||
$afterTime = time();
|
||||
|
||||
$newVersion = Cache::get('extension_cache_version');
|
||||
$newVersion = Cache::get('g7:core:ext.cache_version');
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $newVersion);
|
||||
$this->assertLessThanOrEqual($afterTime, $newVersion);
|
||||
}
|
||||
@@ -680,11 +795,11 @@ class ModuleManagerLayoutTest extends TestCase
|
||||
public function test_cache_version_not_incremented_when_no_layout_changes(): void
|
||||
{
|
||||
$initialVersion = 1000;
|
||||
Cache::put('extension_cache_version', $initialVersion);
|
||||
Cache::put('g7:core:ext.cache_version', $initialVersion);
|
||||
|
||||
// 변경 없는 상태에서는 incrementExtensionCacheVersion이 호출되지 않으므로
|
||||
// 캐시 버전이 유지됨
|
||||
$currentVersion = Cache::get('extension_cache_version');
|
||||
$currentVersion = Cache::get('g7:core:ext.cache_version');
|
||||
|
||||
$this->assertEquals($initialVersion, $currentVersion);
|
||||
}
|
||||
|
||||
@@ -372,6 +372,48 @@ class ModuleManagerUpdateTest extends TestCase
|
||||
$this->assertTrue(File::exists($activePath.'/module.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* copyFromPendingOrBundled($force=true) 가 활성 디렉토리가 존재해도 원본으로 덮어쓰는지 테스트.
|
||||
*
|
||||
* 불완전 설치 복구 시나리오: 활성 디렉토리에 일부 파일만 남아있을 때
|
||||
* --force 로 원본(_bundled/_pending)에서 재복사하여 manifest 포함 전체를 복원한다.
|
||||
*/
|
||||
public function test_copy_from_pending_or_bundled_overwrites_when_force_is_true(): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this->manager, 'copyFromPendingOrBundled');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// 불완전 활성 디렉토리: 일부 파일만 있고 manifest 없음
|
||||
$activePath = $this->modulesPath.'/test-bundled-mod';
|
||||
File::ensureDirectoryExists($activePath);
|
||||
File::put($activePath.'/stale.txt', 'leftover from broken update');
|
||||
$this->assertFalse(File::exists($activePath.'/module.json'), '시작 상태: manifest 없음');
|
||||
|
||||
// force=true 로 호출
|
||||
$method->invoke($this->manager, 'test-bundled-mod', null, true);
|
||||
|
||||
// 원본으로 덮어써서 manifest 복원됨
|
||||
$this->assertTrue(File::exists($activePath.'/module.json'), 'force=true 시 manifest 복원되어야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* force=false (기본) 시 활성 디렉토리 존재하면 복사 스킵 (기존 동작 유지 회귀 테스트).
|
||||
*/
|
||||
public function test_copy_from_pending_or_bundled_preserves_active_when_force_is_false(): void
|
||||
{
|
||||
$activePath = $this->modulesPath.'/test-bundled-mod';
|
||||
File::ensureDirectoryExists($activePath);
|
||||
File::put($activePath.'/custom.txt', 'user content');
|
||||
|
||||
$method = new \ReflectionMethod($this->manager, 'copyFromPendingOrBundled');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->manager, 'test-bundled-mod', null, false);
|
||||
|
||||
// 기본 동작: 활성 디렉토리 유지 (커스텀 파일 보존, manifest 복사 안 됨)
|
||||
$this->assertTrue(File::exists($activePath.'/custom.txt'));
|
||||
$this->assertEquals('user content', File::get($activePath.'/custom.txt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 가드가 updating 상태에서 updateModule을 차단하는지 테스트
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\ModuleManager;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ModuleManager::runUpgradeSteps() 의 onStep 콜백 호출 검증.
|
||||
*
|
||||
* CLI UpdateModuleCommand 에서 progress bar clear → line → display 사이클을 만들기 위해
|
||||
* 각 upgrade step 버전마다 콜백이 호출되어야 한다. runUpgradeSteps 는 protected 이므로
|
||||
* Reflection 으로 직접 호출한다.
|
||||
*/
|
||||
class ModuleManagerUpgradeStepTest extends TestCase
|
||||
{
|
||||
public function test_run_upgrade_steps_invokes_on_step_for_each_version(): void
|
||||
{
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
|
||||
// Module mock: upgrades() 가 2 개 step 반환
|
||||
$module = Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getIdentifier')->andReturn('test-module');
|
||||
$module->shouldReceive('upgrades')->andReturn([
|
||||
'1.1.0' => function () { /* no-op */ },
|
||||
'1.2.0' => function () { /* no-op */ },
|
||||
]);
|
||||
|
||||
$capturedVersions = [];
|
||||
$onStep = function (string $version) use (&$capturedVersions): void {
|
||||
$capturedVersions[] = $version;
|
||||
};
|
||||
|
||||
$reflection = new \ReflectionClass($moduleManager);
|
||||
$method = $reflection->getMethod('runUpgradeSteps');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// $fromVersion=1.0.0, $toVersion=1.2.0 → 1.1.0, 1.2.0 실행
|
||||
$method->invoke($moduleManager, $module, '1.0.0', '1.2.0', false, $onStep);
|
||||
|
||||
$this->assertEquals(['1.1.0', '1.2.0'], $capturedVersions, '각 step 버전이 콜백에 전달되어야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* onStep 이 null 이어도 정상 동작 (하위 호환).
|
||||
*/
|
||||
public function test_run_upgrade_steps_tolerates_null_on_step(): void
|
||||
{
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
|
||||
$module = Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
|
||||
$module->shouldReceive('getIdentifier')->andReturn('test-module');
|
||||
$module->shouldReceive('upgrades')->andReturn([
|
||||
'1.1.0' => function () { /* no-op */ },
|
||||
]);
|
||||
|
||||
$reflection = new \ReflectionClass($moduleManager);
|
||||
$method = $reflection->getMethod('runUpgradeSteps');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->expectNotToPerformAssertions();
|
||||
$method->invoke($moduleManager, $module, '1.0.0', '1.1.0', false, null);
|
||||
}
|
||||
}
|
||||
@@ -156,9 +156,14 @@ class ModuleSeederTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* sirsoft-ecommerce 모듈의 getSeeders()가 SequenceSeeder만 반환하는지 테스트합니다.
|
||||
* sirsoft-ecommerce 모듈의 getSeeders()가 필수 시더를 포함하는지 테스트합니다.
|
||||
*
|
||||
* 2026-02-09 최초 작성 당시에는 SequenceSeeder 1개만 반환했으나,
|
||||
* 2026-03-03 배송사 관리 기능 추가(#19) 이후 ShippingCarrierSeeder 등이
|
||||
* 설치 시 실행되는 필수 시더로 추가되었다. 본 테스트는 설치 시 반드시 실행되어야
|
||||
* 하는 시더들이 모두 포함되어 있는지 검증한다.
|
||||
*/
|
||||
public function test_ecommerce_module_get_seeders_returns_sequence_seeder_only(): void
|
||||
public function test_ecommerce_module_get_seeders_includes_required_seeders(): void
|
||||
{
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
@@ -171,10 +176,20 @@ class ModuleSeederTest extends TestCase
|
||||
|
||||
$seeders = $module->getSeeders();
|
||||
|
||||
$this->assertCount(1, $seeders);
|
||||
$this->assertEquals(
|
||||
// SequenceSeeder 는 주문/상품 시퀀스 초기값 생성을 위해 반드시 포함되어야 함
|
||||
$this->assertContains(
|
||||
'Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\SequenceSeeder',
|
||||
$seeders[0]
|
||||
$seeders,
|
||||
'SequenceSeeder 는 설치 시 필수 시더로 포함되어야 합니다'
|
||||
);
|
||||
|
||||
// 모든 시더가 sirsoft-ecommerce 네임스페이스에 속해야 함
|
||||
foreach ($seeders as $seederClass) {
|
||||
$this->assertStringStartsWith(
|
||||
'Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\',
|
||||
$seederClass,
|
||||
"시더 {$seederClass} 는 sirsoft-ecommerce 네임스페이스에 속해야 합니다"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Helpers\NotificationSyncHelper;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationSyncHelper 의 cleanupStale* 회귀 방지 테스트.
|
||||
*
|
||||
* 정책:
|
||||
* - seeder 에 없는 (extension_type + extension_identifier + type) 조합의 definition 삭제
|
||||
* - definition 삭제 시 FK cascade 로 연관 template 도 자동 정리
|
||||
* - definition 은 유지되지만 channel 이 재구성된 경우: 제거된 channel 의 template 삭제
|
||||
* - user_overrides 무관 삭제
|
||||
*/
|
||||
class NotificationSyncHelperTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationSyncHelper $helper;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->helper = app(NotificationSyncHelper::class);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleDefinitions_deletes_definitions_not_in_seeder_scope(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'in-seeder',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '유지'],
|
||||
'description' => ['ko' => '유지'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationDefinition::create([
|
||||
'type' => 'not-in-seeder',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '삭제'],
|
||||
'description' => ['ko' => '삭제'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStaleDefinitions(
|
||||
'core',
|
||||
'core',
|
||||
['in-seeder'],
|
||||
);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'in-seeder']);
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'not-in-seeder']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleDefinitions_deletes_even_with_user_overrides(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'stale-with-overrides',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '유저 수정 있음'],
|
||||
'description' => ['ko' => '삭제 대상'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
'user_overrides' => ['name'],
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStaleDefinitions('core', 'core', []);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'stale-with-overrides']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleDefinitions_scope_isolation_protects_other_extensions(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'core-type',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => 'core'],
|
||||
'description' => ['ko' => 'core'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationDefinition::create([
|
||||
'type' => 'module-type',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-board',
|
||||
'name' => ['ko' => 'module'],
|
||||
'description' => ['ko' => 'module'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStaleDefinitions('core', 'core', []);
|
||||
|
||||
$this->assertSame(1, $deleted, '코어 scope 만 삭제, 모듈 scope 는 보호');
|
||||
$this->assertDatabaseMissing('notification_definitions', ['type' => 'core-type']);
|
||||
$this->assertDatabaseHas('notification_definitions', ['type' => 'module-type']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleTemplates_deletes_by_channel_diff(): void
|
||||
{
|
||||
$def = NotificationDefinition::create([
|
||||
'type' => 'with-templates',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '정의'],
|
||||
'description' => ['ko' => '정의'],
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $def->id,
|
||||
'channel' => 'mail',
|
||||
'subject' => ['ko' => '유지'],
|
||||
'body' => ['ko' => '유지'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $def->id,
|
||||
'channel' => 'fcm',
|
||||
'subject' => ['ko' => '삭제'],
|
||||
'body' => ['ko' => '삭제'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStaleTemplates($def->id, ['mail']);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseHas('notification_templates', ['definition_id' => $def->id, 'channel' => 'mail']);
|
||||
$this->assertDatabaseMissing('notification_templates', ['definition_id' => $def->id, 'channel' => 'fcm']);
|
||||
}
|
||||
|
||||
public function test_cleanupStaleTemplates_deletes_even_with_user_overrides(): void
|
||||
{
|
||||
$def = NotificationDefinition::create([
|
||||
'type' => 'tpl-overrides-test',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '정의'],
|
||||
'description' => ['ko' => '정의'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $def->id,
|
||||
'channel' => 'fcm',
|
||||
'subject' => ['ko' => '유저 수정 있음'],
|
||||
'body' => ['ko' => '삭제 대상'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
'user_overrides' => ['subject', 'body'],
|
||||
]);
|
||||
|
||||
$deleted = $this->helper->cleanupStaleTemplates($def->id, ['mail']);
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertDatabaseMissing('notification_templates', ['definition_id' => $def->id, 'channel' => 'fcm']);
|
||||
}
|
||||
|
||||
public function test_definition_delete_cascades_to_templates(): void
|
||||
{
|
||||
$def = NotificationDefinition::create([
|
||||
'type' => 'cascade-test',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => 'cascade'],
|
||||
'description' => ['ko' => 'cascade'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
$tpl = NotificationTemplate::create([
|
||||
'definition_id' => $def->id,
|
||||
'channel' => 'mail',
|
||||
'subject' => ['ko' => 'cascade'],
|
||||
'body' => ['ko' => 'cascade'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->helper->cleanupStaleDefinitions('core', 'core', []);
|
||||
|
||||
$this->assertDatabaseMissing('notification_definitions', ['id' => $def->id]);
|
||||
$this->assertDatabaseMissing('notification_templates', ['id' => $tpl->id]);
|
||||
}
|
||||
}
|
||||
@@ -44,16 +44,16 @@ use App\Extension\AbstractPlugin;
|
||||
|
||||
class Plugin extends AbstractPlugin
|
||||
{
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return ['sirsoft-ecommerce' => '>=1.0.0'];
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
File::put($this->stubPluginDir.'/plugin.json', json_encode([
|
||||
'identifier' => 'test-depcheck',
|
||||
'version' => '1.0.0',
|
||||
'name' => ['ko' => '의존성 테스트', 'en' => 'Dep Test'],
|
||||
'dependencies' => [
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
'plugins' => [],
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -157,12 +157,14 @@ PHP);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
|
||||
* (버전 문자열이 아닌 실제 identifier)
|
||||
* enrichDependencies가 중첩 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
|
||||
*
|
||||
* manifest(plugin.json)의 dependencies 필드는 중첩 구조
|
||||
* ['modules' => [...], 'plugins' => [...]] 로 저장되므로 enrichDependencies는
|
||||
* 해당 구조를 그대로 파싱합니다 (커밋 72156e6f0, 2026-02-23).
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_module_identifier_not_version(): void
|
||||
{
|
||||
// 모듈 생성
|
||||
Module::create([
|
||||
'identifier' => 'sirsoft-ecommerce',
|
||||
'vendor' => 'sirsoft',
|
||||
@@ -172,25 +174,24 @@ PHP);
|
||||
'description' => ['ko' => '이커머스 모듈', 'en' => 'Ecommerce module'],
|
||||
]);
|
||||
|
||||
// protected 메서드 테스트를 위해 리플렉션 사용
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->pluginManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('sirsoft-ecommerce', $result[0]['identifier']);
|
||||
$this->assertEquals('module', $result[0]['type']);
|
||||
// 버전 문자열이 identifier로 사용되지 않아야 함
|
||||
$this->assertNotEquals('>=1.0.0', $result[0]['identifier']);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
|
||||
* enrichDependencies가 중첩 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_plugin_identifier(): void
|
||||
{
|
||||
// 플러그인 생성
|
||||
Plugin::create([
|
||||
'identifier' => 'sirsoft-payment',
|
||||
'vendor' => 'sirsoft',
|
||||
@@ -202,7 +203,9 @@ PHP);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'plugins' => ['sirsoft-payment' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->pluginManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
@@ -211,18 +214,37 @@ PHP);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 미등록 의존성을 unknown 타입으로 반환하는지 테스트합니다.
|
||||
* enrichDependencies가 미등록 의존성의 identifier를 그대로 반환하는지 테스트합니다.
|
||||
*
|
||||
* DB에 없는 의존성은 name 필드에 identifier를 사용하지만 type은 여전히
|
||||
* 선언된 구조(module/plugin)를 따릅니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_unknown_for_missing(): void
|
||||
public function test_enrich_dependencies_returns_identifier_for_missing(): void
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['nonexistent-extension' => '>=1.0.0'];
|
||||
$dependencies = [
|
||||
'plugins' => ['nonexistent-extension' => '>=1.0.0'],
|
||||
];
|
||||
$result = $reflection->invoke($this->pluginManager, $dependencies);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('nonexistent-extension', $result[0]['identifier']);
|
||||
$this->assertEquals('unknown', $result[0]['type']);
|
||||
$this->assertEquals('nonexistent-extension', $result[0]['name']);
|
||||
$this->assertEquals('plugin', $result[0]['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichDependencies가 중첩 구조가 아닌 경우 빈 배열을 반환하는지 테스트합니다.
|
||||
*/
|
||||
public function test_enrich_dependencies_returns_empty_for_non_nested_structure(): void
|
||||
{
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
|
||||
|
||||
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
|
||||
$result = $reflection->invoke($this->pluginManager, $dependencies);
|
||||
|
||||
$this->assertCount(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +265,7 @@ PHP);
|
||||
// 의존성이 있는 플러그인 Mock
|
||||
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
|
||||
$plugin->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
|
||||
|
||||
// protected 메서드 리플렉션
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
|
||||
@@ -269,7 +291,7 @@ PHP);
|
||||
|
||||
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
|
||||
$plugin->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
|
||||
|
||||
@@ -284,7 +306,7 @@ PHP);
|
||||
{
|
||||
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
|
||||
$plugin->shouldReceive('getDependencies')
|
||||
->andReturn(['nonexistent-module' => '>=1.0.0']);
|
||||
->andReturn(['modules' => ['nonexistent-module' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
|
||||
|
||||
@@ -309,7 +331,7 @@ PHP);
|
||||
|
||||
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
|
||||
$plugin->shouldReceive('getDependencies')
|
||||
->andReturn(['sirsoft-payment' => '>=1.0.0']);
|
||||
->andReturn(['plugins' => ['sirsoft-payment' => '>=1.0.0']]);
|
||||
|
||||
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Services\DriverRegistryService;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* PluginManager 드라이버 사용 중 경고 로직 테스트
|
||||
*
|
||||
* 플러그인 비활성화 시 해당 플러그인이 제공하는 드라이버가
|
||||
* 현재 사용 중인지 확인하는 DriverRegistryService의 연동을 검증합니다.
|
||||
*/
|
||||
class PluginManagerDriverWarningTest extends TestCase
|
||||
{
|
||||
private DriverRegistryService $driverRegistry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->driverRegistry = new DriverRegistryService();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 제공하는 드라이버가 없으면 빈 배열을 반환하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_empty_when_plugin_provides_no_drivers(): void
|
||||
{
|
||||
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-nonexistent');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEmpty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 드라이버가 등록되었지만 현재 선택되지 않은 경우 빈 배열을 반환하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_empty_when_plugin_driver_not_selected(): void
|
||||
{
|
||||
// 플러그인 드라이버 등록
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_api',
|
||||
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
|
||||
'provider' => 'sirsoft-custom_mail',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
// 현재 설정에서는 코어 드라이버(smtp)가 선택된 상태 → 플러그인 드라이버 미사용
|
||||
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-custom_mail');
|
||||
|
||||
$this->assertEmpty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 드라이버가 등록되고 현재 선택된 경우 사용 중으로 반환하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_detects_plugin_driver_in_use(): void
|
||||
{
|
||||
// 플러그인 드라이버 등록
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_api',
|
||||
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
|
||||
'provider' => 'sirsoft-custom_mail',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
// JsonConfigRepository에서 custom_api가 선택된 상태로 mock
|
||||
$mockConfigRepo = $this->createMock(JsonConfigRepository::class);
|
||||
$mockConfigRepo->method('getCategory')
|
||||
->willReturnCallback(function (string $category) {
|
||||
if ($category === 'mail') {
|
||||
return ['mailer' => 'custom_api'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$this->app->instance(JsonConfigRepository::class, $mockConfigRepo);
|
||||
|
||||
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-custom_mail');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('mail', $result[0]['category']);
|
||||
$this->assertEquals('custom_api', $result[0]['driver_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 플러그인의 드라이버는 감지하지 않는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_only_detects_drivers_from_specified_plugin(): void
|
||||
{
|
||||
// 두 개의 플러그인이 각각 드라이버 등록
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_a_mailer',
|
||||
'label' => ['ko' => 'A 메일러', 'en' => 'A Mailer'],
|
||||
'provider' => 'sirsoft-plugin_a',
|
||||
];
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_b_mailer',
|
||||
'label' => ['ko' => 'B 메일러', 'en' => 'B Mailer'],
|
||||
'provider' => 'sirsoft-plugin_b',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
// plugin_a_mailer가 선택된 상태
|
||||
$mockConfigRepo = $this->createMock(JsonConfigRepository::class);
|
||||
$mockConfigRepo->method('getCategory')
|
||||
->willReturnCallback(function (string $category) {
|
||||
if ($category === 'mail') {
|
||||
return ['mailer' => 'plugin_a_mailer'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$this->app->instance(JsonConfigRepository::class, $mockConfigRepo);
|
||||
|
||||
// plugin_a는 감지, plugin_b는 미감지
|
||||
$resultA = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-plugin_a');
|
||||
$this->assertCount(1, $resultA);
|
||||
|
||||
$resultB = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-plugin_b');
|
||||
$this->assertEmpty($resultB);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
]);
|
||||
|
||||
// 캐시 설정
|
||||
$cacheKey = "template.{$template->id}.layout.{$override->name}";
|
||||
$cacheKey = "g7:core:template.{$template->id}.layout.{$override->name}";
|
||||
Cache::put($cacheKey, $override->content, 3600);
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
@@ -448,7 +448,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
public function test_increment_extension_cache_version(): void
|
||||
{
|
||||
// 초기 캐시 버전 설정
|
||||
Cache::put('extension_cache_version', 1000);
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$reflection = new \ReflectionClass($this->templateManager);
|
||||
$method = $reflection->getMethod('incrementExtensionCacheVersion');
|
||||
@@ -458,7 +458,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
$method->invoke($this->templateManager);
|
||||
$afterTime = time();
|
||||
|
||||
$newVersion = Cache::get('extension_cache_version');
|
||||
$newVersion = Cache::get('g7:core:ext.cache_version');
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $newVersion);
|
||||
$this->assertLessThanOrEqual($afterTime, $newVersion);
|
||||
}
|
||||
@@ -469,7 +469,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
public function test_get_extension_cache_version(): void
|
||||
{
|
||||
$expectedVersion = 1735000000;
|
||||
Cache::put('extension_cache_version', $expectedVersion);
|
||||
Cache::put('g7:core:ext.cache_version', $expectedVersion);
|
||||
|
||||
// ClearsTemplateCaches trait의 정적 메서드 호출
|
||||
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
@@ -482,7 +482,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
*/
|
||||
public function test_get_extension_cache_version_returns_zero_when_not_set(): void
|
||||
{
|
||||
Cache::forget('extension_cache_version');
|
||||
Cache::forget('g7:core:ext.cache_version');
|
||||
|
||||
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
@@ -650,7 +650,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
]);
|
||||
|
||||
// 캐시 설정
|
||||
$cacheKey = "template.{$template->id}.layout.{$orphanName}";
|
||||
$cacheKey = "g7:core:template.{$template->id}.layout.{$orphanName}";
|
||||
Cache::put($cacheKey, ['cached' => true], 3600);
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
|
||||
@@ -218,6 +218,106 @@ class UpgradeStepsTest extends TestCase
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* force + 동일 버전: 해당 버전의 스텝이 실행되는지 테스트
|
||||
*/
|
||||
public function test_run_upgrade_steps_force_with_same_version_executes_step(): void
|
||||
{
|
||||
$executedSteps = [];
|
||||
|
||||
$step = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
|
||||
$executedSteps[] = $ctx->currentStep;
|
||||
});
|
||||
|
||||
$otherStep = Mockery::mock(UpgradeStepInterface::class);
|
||||
$otherStep->shouldNotReceive('run'); // 다른 버전은 실행되지 않아야 함
|
||||
|
||||
$module = Mockery::mock(ModuleInterface::class);
|
||||
$module->shouldReceive('upgrades')->once()->andReturn([
|
||||
'1.0.0' => $step,
|
||||
'1.1.0' => $otherStep,
|
||||
]);
|
||||
$module->shouldReceive('getIdentifier')->andReturn('test-module');
|
||||
|
||||
$manager = $this->createModuleManagerWithMocks();
|
||||
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$method->invoke($manager, $module, '1.0.0', '1.0.0', true);
|
||||
|
||||
$this->assertEquals(['1.0.0'], $executedSteps);
|
||||
}
|
||||
|
||||
/**
|
||||
* force + 프리릴리스 동일 버전: 해당 버전의 스텝이 실행되는지 테스트
|
||||
*/
|
||||
public function test_run_upgrade_steps_force_with_same_prerelease_version(): void
|
||||
{
|
||||
$executedSteps = [];
|
||||
|
||||
$step_beta2 = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step_beta2->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
|
||||
$executedSteps[] = $ctx->currentStep;
|
||||
});
|
||||
|
||||
$step_beta1 = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step_beta1->shouldNotReceive('run');
|
||||
|
||||
$module = Mockery::mock(ModuleInterface::class);
|
||||
$module->shouldReceive('upgrades')->once()->andReturn([
|
||||
'1.0.0-beta.1' => $step_beta1,
|
||||
'1.0.0-beta.2' => $step_beta2,
|
||||
]);
|
||||
$module->shouldReceive('getIdentifier')->andReturn('test-module');
|
||||
|
||||
$manager = $this->createModuleManagerWithMocks();
|
||||
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$method->invoke($manager, $module, '1.0.0-beta.2', '1.0.0-beta.2', true);
|
||||
|
||||
$this->assertEquals(['1.0.0-beta.2'], $executedSteps);
|
||||
}
|
||||
|
||||
/**
|
||||
* force + 다른 버전 범위: 기존 범위 필터링이 정상 동작하는지 테스트
|
||||
*/
|
||||
public function test_run_upgrade_steps_force_with_different_versions_uses_normal_range(): void
|
||||
{
|
||||
$executedSteps = [];
|
||||
|
||||
$step_1_1 = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step_1_1->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
|
||||
$executedSteps[] = $ctx->currentStep;
|
||||
});
|
||||
|
||||
$step_1_0 = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step_1_0->shouldNotReceive('run'); // fromVersion 이하
|
||||
|
||||
$step_1_2 = Mockery::mock(UpgradeStepInterface::class);
|
||||
$step_1_2->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
|
||||
$executedSteps[] = $ctx->currentStep;
|
||||
});
|
||||
|
||||
$module = Mockery::mock(ModuleInterface::class);
|
||||
$module->shouldReceive('upgrades')->once()->andReturn([
|
||||
'1.0.0' => $step_1_0,
|
||||
'1.1.0' => $step_1_1,
|
||||
'1.2.0' => $step_1_2,
|
||||
]);
|
||||
$module->shouldReceive('getIdentifier')->andReturn('test-module');
|
||||
|
||||
$manager = $this->createModuleManagerWithMocks();
|
||||
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// force=true이지만 fromVersion != toVersion이므로 일반 범위 필터링 적용
|
||||
$method->invoke($manager, $module, '1.0.0', '1.2.0', true);
|
||||
|
||||
$this->assertEquals(['1.1.0', '1.2.0'], $executedSteps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝 파일명에서 일반 버전을 올바르게 파싱합니다.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\EnvironmentDetector;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EnvironmentDetectorTest extends TestCase
|
||||
{
|
||||
private EnvironmentDetector $detector;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->detector = new EnvironmentDetector;
|
||||
}
|
||||
|
||||
public function test_has_proc_open_reflects_function_availability(): void
|
||||
{
|
||||
$result = $this->detector->hasProcOpen();
|
||||
$this->assertIsBool($result);
|
||||
|
||||
// proc_open 은 disable_functions 로 차단되지 않은 일반 테스트 환경에서는 true
|
||||
if (function_exists('proc_open')) {
|
||||
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||
if (! in_array('proc_open', $disabled, true)) {
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_has_zip_archive_returns_true_when_class_exists(): void
|
||||
{
|
||||
$this->assertSame(class_exists(\ZipArchive::class), $this->detector->hasZipArchive());
|
||||
}
|
||||
|
||||
public function test_find_composer_binary_returns_null_when_no_candidate_available(): void
|
||||
{
|
||||
$detector = new EnvironmentDetector;
|
||||
$detector->resetCache();
|
||||
|
||||
// 절대 존재하지 않는 경로를 hint로 주고 ENV/config도 비운 상태 가정
|
||||
// (실제 환경에 composer가 설치되어 있으면 PATH 검색 단계에서 발견될 수 있음)
|
||||
$original = config('process.composer_binary');
|
||||
config(['process.composer_binary' => null]);
|
||||
|
||||
try {
|
||||
$result = $detector->findComposerBinary('/nonexistent/path/to/composer');
|
||||
// 반환값은 실제 PATH에 composer가 있느냐에 따라 달라짐 — null 또는 문자열
|
||||
$this->assertTrue($result === null || is_string($result));
|
||||
} finally {
|
||||
config(['process.composer_binary' => $original]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_find_composer_binary_accepts_hint_with_spaces(): void
|
||||
{
|
||||
$detector = new EnvironmentDetector;
|
||||
$detector->resetCache();
|
||||
|
||||
// 공백 포함 힌트 — 파일 존재 검사 스킵하고 그대로 사용
|
||||
$hint = 'php /custom/path/composer.phar';
|
||||
$result = $detector->findComposerBinary($hint);
|
||||
|
||||
$this->assertSame($hint, $result);
|
||||
}
|
||||
|
||||
public function test_summarize_returns_complete_report(): void
|
||||
{
|
||||
$report = $this->detector->summarize();
|
||||
|
||||
$this->assertArrayHasKey('proc_open', $report);
|
||||
$this->assertArrayHasKey('shell_exec', $report);
|
||||
$this->assertArrayHasKey('zip_archive', $report);
|
||||
$this->assertArrayHasKey('composer_binary', $report);
|
||||
$this->assertArrayHasKey('composer_executable', $report);
|
||||
$this->assertArrayHasKey('can_use_composer', $report);
|
||||
$this->assertArrayHasKey('can_use_bundle', $report);
|
||||
|
||||
$this->assertIsBool($report['proc_open']);
|
||||
$this->assertIsBool($report['zip_archive']);
|
||||
$this->assertIsBool($report['can_use_composer']);
|
||||
$this->assertIsBool($report['can_use_bundle']);
|
||||
}
|
||||
|
||||
public function test_reset_cache_clears_cached_values(): void
|
||||
{
|
||||
$this->detector->canExecuteComposer();
|
||||
$this->detector->resetCache();
|
||||
|
||||
// 재호출 시 예외 없이 동작해야 함
|
||||
$result = $this->detector->canExecuteComposer();
|
||||
$this->assertIsBool($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundleInstaller;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VendorBundleInstallerTest extends TestCase
|
||||
{
|
||||
private VendorBundleInstaller $installer;
|
||||
|
||||
private string $testDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->installer = new VendorBundleInstaller(new VendorIntegrityChecker);
|
||||
$this->testDir = storage_path('app/test-vendor-installer-'.uniqid());
|
||||
File::ensureDirectoryExists($this->testDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->testDir)) {
|
||||
File::deleteDirectory($this->testDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 vendor-bundle.zip + manifest 생성.
|
||||
*/
|
||||
private function createValidBundle(array $files = ['vendor/test/pkg/file.php' => '<?php // test']): void
|
||||
{
|
||||
$zipPath = $this->testDir.'/vendor-bundle.zip';
|
||||
$zip = new \ZipArchive;
|
||||
$this->assertTrue($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true);
|
||||
foreach ($files as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$manifest = [
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'generator' => 'test',
|
||||
'target' => 'test',
|
||||
'zip_sha256' => hash_file('sha256', $zipPath),
|
||||
'zip_size' => filesize($zipPath),
|
||||
'package_count' => 1,
|
||||
'packages' => [['name' => 'test/pkg', 'version' => '1.0.0', 'type' => 'library']],
|
||||
];
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode($manifest));
|
||||
}
|
||||
|
||||
public function test_install_throws_when_zip_missing(): void
|
||||
{
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$this->installer->install($this->testDir, $this->testDir);
|
||||
}
|
||||
|
||||
public function test_install_extracts_zip_successfully(): void
|
||||
{
|
||||
$this->createValidBundle();
|
||||
|
||||
$result = $this->installer->install($this->testDir, $this->testDir);
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $result->mode);
|
||||
$this->assertSame('bundled', $result->strategy);
|
||||
$this->assertSame(1, $result->packageCount);
|
||||
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
|
||||
}
|
||||
|
||||
public function test_install_throws_on_hash_mismatch(): void
|
||||
{
|
||||
$this->createValidBundle();
|
||||
|
||||
// manifest 조작 — 잘못된 해시
|
||||
$manifest = json_decode(file_get_contents($this->testDir.'/vendor-bundle.json'), true);
|
||||
$manifest['zip_sha256'] = hash('sha256', 'tampered');
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode($manifest));
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$this->installer->install($this->testDir, $this->testDir);
|
||||
}
|
||||
|
||||
public function test_install_rejects_unsafe_paths(): void
|
||||
{
|
||||
// zip slip 시도
|
||||
$zipPath = $this->testDir.'/vendor-bundle.zip';
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('../evil.php', '<?php // evil');
|
||||
$zip->close();
|
||||
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash_file('sha256', $zipPath),
|
||||
'package_count' => 0,
|
||||
]));
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$this->installer->install($this->testDir, $this->testDir);
|
||||
}
|
||||
|
||||
public function test_install_backs_up_existing_vendor_and_deletes_on_success(): void
|
||||
{
|
||||
// 기존 vendor/ 배치
|
||||
File::ensureDirectoryExists($this->testDir.'/vendor/old');
|
||||
File::put($this->testDir.'/vendor/old/marker.txt', 'old vendor');
|
||||
|
||||
$this->createValidBundle();
|
||||
|
||||
$this->installer->install($this->testDir, $this->testDir);
|
||||
|
||||
// 새 vendor/test/pkg/file.php 존재
|
||||
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
|
||||
|
||||
// 기존 vendor/old/ 는 zip 추출에 의해 덮어써지지 않으므로 제거되지 않음
|
||||
// (백업에서 복원되지는 않으나, 이는 zip 이 해당 경로를 포함하지 않기 때문)
|
||||
// 중요: 백업 디렉토리 (vendor/.bundle_backup_*) 는 성공 시 삭제되어야 함
|
||||
$backups = glob($this->testDir.'/vendor/.bundle_backup_*');
|
||||
$this->assertEmpty($backups, '성공 시 백업 디렉토리는 삭제되어야 함');
|
||||
|
||||
// targetDir (최상위) 에는 vendor.old.* 가 절대 생성되지 않아야 함 (공유 호스팅 호환)
|
||||
$legacyBackups = glob($this->testDir.'/vendor.old.*');
|
||||
$this->assertEmpty($legacyBackups, 'targetDir 에 legacy vendor.old.* 디렉토리가 생성되면 안 됨');
|
||||
}
|
||||
|
||||
public function test_install_succeeds_when_only_vendor_dir_is_writable(): void
|
||||
{
|
||||
// 공유 호스팅 시나리오 재현: targetDir 은 있지만 vendor/ 만 쓰기 가능한 환경.
|
||||
// 실제 권한 변경은 Windows 에서 검증 어려우므로, 대신 새 구조의 동작 원리를 검증:
|
||||
// vendor/ 가 이미 존재하고 zip 추출이 vendor/ 내부 항목만 조작하는지 확인.
|
||||
File::ensureDirectoryExists($this->testDir.'/vendor');
|
||||
File::put($this->testDir.'/vendor/legacy.txt', 'preexisting');
|
||||
|
||||
$this->createValidBundle();
|
||||
|
||||
$result = $this->installer->install($this->testDir, $this->testDir);
|
||||
|
||||
$this->assertSame('bundled', $result->strategy);
|
||||
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
|
||||
|
||||
// 백업 디렉토리는 성공 후 정리되어야 함
|
||||
$backups = glob($this->testDir.'/vendor/.bundle_backup_*');
|
||||
$this->assertEmpty($backups);
|
||||
|
||||
// targetDir 루트에는 어떠한 백업/임시 디렉토리도 만들어지지 않아야 함
|
||||
// (vendor/ 내부 백업 방식이므로 루트는 오염되지 않음)
|
||||
$topLevelEntries = array_filter(scandir($this->testDir), fn ($e) => ! in_array($e, ['.', '..', 'vendor', 'vendor-bundle.zip', 'vendor-bundle.json'], true));
|
||||
$this->assertEmpty($topLevelEntries, 'targetDir 루트가 오염되지 않아야 함');
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VendorBundlerTest extends TestCase
|
||||
{
|
||||
private VendorBundler $bundler;
|
||||
|
||||
private VendorIntegrityChecker $checker;
|
||||
|
||||
private string $testDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->checker = new VendorIntegrityChecker;
|
||||
$this->bundler = new VendorBundler($this->checker);
|
||||
|
||||
// 테스트는 실제 composer 실행 없이 가짜 vendor/ 구조를 스테이징에 생성.
|
||||
// 이는 실제 빌드 흐름(스테이징 → composer install → zip)을 시뮬레이션하되,
|
||||
// 외부 네트워크/composer 의존 없이 단위 테스트 속도를 유지하기 위함.
|
||||
$this->bundler->setComposerInstallRunner(function (string $stagingDir): void {
|
||||
File::ensureDirectoryExists($stagingDir.'/vendor/test/lib');
|
||||
File::put($stagingDir.'/vendor/test/lib/file.php', '<?php // test');
|
||||
File::put($stagingDir.'/vendor/autoload.php', '<?php // autoload');
|
||||
});
|
||||
|
||||
$this->testDir = storage_path('app/test-vendor-bundler-'.uniqid());
|
||||
File::ensureDirectoryExists($this->testDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->testDir)) {
|
||||
File::deleteDirectory($this->testDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 composer 프로젝트 구조 생성.
|
||||
*
|
||||
* 스테이징 기반 빌드에서는 소스 디렉토리에 vendor/ 가 없어도 되고,
|
||||
* composer install 은 setUp() 의 주입된 runner 가 대신 처리한다.
|
||||
* 따라서 소스에는 composer.json + composer.lock 만 배치한다.
|
||||
*/
|
||||
private function createFakeProject(): void
|
||||
{
|
||||
File::put($this->testDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
// VendorBundler 는 php/ext-* 만 있는 확장을 skip 하므로 외부 의존성 선언 필수
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]));
|
||||
File::put($this->testDir.'/composer.lock', json_encode([
|
||||
'packages' => [
|
||||
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
|
||||
['name' => 'test/other', 'version' => '2.0.0', 'type' => 'library'],
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_build_throws_when_composer_json_missing(): void
|
||||
{
|
||||
$this->expectException(VendorInstallException::class);
|
||||
|
||||
// buildForCore의 내부 build()에 도달하는 private 테스트는 어려우므로
|
||||
// buildForExtension 을 통해 간접적으로 검증
|
||||
$this->bundler->buildForExtension('module', 'nonexistent-module-test');
|
||||
}
|
||||
|
||||
/**
|
||||
* build() private 메서드 호출 헬퍼.
|
||||
*
|
||||
* 테스트는 단일 testDir 을 소스/출력 동일하게 사용 (코어 케이스와 유사).
|
||||
*/
|
||||
private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): \App\Extension\Vendor\VendorBundleResult
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->bundler);
|
||||
$method = $reflection->getMethod('build');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(
|
||||
$this->bundler,
|
||||
$sourcePath ?? $this->testDir,
|
||||
$outputPath ?? $this->testDir,
|
||||
$target,
|
||||
$force,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_build_creates_zip_and_manifest(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
|
||||
$result = $this->invokeBuild('test:project');
|
||||
|
||||
$this->assertFalse($result->skipped);
|
||||
$this->assertFileExists($this->testDir.'/vendor-bundle.zip');
|
||||
$this->assertFileExists($this->testDir.'/vendor-bundle.json');
|
||||
$this->assertSame(2, $result->packageCount);
|
||||
$this->assertGreaterThan(0, $result->zipSize);
|
||||
}
|
||||
|
||||
public function test_build_skips_when_up_to_date(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
|
||||
// 1차 빌드
|
||||
$first = $this->invokeBuild('test:project');
|
||||
$this->assertFalse($first->skipped);
|
||||
|
||||
// 2차 빌드 — composer.json 변경 없음 → 스킵
|
||||
$second = $this->invokeBuild('test:project');
|
||||
$this->assertTrue($second->skipped);
|
||||
$this->assertSame('up-to-date', $second->reason);
|
||||
}
|
||||
|
||||
public function test_build_force_rebuilds_even_when_up_to_date(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
|
||||
$this->invokeBuild('test:project');
|
||||
$second = $this->invokeBuild('test:project', force: true);
|
||||
|
||||
$this->assertFalse($second->skipped);
|
||||
}
|
||||
|
||||
public function test_is_stale_returns_true_when_no_bundle(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
$this->assertTrue($this->bundler->isStale($this->testDir));
|
||||
}
|
||||
|
||||
public function test_is_stale_returns_false_after_build(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
$this->invokeBuild('test:project');
|
||||
|
||||
$this->assertFalse($this->bundler->isStale($this->testDir));
|
||||
}
|
||||
|
||||
public function test_is_stale_returns_true_when_composer_json_changed(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
$this->invokeBuild('test:project');
|
||||
|
||||
// composer.json 변경
|
||||
File::put($this->testDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
'require' => ['php' => '^8.2', 'new/pkg' => '^1.0'],
|
||||
]));
|
||||
|
||||
$this->assertTrue($this->bundler->isStale($this->testDir));
|
||||
}
|
||||
|
||||
public function test_built_manifest_has_valid_sha256_for_integrity_check(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
$this->invokeBuild('test:project');
|
||||
|
||||
$integrity = $this->checker->verify($this->testDir);
|
||||
$this->assertTrue($integrity->valid, 'errors: '.implode(',', $integrity->errors));
|
||||
}
|
||||
|
||||
/**
|
||||
* php/ext-* 런타임 제약만 있는 확장은 skip 처리되고 예외가 발생하지 않아야 합니다.
|
||||
*/
|
||||
public function test_build_skips_when_no_external_dependencies(): void
|
||||
{
|
||||
// 외부 의존성 없는 composer.json (php 버전만)
|
||||
File::put($this->testDir.'/composer.json', json_encode([
|
||||
'name' => 'test/no-deps',
|
||||
'require' => ['php' => '^8.2'],
|
||||
]));
|
||||
|
||||
$result = $this->invokeBuild('test:no-deps');
|
||||
|
||||
$this->assertTrue($result->skipped);
|
||||
$this->assertSame('no-external-dependencies', $result->reason);
|
||||
$this->assertFileDoesNotExist($this->testDir.'/vendor-bundle.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* ext-* 확장만 요구하는 경우도 외부 의존성 없음으로 간주됩니다.
|
||||
*/
|
||||
public function test_build_skips_when_only_php_extensions_required(): void
|
||||
{
|
||||
File::put($this->testDir.'/composer.json', json_encode([
|
||||
'name' => 'test/ext-only',
|
||||
'require' => ['php' => '^8.2', 'ext-json' => '*', 'ext-zip' => '*'],
|
||||
]));
|
||||
|
||||
$result = $this->invokeBuild('test:ext-only');
|
||||
|
||||
$this->assertTrue($result->skipped);
|
||||
$this->assertSame('no-external-dependencies', $result->reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분리 구조 — sourcePath 와 outputPath 가 다른 경우 (확장 케이스).
|
||||
*
|
||||
* _bundled 디렉토리에는 composer.json 만 있고, vendor/ 는 활성 디렉토리에만 있는 상황을 재현.
|
||||
*/
|
||||
public function test_build_writes_output_to_different_path_than_source(): void
|
||||
{
|
||||
$sourceDir = $this->testDir.'/active';
|
||||
$outputDir = $this->testDir.'/bundled';
|
||||
File::ensureDirectoryExists($sourceDir);
|
||||
File::ensureDirectoryExists($outputDir);
|
||||
|
||||
// 활성 디렉토리 — composer.json + composer.lock (vendor/ 는 스테이징에서 생성)
|
||||
File::put($sourceDir.'/composer.json', json_encode([
|
||||
'name' => 'test/project',
|
||||
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
|
||||
]));
|
||||
File::put($sourceDir.'/composer.lock', json_encode([
|
||||
'packages' => [
|
||||
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
|
||||
],
|
||||
]));
|
||||
|
||||
// _bundled 디렉토리 — composer.json 만 (vendor/ 없음)
|
||||
File::put($outputDir.'/composer.json', File::get($sourceDir.'/composer.json'));
|
||||
|
||||
$result = $this->invokeBuild('test:split', sourcePath: $sourceDir, outputPath: $outputDir);
|
||||
|
||||
$this->assertFalse($result->skipped);
|
||||
|
||||
// 출력이 outputDir 에만 존재해야 함 (sourceDir 오염 없음)
|
||||
$this->assertFileExists($outputDir.'/vendor-bundle.zip');
|
||||
$this->assertFileExists($outputDir.'/vendor-bundle.json');
|
||||
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.zip');
|
||||
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 의존성 없는 확장은 isStale 이 항상 false 를 반환해야 합니다.
|
||||
*/
|
||||
public function test_is_stale_returns_false_when_no_external_dependencies(): void
|
||||
{
|
||||
File::put($this->testDir.'/composer.json', json_encode([
|
||||
'name' => 'test/no-deps',
|
||||
'require' => ['php' => '^8.2'],
|
||||
]));
|
||||
|
||||
$this->assertFalse($this->bundler->isStale($this->testDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* 스테이징 기반 빌드 — 개발 vendor/ 가 dev 의존성을 포함해도 번들은 깨끗함.
|
||||
*
|
||||
* 회귀 방지: autoload_files.php 가 존재하지 않는 dev 패키지를 require 하는 버그
|
||||
* (244 브랜치 shared hosting 실전 테스트에서 발견된 이슈).
|
||||
*
|
||||
* 주입된 runner 는 "clean --no-dev vendor/" 를 시뮬레이션하여 번들 내용이
|
||||
* 스테이징 vendor/ 와 일치하는지 검증한다.
|
||||
*/
|
||||
public function test_build_uses_staging_vendor_not_source_vendor(): void
|
||||
{
|
||||
$this->createFakeProject();
|
||||
|
||||
// 소스 디렉토리에 dev 파일/디렉토리를 생성 — 번들에 포함되지 않아야 함
|
||||
File::ensureDirectoryExists($this->testDir.'/vendor/fakerphp/faker');
|
||||
File::put($this->testDir.'/vendor/fakerphp/faker/README.md', '# Faker dev package');
|
||||
File::ensureDirectoryExists($this->testDir.'/vendor/myclabs/deep-copy');
|
||||
File::put($this->testDir.'/vendor/myclabs/deep-copy/deep_copy.php', '<?php');
|
||||
|
||||
$this->invokeBuild('test:staging');
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$this->assertTrue($zip->open($this->testDir.'/vendor-bundle.zip') === true);
|
||||
$fileList = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$fileList[] = $zip->getNameIndex($i);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
// 스테이징 runner 가 생성한 test/lib 만 포함되어야 함
|
||||
$this->assertTrue(
|
||||
collect($fileList)->contains(fn ($f) => str_contains($f, 'vendor/test/lib')),
|
||||
'runner 가 생성한 test/lib 은 포함되어야 합니다'
|
||||
);
|
||||
|
||||
// 소스의 dev 디렉토리는 번들에 절대 포함되지 않아야 함
|
||||
$this->assertFalse(
|
||||
collect($fileList)->contains(fn ($f) => str_contains($f, 'fakerphp')),
|
||||
'소스의 dev 패키지 fakerphp 는 번들에 포함되면 안 됩니다 (스테이징이 아닌 소스 사용 금지)'
|
||||
);
|
||||
$this->assertFalse(
|
||||
collect($fileList)->contains(fn ($f) => str_contains($f, 'myclabs')),
|
||||
'소스의 dev 패키지 myclabs 는 번들에 포함되면 안 됩니다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 미설치 환경에서 명확한 예외 발생.
|
||||
*
|
||||
* EnvironmentDetector 가 composer 미실행으로 보고하도록 mock 을 주입하고,
|
||||
* composerInstallRunner 도 null 로 초기화한 뒤 빌드 시도.
|
||||
*/
|
||||
public function test_build_throws_when_composer_not_available(): void
|
||||
{
|
||||
$detector = \Mockery::mock(\App\Extension\Vendor\EnvironmentDetector::class);
|
||||
$detector->shouldReceive('canExecuteComposer')->andReturn(false);
|
||||
|
||||
$bundler = new VendorBundler($this->checker, $detector);
|
||||
// 기본 runner (proc_open) 사용하도록 override 안 함
|
||||
|
||||
$this->createFakeProject();
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
|
||||
$reflection = new \ReflectionClass($bundler);
|
||||
$method = $reflection->getMethod('build');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($bundler, $this->testDir, $this->testDir, 'test:no-composer', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VendorIntegrityCheckerTest extends TestCase
|
||||
{
|
||||
private VendorIntegrityChecker $checker;
|
||||
|
||||
private string $testDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->checker = new VendorIntegrityChecker;
|
||||
$this->testDir = storage_path('app/test-vendor-integrity-'.uniqid());
|
||||
File::ensureDirectoryExists($this->testDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->testDir)) {
|
||||
File::deleteDirectory($this->testDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_verify_fails_when_zip_missing(): void
|
||||
{
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('bundle_zip_missing', $result->errors);
|
||||
}
|
||||
|
||||
public function test_verify_fails_when_manifest_missing(): void
|
||||
{
|
||||
File::put($this->testDir.'/vendor-bundle.zip', 'dummy zip content');
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('bundle_manifest_missing', $result->errors);
|
||||
}
|
||||
|
||||
public function test_verify_fails_when_manifest_invalid_json(): void
|
||||
{
|
||||
File::put($this->testDir.'/vendor-bundle.zip', 'dummy');
|
||||
File::put($this->testDir.'/vendor-bundle.json', '{invalid json');
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('bundle_manifest_invalid', $result->errors);
|
||||
}
|
||||
|
||||
public function test_verify_fails_on_unsupported_schema_version(): void
|
||||
{
|
||||
File::put($this->testDir.'/vendor-bundle.zip', 'dummy');
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => '99.0',
|
||||
'zip_sha256' => hash('sha256', 'dummy'),
|
||||
]));
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('bundle_schema_unsupported', $result->errors);
|
||||
}
|
||||
|
||||
public function test_verify_fails_on_zip_hash_mismatch(): void
|
||||
{
|
||||
File::put($this->testDir.'/vendor-bundle.zip', 'actual content');
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash('sha256', 'different content'),
|
||||
]));
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('zip_hash_mismatch', $result->errors);
|
||||
}
|
||||
|
||||
public function test_verify_passes_on_matching_hash(): void
|
||||
{
|
||||
$content = 'matching zip content';
|
||||
File::put($this->testDir.'/vendor-bundle.zip', $content);
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash('sha256', $content),
|
||||
'package_count' => 42,
|
||||
]));
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertTrue($result->valid, 'errors: '.implode(',', $result->errors));
|
||||
$this->assertEmpty($result->errors);
|
||||
$this->assertSame(42, $result->meta['package_count']);
|
||||
}
|
||||
|
||||
public function test_verify_detects_composer_json_mismatch(): void
|
||||
{
|
||||
$zipContent = 'zip content';
|
||||
$composerJsonContent = '{"name":"new"}';
|
||||
$storedHash = hash('sha256', '{"name":"old"}');
|
||||
|
||||
File::put($this->testDir.'/vendor-bundle.zip', $zipContent);
|
||||
File::put($this->testDir.'/composer.json', $composerJsonContent);
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash('sha256', $zipContent),
|
||||
'composer_json_sha256' => $storedHash,
|
||||
]));
|
||||
|
||||
$result = $this->checker->verify($this->testDir);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertContains('composer_json_sha_mismatch', $result->errors);
|
||||
}
|
||||
|
||||
public function test_read_manifest_returns_null_when_missing(): void
|
||||
{
|
||||
$this->assertNull($this->checker->readManifest($this->testDir));
|
||||
}
|
||||
|
||||
public function test_read_manifest_returns_parsed_array(): void
|
||||
{
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode(['key' => 'value']));
|
||||
|
||||
$result = $this->checker->readManifest($this->testDir);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame('value', $result['key']);
|
||||
}
|
||||
|
||||
public function test_compute_file_hash_returns_sha256(): void
|
||||
{
|
||||
$path = $this->testDir.'/test.txt';
|
||||
File::put($path, 'hello');
|
||||
|
||||
$hash = $this->checker->computeFileHash($path);
|
||||
|
||||
$this->assertSame(hash('sha256', 'hello'), $hash);
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\EnvironmentDetector;
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundleInstaller;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorInstallContext;
|
||||
use App\Extension\Vendor\VendorInstallResult;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Extension\Vendor\VendorResolver;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VendorResolverTest extends TestCase
|
||||
{
|
||||
private string $testDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->testDir = storage_path('app/test-vendor-resolver-'.uniqid());
|
||||
File::ensureDirectoryExists($this->testDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->testDir)) {
|
||||
File::deleteDirectory($this->testDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makeResolver(bool $canExecuteComposer): VendorResolver
|
||||
{
|
||||
$env = new class($canExecuteComposer) extends EnvironmentDetector
|
||||
{
|
||||
public function __construct(private bool $canExec) {}
|
||||
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
return $this->canExec;
|
||||
}
|
||||
};
|
||||
|
||||
$checker = new VendorIntegrityChecker;
|
||||
|
||||
return new VendorResolver(
|
||||
environmentDetector: $env,
|
||||
integrityChecker: $checker,
|
||||
bundleInstaller: new VendorBundleInstaller($checker),
|
||||
);
|
||||
}
|
||||
|
||||
private function createValidBundle(): void
|
||||
{
|
||||
$zipPath = $this->testDir.'/vendor-bundle.zip';
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('vendor/autoload.php', '<?php // test');
|
||||
$zip->close();
|
||||
|
||||
File::put($this->testDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash_file('sha256', $zipPath),
|
||||
'package_count' => 1,
|
||||
]));
|
||||
}
|
||||
|
||||
private function makeContext(VendorMode $requested = VendorMode::Auto, ?VendorMode $previous = null, string $operation = 'install'): VendorInstallContext
|
||||
{
|
||||
return new VendorInstallContext(
|
||||
target: 'module',
|
||||
identifier: 'test-mod',
|
||||
sourceDir: $this->testDir,
|
||||
targetDir: $this->testDir,
|
||||
requestedMode: $requested,
|
||||
previousMode: $previous,
|
||||
operation: $operation,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_resolves_explicit_composer_mode(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Composer);
|
||||
|
||||
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_resolves_explicit_bundled_mode(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Bundled);
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_auto_resolves_to_composer_when_available(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext();
|
||||
|
||||
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_auto_resolves_to_bundled_when_composer_unavailable_but_bundle_exists(): void
|
||||
{
|
||||
$this->createValidBundle();
|
||||
$resolver = $this->makeResolver(canExecuteComposer: false);
|
||||
$ctx = $this->makeContext();
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_auto_throws_when_neither_composer_nor_bundle_available(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: false);
|
||||
$ctx = $this->makeContext();
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$resolver->resolveMode($ctx);
|
||||
}
|
||||
|
||||
public function test_update_inherits_previous_mode(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(
|
||||
requested: VendorMode::Auto,
|
||||
previous: VendorMode::Bundled,
|
||||
operation: 'update',
|
||||
);
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_update_does_not_inherit_auto_previous_mode(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(
|
||||
requested: VendorMode::Auto,
|
||||
previous: VendorMode::Auto,
|
||||
operation: 'update',
|
||||
);
|
||||
|
||||
// auto → auto 해석 → composer 가능하므로 composer 반환
|
||||
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
|
||||
}
|
||||
|
||||
public function test_install_throws_when_composer_mode_but_executor_missing(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Composer);
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$resolver->install($ctx, composerExecutor: null);
|
||||
}
|
||||
|
||||
public function test_install_delegates_to_composer_executor_callback(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Composer);
|
||||
|
||||
$called = false;
|
||||
$executor = function (VendorInstallContext $c) use (&$called) {
|
||||
$called = true;
|
||||
|
||||
return new VendorInstallResult(
|
||||
mode: VendorMode::Composer,
|
||||
strategy: 'composer',
|
||||
packageCount: 10,
|
||||
);
|
||||
};
|
||||
|
||||
$result = $resolver->install($ctx, $executor);
|
||||
|
||||
$this->assertTrue($called);
|
||||
$this->assertSame(VendorMode::Composer, $result->mode);
|
||||
$this->assertSame(10, $result->packageCount);
|
||||
}
|
||||
|
||||
public function test_install_executes_bundle_strategy_end_to_end(): void
|
||||
{
|
||||
$this->createValidBundle();
|
||||
$resolver = $this->makeResolver(canExecuteComposer: false);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Bundled);
|
||||
|
||||
$result = $resolver->install($ctx);
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $result->mode);
|
||||
$this->assertFileExists($this->testDir.'/vendor/autoload.php');
|
||||
}
|
||||
|
||||
public function test_install_fails_when_bundled_mode_but_no_zip(): void
|
||||
{
|
||||
$resolver = $this->makeResolver(canExecuteComposer: true);
|
||||
$ctx = $this->makeContext(requested: VendorMode::Bundled);
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$resolver->install($ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Helpers;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ResponseHelper 단위 테스트
|
||||
*
|
||||
* 디버그 모드에 따른 예외 정보 노출/차단을 검증합니다.
|
||||
*/
|
||||
class ResponseHelperTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* error() + \Throwable + debug=true → message에 예외 메시지 포함 + debug 키 존재
|
||||
*/
|
||||
public function test_error_with_throwable_in_debug_mode_includes_debug_info(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$exception = new \RuntimeException('Test exception message');
|
||||
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertStringContainsString('Test exception message', $data['message']);
|
||||
$this->assertArrayHasKey('debug', $data);
|
||||
$this->assertEquals(\RuntimeException::class, $data['debug']['exception']);
|
||||
$this->assertEquals('Test exception message', $data['debug']['message']);
|
||||
$this->assertArrayHasKey('file', $data['debug']);
|
||||
$this->assertArrayHasKey('line', $data['debug']);
|
||||
$this->assertArrayHasKey('trace', $data['debug']);
|
||||
$this->assertLessThanOrEqual(10, count($data['debug']['trace']));
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + \Throwable + debug=false → 번역 메시지만, debug 키 미존재
|
||||
*/
|
||||
public function test_error_with_throwable_in_production_hides_debug_info(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$exception = new \RuntimeException('Sensitive error detail');
|
||||
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertStringNotContainsString('Sensitive error detail', $data['message']);
|
||||
$this->assertArrayNotHasKey('debug', $data);
|
||||
$this->assertArrayNotHasKey('errors', $data);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + string + 500 + debug=false → errors 키 미존재 (프로덕션 보안)
|
||||
*/
|
||||
public function test_error_with_string_500_in_production_hides_errors(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$response = ResponseHelper::error('messages.failed', 500, 'Internal DB error');
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertArrayNotHasKey('errors', $data);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + string + 500 + debug=true → errors 키 존재
|
||||
*/
|
||||
public function test_error_with_string_500_in_debug_mode_shows_errors(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$response = ResponseHelper::error('messages.failed', 500, 'Internal DB error');
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertArrayHasKey('errors', $data);
|
||||
$this->assertEquals('Internal DB error', $data['errors']);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + array + 422 → debug 무관하게 errors 존재 (기존 validation 동작 유지)
|
||||
*/
|
||||
public function test_error_with_array_422_always_shows_errors(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$validationErrors = ['email' => ['이메일 형식이 올바르지 않습니다.']];
|
||||
$response = ResponseHelper::error('messages.validation_failed', 422, $validationErrors);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertArrayHasKey('errors', $data);
|
||||
$this->assertEquals($validationErrors, $data['errors']);
|
||||
$this->assertEquals(422, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + string + 400 + debug=false → errors 존재 (500 미만은 guard 미적용)
|
||||
*/
|
||||
public function test_error_with_string_400_in_production_shows_errors(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$response = ResponseHelper::error('messages.failed', 400, 'Bad request detail');
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertArrayHasKey('errors', $data);
|
||||
$this->assertEquals('Bad request detail', $data['errors']);
|
||||
}
|
||||
|
||||
/**
|
||||
* serverError() + \Throwable + debug=true → message + debug 상세
|
||||
*/
|
||||
public function test_server_error_with_throwable_in_debug_mode(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$exception = new \InvalidArgumentException('Invalid config value');
|
||||
$response = ResponseHelper::serverError('messages.error_occurred', $exception);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertStringContainsString('Invalid config value', $data['message']);
|
||||
$this->assertArrayHasKey('debug', $data);
|
||||
$this->assertEquals(\InvalidArgumentException::class, $data['debug']['exception']);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* serverError() + \Throwable + debug=false → 번역 메시지만
|
||||
*/
|
||||
public function test_server_error_with_throwable_in_production(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$exception = new \InvalidArgumentException('Sensitive config detail');
|
||||
$response = ResponseHelper::serverError('messages.error_occurred', $exception);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertStringNotContainsString('Sensitive config detail', $data['message']);
|
||||
$this->assertArrayNotHasKey('debug', $data);
|
||||
$this->assertArrayNotHasKey('error', $data);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* moduleError() + \Throwable → error() 위임 동작 확인 (domain 전달)
|
||||
*/
|
||||
public function test_module_error_with_throwable_delegates_to_error(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$exception = new \RuntimeException('Module specific error');
|
||||
$response = ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'messages.error_occurred',
|
||||
500,
|
||||
$exception
|
||||
);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertStringContainsString('Module specific error', $data['message']);
|
||||
$this->assertArrayHasKey('debug', $data);
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* serverError() + string + debug=true → 기존 동작 유지 (error 키에 포함)
|
||||
*/
|
||||
public function test_server_error_with_string_in_debug_mode_keeps_legacy_behavior(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$response = ResponseHelper::serverError('messages.error_occurred', 'Some error string');
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
$this->assertEquals('Some error string', $data['error']);
|
||||
$this->assertArrayNotHasKey('debug', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + \Throwable + messageParams + debug=true → message 중복 방지 + debug 존재
|
||||
*/
|
||||
public function test_error_with_throwable_and_message_params_no_duplicate(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$exception = new \RuntimeException('DB connection failed');
|
||||
$response = ResponseHelper::error(
|
||||
'user.create_failed',
|
||||
500,
|
||||
$exception,
|
||||
['error' => $exception->getMessage()]
|
||||
);
|
||||
$data = $response->getData(true);
|
||||
|
||||
// messageParams가 있으면 message에 예외 메시지를 중복 concatenate 하지 않음
|
||||
$this->assertArrayHasKey('debug', $data);
|
||||
$this->assertEquals('DB connection failed', $data['debug']['message']);
|
||||
// :error가 messageParams로 치환되고, Throwable concatenate는 생략
|
||||
$this->assertStringContainsString('DB connection failed', $data['message']);
|
||||
$this->assertSame(
|
||||
1,
|
||||
substr_count($data['message'], 'DB connection failed')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* error() + null errors → errors/debug 키 모두 미존재
|
||||
*/
|
||||
public function test_error_with_null_errors_has_no_extra_keys(): void
|
||||
{
|
||||
$response = ResponseHelper::error('messages.failed', 400);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertFalse($data['success']);
|
||||
$this->assertArrayNotHasKey('errors', $data);
|
||||
$this->assertArrayNotHasKey('debug', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* formatException trace는 최대 10프레임
|
||||
*/
|
||||
public function test_debug_trace_is_limited_to_10_frames(): void
|
||||
{
|
||||
config(['app.debug' => true]);
|
||||
|
||||
// 깊은 호출 스택을 만들기 위해 재귀 예외 생성
|
||||
$exception = $this->createDeepException(20);
|
||||
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
||||
$data = $response->getData(true);
|
||||
|
||||
$this->assertArrayHasKey('debug', $data);
|
||||
$this->assertLessThanOrEqual(10, count($data['debug']['trace']));
|
||||
}
|
||||
|
||||
/**
|
||||
* 깊은 호출 스택의 예외를 생성합니다.
|
||||
*
|
||||
* @param int $depth 호출 깊이
|
||||
* @return \RuntimeException
|
||||
*/
|
||||
private function createDeepException(int $depth): \RuntimeException
|
||||
{
|
||||
if ($depth <= 0) {
|
||||
return new \RuntimeException('Deep exception');
|
||||
}
|
||||
|
||||
return $this->createDeepException($depth - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Http\Controllers\Base;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* PublicBaseController::cached() 에러 응답 캐시 제외 검증.
|
||||
*
|
||||
* 확장 설치 직후·활성화 전 등의 일시적 상태에서 error 응답이 영구 캐시되어
|
||||
* 복구 후에도 잘못된 응답을 반환하던 문제를 방지하는 공용 룰 검증.
|
||||
*/
|
||||
class PublicBaseControllerCachedTest extends TestCase
|
||||
{
|
||||
private function invokeCached(callable $callback, string $key = 'test-key', int $ttl = 60): mixed
|
||||
{
|
||||
$controller = new class extends PublicBaseController {
|
||||
public function callCached(string $key, callable $callback, int $ttl): mixed
|
||||
{
|
||||
return $this->cached($key, $callback, $ttl);
|
||||
}
|
||||
};
|
||||
|
||||
return $controller->callCached($key, $callback, $ttl);
|
||||
}
|
||||
|
||||
public function test_success_response_is_cached(): void
|
||||
{
|
||||
$cache = app(CacheInterface::class);
|
||||
$cache->forget('test-success');
|
||||
|
||||
$calls = 0;
|
||||
$callback = function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return ['success' => true, 'data' => ['foo' => 'bar']];
|
||||
};
|
||||
|
||||
// 1회차 — 캐시 miss → callback 실행 + 저장
|
||||
$r1 = $this->invokeCached($callback, 'test-success');
|
||||
// 2회차 — 캐시 hit → callback 재실행 안 됨
|
||||
$r2 = $this->invokeCached($callback, 'test-success');
|
||||
|
||||
$this->assertEquals($r1, $r2);
|
||||
$this->assertEquals(1, $calls, 'success 응답은 캐시되어 callback 이 한 번만 실행되어야 함');
|
||||
|
||||
$cache->forget('test-success');
|
||||
}
|
||||
|
||||
public function test_error_response_is_not_cached(): void
|
||||
{
|
||||
$cache = app(CacheInterface::class);
|
||||
$cache->forget('test-error');
|
||||
|
||||
$calls = 0;
|
||||
$callback = function () use (&$calls) {
|
||||
$calls++;
|
||||
|
||||
return ['error' => 'template_not_found'];
|
||||
};
|
||||
|
||||
// 1회차 — callback 실행, 에러라 캐시 안 됨
|
||||
$r1 = $this->invokeCached($callback, 'test-error');
|
||||
// 2회차 — 여전히 캐시 miss, callback 재실행
|
||||
$r2 = $this->invokeCached($callback, 'test-error');
|
||||
|
||||
$this->assertEquals($r1, $r2);
|
||||
$this->assertEquals(2, $calls, 'error 응답은 캐시되지 않아 callback 이 매번 실행되어야 함');
|
||||
}
|
||||
|
||||
public function test_recovers_when_callback_stops_returning_error(): void
|
||||
{
|
||||
$cache = app(CacheInterface::class);
|
||||
$cache->forget('test-recover');
|
||||
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
// 1~2회는 에러, 3회째부터 성공 (활성화 전 → 활성화 후 시뮬레이션)
|
||||
if ($callCount < 3) {
|
||||
return ['error' => 'template_not_found'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => ['recovered' => true]];
|
||||
};
|
||||
|
||||
$r1 = $this->invokeCached($callback, 'test-recover');
|
||||
$this->assertArrayHasKey('error', $r1);
|
||||
|
||||
$r2 = $this->invokeCached($callback, 'test-recover');
|
||||
$this->assertArrayHasKey('error', $r2);
|
||||
|
||||
// 3회째 — 성공 응답 반환되고 캐시에 저장
|
||||
$r3 = $this->invokeCached($callback, 'test-recover');
|
||||
$this->assertArrayHasKey('success', $r3);
|
||||
|
||||
// 4회째 — 캐시 hit
|
||||
$r4 = $this->invokeCached($callback, 'test-recover');
|
||||
$this->assertEquals($r3, $r4);
|
||||
$this->assertEquals(3, $callCount, '3번째 호출에서 성공 후 캐시되어 4번째는 callback 재실행 안 됨');
|
||||
|
||||
$cache->forget('test-recover');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Module\PerformModuleUpdateRequest;
|
||||
use App\Http\Requests\Plugin\PerformPluginUpdateRequest;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 모듈/플러그인 업데이트 요청 검증 규칙 테스트.
|
||||
*
|
||||
* layout_strategy 와 vendor_mode 가 유효한 값만 수용하는지 확인.
|
||||
*/
|
||||
class PerformExtensionUpdateRequestTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider validLayoutStrategyProvider
|
||||
*/
|
||||
public function test_module_update_request_accepts_valid_layout_strategy(string $strategy): void
|
||||
{
|
||||
$rules = (new PerformModuleUpdateRequest())->rules();
|
||||
$validator = Validator::make(['layout_strategy' => $strategy], $rules);
|
||||
|
||||
$this->assertFalse($validator->fails(), "layout_strategy={$strategy} 은 유효해야 합니다");
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider validLayoutStrategyProvider
|
||||
*/
|
||||
public function test_plugin_update_request_accepts_valid_layout_strategy(string $strategy): void
|
||||
{
|
||||
$rules = (new PerformPluginUpdateRequest())->rules();
|
||||
$validator = Validator::make(['layout_strategy' => $strategy], $rules);
|
||||
|
||||
$this->assertFalse($validator->fails(), "layout_strategy={$strategy} 은 유효해야 합니다");
|
||||
}
|
||||
|
||||
public function test_module_update_request_rejects_invalid_layout_strategy(): void
|
||||
{
|
||||
$rules = (new PerformModuleUpdateRequest())->rules();
|
||||
$validator = Validator::make(['layout_strategy' => 'invalid'], $rules);
|
||||
|
||||
$this->assertTrue($validator->fails());
|
||||
$this->assertArrayHasKey('layout_strategy', $validator->errors()->toArray());
|
||||
}
|
||||
|
||||
public function test_plugin_update_request_rejects_invalid_layout_strategy(): void
|
||||
{
|
||||
$rules = (new PerformPluginUpdateRequest())->rules();
|
||||
$validator = Validator::make(['layout_strategy' => 'invalid'], $rules);
|
||||
|
||||
$this->assertTrue($validator->fails());
|
||||
$this->assertArrayHasKey('layout_strategy', $validator->errors()->toArray());
|
||||
}
|
||||
|
||||
public function test_module_update_request_layout_strategy_is_optional(): void
|
||||
{
|
||||
$rules = (new PerformModuleUpdateRequest())->rules();
|
||||
$validator = Validator::make([], $rules);
|
||||
|
||||
$this->assertFalse($validator->fails(), 'layout_strategy 미지정 시에도 통과해야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider validVendorModeProvider
|
||||
*/
|
||||
public function test_module_update_request_accepts_valid_vendor_mode(string $mode): void
|
||||
{
|
||||
$rules = (new PerformModuleUpdateRequest())->rules();
|
||||
$validator = Validator::make(['vendor_mode' => $mode], $rules);
|
||||
|
||||
$this->assertFalse($validator->fails(), "vendor_mode={$mode} 은 유효해야 합니다");
|
||||
}
|
||||
|
||||
public function test_plugin_update_request_rejects_invalid_vendor_mode(): void
|
||||
{
|
||||
$rules = (new PerformPluginUpdateRequest())->rules();
|
||||
$validator = Validator::make(['vendor_mode' => 'invalid'], $rules);
|
||||
|
||||
$this->assertTrue($validator->fails());
|
||||
$this->assertArrayHasKey('vendor_mode', $validator->errors()->toArray());
|
||||
}
|
||||
|
||||
public static function validLayoutStrategyProvider(): array
|
||||
{
|
||||
return [
|
||||
'overwrite' => ['overwrite'],
|
||||
'keep' => ['keep'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function validVendorModeProvider(): array
|
||||
{
|
||||
return [
|
||||
'auto' => ['auto'],
|
||||
'composer' => ['composer'],
|
||||
'bundled' => ['bundled'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Extension\HookArgumentSerializer;
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DispatchHookListenerJob 테스트
|
||||
*
|
||||
* 훅 리스너 큐 Job의 실행과 표시 이름을 검증합니다.
|
||||
*/
|
||||
class DispatchHookListenerJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Job이 리스너 메서드를 정상 호출하는지 검증합니다.
|
||||
*/
|
||||
public function test_job_calls_listener_method(): void
|
||||
{
|
||||
StubJobListener::$receivedArgs = null;
|
||||
|
||||
$args = HookArgumentSerializer::serialize(['hello', 42]);
|
||||
$job = new DispatchHookListenerJob(
|
||||
StubJobListener::class,
|
||||
'handleTest',
|
||||
$args
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
$this->assertEquals(['hello', 42], StubJobListener::$receivedArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* displayName이 리스너 클래스@메서드 형식인지 검증합니다.
|
||||
*/
|
||||
public function test_display_name_format(): void
|
||||
{
|
||||
$job = new DispatchHookListenerJob(
|
||||
StubJobListener::class,
|
||||
'handleTest',
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('StubJobListener@handleTest', $job->displayName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Job의 기본 속성을 검증합니다.
|
||||
*/
|
||||
public function test_job_default_properties(): void
|
||||
{
|
||||
$job = new DispatchHookListenerJob('SomeClass', 'someMethod', []);
|
||||
|
||||
$this->assertEquals(3, $job->tries);
|
||||
$this->assertTrue($job->afterCommit);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle() 진입 시 캡처된 컨텍스트(Auth/Locale/Request)가 복원되는지 검증합니다.
|
||||
*
|
||||
* 큐 워커처럼 빈 컨텍스트에서 시작하여 Job 실행 후 리스너 내부에서
|
||||
* Auth::id(), App::getLocale(), request()->ip()가 캡처 값과 일치하는지 확인합니다.
|
||||
*/
|
||||
public function test_job_restores_captured_context_before_listener(): void
|
||||
{
|
||||
ContextAwareStubListener::reset();
|
||||
|
||||
// Auth::onceUsingId()는 실제 사용자를 DB에서 로드하므로 factory로 생성
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 현재 컨텍스트를 비워 큐 워커 환경 시뮬레이션
|
||||
Auth::logout();
|
||||
App::setLocale('en');
|
||||
|
||||
$job = new DispatchHookListenerJob(
|
||||
ContextAwareStubListener::class,
|
||||
'capture',
|
||||
HookArgumentSerializer::serialize([]),
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '198.51.100.7',
|
||||
'user_agent' => 'WorkerAgent/1.0',
|
||||
'locale' => 'ko',
|
||||
'path' => 'api/admin/settings',
|
||||
]
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
$this->assertEquals($user->id, ContextAwareStubListener::$capturedUserId);
|
||||
$this->assertEquals('ko', ContextAwareStubListener::$capturedLocale);
|
||||
$this->assertEquals('198.51.100.7', ContextAwareStubListener::$capturedIp);
|
||||
$this->assertEquals('api/admin/settings', ContextAwareStubListener::$capturedPath);
|
||||
}
|
||||
}
|
||||
|
||||
class StubJobListener
|
||||
{
|
||||
public static ?array $receivedArgs = null;
|
||||
|
||||
public function handleTest(string $message, int $number): void
|
||||
{
|
||||
self::$receivedArgs = [$message, $number];
|
||||
}
|
||||
}
|
||||
|
||||
class ContextAwareStubListener
|
||||
{
|
||||
public static ?int $capturedUserId = null;
|
||||
|
||||
public static ?string $capturedLocale = null;
|
||||
|
||||
public static ?string $capturedIp = null;
|
||||
|
||||
public static ?string $capturedPath = null;
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$capturedUserId = null;
|
||||
self::$capturedLocale = null;
|
||||
self::$capturedIp = null;
|
||||
self::$capturedPath = null;
|
||||
}
|
||||
|
||||
public function capture(): void
|
||||
{
|
||||
self::$capturedUserId = Auth::id();
|
||||
self::$capturedLocale = App::getLocale();
|
||||
self::$capturedIp = request()->ip();
|
||||
self::$capturedPath = request()->path();
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Tests\Unit\Jobs;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\SitemapGenerator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
@@ -60,9 +60,10 @@ class GenerateSitemapJobTest extends TestCase
|
||||
->once()
|
||||
->andReturn($xml);
|
||||
|
||||
Cache::shouldReceive('put')
|
||||
$cache = Mockery::mock(CacheInterface::class);
|
||||
$cache->shouldReceive('put')
|
||||
->once()
|
||||
->with('seo:sitemap', $xml, 86400);
|
||||
->with('seo.sitemap', $xml, 86400);
|
||||
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
@@ -71,7 +72,7 @@ class GenerateSitemapJobTest extends TestCase
|
||||
}));
|
||||
|
||||
$job = new GenerateSitemapJob();
|
||||
$job->handle($generator);
|
||||
$job->handle($generator, $cache);
|
||||
|
||||
$this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount());
|
||||
}
|
||||
@@ -85,23 +86,25 @@ class GenerateSitemapJobTest extends TestCase
|
||||
|
||||
$generator = Mockery::mock(SitemapGenerator::class);
|
||||
$generator->shouldNotReceive('generate');
|
||||
$cache = Mockery::mock(CacheInterface::class);
|
||||
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('[SEO] Sitemap generation skipped (disabled)');
|
||||
|
||||
$job = new GenerateSitemapJob();
|
||||
$job->handle($generator);
|
||||
$job->handle($generator, $cache);
|
||||
|
||||
$this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 커스텀 TTL이 Cache::put에 전달되는지 확인합니다.
|
||||
* 설정된 커스텀 TTL이 cache->put에 전달되는지 확인합니다.
|
||||
*/
|
||||
public function test_handle_uses_configured_cache_ttl(): void
|
||||
{
|
||||
Config::set('g7_settings.core.seo.sitemap_enabled', true);
|
||||
Config::set('g7_settings.core.cache.seo_sitemap_ttl', 3600);
|
||||
Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600);
|
||||
|
||||
$xml = '<urlset/>';
|
||||
@@ -111,9 +114,10 @@ class GenerateSitemapJobTest extends TestCase
|
||||
->once()
|
||||
->andReturn($xml);
|
||||
|
||||
Cache::shouldReceive('put')
|
||||
$cache = Mockery::mock(CacheInterface::class);
|
||||
$cache->shouldReceive('put')
|
||||
->once()
|
||||
->with('seo:sitemap', $xml, 3600);
|
||||
->with('seo.sitemap', $xml, 3600);
|
||||
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
@@ -122,7 +126,7 @@ class GenerateSitemapJobTest extends TestCase
|
||||
}));
|
||||
|
||||
$job = new GenerateSitemapJob();
|
||||
$job->handle($generator);
|
||||
$job->handle($generator, $cache);
|
||||
|
||||
$this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount());
|
||||
}
|
||||
|
||||
@@ -133,8 +133,8 @@ class CoreActivityLogListenerTest extends TestCase
|
||||
{
|
||||
$hooks = CoreActivityLogListener::getSubscribedHooks();
|
||||
|
||||
// 61개 로깅(after_*) 훅 (스냅샷은 Service에서 캡처하여 인수로 전달)
|
||||
$this->assertCount(61, $hooks);
|
||||
// 59개 로깅(after_*) 훅 (mail_template 2개 제거됨, 스냅샷은 Service에서 캡처하여 인수로 전달)
|
||||
$this->assertCount(59, $hooks);
|
||||
}
|
||||
|
||||
public function test_handle_does_nothing(): void
|
||||
@@ -912,60 +912,6 @@ class CoreActivityLogListenerTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Mail Template 핸들러 테스트 (2개)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleMailTemplateAfterUpdate_logs_activity_with_changes(): void
|
||||
{
|
||||
$template = $this->createModelMock(3, ['name' => 'welcome_email']);
|
||||
|
||||
$this->logChannel->shouldReceive('info')
|
||||
->once()
|
||||
->withArgs(function (string $action, array $context) {
|
||||
return $action === 'mail_template.update'
|
||||
&& $context['log_type'] === ActivityLogType::Admin
|
||||
&& $context['description_key'] === 'activity_log.description.mail_template_update'
|
||||
&& $context['description_params']['template_name'] === 'welcome_email'
|
||||
&& array_key_exists('changes', $context)
|
||||
&& isset($context['loggable']);
|
||||
});
|
||||
|
||||
$this->listener->handleMailTemplateAfterUpdate($template);
|
||||
}
|
||||
|
||||
public function test_handleMailTemplateAfterUpdate_accepts_snapshot_argument(): void
|
||||
{
|
||||
$template = $this->createModelMock(3, ['name' => 'welcome_email']);
|
||||
$snapshot = ['id' => 3, 'subject' => 'Old Subject'];
|
||||
|
||||
$this->logChannel->shouldReceive('info')
|
||||
->once()
|
||||
->withArgs(function (string $action, array $context) {
|
||||
return $action === 'mail_template.update'
|
||||
&& array_key_exists('changes', $context);
|
||||
});
|
||||
|
||||
$this->listener->handleMailTemplateAfterUpdate($template, $snapshot);
|
||||
}
|
||||
|
||||
public function test_handleMailTemplateAfterToggleActive_logs_activity(): void
|
||||
{
|
||||
$template = $this->createModelMock(3, ['name' => 'reset_password']);
|
||||
|
||||
$this->logChannel->shouldReceive('info')
|
||||
->once()
|
||||
->withArgs(function (string $action, array $context) {
|
||||
return $action === 'mail_template.toggle_active'
|
||||
&& $context['log_type'] === ActivityLogType::Admin
|
||||
&& $context['description_key'] === 'activity_log.description.mail_template_toggle_active'
|
||||
&& $context['description_params']['template_name'] === 'reset_password'
|
||||
&& isset($context['loggable']);
|
||||
});
|
||||
|
||||
$this->listener->handleMailTemplateAfterToggleActive($template);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Module 핸들러 테스트 (6개)
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -1430,7 +1376,6 @@ class CoreActivityLogListenerTest extends TestCase
|
||||
'captureRoleSnapshot',
|
||||
'captureMenuSnapshot',
|
||||
'captureScheduleSnapshot',
|
||||
'captureMailTemplateSnapshot',
|
||||
];
|
||||
|
||||
foreach ($hooks as $hookName => $config) {
|
||||
@@ -1515,10 +1460,6 @@ class CoreActivityLogListenerTest extends TestCase
|
||||
['core.attachment.after_delete', 'handleAttachmentAfterDelete', 'attachment.delete', ActivityLogType::Admin, 'activity_log.description.attachment_delete'],
|
||||
['core.attachment.after_bulk_delete', 'handleAttachmentAfterBulkDelete', 'attachment.bulk_delete', ActivityLogType::Admin, 'activity_log.description.attachment_bulk_delete'],
|
||||
|
||||
// Mail Template
|
||||
['core.mail_template.after_update', 'handleMailTemplateAfterUpdate', 'mail_template.update', ActivityLogType::Admin, 'activity_log.description.mail_template_update'],
|
||||
['core.mail_template.after_toggle_active', 'handleMailTemplateAfterToggleActive', 'mail_template.toggle_active', ActivityLogType::Admin, 'activity_log.description.mail_template_toggle_active'],
|
||||
|
||||
// Module
|
||||
['core.modules.after_install', 'handleModuleAfterInstall', 'module.install', ActivityLogType::Admin, 'activity_log.description.module_install'],
|
||||
['core.modules.after_activate', 'handleModuleAfterActivate', 'module.activate', ActivityLogType::Admin, 'activity_log.description.module_activate'],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Listeners\Dashboard;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use App\Listeners\Dashboard\DashboardModuleListener;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
@@ -19,7 +19,7 @@ class DashboardModuleListenerTest extends TestCase
|
||||
*/
|
||||
public function test_listener_broadcasts_stats_on_module_change(): void
|
||||
{
|
||||
Event::fake([DashboardUpdated::class]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
$mockService = Mockery::mock(DashboardService::class);
|
||||
$mockService->shouldReceive('getStats')->once()->andReturn([
|
||||
@@ -27,10 +27,12 @@ class DashboardModuleListenerTest extends TestCase
|
||||
]);
|
||||
|
||||
$listener = new DashboardModuleListener($mockService);
|
||||
$listener->handle();
|
||||
$listener->handleModuleUpdate();
|
||||
|
||||
Event::assertDispatched(DashboardUpdated::class, function ($event) {
|
||||
return $event->type === 'stats';
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
return $event->channel === 'core.admin.dashboard'
|
||||
&& $event->eventName === 'dashboard.stats.updated'
|
||||
&& $event->payload['type'] === 'stats';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Listeners\Dashboard;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use App\Listeners\Dashboard\DashboardStatsListener;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
@@ -19,7 +19,7 @@ class DashboardStatsListenerTest extends TestCase
|
||||
*/
|
||||
public function test_listener_broadcasts_stats_update(): void
|
||||
{
|
||||
Event::fake([DashboardUpdated::class]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
$mockService = Mockery::mock(DashboardService::class);
|
||||
$mockService->shouldReceive('getStats')->once()->andReturn([
|
||||
@@ -27,10 +27,12 @@ class DashboardStatsListenerTest extends TestCase
|
||||
]);
|
||||
|
||||
$listener = new DashboardStatsListener($mockService);
|
||||
$listener->handle();
|
||||
$listener->handleStatsUpdate();
|
||||
|
||||
Event::assertDispatched(DashboardUpdated::class, function ($event) {
|
||||
return $event->type === 'stats';
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
|
||||
return $event->channel === 'core.admin.dashboard'
|
||||
&& $event->eventName === 'dashboard.stats.updated'
|
||||
&& $event->payload['type'] === 'stats';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ class DashboardStatsListenerTest extends TestCase
|
||||
*/
|
||||
public function test_listener_fetches_data_from_dashboard_service(): void
|
||||
{
|
||||
Event::fake([DashboardUpdated::class]);
|
||||
Event::fake([GenericBroadcastEvent::class]);
|
||||
|
||||
$expectedData = [
|
||||
'total_users' => ['count' => 50, 'trend' => 'up'],
|
||||
@@ -50,10 +52,11 @@ class DashboardStatsListenerTest extends TestCase
|
||||
$mockService->shouldReceive('getStats')->once()->andReturn($expectedData);
|
||||
|
||||
$listener = new DashboardStatsListener($mockService);
|
||||
$listener->handle();
|
||||
$listener->handleStatsUpdate();
|
||||
|
||||
Event::assertDispatched(DashboardUpdated::class, function ($event) use ($expectedData) {
|
||||
return $event->type === 'stats' && $event->data === $expectedData;
|
||||
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) use ($expectedData) {
|
||||
return $event->payload['type'] === 'stats'
|
||||
&& $event->payload['data'] === $expectedData;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Enums\MailSendStatus;
|
||||
use App\Listeners\MailSendLogListener;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailSendLogListener 테스트
|
||||
*
|
||||
* 훅 리스너의 메일 발송 이력 기록 동작을 검증합니다.
|
||||
*/
|
||||
class MailSendLogListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private MailSendLogListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = app(MailSendLogListener::class);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// HookListenerInterface 구현
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* HookListenerInterface를 구현하는지 확인
|
||||
*/
|
||||
public function test_implements_hook_listener_interface(): void
|
||||
{
|
||||
$this->assertInstanceOf(HookListenerInterface::class, $this->listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* getSubscribedHooks()가 올바른 훅 3종을 반환하는지 확인
|
||||
*/
|
||||
public function test_get_subscribed_hooks_returns_correct_hooks(): void
|
||||
{
|
||||
$hooks = MailSendLogListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('core.mail.after_send', $hooks);
|
||||
$this->assertArrayHasKey('core.mail.send_failed', $hooks);
|
||||
$this->assertArrayHasKey('core.mail.send_skipped', $hooks);
|
||||
|
||||
$this->assertEquals('handleAfterSend', $hooks['core.mail.after_send']['method']);
|
||||
$this->assertEquals('handleSendFailed', $hooks['core.mail.send_failed']['method']);
|
||||
$this->assertEquals('handleSendSkipped', $hooks['core.mail.send_skipped']['method']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// handleAfterSend (발송 성공)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 성공 이력을 기록함
|
||||
*/
|
||||
public function test_handle_after_send_creates_sent_record(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'recipientName' => 'John Doe',
|
||||
'subject' => 'Welcome Email',
|
||||
'templateType' => 'welcome',
|
||||
'extensionType' => 'core',
|
||||
'extensionIdentifier' => 'core',
|
||||
'source' => 'notification',
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseCount('mail_send_logs', 1);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'recipient_name' => 'John Doe',
|
||||
'subject' => 'Welcome Email',
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* source만 있어도 기록함
|
||||
*/
|
||||
public function test_handle_after_send_with_source_only(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'admin@example.com',
|
||||
'source' => 'test_mail',
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseCount('mail_send_logs', 1);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'admin@example.com',
|
||||
'source' => 'test_mail',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* extensionType 미제공 시 기본값 'core' 사용
|
||||
*/
|
||||
public function test_handle_after_send_defaults_extension_type_to_core(): void
|
||||
{
|
||||
$data = [
|
||||
'recipientEmail' => 'test@example.com',
|
||||
'templateType' => 'password_reset',
|
||||
];
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'template_type' => 'password_reset',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* extensionType/extensionIdentifier 값을 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_after_send_records_extension(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'buyer@example.com',
|
||||
'recipientName' => 'Buyer',
|
||||
'templateType' => 'order_confirmed',
|
||||
'extensionType' => 'module',
|
||||
'extensionIdentifier' => 'sirsoft-ecommerce',
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'template_type' => 'order_confirmed',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* source 값을 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_after_send_records_source(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'templateType' => 'welcome',
|
||||
'source' => 'notification',
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'source' => 'notification',
|
||||
'template_type' => 'welcome',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* source 미제공 시 null로 기록
|
||||
*/
|
||||
public function test_handle_after_send_source_null_when_missing(): void
|
||||
{
|
||||
$data = [
|
||||
'recipientEmail' => 'test@example.com',
|
||||
'templateType' => 'welcome',
|
||||
];
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'template_type' => 'welcome',
|
||||
'source' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* recipientName 미제공 시 null로 기록
|
||||
*/
|
||||
public function test_handle_after_send_null_recipient_name(): void
|
||||
{
|
||||
$data = [
|
||||
'recipientEmail' => 'noname@example.com',
|
||||
'templateType' => 'welcome',
|
||||
];
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'noname@example.com',
|
||||
'recipient_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* body 값을 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_after_send_records_body(): void
|
||||
{
|
||||
$body = '<p>Welcome to G7!</p>';
|
||||
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송자 정보를 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_after_send_records_sender(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'senderEmail' => 'admin@example.com',
|
||||
'senderName' => 'G7 Admin',
|
||||
]);
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'sender_email' => 'admin@example.com',
|
||||
'sender_name' => 'G7 Admin',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송자 정보 미제공 시 null로 기록
|
||||
*/
|
||||
public function test_handle_after_send_sender_null_when_missing(): void
|
||||
{
|
||||
$data = [
|
||||
'recipientEmail' => 'test@example.com',
|
||||
];
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'sender_email' => null,
|
||||
'sender_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* body 미제공 시 null로 기록
|
||||
*/
|
||||
public function test_handle_after_send_body_null_when_missing(): void
|
||||
{
|
||||
$data = [
|
||||
'recipientEmail' => 'test@example.com',
|
||||
];
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'body' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상 실행에서 예외가 발생하지 않음
|
||||
*/
|
||||
public function test_handle_after_send_does_not_throw(): void
|
||||
{
|
||||
$data = $this->createHookData();
|
||||
|
||||
$this->listener->handleAfterSend($data);
|
||||
|
||||
$this->assertDatabaseCount('mail_send_logs', 1);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// handleSendFailed (발송 실패)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 실패 이력을 기록함
|
||||
*/
|
||||
public function test_handle_send_failed_creates_failed_record(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'subject' => 'Order Confirmation',
|
||||
'templateType' => 'order_confirmed',
|
||||
'extensionType' => 'module',
|
||||
'extensionIdentifier' => 'sirsoft-ecommerce',
|
||||
'source' => 'notification',
|
||||
'errorMessage' => 'Connection timeout',
|
||||
]);
|
||||
|
||||
$this->listener->handleSendFailed($data);
|
||||
|
||||
$this->assertDatabaseCount('mail_send_logs', 1);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'subject' => 'Order Confirmation',
|
||||
'template_type' => 'order_confirmed',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
'error_message' => 'Connection timeout',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 실패 시 발송자 정보를 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_send_failed_records_sender(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'errorMessage' => 'SMTP error',
|
||||
'senderEmail' => 'noreply@example.com',
|
||||
'senderName' => 'G7',
|
||||
]);
|
||||
|
||||
$this->listener->handleSendFailed($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'sender_email' => 'noreply@example.com',
|
||||
'sender_name' => 'G7',
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 실패 시 body를 올바르게 기록함
|
||||
*/
|
||||
public function test_handle_send_failed_records_body(): void
|
||||
{
|
||||
$body = '<p>Order details</p>';
|
||||
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'errorMessage' => 'SMTP error',
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->listener->handleSendFailed($data);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'body' => $body,
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// handleSendSkipped (발송 건너뜀)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 건너뜀 이력을 기록함
|
||||
*/
|
||||
public function test_handle_send_skipped_creates_skipped_record(): void
|
||||
{
|
||||
$data = $this->createHookData([
|
||||
'recipientEmail' => 'user@example.com',
|
||||
'recipientName' => 'User',
|
||||
'templateType' => 'welcome',
|
||||
'extensionType' => 'core',
|
||||
'extensionIdentifier' => 'core',
|
||||
'source' => 'notification',
|
||||
]);
|
||||
|
||||
$this->listener->handleSendSkipped($data);
|
||||
|
||||
$this->assertDatabaseCount('mail_send_logs', 1);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'recipient_name' => 'User',
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Skipped->value,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 헬퍼 메서드
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 훅 데이터 생성 헬퍼
|
||||
*
|
||||
* @param array $overrides 오버라이드할 데이터
|
||||
* @return array 훅 데이터
|
||||
*/
|
||||
private function createHookData(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'recipientEmail' => 'test@example.com',
|
||||
'recipientName' => null,
|
||||
'subject' => 'Test',
|
||||
'templateType' => null,
|
||||
'extensionType' => 'core',
|
||||
'extensionIdentifier' => 'core',
|
||||
'source' => null,
|
||||
], $overrides);
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Repositories\MailTemplateRepositoryInterface;
|
||||
use App\Listeners\MailTemplateUserOverridesListener;
|
||||
use App\Models\MailTemplate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailTemplateUserOverridesListener 단위 테스트
|
||||
*
|
||||
* 메일 템플릿 수정 시 사용자가 변경한 필드를 user_overrides에 기록하는 리스너를 검증합니다.
|
||||
*/
|
||||
class MailTemplateUserOverridesListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var MailTemplateRepositoryInterface&\Mockery\MockInterface 리포지토리 목
|
||||
*/
|
||||
private MailTemplateRepositoryInterface $repository;
|
||||
|
||||
/**
|
||||
* @var MailTemplateUserOverridesListener 테스트 대상 리스너
|
||||
*/
|
||||
private MailTemplateUserOverridesListener $listener;
|
||||
|
||||
/**
|
||||
* 테스트 사전 설정
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->repository = Mockery::mock(MailTemplateRepositoryInterface::class);
|
||||
$this->listener = new MailTemplateUserOverridesListener($this->repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* subject 변경 시 user_overrides에 'subject'가 기록되는지 확인합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_records_subject_change_in_user_overrides(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '기존 제목', 'en' => 'Old Subject'],
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'subject' => ['ko' => '변경된 제목', 'en' => 'New Subject'],
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('update')
|
||||
->once()
|
||||
->withArgs(function (MailTemplate $t, array $data) use ($template) {
|
||||
return $t->id === $template->id
|
||||
&& isset($data['user_overrides'])
|
||||
&& in_array('subject', $data['user_overrides'], true);
|
||||
})
|
||||
->andReturn(true);
|
||||
|
||||
$this->listener->handleBeforeUpdate($template, $newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* body 변경 시 user_overrides에 'body'가 기록되는지 확인합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_records_body_change_in_user_overrides(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'body' => ['ko' => '<p>기존 본문</p>', 'en' => '<p>Old Body</p>'],
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'body' => ['ko' => '<p>변경된 본문</p>', 'en' => '<p>New Body</p>'],
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('update')
|
||||
->once()
|
||||
->withArgs(function (MailTemplate $t, array $data) use ($template) {
|
||||
return $t->id === $template->id
|
||||
&& isset($data['user_overrides'])
|
||||
&& in_array('body', $data['user_overrides'], true);
|
||||
})
|
||||
->andReturn(true);
|
||||
|
||||
$this->listener->handleBeforeUpdate($template, $newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* is_active 변경 시 user_overrides에 'is_active'가 기록되는지 확인합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_records_is_active_change_in_user_overrides(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'is_active' => true,
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'is_active' => false,
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->shouldReceive('update')
|
||||
->once()
|
||||
->withArgs(function (MailTemplate $t, array $data) use ($template) {
|
||||
return $t->id === $template->id
|
||||
&& isset($data['user_overrides'])
|
||||
&& in_array('is_active', $data['user_overrides'], true);
|
||||
})
|
||||
->andReturn(true);
|
||||
|
||||
$this->listener->handleBeforeUpdate($template, $newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 user_overrides에 기록된 필드는 중복 추가하지 않는지 확인합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_does_not_duplicate_existing_overrides(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '기존 제목', 'en' => 'Old Subject'],
|
||||
'user_overrides' => ['subject'],
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'subject' => ['ko' => '다시 변경된 제목', 'en' => 'Changed Again'],
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->shouldNotReceive('update');
|
||||
|
||||
$this->listener->handleBeforeUpdate($template, $newData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일한 값을 전달하면 user_overrides가 변경되지 않는지 확인합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_does_not_record_unchanged_fields(): void
|
||||
{
|
||||
$subject = ['ko' => '동일 제목', 'en' => 'Same Subject'];
|
||||
$body = ['ko' => '<p>동일 본문</p>', 'en' => '<p>Same Body</p>'];
|
||||
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'is_active' => true,
|
||||
'user_overrides' => [],
|
||||
]);
|
||||
|
||||
$newData = [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->shouldNotReceive('update');
|
||||
|
||||
$this->listener->handleBeforeUpdate($template, $newData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Listeners\CoreNotificationDataListener;
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 알림 발송 통합 테스트
|
||||
*
|
||||
* NotificationHookListener + NotificationRecipientResolver + CoreNotificationDataListener
|
||||
* 전체 파이프라인을 검증합니다.
|
||||
* 다양한 수신 대상 조건(trigger_user, role, specific_users, 복합, exclude 등)으로 테스트합니다.
|
||||
*/
|
||||
class NotificationDispatchIntegrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationHookListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Notification::fake();
|
||||
$this->listener = app(NotificationHookListener::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 정의와 템플릿을 생성하는 헬퍼
|
||||
*/
|
||||
private function createDefinitionWithTemplate(string $type, string $hook, array $recipients, string $hookPrefix = 'core.test'): NotificationDefinition
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => $type,
|
||||
'hook_prefix' => $hookPrefix,
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => $type],
|
||||
'variables' => [],
|
||||
'channels' => ['database'],
|
||||
'hooks' => [$hook],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'database',
|
||||
'subject' => ['ko' => '테스트 알림'],
|
||||
'body' => ['ko' => '테스트 본문'],
|
||||
'recipients' => $recipients,
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 훅 등록 + 훅 발화 헬퍼
|
||||
*/
|
||||
private function registerAndFire(string $hook, ...$args): void
|
||||
{
|
||||
app(NotificationDefinitionService::class)->invalidateAllCache();
|
||||
$this->listener->registerDynamicHooks();
|
||||
HookManager::doAction($hook, ...$args);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 1. trigger_user: 이벤트 유발자에게 발송
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_trigger_user_sends_to_event_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_trigger',
|
||||
'core.test.after_trigger',
|
||||
[['type' => 'trigger_user']]
|
||||
);
|
||||
|
||||
// extract_data 필터 등록 (trigger_user 컨텍스트 제공)
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default, $type, $args) {
|
||||
$user = $args[0] ?? null;
|
||||
|
||||
return [
|
||||
'notifiable' => null,
|
||||
'notifiables' => null,
|
||||
'data' => ['name' => $user->name ?? ''],
|
||||
'context' => [
|
||||
'trigger_user_id' => $user?->id,
|
||||
'trigger_user' => $user,
|
||||
],
|
||||
];
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_trigger', $user);
|
||||
|
||||
Notification::assertSentTo($user, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 2. role: 특정 역할의 모든 사용자에게 발송
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_role_sends_to_all_role_users(): void
|
||||
{
|
||||
$role = Role::factory()->create(['identifier' => 'test_role_dispatch']);
|
||||
$admin1 = User::factory()->create();
|
||||
$admin2 = User::factory()->create();
|
||||
$role->users()->attach([$admin1->id, $admin2->id]);
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_role',
|
||||
'core.test.after_role',
|
||||
[['type' => 'role', 'value' => 'test_role_dispatch']]
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default, $type, $args) {
|
||||
return [
|
||||
'notifiable' => null,
|
||||
'notifiables' => null,
|
||||
'data' => ['name' => '{recipient_name}'],
|
||||
'context' => [],
|
||||
];
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_role', null);
|
||||
|
||||
Notification::assertSentTo($admin1, \App\Notifications\GenericNotification::class);
|
||||
Notification::assertSentTo($admin2, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 3. specific_users: UUID로 지정된 사용자에게 발송
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_specific_users_sends_to_designated_users(): void
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_specific',
|
||||
'core.test.after_specific',
|
||||
[['type' => 'specific_users', 'value' => [$user1->uuid, $user2->uuid]]]
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default) {
|
||||
return array_merge($default, ['data' => ['name' => 'test']]);
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_specific', null);
|
||||
|
||||
Notification::assertSentTo($user1, \App\Notifications\GenericNotification::class);
|
||||
Notification::assertSentTo($user2, \App\Notifications\GenericNotification::class);
|
||||
Notification::assertNotSentTo($other, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 4. role + exclude_trigger_user: 역할 사용자 중 유발자 제외
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_role_excludes_trigger_user(): void
|
||||
{
|
||||
$triggerUser = User::factory()->create();
|
||||
$otherAdmin = User::factory()->create();
|
||||
$role = Role::factory()->create(['identifier' => 'test_role_excl']);
|
||||
$role->users()->attach([$triggerUser->id, $otherAdmin->id]);
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_excl',
|
||||
'core.test.after_excl',
|
||||
[['type' => 'role', 'value' => 'test_role_excl', 'exclude_trigger_user' => true]]
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default, $type, $args) {
|
||||
$user = $args[0] ?? null;
|
||||
|
||||
return [
|
||||
'notifiable' => null,
|
||||
'notifiables' => null,
|
||||
'data' => ['name' => '{recipient_name}'],
|
||||
'context' => [
|
||||
'trigger_user_id' => $user?->id,
|
||||
],
|
||||
];
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_excl', $triggerUser);
|
||||
|
||||
Notification::assertSentTo($otherAdmin, \App\Notifications\GenericNotification::class);
|
||||
Notification::assertNotSentTo($triggerUser, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 5. 복합: trigger_user + role (중복 제거)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_multiple_rules_combine_and_deduplicate(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::factory()->create(['identifier' => 'test_role_multi']);
|
||||
$role->users()->attach([$user->id]);
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_multi',
|
||||
'core.test.after_multi',
|
||||
[
|
||||
['type' => 'trigger_user'],
|
||||
['type' => 'role', 'value' => 'test_role_multi'],
|
||||
]
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default, $type, $args) {
|
||||
$user = $args[0] ?? null;
|
||||
|
||||
return [
|
||||
'notifiable' => null,
|
||||
'notifiables' => null,
|
||||
'data' => ['name' => $user->name ?? ''],
|
||||
'context' => [
|
||||
'trigger_user_id' => $user?->id,
|
||||
'trigger_user' => $user,
|
||||
],
|
||||
];
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_multi', $user);
|
||||
|
||||
// 동일 사용자이므로 1건만 발송
|
||||
Notification::assertSentTo($user, \App\Notifications\GenericNotification::class, function ($notification) {
|
||||
return true;
|
||||
});
|
||||
Notification::assertCount(1);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 6. extract_data 필터 미등록 시 recipients만으로 발송
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_sends_without_extract_data_filter(): void
|
||||
{
|
||||
$role = Role::factory()->create(['identifier' => 'test_no_filter']);
|
||||
$admin = User::factory()->create();
|
||||
$role->users()->attach([$admin->id]);
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_no_filter',
|
||||
'core.test.after_no_filter',
|
||||
[['type' => 'role', 'value' => 'test_no_filter']],
|
||||
'core.test_nofilter'
|
||||
);
|
||||
|
||||
// extract_data 필터를 등록하지 않음
|
||||
$this->registerAndFire('core.test.after_no_filter', null);
|
||||
|
||||
// 수신자는 결정되지만 data가 빈 상태로 발송
|
||||
Notification::assertSentTo($admin, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 7. 비활성 정의는 발송하지 않음
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_inactive_definition_does_not_send(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'test_inactive',
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '비활성'],
|
||||
'variables' => [],
|
||||
'channels' => ['database'],
|
||||
'hooks' => ['core.test.after_inactive'],
|
||||
'recipients' => [['type' => 'trigger_user']],
|
||||
'is_active' => false,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'database',
|
||||
'subject' => ['ko' => '테스트'],
|
||||
'body' => ['ko' => '테스트'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->registerAndFire('core.test.after_inactive', $user);
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 8. recipients 미설정 시 레거시 notifiable 사용
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_legacy_notifiable_fallback_when_no_recipients(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_legacy',
|
||||
'core.test.after_legacy',
|
||||
[] // recipients 미설정
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default, $type, $args) {
|
||||
$user = $args[0] ?? null;
|
||||
|
||||
return [
|
||||
'notifiable' => $user,
|
||||
'notifiables' => null,
|
||||
'data' => ['name' => $user->name ?? ''],
|
||||
'context' => [],
|
||||
];
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_legacy', $user);
|
||||
|
||||
Notification::assertSentTo($user, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 9. 코어 CoreNotificationDataListener — welcome
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_core_welcome_extract_data(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$listener = new CoreNotificationDataListener();
|
||||
|
||||
$result = $listener->extractData(
|
||||
['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []],
|
||||
'welcome',
|
||||
[$user]
|
||||
);
|
||||
|
||||
$this->assertEquals($user->name, $result['data']['name']);
|
||||
$this->assertEquals(config('app.name'), $result['data']['app_name']);
|
||||
$this->assertEquals($user->id, $result['context']['trigger_user_id']);
|
||||
$this->assertSame($user, $result['context']['trigger_user']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 10. 코어 CoreNotificationDataListener — reset_password
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_core_reset_password_extract_data(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$listener = new CoreNotificationDataListener();
|
||||
|
||||
$result = $listener->extractData(
|
||||
['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []],
|
||||
'reset_password',
|
||||
[$user, ['reset_url' => 'https://example.com/reset?token=abc']]
|
||||
);
|
||||
|
||||
$this->assertEquals('https://example.com/reset?token=abc', $result['data']['action_url']);
|
||||
$this->assertEquals($user->id, $result['context']['trigger_user_id']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 11. 코어 CoreNotificationDataListener — password_changed
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_core_password_changed_extract_data(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$listener = new CoreNotificationDataListener();
|
||||
|
||||
$result = $listener->extractData(
|
||||
['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []],
|
||||
'password_changed',
|
||||
[$user]
|
||||
);
|
||||
|
||||
$this->assertEquals($user->name, $result['data']['name']);
|
||||
$this->assertEquals($user->id, $result['context']['trigger_user_id']);
|
||||
$this->assertStringContainsString('/login', $result['data']['action_url']);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 12. 코어 CoreNotificationDataListener — 미지원 타입은 기본값 반환
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_core_unknown_type_returns_default(): void
|
||||
{
|
||||
$listener = new CoreNotificationDataListener();
|
||||
$default = ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []];
|
||||
|
||||
$result = $listener->extractData($default, 'unknown_type', []);
|
||||
|
||||
$this->assertSame($default, $result);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 13. role 폴백: 역할 사용자 없으면 superAdmin에게 발송
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_role_falls_back_to_super_admin(): void
|
||||
{
|
||||
Role::factory()->create(['identifier' => 'empty_role_dispatch']);
|
||||
$superAdmin = User::factory()->create(['is_super' => true]);
|
||||
|
||||
$this->createDefinitionWithTemplate(
|
||||
'test_fallback',
|
||||
'core.test.after_fallback',
|
||||
[['type' => 'role', 'value' => 'empty_role_dispatch']]
|
||||
);
|
||||
|
||||
HookManager::addFilter('core.test.notification.extract_data', function ($default) {
|
||||
return array_merge($default, ['data' => ['name' => 'test']]);
|
||||
}, priority: 20);
|
||||
|
||||
$this->registerAndFire('core.test.after_fallback', null);
|
||||
|
||||
Notification::assertSentTo($superAdmin, \App\Notifications\GenericNotification::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationHookListener 테스트
|
||||
*
|
||||
* DB 정의 기반 동적 훅 구독 등록을 검증합니다.
|
||||
*/
|
||||
class NotificationHookListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 정적 훅 목록이 빈 배열인지 확인 (동적 구독 방식)
|
||||
*/
|
||||
public function test_get_subscribed_hooks_returns_empty_array(): void
|
||||
{
|
||||
$hooks = NotificationHookListener::getSubscribedHooks();
|
||||
|
||||
$this->assertIsArray($hooks);
|
||||
$this->assertEmpty($hooks);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerDynamicHooks()가 테이블 미존재 시 예외 없이 스킵
|
||||
*/
|
||||
public function test_register_dynamic_hooks_skips_when_no_table(): void
|
||||
{
|
||||
// notification_definitions 테이블이 있으므로 정상 동작 확인
|
||||
$listener = app(NotificationHookListener::class);
|
||||
|
||||
// 예외 없이 실행되면 통과
|
||||
$listener->registerDynamicHooks();
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 정의가 있을 때 registerDynamicHooks()가 정상 실행
|
||||
*/
|
||||
public function test_register_dynamic_hooks_with_active_definitions(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'hook_test',
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '훅 테스트'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.test.after_action'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
app(NotificationDefinitionService::class)->invalidateAllCache();
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
$listener->registerDynamicHooks();
|
||||
|
||||
// 예외 없이 실행 완료
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 정의는 훅을 등록하지 않음
|
||||
*/
|
||||
public function test_register_dynamic_hooks_ignores_inactive_definitions(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'inactive_hook',
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '비활성'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.test.inactive_hook'],
|
||||
'is_active' => false,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
app(NotificationDefinitionService::class)->invalidateAllCache();
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
$listener->registerDynamicHooks();
|
||||
|
||||
// getAllActive()는 비활성 제외이므로 등록 안됨
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Listeners\NotificationLogListener;
|
||||
use App\Models\NotificationLog;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationLogListener 테스트
|
||||
*
|
||||
* 메일 훅 이벤트(배열 데이터) 수신 시 notification_logs에 기록되는지 검증합니다.
|
||||
*/
|
||||
class NotificationLogListenerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* handleChannelSent()가 sent 로그를 기록하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_sent_creates_log(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelSent('mail', [
|
||||
'recipient_identifier' => 'test@example.com',
|
||||
'recipient_name' => 'Test User',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notification_logs', [
|
||||
'channel' => 'mail',
|
||||
'notification_type' => 'welcome',
|
||||
'recipient_identifier' => 'test@example.com',
|
||||
'status' => 'sent',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleChannelSent()가 database 채널도 기록하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_sent_logs_database_channel(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelSent('database', [
|
||||
'recipient_identifier' => '1',
|
||||
'recipient_name' => 'Test User',
|
||||
'notification_type' => 'password_changed',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notification_logs', [
|
||||
'channel' => 'database',
|
||||
'notification_type' => 'password_changed',
|
||||
'status' => 'sent',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleChannelFailed()가 failed 로그를 기록하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_failed_creates_log(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelFailed('mail', [
|
||||
'recipient_identifier' => 'fail@example.com',
|
||||
'notification_type' => 'reset_password',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'error' => 'SMTP connection refused',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notification_logs', [
|
||||
'channel' => 'mail',
|
||||
'notification_type' => 'reset_password',
|
||||
'recipient_identifier' => 'fail@example.com',
|
||||
'status' => 'failed',
|
||||
]);
|
||||
|
||||
$log = NotificationLog::where('recipient_identifier', 'fail@example.com')->first();
|
||||
$this->assertStringContainsString('SMTP connection refused', $log->error_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleMailSkipped()가 skipped 로그를 기록하는지 확인
|
||||
*/
|
||||
public function test_handle_mail_skipped_creates_log(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleMailSkipped([
|
||||
'recipientEmail' => 'skip@example.com',
|
||||
'templateType' => 'welcome',
|
||||
'extensionType' => ExtensionOwnerType::Core,
|
||||
'extensionIdentifier' => 'core',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notification_logs', [
|
||||
'channel' => 'mail',
|
||||
'notification_type' => 'welcome',
|
||||
'recipient_identifier' => 'skip@example.com',
|
||||
'status' => 'skipped',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleChannelSent()가 context에 subject/body가 있으면 로그에 저장하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_sent_saves_subject_and_body(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelSent('mail', [
|
||||
'recipient_identifier' => 'test@example.com',
|
||||
'recipient_name' => 'Test User',
|
||||
'notification_type' => 'password_changed',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'subject' => '비밀번호가 변경되었습니다',
|
||||
'body' => '<p>비밀번호가 성공적으로 변경되었습니다.</p>',
|
||||
]);
|
||||
|
||||
$log = NotificationLog::where('recipient_identifier', 'test@example.com')
|
||||
->where('notification_type', 'password_changed')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($log);
|
||||
$this->assertEquals('비밀번호가 변경되었습니다', $log->subject);
|
||||
$this->assertEquals('<p>비밀번호가 성공적으로 변경되었습니다.</p>', $log->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleChannelSent()가 context에 subject/body가 없으면 null로 저장하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_sent_saves_null_when_no_subject_body(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelSent('mail', [
|
||||
'recipient_identifier' => 'null@example.com',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
]);
|
||||
|
||||
$log = NotificationLog::where('recipient_identifier', 'null@example.com')->first();
|
||||
$this->assertNotNull($log);
|
||||
$this->assertNull($log->subject);
|
||||
$this->assertNull($log->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* handleChannelFailed()가 context에 subject/body가 있으면 로그에 저장하는지 확인
|
||||
*/
|
||||
public function test_handle_channel_failed_saves_subject_and_body(): void
|
||||
{
|
||||
$listener = app(NotificationLogListener::class);
|
||||
$listener->handleChannelFailed('mail', [
|
||||
'recipient_identifier' => 'fail@example.com',
|
||||
'notification_type' => 'order_confirmed',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'subject' => '주문이 확인되었습니다',
|
||||
'body' => '<p>주문 내역을 확인하세요.</p>',
|
||||
'error' => 'SMTP timeout',
|
||||
]);
|
||||
|
||||
$log = NotificationLog::where('recipient_identifier', 'fail@example.com')
|
||||
->where('notification_type', 'order_confirmed')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($log);
|
||||
$this->assertEquals('주문이 확인되었습니다', $log->subject);
|
||||
$this->assertEquals('<p>주문 내역을 확인하세요.</p>', $log->body);
|
||||
$this->assertEquals('failed', $log->status->value ?? $log->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 훅 목록이 공통 채널 훅 + mail skipped 훅을 구독하는지 확인
|
||||
*/
|
||||
public function test_subscribed_hooks_contains_channel_hooks(): void
|
||||
{
|
||||
$hooks = NotificationLogListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('core.notification.after_channel_send', $hooks);
|
||||
$this->assertArrayHasKey('core.notification.channel_send_failed', $hooks);
|
||||
$this->assertArrayHasKey('core.mail.send_skipped', $hooks);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,38 @@ class SetTimezoneTest extends TestCase
|
||||
$this->assertEquals('Asia/Seoul', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 화이트리스트에 없던 IANA 타임존도 허용되는지 테스트합니다.
|
||||
*
|
||||
* @dataProvider arbitraryIanaTimezoneProvider
|
||||
*/
|
||||
public function test_arbitrary_iana_timezone_accepted(string $timezone): void
|
||||
{
|
||||
$user = User::factory()->create(['timezone' => $timezone]);
|
||||
Auth::login($user);
|
||||
|
||||
$request = Request::create('/api/test', 'GET');
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
$this->assertEquals($timezone, SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function arbitraryIanaTimezoneProvider(): array
|
||||
{
|
||||
return [
|
||||
'Africa/Cairo' => ['Africa/Cairo'],
|
||||
'Pacific/Auckland' => ['Pacific/Auckland'],
|
||||
'America/Chicago' => ['America/Chicago'],
|
||||
'Asia/Dubai' => ['Asia/Dubai'],
|
||||
'Antarctica/McMurdo' => ['Antarctica/McMurdo'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임존 설정이 없는 경우 기본값이 적용되는지 테스트합니다.
|
||||
*/
|
||||
@@ -147,6 +179,8 @@ class SetTimezoneTest extends TestCase
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
// 마스터 스위치 ON (g7_core_settings 가드 통과)
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
// GeoIpService 목업: 특정 타임존 반환
|
||||
$mockGeoIpService = $this->createMock(GeoIpService::class);
|
||||
@@ -156,7 +190,6 @@ class SetTimezoneTest extends TestCase
|
||||
$this->app->instance(GeoIpService::class, $mockGeoIpService);
|
||||
|
||||
$request = Request::create('/api/test', 'GET');
|
||||
// X-Timezone 헤더 없음
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
@@ -164,6 +197,56 @@ class SetTimezoneTest extends TestCase
|
||||
$this->assertEquals('America/New_York', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터 스위치(geoip.feature_enabled)가 OFF면 GeoIP 조회가 완전히 스킵되는지 테스트합니다.
|
||||
*
|
||||
* 미들웨어 레벨의 short-circuit을 검증합니다 — GeoIpService::getTimezoneByIp()가
|
||||
* 단 한 번도 호출되지 않아야 합니다.
|
||||
*/
|
||||
public function test_geoip_skipped_when_feature_disabled(): void
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
config(['g7_settings.core.geoip.feature_enabled' => false]);
|
||||
|
||||
$mockGeoIpService = $this->createMock(GeoIpService::class);
|
||||
$mockGeoIpService->expects($this->never())
|
||||
->method('getTimezoneByIp');
|
||||
|
||||
$this->app->instance(GeoIpService::class, $mockGeoIpService);
|
||||
|
||||
$request = Request::create('/api/test', 'GET');
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
$this->assertEquals('Asia/Seoul', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터 스위치가 ON이면 GeoIP 조회가 수행되는지 테스트합니다.
|
||||
*/
|
||||
public function test_geoip_invoked_when_feature_enabled(): void
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
$mockGeoIpService = $this->createMock(GeoIpService::class);
|
||||
$mockGeoIpService->expects($this->once())
|
||||
->method('getTimezoneByIp')
|
||||
->willReturn('America/Chicago');
|
||||
|
||||
$this->app->instance(GeoIpService::class, $mockGeoIpService);
|
||||
|
||||
$request = Request::create('/api/test', 'GET');
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
$this->assertEquals('America/Chicago', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* X-Timezone 헤더가 GeoIP보다 우선하는지 테스트합니다.
|
||||
*/
|
||||
@@ -213,7 +296,10 @@ class SetTimezoneTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 GeoIP DB를 사용한 통합 테스트 (한국 IP)
|
||||
* 실제 GeoIP DB를 사용한 통합 테스트 (한국 IP).
|
||||
*
|
||||
* 마스터 스위치 ON + 실제 mmdb 파일 + 실제 GeoIpService로
|
||||
* 미들웨어 전 계층(가드 → Service → Reader)이 정상 동작하는지 검증합니다.
|
||||
*/
|
||||
public function test_real_geoip_with_korean_ip(): void
|
||||
{
|
||||
@@ -227,8 +313,9 @@ class SetTimezoneTest extends TestCase
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
// 새 GeoIpService 인스턴스 생성
|
||||
// 실제 GeoIpService 사용 (mock 아님)
|
||||
$this->app->forgetInstance(GeoIpService::class);
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
|
||||
@@ -241,4 +328,134 @@ class SetTimezoneTest extends TestCase
|
||||
|
||||
$this->assertEquals('Asia/Seoul', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 GeoIP DB를 사용한 통합 테스트 (미국 IP).
|
||||
*
|
||||
* 한국 외 지역 IP에서도 올바른 타임존이 감지되는지 검증합니다.
|
||||
*/
|
||||
public function test_real_geoip_with_us_ip(): void
|
||||
{
|
||||
$dbPath = storage_path('app/geoip/GeoLite2-City.mmdb');
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
$this->markTestSkipped('GeoLite2-City.mmdb 파일이 없습니다.');
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
$this->app->forgetInstance(GeoIpService::class);
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
|
||||
$request = Request::create('/api/test', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '8.8.8.8', // Google Public DNS (미국)
|
||||
]);
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
// Google DNS는 미국 소재 — America/* 타임존이어야 함
|
||||
$timezone = SetTimezone::getTimezone();
|
||||
$this->assertStringStartsWith('America/', $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 GeoIP DB를 사용한 통합 테스트 (일본 IP).
|
||||
*/
|
||||
public function test_real_geoip_with_japanese_ip(): void
|
||||
{
|
||||
$dbPath = storage_path('app/geoip/GeoLite2-City.mmdb');
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
$this->markTestSkipped('GeoLite2-City.mmdb 파일이 없습니다.');
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
$this->app->forgetInstance(GeoIpService::class);
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
|
||||
$request = Request::create('/api/test', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '203.178.136.1', // WIDE Project (일본)
|
||||
]);
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
$this->assertEquals('Asia/Tokyo', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터 스위치 OFF 시 실제 mmdb 파일이 있어도 GeoIP가 스킵되는지 테스트합니다.
|
||||
*/
|
||||
public function test_real_geoip_skipped_when_feature_disabled_even_with_db(): void
|
||||
{
|
||||
$dbPath = storage_path('app/geoip/GeoLite2-City.mmdb');
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
$this->markTestSkipped('GeoLite2-City.mmdb 파일이 없습니다.');
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
// 마스터 스위치 OFF
|
||||
config(['g7_settings.core.geoip.feature_enabled' => false]);
|
||||
|
||||
$this->app->forgetInstance(GeoIpService::class);
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
|
||||
$request = Request::create('/api/test', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '168.126.63.1', // 한국 IP인데 마스터 OFF
|
||||
]);
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
// GeoIP 스킵 → 기본값 적용
|
||||
$this->assertEquals('Asia/Seoul', SetTimezone::getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 사용자 타임존이 GeoIP 결과보다 우선하는지 통합 테스트합니다.
|
||||
*/
|
||||
public function test_authenticated_user_timezone_overrides_geoip(): void
|
||||
{
|
||||
$dbPath = storage_path('app/geoip/GeoLite2-City.mmdb');
|
||||
|
||||
if (! file_exists($dbPath)) {
|
||||
$this->markTestSkipped('GeoLite2-City.mmdb 파일이 없습니다.');
|
||||
}
|
||||
|
||||
// 유럽 타임존 사용자가 한국 IP에서 접속하는 시나리오
|
||||
$user = User::factory()->create(['timezone' => 'Europe/London']);
|
||||
Auth::login($user);
|
||||
|
||||
config(['geoip.enabled' => true]);
|
||||
config(['geoip.database_path' => $dbPath]);
|
||||
config(['geoip.cache.enabled' => false]);
|
||||
config(['g7_settings.core.geoip.feature_enabled' => true]);
|
||||
|
||||
$this->app->forgetInstance(GeoIpService::class);
|
||||
$this->app->singleton(GeoIpService::class);
|
||||
|
||||
$request = Request::create('/api/test', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '168.126.63.1', // 한국 IP
|
||||
]);
|
||||
|
||||
$middleware = $this->createMiddleware();
|
||||
$middleware->handle($request, $this->getNextClosure());
|
||||
|
||||
// 인증 사용자 타임존이 GeoIP(Asia/Seoul)보다 우선
|
||||
$this->assertEquals('Europe/London', SetTimezone::getTimezone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models\Concerns;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* mass update 시 HasUserOverrides trait 의 투명 추적 회귀 방지 테스트.
|
||||
*
|
||||
* `UserOverridesAwareBuilder` 가 `Model::where(...)->update(...)` 를 가로채
|
||||
* user_overrides 에 수정 필드를 자동 기록하는지 검증합니다.
|
||||
*
|
||||
* 요구사항: `use HasUserOverrides;` 선언만으로 모든 수정 경로(인스턴스
|
||||
* update/save, mass update) 에서 user_overrides 가 자동 기록되어야 함.
|
||||
*/
|
||||
class HasUserOverridesMassUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 기본 definition 생성 헬퍼.
|
||||
*/
|
||||
private function createDefinition(array $overrides = []): NotificationDefinition
|
||||
{
|
||||
return NotificationDefinition::create(array_merge([
|
||||
'type' => 'mass-update-test',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '원본'],
|
||||
'description' => ['ko' => '원본'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'variables' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_mass_update_records_user_overrides_for_trackable_fields(): void
|
||||
{
|
||||
$definition = $this->createDefinition();
|
||||
|
||||
// 사용자가 UI 에서 관리자 일괄 저장 시 흔히 발생하는 패턴.
|
||||
NotificationDefinition::where('id', $definition->id)->update([
|
||||
'name' => ['ko' => '사용자 수정'],
|
||||
]);
|
||||
|
||||
$reloaded = NotificationDefinition::find($definition->id);
|
||||
|
||||
$this->assertSame(['ko' => '사용자 수정'], $reloaded->name);
|
||||
$this->assertIsArray($reloaded->user_overrides);
|
||||
$this->assertContains('name', $reloaded->user_overrides, 'mass update 시에도 trackable 필드가 user_overrides 에 자동 기록');
|
||||
}
|
||||
|
||||
public function test_mass_update_accumulates_overrides_across_multiple_edits(): void
|
||||
{
|
||||
$definition = $this->createDefinition();
|
||||
|
||||
NotificationDefinition::where('id', $definition->id)->update([
|
||||
'name' => ['ko' => '수정1'],
|
||||
]);
|
||||
NotificationDefinition::where('id', $definition->id)->update([
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$reloaded = NotificationDefinition::find($definition->id);
|
||||
|
||||
$this->assertContains('name', $reloaded->user_overrides);
|
||||
$this->assertContains('is_active', $reloaded->user_overrides, '여러 번의 mass update 가 누적 기록');
|
||||
}
|
||||
|
||||
public function test_mass_update_ignores_non_trackable_fields(): void
|
||||
{
|
||||
$definition = $this->createDefinition();
|
||||
|
||||
// hook_prefix 는 trackableFields 에 없음
|
||||
NotificationDefinition::where('id', $definition->id)->update([
|
||||
'hook_prefix' => 'changed.prefix',
|
||||
]);
|
||||
|
||||
$reloaded = NotificationDefinition::find($definition->id);
|
||||
|
||||
$this->assertSame('changed.prefix', $reloaded->hook_prefix);
|
||||
$this->assertEmpty($reloaded->user_overrides ?? [], 'trackable 외 필드는 user_overrides 기록 안 됨');
|
||||
}
|
||||
|
||||
public function test_mass_update_across_multiple_rows_tracks_each_independently(): void
|
||||
{
|
||||
$a = $this->createDefinition(['type' => 'row-a']);
|
||||
$b = $this->createDefinition(['type' => 'row-b']);
|
||||
|
||||
// 2행 동시 mass update
|
||||
NotificationDefinition::whereIn('id', [$a->id, $b->id])->update([
|
||||
'name' => ['ko' => '일괄수정'],
|
||||
]);
|
||||
|
||||
$reloadedA = NotificationDefinition::find($a->id);
|
||||
$reloadedB = NotificationDefinition::find($b->id);
|
||||
|
||||
$this->assertContains('name', $reloadedA->user_overrides ?? []);
|
||||
$this->assertContains('name', $reloadedB->user_overrides ?? []);
|
||||
}
|
||||
|
||||
public function test_seeder_context_bypasses_tracking_on_mass_update(): void
|
||||
{
|
||||
$definition = $this->createDefinition();
|
||||
|
||||
app()->instance('user_overrides.seeding', true);
|
||||
try {
|
||||
NotificationDefinition::where('id', $definition->id)->update([
|
||||
'name' => ['ko' => '시더 수정'],
|
||||
]);
|
||||
} finally {
|
||||
app()->forgetInstance('user_overrides.seeding');
|
||||
}
|
||||
|
||||
$reloaded = NotificationDefinition::find($definition->id);
|
||||
|
||||
$this->assertSame(['ko' => '시더 수정'], $reloaded->name);
|
||||
$this->assertEmpty(
|
||||
$reloaded->user_overrides ?? [],
|
||||
'시더 컨텍스트에서는 user_overrides 기록 안 됨 (성능 유지 + syncFromUpgrade 의미 보존)',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_raw_db_facade_update_bypasses_tracking(): void
|
||||
{
|
||||
$definition = $this->createDefinition();
|
||||
|
||||
// 실제 대량 처리 성능이 필요한 경우를 위한 escape hatch — DB facade 사용.
|
||||
DB::table('notification_definitions')
|
||||
->where('id', $definition->id)
|
||||
->update(['name' => json_encode(['ko' => 'raw 수정'])]);
|
||||
|
||||
$reloaded = NotificationDefinition::find($definition->id);
|
||||
|
||||
$this->assertEmpty(
|
||||
$reloaded->user_overrides ?? [],
|
||||
'DB facade 직접 호출은 Eloquent Builder 를 거치지 않아 trait 미적용 — escape hatch',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models\Concerns;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* HasUserOverrides Trait 단위 테스트.
|
||||
*
|
||||
* NotificationTemplate 을 reference 모델로 사용하여 trait 의 8개 시나리오를 검증합니다.
|
||||
*
|
||||
* 검증 시나리오:
|
||||
* 1. 신규 생성 시 user_overrides 가 null 이고 자동 기록 안 됨
|
||||
* 2. 사용자 수정(일반 update) → trackable 필드가 user_overrides 에 자동 기록
|
||||
* 3. trackable 외 필드 수정 → 기록 안 됨
|
||||
* 4. syncFromUpgrade() 호출 시 user_overrides 에 기록된 필드는 갱신 스킵
|
||||
* 5. syncFromUpgrade() 호출 시 trackable 외 필드는 항상 갱신
|
||||
* 6. syncFromUpgrade() 후 user_overrides 자체는 변경 안 됨 (자동 기록 비활성)
|
||||
* 7. createFromUpgrade() 로 생성 시 자동 기록 비활성
|
||||
* 8. 동일 필드를 두 번 수정 시 user_overrides 에 중복 기록 안 됨
|
||||
*/
|
||||
class HasUserOverridesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 외래키 충족용 알림 정의 생성 후 NotificationTemplate 을 생성합니다.
|
||||
*/
|
||||
private function createTemplate(array $attributes = []): NotificationTemplate
|
||||
{
|
||||
$definition = NotificationDefinition::factory()->create();
|
||||
|
||||
return NotificationTemplate::create(array_merge([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'mail',
|
||||
'subject' => ['ko' => '초기 제목', 'en' => 'Initial Subject'],
|
||||
'body' => ['ko' => '초기 본문', 'en' => 'Initial Body'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 1: 신규 생성 시 user_overrides 가 null.
|
||||
*/
|
||||
public function test_new_template_has_null_user_overrides(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
|
||||
$this->assertNull($template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 2: 사용자 수정 시 trackable 필드 자동 기록.
|
||||
*/
|
||||
public function test_user_update_records_trackable_fields(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
$template->subject = ['ko' => '수정된 제목', 'en' => 'Modified'];
|
||||
$template->save();
|
||||
|
||||
$this->assertEquals(['subject'], $template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 3: trackable 외 필드 수정 → 기록 안 됨.
|
||||
*/
|
||||
public function test_non_trackable_field_update_is_not_recorded(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
// updated_by 는 trackableFields 에 없는 필드
|
||||
$template->updated_by = null;
|
||||
$template->is_default = false; // is_default 도 trackableFields 에 없음
|
||||
$template->save();
|
||||
|
||||
$this->assertNull($template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 4: syncFromUpgrade 호출 시 user_overrides 에 기록된 필드는 갱신 스킵.
|
||||
*/
|
||||
public function test_sync_from_upgrade_preserves_overridden_fields(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
$template->subject = ['ko' => '사용자 수정 제목', 'en' => 'User Modified'];
|
||||
$template->save();
|
||||
|
||||
// 시더가 다시 실행되는 상황 시뮬레이션
|
||||
$template->syncFromUpgrade([
|
||||
'subject' => ['ko' => '시더 제목', 'en' => 'Seeder Subject'],
|
||||
'body' => ['ko' => '시더 본문', 'en' => 'Seeder Body'],
|
||||
]);
|
||||
|
||||
$fresh = $template->fresh();
|
||||
$this->assertEquals(['ko' => '사용자 수정 제목', 'en' => 'User Modified'], $fresh->subject);
|
||||
$this->assertEquals(['ko' => '시더 본문', 'en' => 'Seeder Body'], $fresh->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 5: syncFromUpgrade 호출 시 trackable 외 필드는 항상 갱신.
|
||||
*/
|
||||
public function test_sync_from_upgrade_always_updates_non_trackable_fields(): void
|
||||
{
|
||||
$template = $this->createTemplate(['is_default' => true]);
|
||||
|
||||
$template->syncFromUpgrade([
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$this->assertFalse($template->fresh()->is_default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 6: syncFromUpgrade 후 user_overrides 자체는 변경 안 됨 (자동 기록 비활성).
|
||||
*/
|
||||
public function test_sync_from_upgrade_does_not_record_overrides(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
|
||||
$template->syncFromUpgrade([
|
||||
'subject' => ['ko' => '시더 제목', 'en' => 'Seeder Subject'],
|
||||
'body' => ['ko' => '시더 본문', 'en' => 'Seeder Body'],
|
||||
]);
|
||||
|
||||
$this->assertNull($template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 7: createFromUpgrade 로 생성 시 자동 기록 비활성.
|
||||
*/
|
||||
public function test_create_from_upgrade_does_not_record_overrides(): void
|
||||
{
|
||||
$definition = NotificationDefinition::factory()->create();
|
||||
|
||||
$template = NotificationTemplate::createFromUpgrade([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'mail',
|
||||
'subject' => ['ko' => '시더 제목', 'en' => 'Seeder'],
|
||||
'body' => ['ko' => '시더 본문', 'en' => 'Seeder Body'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->assertNull($template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 8: 동일 필드를 두 번 수정 시 user_overrides 에 중복 기록 안 됨.
|
||||
*/
|
||||
public function test_repeated_user_update_does_not_duplicate_overrides(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
$template->subject = ['ko' => '첫 수정', 'en' => 'First'];
|
||||
$template->save();
|
||||
|
||||
$template->subject = ['ko' => '두 번째 수정', 'en' => 'Second'];
|
||||
$template->save();
|
||||
|
||||
$this->assertEquals(['subject'], $template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가: syncOrCreateFromUpgrade — 존재하지 않으면 생성.
|
||||
*/
|
||||
public function test_sync_or_create_from_upgrade_creates_when_missing(): void
|
||||
{
|
||||
$definition = NotificationDefinition::factory()->create();
|
||||
|
||||
$template = NotificationTemplate::syncOrCreateFromUpgrade(
|
||||
['definition_id' => $definition->id, 'channel' => 'mail'],
|
||||
[
|
||||
'subject' => ['ko' => '시더 제목', 'en' => 'Seeder'],
|
||||
'body' => ['ko' => '시더 본문', 'en' => 'Seeder Body'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertNotNull($template->id);
|
||||
$this->assertNull($template->fresh()->user_overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가: syncOrCreateFromUpgrade — 존재하면 syncFromUpgrade 호출.
|
||||
*/
|
||||
public function test_sync_or_create_from_upgrade_preserves_existing_overrides(): void
|
||||
{
|
||||
$template = $this->createTemplate();
|
||||
$template->subject = ['ko' => '사용자 수정', 'en' => 'User'];
|
||||
$template->save();
|
||||
|
||||
NotificationTemplate::syncOrCreateFromUpgrade(
|
||||
['definition_id' => $template->definition_id, 'channel' => 'mail'],
|
||||
[
|
||||
'subject' => ['ko' => '시더 덮어쓰기', 'en' => 'Seeder Override'],
|
||||
'body' => ['ko' => '시더 본문', 'en' => 'Seeder Body'],
|
||||
]
|
||||
);
|
||||
|
||||
$fresh = $template->fresh();
|
||||
$this->assertEquals(['ko' => '사용자 수정', 'en' => 'User'], $fresh->subject);
|
||||
$this->assertEquals(['ko' => '시더 본문', 'en' => 'Seeder Body'], $fresh->body);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\MailSendStatus;
|
||||
use App\Models\MailSendLog;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailSendLog 모델 테스트
|
||||
*
|
||||
* 모델의 fillable, 캐스팅, 스코프 메서드를 검증합니다.
|
||||
*/
|
||||
class MailSendLogTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ========================================================================
|
||||
// fillable / casts
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* source 컬럼이 fillable에 포함
|
||||
*/
|
||||
public function test_source_is_fillable(): void
|
||||
{
|
||||
$log = MailSendLog::create([
|
||||
'recipient_email' => 'test@example.com',
|
||||
'subject' => 'Test',
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertEquals('notification', $log->source);
|
||||
}
|
||||
|
||||
/**
|
||||
* subject가 nullable
|
||||
*/
|
||||
public function test_subject_is_nullable(): void
|
||||
{
|
||||
$log = MailSendLog::create([
|
||||
'recipient_email' => 'test@example.com',
|
||||
'subject' => null,
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertNull($log->subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* sent_at이 datetime으로 캐스팅됨
|
||||
*/
|
||||
public function test_sent_at_is_cast_to_datetime(): void
|
||||
{
|
||||
$log = MailSendLog::factory()->create();
|
||||
|
||||
$this->assertInstanceOf(\Carbon\Carbon::class, $log->sent_at);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeByTemplateType
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 특정 템플릿 유형으로 필터
|
||||
*/
|
||||
public function test_scope_by_template_type_filters_correctly(): void
|
||||
{
|
||||
MailSendLog::factory()->withTemplateType('welcome')->count(2)->create();
|
||||
MailSendLog::factory()->withTemplateType('password_reset')->count(3)->create();
|
||||
|
||||
$result = MailSendLog::byTemplateType('welcome')->get();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* null 템플릿 유형으로 필터 (whereNull)
|
||||
*/
|
||||
public function test_scope_by_template_type_handles_null(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['template_type' => null]);
|
||||
MailSendLog::factory()->withTemplateType('welcome')->create();
|
||||
|
||||
$result = MailSendLog::byTemplateType(null)->get();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertNull($result->first()->template_type);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeByExtension
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 확장 타입별 필터
|
||||
*/
|
||||
public function test_scope_by_extension_filters_by_type(): void
|
||||
{
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Core, 'core')->count(3)->create();
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-board')->count(2)->create();
|
||||
|
||||
$result = MailSendLog::byExtension(ExtensionOwnerType::Core)->get();
|
||||
|
||||
$this->assertCount(3, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 타입 + 식별자 필터
|
||||
*/
|
||||
public function test_scope_by_extension_filters_by_type_and_identifier(): void
|
||||
{
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-board')->count(2)->create();
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-ecommerce')->count(3)->create();
|
||||
|
||||
$result = MailSendLog::byExtension(ExtensionOwnerType::Module, 'sirsoft-board')->get();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeBySource
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 출처별 필터
|
||||
*/
|
||||
public function test_scope_by_source_filters_correctly(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['source' => 'notification']);
|
||||
MailSendLog::factory()->create(['source' => 'test_mail']);
|
||||
MailSendLog::factory()->create(['source' => 'notification']);
|
||||
|
||||
$result = MailSendLog::bySource('notification')->get();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* null 발송 출처로 필터 (whereNull)
|
||||
*/
|
||||
public function test_scope_by_source_handles_null(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['source' => null]);
|
||||
MailSendLog::factory()->create(['source' => 'notification']);
|
||||
|
||||
$result = MailSendLog::bySource(null)->get();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertNull($result->first()->source);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeByStatus
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 상태별 필터
|
||||
*/
|
||||
public function test_scope_by_status_filters_correctly(): void
|
||||
{
|
||||
MailSendLog::factory()->count(3)->create(['status' => MailSendStatus::Sent->value]);
|
||||
MailSendLog::factory()->failed()->count(2)->create();
|
||||
|
||||
$result = MailSendLog::byStatus(MailSendStatus::Failed->value)->get();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailTemplate 모델 테스트
|
||||
*
|
||||
* 모델의 fillable, casts, 스코프, replaceVariables, 관계를 검증합니다.
|
||||
*/
|
||||
class MailTemplateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ========================================================================
|
||||
// fillable / casts
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* type이 fillable에 포함
|
||||
*/
|
||||
public function test_type_is_fillable(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withType('welcome')->create();
|
||||
|
||||
$this->assertEquals('welcome', $template->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* subject가 array로 캐스팅됨
|
||||
*/
|
||||
public function test_subject_is_cast_to_array(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '한국어 제목', 'en' => 'English Subject'],
|
||||
]);
|
||||
|
||||
$this->assertIsArray($template->subject);
|
||||
$this->assertEquals('한국어 제목', $template->subject['ko']);
|
||||
}
|
||||
|
||||
/**
|
||||
* body가 array로 캐스팅됨
|
||||
*/
|
||||
public function test_body_is_cast_to_array(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'body' => ['ko' => '<p>본문</p>', 'en' => '<p>Body</p>'],
|
||||
]);
|
||||
|
||||
$this->assertIsArray($template->body);
|
||||
$this->assertEquals('<p>본문</p>', $template->body['ko']);
|
||||
}
|
||||
|
||||
/**
|
||||
* variables가 array로 캐스팅됨
|
||||
*/
|
||||
public function test_variables_is_cast_to_array(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'variables' => [['key' => 'name', 'description' => '이름']],
|
||||
]);
|
||||
|
||||
$this->assertIsArray($template->variables);
|
||||
$this->assertEquals('name', $template->variables[0]['key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* is_active가 boolean으로 캐스팅됨
|
||||
*/
|
||||
public function test_is_active_is_cast_to_boolean(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create(['is_active' => 1]);
|
||||
|
||||
$this->assertIsBool($template->is_active);
|
||||
$this->assertTrue($template->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* is_default가 boolean으로 캐스팅됨
|
||||
*/
|
||||
public function test_is_default_is_cast_to_boolean(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create(['is_default' => 0]);
|
||||
|
||||
$this->assertIsBool($template->is_default);
|
||||
$this->assertFalse($template->is_default);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 관계
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* updater 관계가 User를 반환
|
||||
*/
|
||||
public function test_updater_returns_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$template = MailTemplate::factory()->create(['updated_by' => $user->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $template->updater);
|
||||
$this->assertEquals($user->id, $template->updater->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* updated_by가 null이면 updater도 null
|
||||
*/
|
||||
public function test_updater_returns_null_when_no_updater(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create(['updated_by' => null]);
|
||||
|
||||
$this->assertNull($template->updater);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeActive
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* active 스코프가 활성 템플릿만 반환
|
||||
*/
|
||||
public function test_scope_active_filters_correctly(): void
|
||||
{
|
||||
MailTemplate::factory()->count(2)->create(['is_active' => true]);
|
||||
MailTemplate::factory()->inactive()->create();
|
||||
|
||||
$result = MailTemplate::active()->get();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// scopeByType
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* byType 스코프가 특정 유형만 반환
|
||||
*/
|
||||
public function test_scope_by_type_filters_correctly(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create();
|
||||
MailTemplate::factory()->withType('reset_password')->create();
|
||||
|
||||
$result = MailTemplate::byType('welcome')->get();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('welcome', $result->first()->type);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getLocalizedSubject / getLocalizedBody
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* getLocalizedSubject가 현재 로케일 제목을 반환
|
||||
*/
|
||||
public function test_get_localized_subject_returns_current_locale(): void
|
||||
{
|
||||
app()->setLocale('ko');
|
||||
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '한국어 제목', 'en' => 'English Subject'],
|
||||
]);
|
||||
|
||||
$this->assertEquals('한국어 제목', $template->getLocalizedSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
* getLocalizedSubject가 지정된 로케일 제목을 반환
|
||||
*/
|
||||
public function test_get_localized_subject_with_specific_locale(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '한국어 제목', 'en' => 'English Subject'],
|
||||
]);
|
||||
|
||||
$this->assertEquals('English Subject', $template->getLocalizedSubject('en'));
|
||||
}
|
||||
|
||||
/**
|
||||
* getLocalizedSubject가 fallback으로 ko를 반환
|
||||
*/
|
||||
public function test_get_localized_subject_falls_back_to_ko(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create([
|
||||
'subject' => ['ko' => '한국어 제목'],
|
||||
]);
|
||||
|
||||
$this->assertEquals('한국어 제목', $template->getLocalizedSubject('ja'));
|
||||
}
|
||||
|
||||
/**
|
||||
* getLocalizedBody가 현재 로케일 본문을 반환
|
||||
*/
|
||||
public function test_get_localized_body_returns_current_locale(): void
|
||||
{
|
||||
app()->setLocale('en');
|
||||
|
||||
$template = MailTemplate::factory()->create([
|
||||
'body' => ['ko' => '<p>본문</p>', 'en' => '<p>Body</p>'],
|
||||
]);
|
||||
|
||||
$this->assertEquals('<p>Body</p>', $template->getLocalizedBody());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// replaceVariables
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* replaceVariables가 변수를 올바르게 치환
|
||||
*/
|
||||
public function test_replace_variables_substitutes_correctly(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withVariables()->create();
|
||||
|
||||
$result = $template->replaceVariables([
|
||||
'name' => '홍길동',
|
||||
'app_name' => 'G7',
|
||||
], 'ko');
|
||||
|
||||
$this->assertEquals('[G7] 홍길동님 환영합니다', $result['subject']);
|
||||
$this->assertStringContainsString('홍길동님, G7에 가입해 주셔서', $result['body']);
|
||||
}
|
||||
|
||||
/**
|
||||
* replaceVariables가 누락된 변수는 그대로 유지
|
||||
*/
|
||||
public function test_replace_variables_keeps_unreplaced_variables(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withVariables()->create();
|
||||
|
||||
$result = $template->replaceVariables(['name' => '홍길동'], 'ko');
|
||||
|
||||
$this->assertStringContainsString('{app_name}', $result['subject']);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Auth\PasswordChangedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* PasswordChangedNotification 테스트
|
||||
*
|
||||
* 비밀번호 변경 완료 알림의 채널 결정 및 이메일 생성을 검증합니다.
|
||||
*/
|
||||
class PasswordChangedNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
Config::set('app.name', 'G7 Test');
|
||||
Config::set('app.url', 'https://g7.test');
|
||||
}
|
||||
|
||||
/**
|
||||
* via()가 기본적으로 mail 채널을 반환하는지 확인
|
||||
*/
|
||||
public function test_via_returns_mail_channel_by_default(): void
|
||||
{
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = new User(['email' => 'test@example.com', 'name' => 'Test User']);
|
||||
|
||||
$channels = $notification->via($user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* toMail()이 DbTemplateMail을 반환하는지 확인
|
||||
*/
|
||||
public function test_to_mail_returns_db_template_mail(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('password_changed')->create();
|
||||
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 templateType이 password_changed인지 확인
|
||||
*/
|
||||
public function test_mailable_template_type_is_password_changed(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('password_changed')->create();
|
||||
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals('password_changed', $mailable->getTemplateType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 module이 core인지 확인
|
||||
*/
|
||||
public function test_mailable_module_is_core(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('password_changed')->create();
|
||||
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals(ExtensionOwnerType::Core, $mailable->getExtensionType());
|
||||
$this->assertEquals('core', $mailable->getExtensionIdentifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 템플릿에서 스킵 인스턴스 반환
|
||||
*/
|
||||
public function test_to_mail_returns_skipped_when_template_inactive(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('password_changed')->inactive()->create();
|
||||
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
$this->assertTrue($mailable->isSkipped());
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 미존재 시 스킵 인스턴스 반환
|
||||
*/
|
||||
public function test_to_mail_returns_skipped_when_no_template(): void
|
||||
{
|
||||
$notification = new PasswordChangedNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
$this->assertTrue($mailable->isSkipped());
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Auth\ResetPasswordNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ResetPasswordNotification 테스트
|
||||
*
|
||||
* 비밀번호 재설정 알림의 채널 결정 및 이메일 생성을 검증합니다.
|
||||
*/
|
||||
class ResetPasswordNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $testToken = 'test-reset-token-12345';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
Config::set('app.name', 'G7 Test');
|
||||
Config::set('app.url', 'https://g7.test');
|
||||
}
|
||||
|
||||
/**
|
||||
* via()가 기본적으로 mail 채널을 반환하는지 확인
|
||||
*/
|
||||
public function test_via_returns_mail_channel_by_default(): void
|
||||
{
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = new User(['email' => 'test@example.com', 'name' => 'Test User']);
|
||||
|
||||
$channels = $notification->via($user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* toMail()이 DbTemplateMail을 반환하는지 확인
|
||||
*/
|
||||
public function test_to_mail_returns_db_template_mail(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('reset_password')->create();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 templateType이 reset_password인지 확인
|
||||
*/
|
||||
public function test_mailable_template_type_is_reset_password(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('reset_password')->create();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals('reset_password', $mailable->getTemplateType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 module이 core인지 확인
|
||||
*/
|
||||
public function test_mailable_module_is_core(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('reset_password')->create();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals(ExtensionOwnerType::Core, $mailable->getExtensionType());
|
||||
$this->assertEquals('core', $mailable->getExtensionIdentifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 템플릿에서 스킵 인스턴스 반환
|
||||
*/
|
||||
public function test_to_mail_returns_skipped_when_template_inactive(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('reset_password')->inactive()->create();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
$this->assertTrue($mailable->isSkipped());
|
||||
}
|
||||
|
||||
/**
|
||||
* getToken()이 토큰을 반환하는지 확인
|
||||
*/
|
||||
public function test_get_token_returns_token(): void
|
||||
{
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
|
||||
$this->assertEquals($this->testToken, $notification->getToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* action_url 변수를 포함하는 비밀번호 재설정 템플릿을 생성합니다.
|
||||
*/
|
||||
private function createResetPasswordTemplate(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('reset_password')->create([
|
||||
'subject' => [
|
||||
'ko' => '비밀번호 재설정',
|
||||
'en' => 'Reset Password',
|
||||
],
|
||||
'body' => [
|
||||
'ko' => '<p><a href="{action_url}">비밀번호 재설정</a></p>',
|
||||
'en' => '<p><a href="{action_url}">Reset Password</a></p>',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect_prefix 없이 기본 /reset-password URL 생성
|
||||
*/
|
||||
public function test_to_mail_generates_default_reset_url(): void
|
||||
{
|
||||
$this->createResetPasswordTemplate();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken);
|
||||
$user = User::factory()->create(['email' => 'user@example.com']);
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
// renderedBody는 생성자 파라미터이므로 Reflection으로 접근
|
||||
$ref = new \ReflectionProperty($mailable, 'renderedBody');
|
||||
$body = $ref->getValue($mailable);
|
||||
$this->assertStringContainsString('/reset-password?', $body);
|
||||
$this->assertStringNotContainsString('/admin/reset-password?', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect_prefix='admin' 시 /admin/reset-password URL 생성
|
||||
*/
|
||||
public function test_to_mail_generates_admin_reset_url_with_redirect_prefix(): void
|
||||
{
|
||||
$this->createResetPasswordTemplate();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken, 'admin');
|
||||
$user = User::factory()->create(['email' => 'admin@example.com']);
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$ref = new \ReflectionProperty($mailable, 'renderedBody');
|
||||
$body = $ref->getValue($mailable);
|
||||
$this->assertStringContainsString('/admin/reset-password?', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect_prefix=null 시 기본 URL (하위 호환성)
|
||||
*/
|
||||
public function test_to_mail_null_redirect_prefix_uses_default_url(): void
|
||||
{
|
||||
$this->createResetPasswordTemplate();
|
||||
|
||||
$notification = new ResetPasswordNotification($this->testToken, null);
|
||||
$user = User::factory()->create(['email' => 'test@example.com']);
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$ref = new \ReflectionProperty($mailable, 'renderedBody');
|
||||
$body = $ref->getValue($mailable);
|
||||
$this->assertStringContainsString('/reset-password?', $body);
|
||||
$this->assertStringNotContainsString('/admin/reset-password?', $body);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Auth\WelcomeNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* WelcomeNotification 테스트
|
||||
*
|
||||
* 회원가입 완료 알림의 채널 결정 및 이메일 생성을 검증합니다.
|
||||
*/
|
||||
class WelcomeNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
Config::set('app.name', 'G7 Test');
|
||||
Config::set('app.url', 'https://g7.test');
|
||||
}
|
||||
|
||||
/**
|
||||
* via()가 기본적으로 mail 채널을 반환하는지 확인
|
||||
*/
|
||||
public function test_via_returns_mail_channel_by_default(): void
|
||||
{
|
||||
$notification = new WelcomeNotification();
|
||||
$user = new User(['email' => 'test@example.com', 'name' => 'Test User']);
|
||||
|
||||
$channels = $notification->via($user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* toMail()이 DbTemplateMail을 반환하는지 확인
|
||||
*/
|
||||
public function test_to_mail_returns_db_template_mail(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create();
|
||||
|
||||
$notification = new WelcomeNotification();
|
||||
$user = User::factory()->create(['email' => 'test@example.com', 'name' => 'Test User']);
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 templateType이 welcome인지 확인
|
||||
*/
|
||||
public function test_mailable_template_type_is_welcome(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create();
|
||||
|
||||
$notification = new WelcomeNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals('welcome', $mailable->getTemplateType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailable의 module이 core인지 확인
|
||||
*/
|
||||
public function test_mailable_module_is_core(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create();
|
||||
|
||||
$notification = new WelcomeNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertEquals(ExtensionOwnerType::Core, $mailable->getExtensionType());
|
||||
$this->assertEquals('core', $mailable->getExtensionIdentifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 템플릿에서 스킵 인스턴스 반환
|
||||
*/
|
||||
public function test_to_mail_returns_skipped_when_template_inactive(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->inactive()->create();
|
||||
|
||||
$notification = new WelcomeNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
$this->assertTrue($mailable->isSkipped());
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 미존재 시 스킵 인스턴스 반환
|
||||
*/
|
||||
public function test_to_mail_returns_skipped_when_no_template(): void
|
||||
{
|
||||
$notification = new WelcomeNotification();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
$this->assertInstanceOf(DbTemplateMail::class, $mailable);
|
||||
$this->assertTrue($mailable->isSkipped());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications;
|
||||
|
||||
use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\GenericNotification;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* GenericNotification 테스트
|
||||
*
|
||||
* DB 기반 범용 알림 클래스의 via(), toMail(), toArray() 동작을 검증합니다.
|
||||
*/
|
||||
class GenericNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// readiness mock: 모든 채널 ready
|
||||
$this->app->singleton(ChannelReadinessCheckerInterface::class, function () {
|
||||
return new class implements ChannelReadinessCheckerInterface {
|
||||
public function isReady(string $channelId): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function check(string $channelId): array
|
||||
{
|
||||
return ['ready' => true, 'reason' => null];
|
||||
}
|
||||
|
||||
public function checkAll(array $channelIds): array
|
||||
{
|
||||
return array_fill_keys($channelIds, ['ready' => true, 'reason' => null]);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification을 상속하는지 확인
|
||||
*/
|
||||
public function test_extends_laravel_notification(): void
|
||||
{
|
||||
$notification = new GenericNotification('welcome', 'core.auth', ['name' => 'Test']);
|
||||
|
||||
$this->assertInstanceOf(Notification::class, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* getType()이 올바른 타입을 반환하는지 확인
|
||||
*/
|
||||
public function test_get_type_returns_correct_type(): void
|
||||
{
|
||||
$notification = new GenericNotification('welcome', 'core.auth');
|
||||
|
||||
$this->assertEquals('welcome', $notification->getType());
|
||||
}
|
||||
|
||||
/**
|
||||
* getData()가 전달된 데이터를 반환하는지 확인
|
||||
*/
|
||||
public function test_get_data_returns_provided_data(): void
|
||||
{
|
||||
$data = ['name' => 'Test', 'app_name' => 'G7'];
|
||||
$notification = new GenericNotification('welcome', 'core.auth', $data);
|
||||
|
||||
$this->assertEquals($data, $notification->getData());
|
||||
}
|
||||
|
||||
/**
|
||||
* via()가 notification_definitions 테이블에서 채널을 조회하는지 확인
|
||||
*/
|
||||
public function test_via_reads_channels_from_definition(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'test_notification',
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '테스트', 'en' => 'Test'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => ['core.test.after_action'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// 캐시를 무효화하여 최신 데이터 반영
|
||||
app(NotificationDefinitionService::class)->invalidateCache('test_notification');
|
||||
|
||||
$notification = new GenericNotification('test_notification', 'core.test');
|
||||
$user = new User(['email' => 'test@example.com']);
|
||||
|
||||
$channels = $notification->via($user);
|
||||
|
||||
$this->assertEquals(['mail', 'database'], $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정의가 없을 때 via()가 기본 ['mail'] 채널을 반환하는지 확인
|
||||
*/
|
||||
public function test_via_returns_mail_default_when_no_definition(): void
|
||||
{
|
||||
$notification = new GenericNotification('nonexistent_type', 'core.auth');
|
||||
$user = new User(['email' => 'test@example.com']);
|
||||
|
||||
$channels = $notification->via($user);
|
||||
|
||||
$this->assertEquals(['mail'], $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* toArray()가 database 채널 템플릿을 사용하는지 확인
|
||||
*/
|
||||
public function test_to_array_uses_database_channel_template(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'test_db',
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '테스트', 'en' => 'Test'],
|
||||
'variables' => [],
|
||||
'channels' => ['database'],
|
||||
'hooks' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'database',
|
||||
'subject' => ['ko' => '{name}님 알림', 'en' => 'Notification for {name}'],
|
||||
'body' => ['ko' => '{app_name}에서 알림입니다', 'en' => 'Notification from {app_name}'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
app(NotificationTemplateService::class)->invalidateCache('test_db', 'database');
|
||||
|
||||
$notification = new GenericNotification('test_db', 'core.test', [
|
||||
'name' => '홍길동',
|
||||
'app_name' => 'G7',
|
||||
]);
|
||||
|
||||
$user = new User(['email' => 'test@example.com', 'name' => '홍길동']);
|
||||
$user->forceFill(['locale' => 'ko']);
|
||||
|
||||
$result = $notification->toArray($user);
|
||||
|
||||
$this->assertEquals('test_db', $result['type']);
|
||||
$this->assertEquals('홍길동님 알림', $result['subject']);
|
||||
$this->assertEquals('G7에서 알림입니다', $result['body']);
|
||||
}
|
||||
|
||||
/**
|
||||
* toArray()가 템플릿 없을 때 기본 데이터를 반환하는지 확인
|
||||
*/
|
||||
public function test_to_array_returns_basic_data_when_no_template(): void
|
||||
{
|
||||
$data = ['name' => 'Test'];
|
||||
$notification = new GenericNotification('no_template', 'core.test', $data);
|
||||
|
||||
$user = new User(['email' => 'test@example.com']);
|
||||
|
||||
$result = $notification->toArray($user);
|
||||
|
||||
$this->assertEquals('no_template', $result['type']);
|
||||
$this->assertEquals($data, $result['data']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notifications;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\User;
|
||||
use App\Notifications\GenericNotification;
|
||||
use App\Services\NotificationChannelService;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* GenericNotification::via() 채널 결정 테스트
|
||||
*
|
||||
* 채널 활성화 + readiness + 템플릿 존재 검증을 단계별로 확인합니다.
|
||||
* 핵심 회귀: 활성 템플릿이 없는 채널은 제외되어야 함 (빈 subject/body 방지).
|
||||
*/
|
||||
class GenericNotificationViaTest extends TestCase
|
||||
{
|
||||
private User $user;
|
||||
|
||||
private NotificationDefinition $definition;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 이전 테스트의 캐시된 템플릿 결과가 간섭하지 않도록 무효화
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$templateService->invalidateCache('test_via_check', 'mail');
|
||||
$templateService->invalidateCache('test_via_check', 'database');
|
||||
$templateService->invalidateCache('nonexistent_type_xyz', 'mail');
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// 테스트용 알림 정의 — mail + database 채널 활성화
|
||||
$this->definition = NotificationDefinition::updateOrCreate(
|
||||
['type' => 'test_via_check'],
|
||||
[
|
||||
'hook_prefix' => 'core.test',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '테스트', 'en' => 'Test'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => false,
|
||||
]
|
||||
);
|
||||
|
||||
// 기본: 모든 채널을 활성으로 mocking (공유 storage/app/settings/notifications.json의
|
||||
// 실제 값과 격리). 개별 테스트에서 mockChannelEnabled(['xxx' => false])로 재정의 가능.
|
||||
$this->mockChannelEnabled([]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 캐시 무효화 후 데이터 정리
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$templateService->invalidateCache('test_via_check', 'mail');
|
||||
$templateService->invalidateCache('test_via_check', 'database');
|
||||
$templateService->invalidateCache('nonexistent_type_xyz', 'mail');
|
||||
|
||||
NotificationTemplate::where('definition_id', $this->definition->id)->delete();
|
||||
$this->definition->delete();
|
||||
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 설정에서 mail 채널이 OFF면 via()가 mail을 제외한다 (database만 반환).
|
||||
*/
|
||||
public function test_via_excludes_channel_disabled_by_extension_toggle(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
$this->createTemplate('database');
|
||||
|
||||
$this->mockChannelEnabled([
|
||||
'mail' => false,
|
||||
'database' => true,
|
||||
]);
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertNotContains('mail', $channels);
|
||||
$this->assertContains('database', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 설정에서 모든 채널이 OFF면 빈 배열 반환.
|
||||
*/
|
||||
public function test_via_returns_empty_when_all_channels_disabled_by_extension(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
$this->createTemplate('database');
|
||||
|
||||
$this->mockChannelEnabled([
|
||||
'mail' => false,
|
||||
'database' => false,
|
||||
]);
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertEmpty($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* $channel 직접 지정 발송 경로에도 확장 토글이 적용된다.
|
||||
*/
|
||||
public function test_via_with_explicit_channel_honors_extension_toggle(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
|
||||
$this->mockChannelEnabled(['mail' => false]);
|
||||
|
||||
$notification = new GenericNotification(
|
||||
type: 'test_via_check',
|
||||
hookPrefix: 'core.test',
|
||||
data: [],
|
||||
extensionType: 'core',
|
||||
extensionIdentifier: 'core',
|
||||
channel: 'mail'
|
||||
);
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertEmpty($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* $channel 직접 지정 + 확장 토글 ON → 해당 채널이 그대로 반환.
|
||||
*/
|
||||
public function test_via_with_explicit_channel_returns_when_enabled(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
|
||||
$this->mockChannelEnabled(['mail' => true]);
|
||||
|
||||
$notification = new GenericNotification(
|
||||
type: 'test_via_check',
|
||||
hookPrefix: 'core.test',
|
||||
data: [],
|
||||
extensionType: 'core',
|
||||
extensionIdentifier: 'core',
|
||||
channel: 'mail'
|
||||
);
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertSame(['mail'], $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking.
|
||||
*
|
||||
* @param array<string, bool> $channelMap
|
||||
*/
|
||||
private function mockChannelEnabled(array $channelMap): void
|
||||
{
|
||||
$mock = Mockery::mock(NotificationChannelService::class);
|
||||
$mock->shouldReceive('isChannelEnabledForExtension')
|
||||
->andReturnUsing(function ($type, $identifier, $channel) use ($channelMap) {
|
||||
return $channelMap[$channel] ?? true;
|
||||
});
|
||||
$this->app->instance(NotificationChannelService::class, $mock);
|
||||
}
|
||||
|
||||
/**
|
||||
* 양쪽 채널 모두 활성 템플릿이 있으면 두 채널 모두 반환
|
||||
*/
|
||||
public function test_via_returns_both_channels_when_both_have_templates(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
$this->createTemplate('database');
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
$this->assertContains('database', $channels);
|
||||
$this->assertCount(2, $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* mail 템플릿만 있으면 mail만 반환, database 제외
|
||||
*/
|
||||
public function test_via_excludes_channel_without_template(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
// database 템플릿 없음
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
$this->assertNotContains('database', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* database 템플릿만 있으면 database만 반환 (mail은 readiness도 영향)
|
||||
*/
|
||||
public function test_via_returns_only_database_when_mail_has_no_template(): void
|
||||
{
|
||||
// mail 템플릿 없음
|
||||
$this->createTemplate('database');
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
// mail은 템플릿 없음 또는 readiness 실패로 제외
|
||||
$this->assertNotContains('mail', $channels);
|
||||
$this->assertContains('database', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 템플릿은 없는 것으로 취급 — 채널 제외
|
||||
*/
|
||||
public function test_via_excludes_channel_with_inactive_template(): void
|
||||
{
|
||||
$this->createTemplate('mail');
|
||||
$this->createTemplate('database', isActive: false);
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertContains('mail', $channels);
|
||||
$this->assertNotContains('database', $channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 채널에 템플릿이 없으면 빈 배열 반환
|
||||
*/
|
||||
public function test_via_returns_empty_when_no_templates(): void
|
||||
{
|
||||
// 템플릿 없음
|
||||
|
||||
$notification = new GenericNotification('test_via_check', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertEmpty($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 정의가 없는 type은 기본 mail 채널이지만 템플릿도 없으므로 빈 배열
|
||||
*/
|
||||
public function test_via_unknown_type_returns_empty(): void
|
||||
{
|
||||
$notification = new GenericNotification('nonexistent_type_xyz', 'core.test');
|
||||
$channels = $notification->via($this->user);
|
||||
|
||||
$this->assertEmpty($channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 템플릿 생성 헬퍼
|
||||
*
|
||||
* @param string $channel
|
||||
* @param bool $isActive
|
||||
* @return NotificationTemplate
|
||||
*/
|
||||
private function createTemplate(string $channel, bool $isActive = true): NotificationTemplate
|
||||
{
|
||||
return NotificationTemplate::updateOrCreate(
|
||||
['definition_id' => $this->definition->id, 'channel' => $channel],
|
||||
[
|
||||
'subject' => ['ko' => '테스트 제목', 'en' => 'Test Subject'],
|
||||
'body' => ['ko' => '테스트 본문', 'en' => 'Test Body'],
|
||||
'is_active' => $isActive,
|
||||
'is_default' => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Providers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Services\DriverRegistryService;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreServiceProvider의 확장 드라이버 Config 적용 로직 테스트
|
||||
*
|
||||
* applyExtensionDriverConfigs()의 핵심 흐름을 검증합니다:
|
||||
* - 코어 드라이버 선택 시 스킵
|
||||
* - 플러그인 드라이버 사용 가능 시 액션 훅 발행
|
||||
* - 플러그인 드라이버 사용 불가 시 기본 드라이버 폴백 + 로그 경고
|
||||
*/
|
||||
class CoreServiceProviderDriverHookTest extends TestCase
|
||||
{
|
||||
private DriverRegistryService $driverRegistry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->driverRegistry = new DriverRegistryService();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 드라이버가 선택된 경우 액션 훅이 발행되지 않는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_skips_core_drivers(): void
|
||||
{
|
||||
$hookFired = false;
|
||||
HookManager::addAction('core.settings.apply_driver_config', function () use (&$hookFired) {
|
||||
$hookFired = true;
|
||||
});
|
||||
|
||||
// smtp는 코어 드라이버 → 훅 발행되면 안 됨
|
||||
$this->assertTrue($this->driverRegistry->isCoreDriver('mail', 'smtp'));
|
||||
$this->assertFalse($hookFired);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 드라이버가 등록되고 사용 가능할 때 액션 훅이 발행되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_fires_action_hook_for_available_plugin_driver(): void
|
||||
{
|
||||
// 플러그인 드라이버 등록
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_api',
|
||||
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
|
||||
'provider' => 'sirsoft-custom_mail',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
// custom_api가 사용 가능한지 확인
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('mail', 'custom_api'));
|
||||
|
||||
// 액션 훅 발행 검증
|
||||
$hookParams = null;
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) use (&$hookParams) {
|
||||
$hookParams = compact('category', 'driver', 'settings');
|
||||
});
|
||||
|
||||
// 액션 훅 직접 발행 (CoreServiceProvider가 하는 것과 동일)
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'mail', 'custom_api', ['mailer' => 'custom_api']);
|
||||
|
||||
$this->assertNotNull($hookParams);
|
||||
$this->assertEquals('mail', $hookParams['category']);
|
||||
$this->assertEquals('custom_api', $hookParams['driver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 드라이버가 등록되지 않은 경우(사용 불가) 기본 드라이버로 폴백하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_falls_back_to_default_when_plugin_driver_unavailable(): void
|
||||
{
|
||||
// 'custom_api'는 어디에도 등록되지 않은 드라이버
|
||||
$this->assertFalse($this->driverRegistry->isDriverAvailable('mail', 'custom_api'));
|
||||
|
||||
// 폴백 시 Config::set이 호출되어야 함
|
||||
$defaultDriver = $this->driverRegistry->getDefaultDriver('mail');
|
||||
$configKey = $this->driverRegistry->getConfigKey('mail');
|
||||
|
||||
$this->assertEquals('smtp', $defaultDriver);
|
||||
$this->assertEquals('mail.default', $configKey);
|
||||
|
||||
// 폴백 적용 시뮬레이션
|
||||
Config::set($configKey, $defaultDriver);
|
||||
|
||||
$this->assertEquals('smtp', Config::get('mail.default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴백 시 경고 로그가 기록되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_logs_warning_on_fallback(): void
|
||||
{
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->withArgs(function (string $message) {
|
||||
return str_contains($message, 'custom_api')
|
||||
&& str_contains($message, 'mail')
|
||||
&& str_contains($message, '폴백');
|
||||
});
|
||||
|
||||
// 사용 불가능한 드라이버에 대해 폴백 로직 시뮬레이션
|
||||
$selectedDriver = 'custom_api';
|
||||
$category = 'mail';
|
||||
|
||||
if (! $this->driverRegistry->isDriverAvailable($category, $selectedDriver)) {
|
||||
$defaultDriver = $this->driverRegistry->getDefaultDriver($category);
|
||||
$configKey = $this->driverRegistry->getConfigKey($category);
|
||||
|
||||
if ($configKey && $defaultDriver) {
|
||||
Config::set($configKey, $defaultDriver);
|
||||
}
|
||||
|
||||
Log::warning("플러그인 드라이버 '{$selectedDriver}'가 '{$category}' 카테고리에서 사용 불가능합니다. 기본 드라이버 '{$defaultDriver}'로 폴백합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 Config 키 매핑이 올바른지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_maps_categories_to_correct_config_keys(): void
|
||||
{
|
||||
$this->assertEquals('filesystems.default', $this->driverRegistry->getConfigKey('storage'));
|
||||
$this->assertEquals('cache.default', $this->driverRegistry->getConfigKey('cache'));
|
||||
$this->assertEquals('session.driver', $this->driverRegistry->getConfigKey('session'));
|
||||
$this->assertEquals('queue.default', $this->driverRegistry->getConfigKey('queue'));
|
||||
$this->assertEquals('mail.default', $this->driverRegistry->getConfigKey('mail'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Providers;
|
||||
|
||||
use App\Providers\SettingsServiceProvider;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SettingsServiceProvider 웹소켓 설정 테스트
|
||||
*
|
||||
* applyWebsocketConfig()가 drivers.json의 websocket_enabled 값에 따라
|
||||
* 브로드캐스트 드라이버를 올바르게 제어하는지 검증합니다.
|
||||
*
|
||||
* 재현 시나리오 (이슈: 비밀번호 변경 시 Reverb 발송 시도):
|
||||
* - drivers.json: websocket_enabled=false (환경설정에서 OFF)
|
||||
* - .env: BROADCAST_CONNECTION=reverb, REVERB_HOST=localhost
|
||||
* - 기대 동작: broadcasting.default가 'null'로 강제되어 broadcast 경로가 차단되어야 함
|
||||
*/
|
||||
class SettingsServiceProviderWebsocketConfigTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* applyWebsocketConfig를 리플렉션으로 호출합니다.
|
||||
*
|
||||
* @param array $driverSettings drivers 카테고리 설정 데이터
|
||||
*/
|
||||
private function callApplyWebsocketConfig(array $driverSettings): void
|
||||
{
|
||||
$provider = new SettingsServiceProvider($this->app);
|
||||
$method = new ReflectionMethod($provider, 'applyWebsocketConfig');
|
||||
$method->invoke($provider, $driverSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹소켓 비활성화 시 broadcasting.default가 'null'로 강제되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcasting_default_forced_to_null_when_websocket_disabled(): void
|
||||
{
|
||||
// .env 초기 상태: BROADCAST_CONNECTION=reverb
|
||||
Config::set('broadcasting.default', 'reverb');
|
||||
Config::set('broadcasting.connections.reverb.options.host', 'localhost');
|
||||
|
||||
// PO 설정: 웹소켓 OFF
|
||||
$this->callApplyWebsocketConfig([
|
||||
'websocket_enabled' => false,
|
||||
'websocket_host' => 'g7.dev',
|
||||
'websocket_port' => '443',
|
||||
'websocket_scheme' => 'https',
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
'null',
|
||||
config('broadcasting.default'),
|
||||
'웹소켓 비활성화 시 broadcasting.default는 "null"로 강제되어야 함 (HookManager::broadcast가 즉시 return)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹소켓 활성화 시 broadcasting.default가 유지되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcasting_default_preserved_when_websocket_enabled(): void
|
||||
{
|
||||
Config::set('broadcasting.default', 'reverb');
|
||||
|
||||
$this->callApplyWebsocketConfig([
|
||||
'websocket_enabled' => true,
|
||||
'websocket_app_id' => 'test-app',
|
||||
'websocket_app_key' => 'test-key',
|
||||
'websocket_app_secret' => 'test-secret',
|
||||
'websocket_host' => 'g7.dev',
|
||||
'websocket_port' => '443',
|
||||
'websocket_scheme' => 'https',
|
||||
'websocket_server_host' => '127.0.0.1',
|
||||
'websocket_server_port' => 8080,
|
||||
'websocket_server_scheme' => 'http',
|
||||
]);
|
||||
|
||||
$this->assertSame('reverb', config('broadcasting.default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* websocket_enabled 키 미존재 시 broadcasting.default가 'null'로 강제되는지 테스트합니다.
|
||||
*/
|
||||
public function test_broadcasting_default_forced_to_null_when_websocket_key_missing(): void
|
||||
{
|
||||
Config::set('broadcasting.default', 'reverb');
|
||||
|
||||
$this->callApplyWebsocketConfig([]);
|
||||
|
||||
$this->assertSame('null', config('broadcasting.default'));
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class LayoutRepositoryCacheTest extends TestCase
|
||||
'name' => 'sirsoft-tosspayments.plugin_settings',
|
||||
]);
|
||||
|
||||
$cacheKey = "template.{$this->template->id}.layout.sirsoft-tosspayments.plugin_settings";
|
||||
$cacheKey = "g7:core:template.{$this->template->id}.layout.sirsoft-tosspayments.plugin_settings";
|
||||
Cache::put($cacheKey, ['version' => '1.0.0', 'components' => []], 3600);
|
||||
|
||||
// 캐시가 존재하는지 확인
|
||||
@@ -141,7 +141,7 @@ class LayoutRepositoryCacheTest extends TestCase
|
||||
$layouts = $this->repository->getBySourceIdentifier('sirsoft-tosspayments', LayoutSourceType::Plugin);
|
||||
|
||||
foreach ($layouts as $layout) {
|
||||
Cache::forget("template.{$layout->template_id}.layout.{$layout->name}");
|
||||
Cache::forget("g7:core:template.{$layout->template_id}.layout.{$layout->name}");
|
||||
}
|
||||
|
||||
// Then: 캐시가 삭제됨
|
||||
@@ -153,17 +153,17 @@ class LayoutRepositoryCacheTest extends TestCase
|
||||
{
|
||||
// Given: PublicLayoutController 형식의 버전 포함 캐시 키가 존재
|
||||
$cacheVersion = time();
|
||||
Cache::put('extension_cache_version', $cacheVersion);
|
||||
Cache::put('g7:core:ext.cache_version', $cacheVersion);
|
||||
|
||||
$cacheKey = "layout.sirsoft-admin_basic.sirsoft-tosspayments.plugin_settings.v{$cacheVersion}";
|
||||
$cacheKey = "g7:core:layout.sirsoft-admin_basic.sirsoft-tosspayments.plugin_settings.v{$cacheVersion}";
|
||||
Cache::put($cacheKey, ['version' => '1.0.0', 'components' => []], 3600);
|
||||
|
||||
// 캐시가 존재하는지 확인
|
||||
$this->assertNotNull(Cache::get($cacheKey));
|
||||
|
||||
// When: 현재 캐시 버전을 읽어 올바른 캐시 키를 삭제
|
||||
$currentVersion = (int) Cache::get('extension_cache_version', 0);
|
||||
Cache::forget("layout.sirsoft-admin_basic.sirsoft-tosspayments.plugin_settings.v{$currentVersion}");
|
||||
$currentVersion = (int) Cache::get('g7:core:ext.cache_version', 0);
|
||||
Cache::forget("g7:core:layout.sirsoft-admin_basic.sirsoft-tosspayments.plugin_settings.v{$currentVersion}");
|
||||
|
||||
// Then: 버전 포함 캐시가 삭제됨
|
||||
$this->assertNull(Cache::get($cacheKey));
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Repositories;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\MailSendStatus;
|
||||
use App\Models\MailSendLog;
|
||||
use App\Repositories\MailSendLogRepository;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailSendLogRepository 테스트
|
||||
*
|
||||
* 메일 발송 이력 리포지토리의 필터링, 페이지네이션, 통계 조회를 검증합니다.
|
||||
*/
|
||||
class MailSendLogRepositoryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private MailSendLogRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->repository = app(MailSendLogRepository::class);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// create
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 이력을 생성합니다
|
||||
*/
|
||||
public function test_create_returns_mail_send_log(): void
|
||||
{
|
||||
$data = [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'recipient_name' => 'Test User',
|
||||
'subject' => 'Test Subject',
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => 'sent',
|
||||
'sent_at' => now(),
|
||||
];
|
||||
|
||||
$log = $this->repository->create($data);
|
||||
|
||||
$this->assertInstanceOf(MailSendLog::class, $log);
|
||||
$this->assertTrue($log->exists);
|
||||
$this->assertEquals('test@example.com', $log->recipient_email);
|
||||
$this->assertEquals('notification', $log->source);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'source' => 'notification',
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getPaginated - 기본 동작
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 필터 없이 전체 목록을 페이지네이션하여 반환
|
||||
*/
|
||||
public function test_get_paginated_returns_all_logs_without_filters(): void
|
||||
{
|
||||
MailSendLog::factory()->count(5)->create();
|
||||
|
||||
$result = $this->repository->getPaginated();
|
||||
|
||||
$this->assertCount(5, $result->items());
|
||||
$this->assertEquals(5, $result->total());
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 결과를 올바르게 반환
|
||||
*/
|
||||
public function test_get_paginated_returns_empty_when_no_logs(): void
|
||||
{
|
||||
$result = $this->repository->getPaginated();
|
||||
|
||||
$this->assertCount(0, $result->items());
|
||||
$this->assertEquals(0, $result->total());
|
||||
}
|
||||
|
||||
/**
|
||||
* sent_at 기준 최신순 정렬
|
||||
*/
|
||||
public function test_get_paginated_orders_by_sent_at_desc(): void
|
||||
{
|
||||
$older = MailSendLog::factory()->create(['sent_at' => now()->subDays(2)]);
|
||||
$newer = MailSendLog::factory()->create(['sent_at' => now()]);
|
||||
|
||||
$result = $this->repository->getPaginated();
|
||||
$items = $result->items();
|
||||
|
||||
$this->assertEquals($newer->id, $items[0]->id);
|
||||
$this->assertEquals($older->id, $items[1]->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* perPage 파라미터로 페이지 크기 제어
|
||||
*/
|
||||
public function test_get_paginated_respects_per_page(): void
|
||||
{
|
||||
MailSendLog::factory()->count(10)->create();
|
||||
|
||||
$result = $this->repository->getPaginated([], 3);
|
||||
|
||||
$this->assertCount(3, $result->items());
|
||||
$this->assertEquals(10, $result->total());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getPaginated - 필터
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* extension_type 필터
|
||||
*/
|
||||
public function test_get_paginated_filters_by_extension_type(): void
|
||||
{
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Core, 'core')->count(3)->create();
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-ecommerce')->count(2)->create();
|
||||
|
||||
$result = $this->repository->getPaginated(['extension_type' => 'core']);
|
||||
|
||||
$this->assertCount(3, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* template_type 필터
|
||||
*/
|
||||
public function test_get_paginated_filters_by_template_type(): void
|
||||
{
|
||||
MailSendLog::factory()->withTemplateType('welcome')->count(2)->create();
|
||||
MailSendLog::factory()->withTemplateType('password_reset')->count(3)->create();
|
||||
|
||||
$result = $this->repository->getPaginated(['template_type' => 'welcome']);
|
||||
|
||||
$this->assertCount(2, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* status 필터
|
||||
*/
|
||||
public function test_get_paginated_filters_by_status(): void
|
||||
{
|
||||
MailSendLog::factory()->count(4)->create(['status' => MailSendStatus::Sent->value]);
|
||||
MailSendLog::factory()->failed()->count(2)->create();
|
||||
|
||||
$result = $this->repository->getPaginated(['status' => MailSendStatus::Failed->value]);
|
||||
|
||||
$this->assertCount(2, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* search 필터 - 이메일 검색
|
||||
*/
|
||||
public function test_get_paginated_search_matches_email(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['recipient_email' => 'admin@example.com']);
|
||||
MailSendLog::factory()->create(['recipient_email' => 'user@example.com']);
|
||||
|
||||
$result = $this->repository->getPaginated(['search' => 'example.com']);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
$this->assertEquals('admin@example.com', $result->items()[0]->recipient_email);
|
||||
}
|
||||
|
||||
/**
|
||||
* search 필터 - 수신자 이름 검색
|
||||
*/
|
||||
public function test_get_paginated_search_matches_recipient_name(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['recipient_name' => 'Kim Admin']);
|
||||
MailSendLog::factory()->create(['recipient_name' => 'Lee User']);
|
||||
|
||||
$result = $this->repository->getPaginated(['search' => 'Kim']);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* search 필터 - 제목 검색
|
||||
*/
|
||||
public function test_get_paginated_search_matches_subject(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['subject' => '비밀번호 재설정 안내']);
|
||||
MailSendLog::factory()->create(['subject' => '환영합니다']);
|
||||
|
||||
$result = $this->repository->getPaginated(['search' => '비밀번호']);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* date_from 필터
|
||||
*/
|
||||
public function test_get_paginated_filters_by_date_from(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['sent_at' => Carbon::parse('2026-01-01')]);
|
||||
MailSendLog::factory()->create(['sent_at' => Carbon::parse('2026-03-01')]);
|
||||
|
||||
$result = $this->repository->getPaginated(['date_from' => '2026-02-01']);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* date_to 필터
|
||||
*/
|
||||
public function test_get_paginated_filters_by_date_to(): void
|
||||
{
|
||||
MailSendLog::factory()->create(['sent_at' => Carbon::parse('2026-01-15')]);
|
||||
MailSendLog::factory()->create(['sent_at' => Carbon::parse('2026-03-15')]);
|
||||
|
||||
$result = $this->repository->getPaginated(['date_to' => '2026-02-01']);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
}
|
||||
|
||||
/**
|
||||
* 복합 필터 조합
|
||||
*/
|
||||
public function test_get_paginated_combines_multiple_filters(): void
|
||||
{
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Core, 'core')->withTemplateType('welcome')->create([
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Core, 'core')->withTemplateType('welcome')->failed()->create();
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-ecommerce')->withTemplateType('welcome')->create();
|
||||
|
||||
$result = $this->repository->getPaginated([
|
||||
'extension_type' => 'core',
|
||||
'template_type' => 'welcome',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $result->items());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getStatistics
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 빈 테이블에서 통계 반환
|
||||
*/
|
||||
public function test_get_statistics_returns_zeros_when_empty(): void
|
||||
{
|
||||
$stats = $this->repository->getStatistics();
|
||||
|
||||
$this->assertEquals(0, $stats['total']);
|
||||
$this->assertEquals(0, $stats['sent']);
|
||||
$this->assertEquals(0, $stats['failed']);
|
||||
$this->assertEquals(0, $stats['today']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 올바른 통계 수치 반환
|
||||
*/
|
||||
public function test_get_statistics_returns_correct_counts(): void
|
||||
{
|
||||
// sent 3건
|
||||
MailSendLog::factory()->count(3)->create([
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
// failed 2건
|
||||
MailSendLog::factory()->failed()->count(2)->create([
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
// 어제 sent 1건
|
||||
MailSendLog::factory()->create([
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
'sent_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$stats = $this->repository->getStatistics();
|
||||
|
||||
$this->assertEquals(6, $stats['total']);
|
||||
$this->assertEquals(4, $stats['sent']);
|
||||
$this->assertEquals(2, $stats['failed']);
|
||||
$this->assertEquals(5, $stats['today']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Resources;
|
||||
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Resources\BaseApiCollection;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* BaseApiCollection 단위 테스트
|
||||
*/
|
||||
class BaseApiCollectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* abilityMap 빈 배열 시 abilities 빈 배열 반환
|
||||
*/
|
||||
public function test_empty_ability_map_returns_empty_abilities(): void
|
||||
{
|
||||
$collection = new class(collect([])) extends BaseApiCollection
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return ['data' => []];
|
||||
}
|
||||
};
|
||||
|
||||
$request = Request::create('/test');
|
||||
|
||||
$this->assertEmpty($collection->resolveCollectionAbilities($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* abilityMap 정의 시 권한에 따라 boolean 반환
|
||||
*/
|
||||
public function test_ability_map_resolves_permissions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create([
|
||||
'name' => ['ko' => '테스트'],
|
||||
'identifier' => 'test_role',
|
||||
'is_admin' => true,
|
||||
]);
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => 'core.test.delete'],
|
||||
['name' => ['ko' => '삭제'], 'type' => 'admin', 'order' => 1]
|
||||
);
|
||||
$role->permissions()->attach($perm->id);
|
||||
$user->roles()->attach($role->id);
|
||||
|
||||
$collection = new class(collect([])) extends BaseApiCollection
|
||||
{
|
||||
protected function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_delete' => 'core.test.delete',
|
||||
'can_create' => 'core.test.create',
|
||||
];
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return ['data' => []];
|
||||
}
|
||||
};
|
||||
|
||||
$request = Request::create('/test');
|
||||
$request->setUserResolver(fn () => $user->fresh());
|
||||
|
||||
$abilities = $collection->resolveCollectionAbilities($request);
|
||||
|
||||
$this->assertTrue($abilities['can_delete']);
|
||||
$this->assertFalse($abilities['can_create']);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveCollectionAbilities는 public 접근 가능
|
||||
*/
|
||||
public function test_resolve_collection_abilities_is_public(): void
|
||||
{
|
||||
$collection = new class(collect([])) extends BaseApiCollection
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return ['data' => []];
|
||||
}
|
||||
};
|
||||
|
||||
$ref = new \ReflectionMethod($collection, 'resolveCollectionAbilities');
|
||||
$this->assertTrue($ref->isPublic());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Resources;
|
||||
|
||||
use App\Http\Resources\NotificationDefinitionResource;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationDefinitionResource 단위 테스트
|
||||
*/
|
||||
class NotificationDefinitionResourceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* templates 관계 미로드 시 null 반환 (json 직렬화 에러 없음)
|
||||
*/
|
||||
public function test_templates_returns_null_when_relation_not_loaded(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'welcome',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '환영'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
]);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$resource = new NotificationDefinitionResource($definition);
|
||||
$array = $resource->toArray($request);
|
||||
|
||||
$this->assertNull($array['templates']);
|
||||
|
||||
// json_encode 시 에러 없음 검증
|
||||
$json = json_encode($array);
|
||||
$this->assertNotFalse($json);
|
||||
$this->assertEquals(JSON_ERROR_NONE, json_last_error());
|
||||
}
|
||||
|
||||
/**
|
||||
* templates 관계 로드 시 배열 반환
|
||||
*/
|
||||
public function test_templates_returns_array_when_relation_loaded(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'welcome',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '환영'],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
]);
|
||||
|
||||
NotificationTemplate::create([
|
||||
'definition_id' => $definition->id,
|
||||
'channel' => 'mail',
|
||||
'subject' => ['ko' => '제목'],
|
||||
'body' => ['ko' => '본문'],
|
||||
]);
|
||||
|
||||
$definition->load('templates');
|
||||
|
||||
$request = Request::create('/test');
|
||||
$resource = new NotificationDefinitionResource($definition);
|
||||
$array = $resource->toArray($request);
|
||||
|
||||
$this->assertNotNull($array['templates']);
|
||||
// Resource::collection()은 AnonymousResourceCollection을 반환
|
||||
// json_encode 시 배열로 직렬화됨
|
||||
$json = json_encode($array);
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded['templates']);
|
||||
$this->assertCount(1, $decoded['templates']);
|
||||
|
||||
// json_encode 시 에러 없음 검증
|
||||
$json = json_encode($array);
|
||||
$this->assertNotFalse($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 필드가 올바르게 반환됨
|
||||
*/
|
||||
public function test_basic_fields_returned(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'order_confirmed',
|
||||
'hook_prefix' => 'sirsoft-ecommerce',
|
||||
'extension_type' => 'module',
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'name' => ['ko' => '주문 확인', 'en' => 'Order Confirmed'],
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => ['sirsoft-ecommerce.order.after_confirm'],
|
||||
'variables' => [['key' => 'name', 'description' => '이름']],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$array = (new NotificationDefinitionResource($definition))->toArray($request);
|
||||
|
||||
$this->assertEquals('order_confirmed', $array['type']);
|
||||
$this->assertEquals('sirsoft-ecommerce', $array['hook_prefix']);
|
||||
$this->assertEquals('module', $array['extension_type']);
|
||||
$this->assertEquals(['mail', 'database'], $array['channels']);
|
||||
$this->assertTrue($array['is_active']);
|
||||
$this->assertArrayHasKey('name', $array);
|
||||
$this->assertArrayHasKey('variables', $array);
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('[SEO] Extension changed — all cache cleared', Mockery::on(function ($context) {
|
||||
return $context['identifier'] === 'sirsoft-ecommerce';
|
||||
@@ -98,11 +94,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')->once();
|
||||
Log::shouldReceive('info')->once();
|
||||
|
||||
$this->listener->onExtensionChanged('sirsoft-ecommerce', ['name' => 'Ecommerce']);
|
||||
|
||||
@@ -116,11 +108,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')->once();
|
||||
Log::shouldReceive('info')->once();
|
||||
|
||||
$this->listener->onExtensionChanged('sirsoft-ecommerce', ['success' => true], ['name' => 'Ecommerce']);
|
||||
|
||||
@@ -136,11 +124,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')->once();
|
||||
Log::shouldReceive('info')->once();
|
||||
|
||||
$this->listener->onExtensionChanged('sirsoft-payment', ['name' => 'Payment']);
|
||||
|
||||
@@ -156,11 +140,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')->once();
|
||||
Log::shouldReceive('info')->once();
|
||||
|
||||
// 템플릿 활성화는 Template model 객체가 전달됨
|
||||
$templateModel = Mockery::mock();
|
||||
@@ -179,11 +159,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')->once();
|
||||
Log::shouldReceive('info')->once();
|
||||
|
||||
$this->listener->onExtensionChanged('sirsoft-basic', ['success' => true], ['name' => 'Basic']);
|
||||
|
||||
@@ -232,11 +208,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('[SEO] Extension changed — all cache cleared', Mockery::on(function ($context) {
|
||||
return isset($context['identifier'])
|
||||
@@ -257,11 +229,7 @@ class SeoExtensionCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('[SEO] Extension changed — all cache cleared', Mockery::on(function ($context) {
|
||||
return $context['identifier'] === 'unknown';
|
||||
|
||||
@@ -59,10 +59,6 @@ class SeoSettingsCacheListenerTest extends TestCase
|
||||
{
|
||||
$this->cacheMock->shouldReceive('clearAll')->once();
|
||||
|
||||
Cache::shouldReceive('forget')
|
||||
->once()
|
||||
->with('seo:sitemap');
|
||||
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('[SEO] Core SEO settings changed — all cache cleared', Mockery::type('array'));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Seo\SeoCacheManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
@@ -24,7 +25,7 @@ class SeoCacheManagerTest extends TestCase
|
||||
// 이전 테스트 캐시 잔여물 제거
|
||||
Cache::flush();
|
||||
|
||||
$this->cacheManager = new SeoCacheManager;
|
||||
$this->cacheManager = new SeoCacheManager(new CoreCacheDriver('array'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,7 @@ class SeoCacheManagerTest extends TestCase
|
||||
*/
|
||||
public function test_cache_disabled_ignores_put_and_returns_null(): void
|
||||
{
|
||||
config()->set('g7_settings.core.cache.seo_enabled', false);
|
||||
config()->set('g7_settings.core.seo.cache_enabled', false);
|
||||
|
||||
$url = '/products/123';
|
||||
@@ -199,6 +201,7 @@ class SeoCacheManagerTest extends TestCase
|
||||
*/
|
||||
public function test_put_with_layout_ignored_when_cache_disabled(): void
|
||||
{
|
||||
config()->set('g7_settings.core.cache.seo_enabled', false);
|
||||
config()->set('g7_settings.core.seo.cache_enabled', false);
|
||||
|
||||
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>상품1</html>', 'shop/show');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Seo;
|
||||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||||
use App\Extension\AbstractModule;
|
||||
use App\Extension\AbstractPlugin;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Seo\SeoConfigMerger;
|
||||
@@ -38,6 +39,7 @@ class SeoConfigMergerTest extends TestCase
|
||||
$this->moduleManager,
|
||||
$this->pluginManager,
|
||||
$this->templateRepository,
|
||||
new CoreCacheDriver('array'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,8 +299,8 @@ class SeoConfigMergerTest extends TestCase
|
||||
|
||||
$this->assertEquals($result1, $result2);
|
||||
|
||||
// 캐시 키 존재 확인
|
||||
$this->assertTrue(Cache::has('seo:config:merged:sirsoft-basic'));
|
||||
// 캐시 키 존재 확인 — CoreCacheDriver 와 Cache 파사드는 동일 스토어 사용
|
||||
$this->assertTrue(Cache::store('array')->has('g7:core:seo.config.sirsoft-basic'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,17 +313,17 @@ class SeoConfigMergerTest extends TestCase
|
||||
|
||||
// 첫 호출
|
||||
$this->merger->getMergedConfig('sirsoft-basic');
|
||||
$this->assertTrue(Cache::has('seo:config:merged:sirsoft-basic'));
|
||||
$this->assertTrue(Cache::store('array')->has('g7:core:seo.config.sirsoft-basic'));
|
||||
|
||||
// 캐시 클리어
|
||||
$this->merger->clearCache('sirsoft-basic');
|
||||
$this->assertFalse(Cache::has('seo:config:merged:sirsoft-basic'));
|
||||
$this->assertFalse(Cache::store('array')->has('g7:core:seo.config.sirsoft-basic'));
|
||||
|
||||
// 재호출 — 캐시 미스이므로 다시 빌드
|
||||
$result = $this->merger->getMergedConfig('sirsoft-basic');
|
||||
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertTrue(Cache::has('seo:config:merged:sirsoft-basic'));
|
||||
$this->assertTrue(Cache::store('array')->has('g7:core:seo.config.sirsoft-basic'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1151,4 +1151,85 @@ class SeoMetaResolverTest extends TestCase
|
||||
$this->assertArrayHasKey('offers', $jsonLd);
|
||||
$this->assertSame('129000', $jsonLd['offers']['price']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// _seo context 참조 테스트
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* _seo.product.title이 context에 있으면 Tier 2로 사용됩니다.
|
||||
*/
|
||||
public function test_seo_context_title_used_as_tier2(): void
|
||||
{
|
||||
$seoConfig = [
|
||||
'enabled' => true,
|
||||
'page_type' => 'product',
|
||||
];
|
||||
|
||||
$context = [
|
||||
'_seo' => [
|
||||
'product' => [
|
||||
'title' => '테스트쇼핑몰 - 에어맥스',
|
||||
'description' => '에어맥스 설명',
|
||||
],
|
||||
],
|
||||
'product' => ['data' => ['name' => '에어맥스']],
|
||||
];
|
||||
|
||||
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
|
||||
|
||||
$this->assertEquals('테스트쇼핑몰 - 에어맥스', $result['title']);
|
||||
$this->assertEquals('에어맥스 설명', $result['description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 3 (resource meta_title)가 _seo context보다 우선합니다.
|
||||
*/
|
||||
public function test_resource_meta_title_overrides_seo_context(): void
|
||||
{
|
||||
$seoConfig = [
|
||||
'enabled' => true,
|
||||
'page_type' => 'product',
|
||||
];
|
||||
|
||||
$context = [
|
||||
'_seo' => [
|
||||
'product' => [
|
||||
'title' => '테스트쇼핑몰 - 에어맥스',
|
||||
'description' => '쇼핑몰 설명',
|
||||
],
|
||||
],
|
||||
'product' => ['data' => [
|
||||
'name' => '에어맥스',
|
||||
'meta_title' => '커스텀 SEO 타이틀',
|
||||
'meta_description' => '커스텀 SEO 설명',
|
||||
]],
|
||||
];
|
||||
|
||||
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
|
||||
|
||||
// Tier 3가 Tier 2(_seo)보다 우선
|
||||
$this->assertEquals('커스텀 SEO 타이틀', $result['title']);
|
||||
$this->assertEquals('커스텀 SEO 설명', $result['description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* _seo context 없고 moduleIdentifier도 null이면 빈 타이틀로 fallback합니다.
|
||||
*/
|
||||
public function test_no_seo_context_and_no_module_falls_through(): void
|
||||
{
|
||||
$seoConfig = [
|
||||
'enabled' => true,
|
||||
'page_type' => 'product',
|
||||
];
|
||||
|
||||
$context = [
|
||||
'product' => ['data' => ['name' => '에어맥스']],
|
||||
];
|
||||
|
||||
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
|
||||
|
||||
// _seo도 없고 moduleIdentifier도 null → Tier 2 스킵 → 빈 문자열
|
||||
$this->assertEquals('', $result['title']);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Seo\ComponentHtmlMapper;
|
||||
use App\Seo\DataSourceResolver;
|
||||
@@ -42,6 +44,10 @@ class SeoRendererTest extends TestCase
|
||||
|
||||
private PluginSettingsService|MockInterface $pluginSettingsService;
|
||||
|
||||
private ModuleManagerInterface|MockInterface $moduleManager;
|
||||
|
||||
private PluginManagerInterface|MockInterface $pluginManager;
|
||||
|
||||
private SeoRenderer $renderer;
|
||||
|
||||
/**
|
||||
@@ -61,6 +67,8 @@ class SeoRendererTest extends TestCase
|
||||
$this->seoConfigMerger = Mockery::mock(SeoConfigMerger::class);
|
||||
$this->settingsService = Mockery::mock(SettingsService::class);
|
||||
$this->pluginSettingsService = Mockery::mock(PluginSettingsService::class);
|
||||
$this->moduleManager = Mockery::mock(ModuleManagerInterface::class);
|
||||
$this->pluginManager = Mockery::mock(PluginManagerInterface::class);
|
||||
|
||||
// 기본 SettingsService / PluginSettingsService mock
|
||||
$this->settingsService->shouldReceive('getFrontendSettings')
|
||||
@@ -80,9 +88,16 @@ class SeoRendererTest extends TestCase
|
||||
->andReturn(['success' => true, 'data' => [], 'error' => null])
|
||||
->byDefault();
|
||||
|
||||
// 기본 evaluator mock (setTranslations 허용)
|
||||
// 기본 evaluator mock (setTranslations, getPipeRegistry 허용)
|
||||
$this->evaluator->shouldReceive('setTranslations')
|
||||
->byDefault();
|
||||
$pipeRegistry = Mockery::mock(\App\Seo\PipeRegistry::class);
|
||||
$pipeRegistry->shouldReceive('setLocale')->byDefault();
|
||||
$this->evaluator->shouldReceive('getPipeRegistry')
|
||||
->andReturn($pipeRegistry)
|
||||
->byDefault();
|
||||
$this->evaluator->shouldReceive('setSeoOverrides')
|
||||
->byDefault();
|
||||
|
||||
// 기본 htmlMapper mock (seo-config.json 로드 시 호출되는 설정 메서드 허용)
|
||||
$this->htmlMapper->shouldReceive('setComponentMap')->byDefault();
|
||||
@@ -111,6 +126,8 @@ class SeoRendererTest extends TestCase
|
||||
$this->seoConfigMerger,
|
||||
$this->settingsService,
|
||||
$this->pluginSettingsService,
|
||||
$this->moduleManager,
|
||||
$this->pluginManager,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -869,12 +886,16 @@ class SeoRendererTest extends TestCase
|
||||
?array $initActions = null,
|
||||
?array $structuredData = null,
|
||||
?array $computed = null,
|
||||
?array $extensions = null,
|
||||
): array {
|
||||
$seo = [
|
||||
'enabled' => $seoEnabled,
|
||||
'data_sources' => $seoDataSources,
|
||||
];
|
||||
|
||||
if ($extensions !== null) {
|
||||
$seo['extensions'] = $extensions;
|
||||
}
|
||||
if ($toggleSetting !== null) {
|
||||
$seo['toggle_setting'] = $toggleSetting;
|
||||
}
|
||||
@@ -3246,4 +3267,348 @@ class SeoRendererTest extends TestCase
|
||||
$this->assertNull($capturedContext['_computed']['broken']);
|
||||
$this->assertEquals('still works', $capturedContext['_computed']['valid']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// _seo context 주입 테스트 (extensions 기반)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상품 상세 페이지에서 extensions + seoVariables()로 _seo.product context가 주입됩니다.
|
||||
*/
|
||||
public function test_seo_context_injected_for_product_page(): void
|
||||
{
|
||||
$request = Request::create('/shop/products/1');
|
||||
$this->setupRouteResolver('/shop/products/1', [
|
||||
'templateIdentifier' => 'sirsoft-basic',
|
||||
'layoutName' => 'shop/show',
|
||||
'routeParams' => ['id' => '1'],
|
||||
'moduleIdentifier' => null,
|
||||
'routeMeta' => [],
|
||||
]);
|
||||
|
||||
$mergedLayout = $this->buildMergedLayout(
|
||||
seoEnabled: true,
|
||||
seoDataSources: ['product'],
|
||||
pageType: 'product',
|
||||
extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']],
|
||||
vars: [
|
||||
'product_name' => '{{product.data.name}}',
|
||||
'product_description' => '{{product.data.description}}',
|
||||
],
|
||||
);
|
||||
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
|
||||
|
||||
// 모듈 인스턴스 mock
|
||||
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
|
||||
$moduleMock->shouldReceive('seoVariables')->andReturn([
|
||||
'_common' => [
|
||||
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
|
||||
],
|
||||
'product' => [
|
||||
'product_name' => ['source' => 'data', 'required' => true],
|
||||
'product_description' => ['source' => 'data'],
|
||||
],
|
||||
]);
|
||||
$this->moduleManager->shouldReceive('getModule')
|
||||
->with('sirsoft-ecommerce')
|
||||
->andReturn($moduleMock);
|
||||
|
||||
// 설정값 mock
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{commerce_name} - {product_name}');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_description', '{product_description}');
|
||||
|
||||
$this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([
|
||||
'product' => ['data' => ['name' => '에어맥스', 'description' => '나이키 에어맥스 상품']],
|
||||
]);
|
||||
|
||||
// vars 해석 시 evaluator 호출
|
||||
$this->evaluator->shouldReceive('evaluate')
|
||||
->with('{{product.data.name}}', Mockery::any())
|
||||
->andReturn('에어맥스');
|
||||
$this->evaluator->shouldReceive('evaluate')
|
||||
->with('{{product.data.description}}', Mockery::any())
|
||||
->andReturn('나이키 에어맥스 상품');
|
||||
|
||||
// _seo context 캡처
|
||||
$capturedContext = null;
|
||||
$this->metaResolver->shouldReceive('resolve')
|
||||
->once()
|
||||
->withArgs(function ($seoConfig, $context) use (&$capturedContext) {
|
||||
$capturedContext = $context;
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 에어맥스'));
|
||||
|
||||
$viewMock = Mockery::mock(\Illuminate\View\View::class);
|
||||
$viewMock->shouldReceive('render')->andReturn('<html></html>');
|
||||
View::shouldReceive('make')->once()->andReturn($viewMock);
|
||||
|
||||
$this->renderer->render($request);
|
||||
|
||||
$this->assertNotNull($capturedContext);
|
||||
$this->assertArrayHasKey('_seo', $capturedContext);
|
||||
$this->assertArrayHasKey('product', $capturedContext['_seo']);
|
||||
$this->assertEquals('테스트쇼핑몰 - 에어맥스', $capturedContext['_seo']['product']['title']);
|
||||
$this->assertEquals('나이키 에어맥스 상품', $capturedContext['_seo']['product']['description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 페이지에서 _seo.category context가 주입됩니다.
|
||||
*/
|
||||
public function test_seo_context_injected_for_category_page(): void
|
||||
{
|
||||
$request = Request::create('/shop/category/shoes');
|
||||
$this->setupRouteResolver('/shop/category/shoes', [
|
||||
'templateIdentifier' => 'sirsoft-basic',
|
||||
'layoutName' => 'shop/category',
|
||||
'routeParams' => ['slug' => 'shoes'],
|
||||
'moduleIdentifier' => null,
|
||||
'routeMeta' => [],
|
||||
]);
|
||||
|
||||
$mergedLayout = $this->buildMergedLayout(
|
||||
seoEnabled: true,
|
||||
seoDataSources: ['category'],
|
||||
pageType: 'category',
|
||||
extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']],
|
||||
vars: [
|
||||
'category_name' => '{{category.data.name}}',
|
||||
'category_description' => '{{category.data.description}}',
|
||||
],
|
||||
);
|
||||
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
|
||||
|
||||
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
|
||||
$moduleMock->shouldReceive('seoVariables')->andReturn([
|
||||
'_common' => [
|
||||
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
|
||||
],
|
||||
'category' => [
|
||||
'category_name' => ['source' => 'data', 'required' => true],
|
||||
'category_description' => ['source' => 'data'],
|
||||
],
|
||||
]);
|
||||
$this->moduleManager->shouldReceive('getModule')
|
||||
->with('sirsoft-ecommerce')
|
||||
->andReturn($moduleMock);
|
||||
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_title', '{commerce_name} - {category_name}');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_description', '{category_description}');
|
||||
|
||||
$this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([
|
||||
'category' => ['data' => ['name' => '신발', 'description' => '신발 카테고리']],
|
||||
]);
|
||||
|
||||
$this->evaluator->shouldReceive('evaluate')
|
||||
->with('{{category.data.name}}', Mockery::any())
|
||||
->andReturn('신발');
|
||||
$this->evaluator->shouldReceive('evaluate')
|
||||
->with('{{category.data.description}}', Mockery::any())
|
||||
->andReturn('신발 카테고리');
|
||||
|
||||
$capturedContext = null;
|
||||
$this->metaResolver->shouldReceive('resolve')
|
||||
->once()
|
||||
->withArgs(function ($seoConfig, $context) use (&$capturedContext) {
|
||||
$capturedContext = $context;
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 신발'));
|
||||
|
||||
$viewMock = Mockery::mock(\Illuminate\View\View::class);
|
||||
$viewMock->shouldReceive('render')->andReturn('<html></html>');
|
||||
View::shouldReceive('make')->once()->andReturn($viewMock);
|
||||
|
||||
$this->renderer->render($request);
|
||||
|
||||
$this->assertNotNull($capturedContext);
|
||||
$this->assertEquals('테스트쇼핑몰 - 신발', $capturedContext['_seo']['category']['title']);
|
||||
$this->assertEquals('신발 카테고리', $capturedContext['_seo']['category']['description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색결과 페이지에서 _seo.search context가 주입됩니다 (query source 변수 포함).
|
||||
*/
|
||||
public function test_seo_context_injected_for_search_page(): void
|
||||
{
|
||||
$request = Request::create('/search', 'GET', ['q' => '운동화']);
|
||||
// request() 헬퍼가 이 request를 반환하도록 바인딩
|
||||
app()->instance('request', $request);
|
||||
|
||||
$this->setupRouteResolver('/search', [
|
||||
'templateIdentifier' => 'sirsoft-basic',
|
||||
'layoutName' => 'search/index',
|
||||
'routeParams' => [],
|
||||
'moduleIdentifier' => null,
|
||||
'routeMeta' => [],
|
||||
]);
|
||||
|
||||
$mergedLayout = $this->buildMergedLayout(
|
||||
seoEnabled: true,
|
||||
seoDataSources: [],
|
||||
pageType: 'search',
|
||||
extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']],
|
||||
);
|
||||
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
|
||||
|
||||
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
|
||||
$moduleMock->shouldReceive('seoVariables')->andReturn([
|
||||
'_common' => [
|
||||
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
|
||||
],
|
||||
'search' => [
|
||||
'keyword_name' => ['source' => 'query', 'key' => 'q'],
|
||||
],
|
||||
]);
|
||||
$this->moduleManager->shouldReceive('getModule')
|
||||
->with('sirsoft-ecommerce')
|
||||
->andReturn($moduleMock);
|
||||
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_search_title', '{commerce_name} - {keyword_name}');
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_search_description', '');
|
||||
|
||||
$this->dataSourceResolver->shouldReceive('resolve')->never();
|
||||
|
||||
$capturedContext = null;
|
||||
$this->metaResolver->shouldReceive('resolve')
|
||||
->once()
|
||||
->withArgs(function ($seoConfig, $context) use (&$capturedContext) {
|
||||
$capturedContext = $context;
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 운동화'));
|
||||
|
||||
$viewMock = Mockery::mock(\Illuminate\View\View::class);
|
||||
$viewMock->shouldReceive('render')->andReturn('<html></html>');
|
||||
View::shouldReceive('make')->once()->andReturn($viewMock);
|
||||
|
||||
$this->renderer->render($request);
|
||||
|
||||
$this->assertNotNull($capturedContext);
|
||||
$this->assertEquals('테스트쇼핑몰 - 운동화', $capturedContext['_seo']['search']['title']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 템플릿 값이 null인 경우에도 TypeError 없이 정상 동작합니다.
|
||||
*/
|
||||
public function test_seo_context_handles_null_settings_template(): void
|
||||
{
|
||||
$request = Request::create('/shop/products/1');
|
||||
$this->setupRouteResolver('/shop/products/1', [
|
||||
'templateIdentifier' => 'sirsoft-basic',
|
||||
'layoutName' => 'shop/show',
|
||||
'routeParams' => ['id' => '1'],
|
||||
'moduleIdentifier' => null,
|
||||
'routeMeta' => [],
|
||||
]);
|
||||
|
||||
$mergedLayout = $this->buildMergedLayout(
|
||||
seoEnabled: true,
|
||||
seoDataSources: ['product'],
|
||||
pageType: 'product',
|
||||
extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']],
|
||||
vars: [
|
||||
'product_name' => '{{product.data.name}}',
|
||||
],
|
||||
);
|
||||
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
|
||||
|
||||
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
|
||||
$moduleMock->shouldReceive('seoVariables')->andReturn([
|
||||
'product' => [
|
||||
'product_name' => ['source' => 'data', 'required' => true],
|
||||
],
|
||||
]);
|
||||
$this->moduleManager->shouldReceive('getModule')
|
||||
->with('sirsoft-ecommerce')
|
||||
->andReturn($moduleMock);
|
||||
|
||||
// 설정값을 null로 설정 (미구성 상태 재현)
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', null);
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_description', null);
|
||||
|
||||
$this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([
|
||||
'product' => ['data' => ['name' => '에어맥스']],
|
||||
]);
|
||||
|
||||
$this->evaluator->shouldReceive('evaluate')
|
||||
->with('{{product.data.name}}', Mockery::any())
|
||||
->andReturn('에어맥스');
|
||||
|
||||
$capturedContext = null;
|
||||
$this->metaResolver->shouldReceive('resolve')
|
||||
->once()
|
||||
->withArgs(function ($seoConfig, $context) use (&$capturedContext) {
|
||||
$capturedContext = $context;
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn($this->buildMetaResult(title: '에어맥스'));
|
||||
|
||||
$viewMock = Mockery::mock(\Illuminate\View\View::class);
|
||||
$viewMock->shouldReceive('render')->andReturn('<html></html>');
|
||||
View::shouldReceive('make')->once()->andReturn($viewMock);
|
||||
|
||||
// TypeError 없이 정상 실행되어야 함
|
||||
$result = $this->renderer->render($request);
|
||||
$this->assertNotNull($result);
|
||||
|
||||
// null 템플릿 → _seo context에 주입되지 않음 (빈 문자열이므로)
|
||||
$this->assertNotNull($capturedContext);
|
||||
$this->assertArrayNotHasKey('_seo', $capturedContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* extensions 미선언 시 _seo context가 주입되지 않습니다 (하위호환).
|
||||
*/
|
||||
public function test_seo_context_not_injected_without_extensions(): void
|
||||
{
|
||||
$request = Request::create('/products/1');
|
||||
$this->setupRouteResolver('/products/1', [
|
||||
'templateIdentifier' => 'sirsoft-basic',
|
||||
'layoutName' => 'shop/show',
|
||||
'routeParams' => ['id' => '1'],
|
||||
'moduleIdentifier' => 'sirsoft-ecommerce',
|
||||
'routeMeta' => [],
|
||||
]);
|
||||
|
||||
$mergedLayout = $this->buildMergedLayout(
|
||||
seoEnabled: true,
|
||||
seoDataSources: ['product'],
|
||||
pageType: 'product',
|
||||
toggleSetting: '$module_settings:seo.seo_product_detail',
|
||||
);
|
||||
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
|
||||
|
||||
config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true);
|
||||
|
||||
$this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([
|
||||
'product' => ['data' => ['name' => '에어맥스']],
|
||||
]);
|
||||
|
||||
$capturedContext = null;
|
||||
$this->metaResolver->shouldReceive('resolve')
|
||||
->once()
|
||||
->withArgs(function ($seoConfig, $context) use (&$capturedContext) {
|
||||
$capturedContext = $context;
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn($this->buildMetaResult(title: '에어맥스'));
|
||||
|
||||
$viewMock = Mockery::mock(\Illuminate\View\View::class);
|
||||
$viewMock->shouldReceive('render')->andReturn('<html></html>');
|
||||
View::shouldReceive('make')->once()->andReturn($viewMock);
|
||||
|
||||
$this->renderer->render($request);
|
||||
|
||||
$this->assertNotNull($capturedContext);
|
||||
$this->assertArrayNotHasKey('_seo', $capturedContext);
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CacheService 단위 테스트
|
||||
*
|
||||
* 빈 배열 캐싱 금지 기능을 검증합니다.
|
||||
*/
|
||||
class CacheServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Cache::flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 배열은 캐시하지 않아야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_does_not_cache_empty_arrays(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 첫 번째 호출
|
||||
$result1 = CacheService::remember('test', 'empty_array', $callback);
|
||||
$this->assertEquals([], $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
// 두 번째 호출 - 캐시되지 않았으므로 콜백 재실행
|
||||
$result2 = CacheService::remember('test', 'empty_array', $callback);
|
||||
$this->assertEquals([], $result2);
|
||||
$this->assertEquals(2, $callCount);
|
||||
|
||||
// 캐시에 값이 없음을 확인
|
||||
$this->assertNull(Cache::get(CacheService::key('test', 'empty_array')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비어있지 않은 배열은 정상적으로 캐시되어야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_caches_non_empty_arrays(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return ['item1', 'item2'];
|
||||
};
|
||||
|
||||
// 첫 번째 호출
|
||||
$result1 = CacheService::remember('test', 'non_empty_array', $callback);
|
||||
$this->assertEquals(['item1', 'item2'], $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
// 두 번째 호출 - 캐시 히트
|
||||
$result2 = CacheService::remember('test', 'non_empty_array', $callback);
|
||||
$this->assertEquals(['item1', 'item2'], $result2);
|
||||
$this->assertEquals(1, $callCount); // 콜백 재실행 안함
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 등 배열이 아닌 값은 정상적으로 캐시되어야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_caches_non_array_values(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return 'string_value';
|
||||
};
|
||||
|
||||
$result1 = CacheService::remember('test', 'string', $callback);
|
||||
$this->assertEquals('string_value', $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
$result2 = CacheService::remember('test', 'string', $callback);
|
||||
$this->assertEquals('string_value', $result2);
|
||||
$this->assertEquals(1, $callCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정수 값은 정상적으로 캐시되어야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_caches_integer_values(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return 42;
|
||||
};
|
||||
|
||||
$result1 = CacheService::remember('test', 'integer', $callback);
|
||||
$this->assertEquals(42, $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
$result2 = CacheService::remember('test', 'integer', $callback);
|
||||
$this->assertEquals(42, $result2);
|
||||
$this->assertEquals(1, $callCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연관 배열도 정상적으로 캐시되어야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_caches_associative_arrays(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return ['key' => 'value', 'count' => 10];
|
||||
};
|
||||
|
||||
$result1 = CacheService::remember('test', 'assoc_array', $callback);
|
||||
$this->assertEquals(['key' => 'value', 'count' => 10], $result1);
|
||||
$this->assertEquals(1, $callCount);
|
||||
|
||||
$result2 = CacheService::remember('test', 'assoc_array', $callback);
|
||||
$this->assertEquals(['key' => 'value', 'count' => 10], $result2);
|
||||
$this->assertEquals(1, $callCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 테이블이 없으면 빈 배열을 반환하고 캐시하지 않아야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_empty_array_without_caching_when_table_not_exists(): void
|
||||
{
|
||||
$callCount = 0;
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return ['data'];
|
||||
};
|
||||
|
||||
// 존재하지 않는 테이블 지정
|
||||
$result = CacheService::remember(
|
||||
'test',
|
||||
'no_table',
|
||||
$callback,
|
||||
null,
|
||||
'non_existent_table_xyz_123'
|
||||
);
|
||||
|
||||
$this->assertEquals([], $result);
|
||||
$this->assertEquals(0, $callCount); // 콜백 실행 안함
|
||||
$this->assertNull(Cache::get(CacheService::key('test', 'no_table')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 키가 올바르게 생성되어야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_generates_correct_cache_key(): void
|
||||
{
|
||||
$key = CacheService::key('modules', 'active_identifiers');
|
||||
$this->assertEquals('g7:modules:active_identifiers', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* forget 메서드가 캐시를 삭제해야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_forgets_cached_values(): void
|
||||
{
|
||||
// 값 캐시
|
||||
CacheService::remember('test', 'to_forget', fn () => ['data']);
|
||||
|
||||
// 캐시 확인
|
||||
$this->assertNotNull(Cache::get(CacheService::key('test', 'to_forget')));
|
||||
|
||||
// 캐시 삭제
|
||||
CacheService::forget('test', 'to_forget');
|
||||
|
||||
// 삭제 확인
|
||||
$this->assertNull(Cache::get(CacheService::key('test', 'to_forget')));
|
||||
}
|
||||
|
||||
/**
|
||||
* forgetMany 메서드가 여러 캐시를 삭제해야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_forgets_many_cached_values(): void
|
||||
{
|
||||
// 여러 값 캐시
|
||||
CacheService::remember('test', 'key1', fn () => ['data1']);
|
||||
CacheService::remember('test', 'key2', fn () => ['data2']);
|
||||
|
||||
// 캐시 확인
|
||||
$this->assertNotNull(Cache::get(CacheService::key('test', 'key1')));
|
||||
$this->assertNotNull(Cache::get(CacheService::key('test', 'key2')));
|
||||
|
||||
// 여러 캐시 삭제
|
||||
CacheService::forgetMany('test', ['key1', 'key2']);
|
||||
|
||||
// 삭제 확인
|
||||
$this->assertNull(Cache::get(CacheService::key('test', 'key1')));
|
||||
$this->assertNull(Cache::get(CacheService::key('test', 'key2')));
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh 메서드가 캐시를 갱신해야 합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_refreshes_cached_values(): void
|
||||
{
|
||||
$value = 1;
|
||||
|
||||
// 초기 캐시
|
||||
CacheService::remember('test', 'refresh', fn () => $value);
|
||||
$this->assertEquals(1, Cache::get(CacheService::key('test', 'refresh')));
|
||||
|
||||
// 값 변경 후 refresh
|
||||
$value = 2;
|
||||
$result = CacheService::refresh('test', 'refresh', fn () => $value);
|
||||
|
||||
$this->assertEquals(2, $result);
|
||||
$this->assertEquals(2, Cache::get(CacheService::key('test', 'refresh')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Services\ChannelReadinessService;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ChannelReadinessService 단위 테스트
|
||||
*/
|
||||
class ChannelReadinessServiceTest extends TestCase
|
||||
{
|
||||
private ConfigRepositoryInterface $configRepo;
|
||||
|
||||
private ChannelReadinessService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->configRepo = Mockery::mock(ConfigRepositoryInterface::class);
|
||||
$this->service = new ChannelReadinessService($this->configRepo);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 설정 완료 시 mail 채널 ready
|
||||
*/
|
||||
public function test_mail_smtp_ready_when_configured(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => 'smtp.gmail.com',
|
||||
'port' => 587,
|
||||
'from_address' => 'test@example.com',
|
||||
'from_name' => 'Test',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertTrue($result['ready']);
|
||||
$this->assertNull($result['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP host 미설정 시 not ready
|
||||
*/
|
||||
public function test_mail_smtp_not_ready_when_host_empty(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => '',
|
||||
'port' => 587,
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertFalse($result['ready']);
|
||||
$this->assertEquals('notification.readiness.mail_smtp_host_empty', $result['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* from_address 기본값(noreply@example.com) 시 not ready
|
||||
*/
|
||||
public function test_mail_not_ready_when_from_address_is_default(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => 'smtp.gmail.com',
|
||||
'port' => 587,
|
||||
'from_address' => 'noreply@example.com',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertFalse($result['ready']);
|
||||
$this->assertEquals('notification.readiness.mail_from_address_not_configured', $result['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailgun 설정 미완료 시 not ready
|
||||
*/
|
||||
public function test_mail_mailgun_not_ready_when_domain_empty(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'mailgun',
|
||||
'mailgun_domain' => '',
|
||||
'mailgun_secret' => 'secret',
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertFalse($result['ready']);
|
||||
$this->assertEquals('notification.readiness.mail_mailgun_domain_empty', $result['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* SES 설정 미완료 시 not ready
|
||||
*/
|
||||
public function test_mail_ses_not_ready_when_key_empty(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'ses',
|
||||
'ses_key' => '',
|
||||
'ses_secret' => 'secret',
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertFalse($result['ready']);
|
||||
$this->assertEquals('notification.readiness.mail_ses_key_empty', $result['reason']);
|
||||
}
|
||||
|
||||
/**
|
||||
* log mailer는 항상 ready
|
||||
*/
|
||||
public function test_mail_log_mailer_always_ready(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'log',
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$result = $this->service->check('mail');
|
||||
$this->assertTrue($result['ready']);
|
||||
}
|
||||
|
||||
/**
|
||||
* database 채널 — notifications 테이블 존재 시 ready
|
||||
*/
|
||||
public function test_database_ready_when_table_exists(): void
|
||||
{
|
||||
Schema::shouldReceive('hasTable')
|
||||
->with('notifications')
|
||||
->andReturn(true);
|
||||
|
||||
$result = $this->service->check('database');
|
||||
$this->assertTrue($result['ready']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알 수 없는 채널은 기본 ready
|
||||
*/
|
||||
public function test_unknown_channel_defaults_to_ready(): void
|
||||
{
|
||||
$result = $this->service->check('fcm');
|
||||
$this->assertTrue($result['ready']);
|
||||
}
|
||||
|
||||
/**
|
||||
* isReady() 편의 메서드
|
||||
*/
|
||||
public function test_is_ready_returns_boolean(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => '',
|
||||
'port' => 587,
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->service->isReady('mail'));
|
||||
}
|
||||
|
||||
/**
|
||||
* checkAll() 일괄 체크
|
||||
*/
|
||||
public function test_check_all_returns_all_channels(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => 'smtp.gmail.com',
|
||||
'port' => 587,
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
Schema::shouldReceive('hasTable')
|
||||
->with('notifications')
|
||||
->andReturn(true);
|
||||
|
||||
$results = $this->service->checkAll(['mail', 'database']);
|
||||
$this->assertArrayHasKey('mail', $results);
|
||||
$this->assertArrayHasKey('database', $results);
|
||||
$this->assertTrue($results['mail']['ready']);
|
||||
$this->assertTrue($results['database']['ready']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티 캐시 — 동일 채널 반복 호출 시 1회만 체크
|
||||
*/
|
||||
public function test_result_is_cached_within_instance(): void
|
||||
{
|
||||
$this->configRepo->shouldReceive('getCategory')
|
||||
->with('mail')
|
||||
->once() // 1회만 호출되어야 함
|
||||
->andReturn([
|
||||
'mailer' => 'smtp',
|
||||
'host' => 'smtp.gmail.com',
|
||||
'port' => 587,
|
||||
'from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$result1 = $this->service->check('mail');
|
||||
$result2 = $this->service->check('mail');
|
||||
$result3 = $this->service->isReady('mail');
|
||||
|
||||
$this->assertTrue($result1['ready']);
|
||||
$this->assertSame($result1, $result2);
|
||||
$this->assertTrue($result3);
|
||||
}
|
||||
}
|
||||
@@ -144,69 +144,15 @@ class CoreUpdateServiceTest extends TestCase
|
||||
$this->assertTrue($result['check_failed']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// buildGithubHeaders() - GitHub API 헤더 생성
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 토큰 없이 기본 헤더만 생성되는지 검증합니다.
|
||||
*/
|
||||
public function test_build_github_headers_without_token(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(CoreUpdateService::class, 'buildGithubHeaders');
|
||||
|
||||
$headers = $method->invoke($this->service, '');
|
||||
|
||||
$this->assertCount(2, $headers);
|
||||
$this->assertContains('User-Agent: G7', $headers);
|
||||
$this->assertContains('Accept: application/vnd.github.v3+json', $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰이 있을 때 Authorization 헤더가 포함되는지 검증합니다.
|
||||
*/
|
||||
public function test_build_github_headers_with_token(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(CoreUpdateService::class, 'buildGithubHeaders');
|
||||
|
||||
$headers = $method->invoke($this->service, 'ghp_test_token_123');
|
||||
|
||||
$this->assertCount(3, $headers);
|
||||
$this->assertContains('Authorization: Bearer ghp_test_token_123', $headers);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// extractHttpStatusCode() - HTTP 상태 코드 추출
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* HTTP 응답 헤더에서 상태 코드를 올바르게 추출하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_http_status_code(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(CoreUpdateService::class, 'extractHttpStatusCode');
|
||||
|
||||
// 일반 200 응답
|
||||
$this->assertEquals(200, $method->invoke($this->service, ['HTTP/1.1 200 OK']));
|
||||
|
||||
// 404 응답
|
||||
$this->assertEquals(404, $method->invoke($this->service, ['HTTP/1.1 404 Not Found']));
|
||||
|
||||
// 리다이렉트 후 최종 상태 코드
|
||||
$this->assertEquals(200, $method->invoke($this->service, [
|
||||
'HTTP/1.1 302 Found',
|
||||
'Location: https://example.com',
|
||||
'HTTP/1.1 200 OK',
|
||||
]));
|
||||
|
||||
// 빈 헤더
|
||||
$this->assertEquals(0, $method->invoke($this->service, []));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getChangelog() - CHANGELOG.md 파싱
|
||||
// ========================================================================
|
||||
|
||||
// 참고: GitHub API 헤더/상태코드/URL 해석/다운로드 로직은 `GithubHelper`로 이관되어
|
||||
// `GithubHelperTest`에서 Http::fake() 기반으로 검증합니다. CoreUpdateService에 있던
|
||||
// buildGithubHeaders / extractHttpStatusCode / resolveGithubArchiveUrl / downloadArchive
|
||||
// 중복 구현은 제거되었습니다. (`allow_url_fopen=Off` 대응)
|
||||
|
||||
/**
|
||||
* CHANGELOG.md 파일을 파싱하여 올바른 버전 엔트리를 반환하는지 검증합니다.
|
||||
*/
|
||||
@@ -529,7 +475,7 @@ MD;
|
||||
// 파일 내용 검증
|
||||
$content = File::get($reportPath);
|
||||
|
||||
$this->assertStringContainsString('G7 코어 업데이트 실패 리포트', $content);
|
||||
$this->assertStringContainsString('그누보드7 코어 업데이트 실패 리포트', $content);
|
||||
$this->assertStringContainsString('시작 버전: 1.0.0', $content);
|
||||
$this->assertStringContainsString('대상 버전: 2.0.0', $content);
|
||||
$this->assertStringContainsString('테스트 오류 메시지', $content);
|
||||
@@ -908,28 +854,6 @@ MD;
|
||||
$this->assertEquals('Hello World', File::get($extractDir.DIRECTORY_SEPARATOR.'test-dir'.DIRECTORY_SEPARATOR.'hello.txt'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// resolveGithubArchiveUrl() — GitHub URL 해석
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* resolveGithubArchiveUrl이 유효하지 않은 태그에 대해 null을 반환하는지 검증합니다.
|
||||
*/
|
||||
public function test_resolve_github_archive_url_returns_null_for_invalid_tag(): void
|
||||
{
|
||||
$authHeaders = $this->invokeProtectedMethod($this->service, 'buildGithubHeaders', ['']);
|
||||
|
||||
$result = $this->invokeProtectedMethod($this->service, 'resolveGithubArchiveUrl', [
|
||||
'nonexistent-owner',
|
||||
'nonexistent-repo',
|
||||
'999.999.999',
|
||||
'zipball',
|
||||
$authHeaders,
|
||||
]);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// validatePendingUpdate() — 패키지 검증 (G7 프로젝트 확인)
|
||||
// ========================================================================
|
||||
@@ -1004,6 +928,109 @@ MD;
|
||||
return $reflection->invoke($object, ...$args);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// extractZipToPending() — 외부 ZIP 추출
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 평탄 루트 G7 ZIP 을 _pending 으로 추출하고 검증을 통과하는지 확인합니다.
|
||||
*/
|
||||
public function test_extract_zip_to_pending_flat_layout(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$pendingBase = storage_path('test_core_zip_pending_'.uniqid());
|
||||
$this->tempDirs[] = $pendingBase;
|
||||
File::ensureDirectoryExists($pendingBase);
|
||||
config(['app.update.pending_path' => $pendingBase]);
|
||||
|
||||
$zipDir = storage_path('test_core_zip_src_'.uniqid());
|
||||
$this->tempDirs[] = $zipDir;
|
||||
File::ensureDirectoryExists($zipDir);
|
||||
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'g7.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('composer.json', '{"name":"g7/core"}');
|
||||
$zip->addFromString('app/.gitkeep', '');
|
||||
$zip->addFromString('config/app.php', "<?php\nreturn ['version' => '9.9.9'];");
|
||||
$zip->close();
|
||||
|
||||
$result = $this->service->extractZipToPending($zipPath);
|
||||
$this->assertTrue(File::exists($result.DIRECTORY_SEPARATOR.'composer.json'));
|
||||
$this->assertTrue(File::isDirectory($result.DIRECTORY_SEPARATOR.'app'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 래퍼 디렉토리(owner-repo-hash/) 를 자동 감지하여 그 내부를 반환하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_zip_to_pending_unwraps_wrapper_directory(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$pendingBase = storage_path('test_core_zip_pending_wrap_'.uniqid());
|
||||
$this->tempDirs[] = $pendingBase;
|
||||
File::ensureDirectoryExists($pendingBase);
|
||||
config(['app.update.pending_path' => $pendingBase]);
|
||||
|
||||
$zipDir = storage_path('test_core_zip_wrap_src_'.uniqid());
|
||||
$this->tempDirs[] = $zipDir;
|
||||
File::ensureDirectoryExists($zipDir);
|
||||
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'g7-wrap.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('g7-core-abc123/composer.json', '{"name":"g7/core"}');
|
||||
$zip->addFromString('g7-core-abc123/app/.gitkeep', '');
|
||||
$zip->addFromString('g7-core-abc123/config/app.php', "<?php\nreturn ['version' => '7.0.1'];");
|
||||
$zip->close();
|
||||
|
||||
$result = $this->service->extractZipToPending($zipPath);
|
||||
$this->assertStringEndsWith('g7-core-abc123', $result);
|
||||
$this->assertTrue(File::exists($result.DIRECTORY_SEPARATOR.'composer.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 파일이 존재하지 않을 때 RuntimeException 이 발생하는지 검증합니다.
|
||||
*/
|
||||
public function test_extract_zip_to_pending_throws_when_zip_missing(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->service->extractZipToPending(storage_path('nonexistent_'.uniqid().'.zip'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 내용이 G7 패키지가 아닐 때 검증에서 RuntimeException 이 발생하는지 확인합니다.
|
||||
*/
|
||||
public function test_extract_zip_to_pending_throws_for_invalid_package(): void
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive not available');
|
||||
}
|
||||
|
||||
$pendingBase = storage_path('test_core_zip_pending_invalid_'.uniqid());
|
||||
$this->tempDirs[] = $pendingBase;
|
||||
File::ensureDirectoryExists($pendingBase);
|
||||
config(['app.update.pending_path' => $pendingBase]);
|
||||
|
||||
$zipDir = storage_path('test_core_zip_invalid_src_'.uniqid());
|
||||
$this->tempDirs[] = $zipDir;
|
||||
File::ensureDirectoryExists($zipDir);
|
||||
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'invalid.zip';
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE);
|
||||
$zip->addFromString('README.md', '# not g7');
|
||||
$zip->close();
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->service->extractZipToPending($zipPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* applyUpdate가 소스에 없는 파일을 삭제하는지 검증합니다.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* CoreUpdateService::runVendorInstallInPending() 검증.
|
||||
*
|
||||
* VendorResolver 경유로 Composer/Bundled 모드 분기가 올바르게 동작하는지 확인.
|
||||
*/
|
||||
class CoreUpdateServiceVendorModeTest extends TestCase
|
||||
{
|
||||
private CoreUpdateService $service;
|
||||
|
||||
private string $pendingDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(CoreUpdateService::class);
|
||||
$this->pendingDir = storage_path('app/test-core-vendor-pending-'.uniqid());
|
||||
File::ensureDirectoryExists($this->pendingDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->pendingDir)) {
|
||||
File::deleteDirectory($this->pendingDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 _pending 디렉토리에 vendor-bundle.zip 생성.
|
||||
*/
|
||||
private function createPendingWithBundle(): void
|
||||
{
|
||||
File::put($this->pendingDir.'/composer.json', json_encode([
|
||||
'name' => 'test/core',
|
||||
'require' => ['php' => '^8.2'],
|
||||
]));
|
||||
|
||||
$zipPath = $this->pendingDir.'/vendor-bundle.zip';
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('vendor/autoload.php', '<?php // test autoload');
|
||||
$zip->addFromString('vendor/laravel/framework/src/file.php', '<?php // test');
|
||||
$zip->close();
|
||||
|
||||
File::put($this->pendingDir.'/vendor-bundle.json', json_encode([
|
||||
'schema_version' => VendorBundler::SCHEMA_VERSION,
|
||||
'zip_sha256' => hash_file('sha256', $zipPath),
|
||||
'package_count' => 1,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_bundled_mode_extracts_vendor_bundle_zip(): void
|
||||
{
|
||||
$this->createPendingWithBundle();
|
||||
|
||||
$result = $this->service->runVendorInstallInPending($this->pendingDir, VendorMode::Bundled);
|
||||
|
||||
$this->assertSame(VendorMode::Bundled, $result->mode);
|
||||
$this->assertSame('bundled', $result->strategy);
|
||||
$this->assertFileExists($this->pendingDir.'/vendor/autoload.php');
|
||||
}
|
||||
|
||||
public function test_bundled_mode_fails_when_no_bundle_present(): void
|
||||
{
|
||||
File::put($this->pendingDir.'/composer.json', json_encode(['name' => 'test/core']));
|
||||
|
||||
$this->expectException(VendorInstallException::class);
|
||||
$this->service->runVendorInstallInPending($this->pendingDir, VendorMode::Bundled);
|
||||
}
|
||||
|
||||
public function test_auto_mode_prefers_bundle_when_composer_cannot_execute(): void
|
||||
{
|
||||
$this->createPendingWithBundle();
|
||||
|
||||
// canExecuteComposer를 직접 제어할 수 없으므로 bundle 존재를 전제로
|
||||
// Auto가 composer 가능하면 composer 콜백을 호출하려 시도할 것이고,
|
||||
// composer 콜백은 실패할 것 — 대신 bundled로 해결되는 경로를 Bundled 명시로 시뮬레이션
|
||||
$result = $this->service->runVendorInstallInPending($this->pendingDir, VendorMode::Bundled);
|
||||
|
||||
$this->assertSame('bundled', $result->strategy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Storage\CoreStorageDriver;
|
||||
use App\Extension\Storage\ModuleStorageDriver;
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use App\Services\DriverRegistryService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 드라이버 확장 시스템 통합 테스트
|
||||
*
|
||||
* 플러그인이 필터 훅으로 새 드라이버를 등록했을 때,
|
||||
* 각 카테고리별로 실제 동작이 정상 수행되는지 검증합니다.
|
||||
*/
|
||||
class DriverExtensionIntegrationTest extends TestCase
|
||||
{
|
||||
private DriverRegistryService $driverRegistry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->driverRegistry = new DriverRegistryService();
|
||||
|
||||
// 테스트용 디렉토리 정리
|
||||
Storage::disk('modules')->deleteDirectory('test-driver-module');
|
||||
Storage::disk('plugins')->deleteDirectory('test-driver-plugin');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Storage::disk('modules')->deleteDirectory('test-driver-module');
|
||||
Storage::disk('plugins')->deleteDirectory('test-driver-plugin');
|
||||
|
||||
// 캐시 정리
|
||||
Cache::flush();
|
||||
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 공통: 플러그인 드라이버 등록 + 사용 가능 여부
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 7개 카테고리 모두에 플러그인 드라이버를 등록하면 available 목록에 포함되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function plugin_can_register_drivers_for_all_categories(): void
|
||||
{
|
||||
$categories = ['storage', 'cache', 'session', 'queue', 'log', 'websocket', 'mail'];
|
||||
|
||||
foreach ($categories as $category) {
|
||||
HookManager::addFilter(
|
||||
"core.settings.available_{$category}_drivers",
|
||||
function (array $drivers) use ($category) {
|
||||
$drivers[] = [
|
||||
'id' => "test_plugin_{$category}",
|
||||
'label' => ['ko' => "테스트 {$category}", 'en' => "Test {$category}"],
|
||||
'provider' => 'sirsoft-test_plugin',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$available = $this->driverRegistry->getAvailableDrivers($category);
|
||||
$ids = array_column($available, 'id');
|
||||
|
||||
$this->assertContains(
|
||||
"test_plugin_{$category}",
|
||||
$ids,
|
||||
"{$category} 카테고리에 플러그인 드라이버가 등록되지 않았습니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 드라이버 등록 후 apply_driver_config 액션 훅이 올바른 파라미터로 발행되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function apply_driver_config_hook_fires_with_correct_params(): void
|
||||
{
|
||||
$firedParams = [];
|
||||
|
||||
HookManager::addFilter('core.settings.available_cache_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_redis_cluster',
|
||||
'label' => ['ko' => 'Redis 클러스터', 'en' => 'Redis Cluster'],
|
||||
'provider' => 'sirsoft-redis_cluster',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) use (&$firedParams) {
|
||||
$firedParams = compact('category', 'driver', 'settings');
|
||||
});
|
||||
|
||||
// 시뮬레이션: CoreServiceProvider가 플러그인 드라이버 선택 시 훅 발행
|
||||
$category = 'cache';
|
||||
$selectedDriver = 'plugin_redis_cluster';
|
||||
$settings = ['cache_driver' => 'plugin_redis_cluster'];
|
||||
|
||||
if ($this->driverRegistry->isDriverAvailable($category, $selectedDriver)) {
|
||||
HookManager::doAction('core.settings.apply_driver_config', $category, $selectedDriver, $settings);
|
||||
}
|
||||
|
||||
$this->assertEquals('cache', $firedParams['category']);
|
||||
$this->assertEquals('plugin_redis_cluster', $firedParams['driver']);
|
||||
$this->assertEquals(['cache_driver' => 'plugin_redis_cluster'], $firedParams['settings']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Storage 드라이버: 플러그인이 커스텀 스토리지 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 스토리지 드라이버를 등록하고, apply_driver_config으로 Config을 적용하면
|
||||
* 코어 StorageDriver로 실제 파일 읽기/쓰기가 가능한지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function storage_driver_plugin_core_file_read_write(): void
|
||||
{
|
||||
$this->registerStoragePlugin();
|
||||
$this->applyPluginStorageConfig();
|
||||
|
||||
// 코어 StorageDriver로 파일 쓰기/읽기
|
||||
$coreStorage = new CoreStorageDriver(Config::get('filesystems.default'));
|
||||
$coreStorage->put('temp', 'core-test.txt', 'core-content');
|
||||
|
||||
$this->assertTrue($coreStorage->exists('temp', 'core-test.txt'));
|
||||
$this->assertEquals('core-content', $coreStorage->get('temp', 'core-test.txt'));
|
||||
|
||||
// 정리
|
||||
$coreStorage->delete('temp', 'core-test.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 스토리지 드라이버 등록 후 모듈 StorageDriver로 파일 읽기/쓰기가 가능한지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function storage_driver_plugin_module_file_read_write(): void
|
||||
{
|
||||
// 모듈은 자체 disk('modules')를 사용하므로 filesystems.default 변경과 독립
|
||||
$moduleStorage = new ModuleStorageDriver('test-driver-module', 'modules');
|
||||
|
||||
$moduleStorage->put('attachments', 'module-file.txt', 'module-content-123');
|
||||
|
||||
$this->assertTrue($moduleStorage->exists('attachments', 'module-file.txt'));
|
||||
$this->assertEquals('module-content-123', $moduleStorage->get('attachments', 'module-file.txt'));
|
||||
|
||||
// 다른 카테고리도 격리 확인
|
||||
$moduleStorage->put('settings', 'config.json', '{"key": "value"}');
|
||||
$this->assertEquals('{"key": "value"}', $moduleStorage->get('settings', 'config.json'));
|
||||
|
||||
// 파일 목록 확인
|
||||
$files = $moduleStorage->files('attachments');
|
||||
$this->assertNotEmpty($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 StorageDriver로 자체 파일 읽기/쓰기가 가능한지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function storage_driver_plugin_own_file_read_write(): void
|
||||
{
|
||||
$pluginStorage = new PluginStorageDriver('test-driver-plugin', 'plugins');
|
||||
|
||||
$pluginStorage->put('data', 'analytics.json', '{"visits": 100}');
|
||||
|
||||
$this->assertTrue($pluginStorage->exists('data', 'analytics.json'));
|
||||
$this->assertEquals('{"visits": 100}', $pluginStorage->get('data', 'analytics.json'));
|
||||
|
||||
// 삭제 검증
|
||||
$pluginStorage->delete('data', 'analytics.json');
|
||||
$this->assertFalse($pluginStorage->exists('data', 'analytics.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 스토리지 드라이버 변경 시 모듈/플러그인 StorageDriver 경로가 격리되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function storage_driver_change_does_not_affect_module_plugin_isolation(): void
|
||||
{
|
||||
$moduleStorage = new ModuleStorageDriver('test-driver-module', 'modules');
|
||||
$pluginStorage = new PluginStorageDriver('test-driver-plugin', 'plugins');
|
||||
|
||||
// 동일 파일명이지만 격리된 경로
|
||||
$moduleStorage->put('settings', 'shared-name.txt', 'module-data');
|
||||
$pluginStorage->put('settings', 'shared-name.txt', 'plugin-data');
|
||||
|
||||
$this->assertEquals('module-data', $moduleStorage->get('settings', 'shared-name.txt'));
|
||||
$this->assertEquals('plugin-data', $pluginStorage->get('settings', 'shared-name.txt'));
|
||||
|
||||
// 기본 스토리지 드라이버 변경이 모듈/플러그인에 영향 없음
|
||||
Config::set('filesystems.default', 'local');
|
||||
$this->assertEquals('module-data', $moduleStorage->get('settings', 'shared-name.txt'));
|
||||
$this->assertEquals('plugin-data', $pluginStorage->get('settings', 'shared-name.txt'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Cache 드라이버: 플러그인이 커스텀 캐시 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 기본 캐시 드라이버(file)로 코어 캐시 읽기/쓰기가 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function cache_driver_core_read_write(): void
|
||||
{
|
||||
Cache::put('core_test_key', 'core_value', 60);
|
||||
$this->assertEquals('core_value', Cache::get('core_test_key'));
|
||||
|
||||
Cache::forget('core_test_key');
|
||||
$this->assertNull(Cache::get('core_test_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 캐시 드라이버를 등록하고, 액션 훅으로 Config을 적용하면
|
||||
* cache store가 전환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function cache_driver_plugin_registration_and_config_application(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_cache_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'array_custom',
|
||||
'label' => ['ko' => '어레이 커스텀', 'en' => 'Array Custom'],
|
||||
'provider' => 'sirsoft-array_cache',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
// 플러그인이 액션 훅에서 Config 적용
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'cache' && $driver === 'array_custom') {
|
||||
Config::set('cache.default', 'array');
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('cache', 'array_custom'));
|
||||
|
||||
// 액션 훅 발행 시뮬레이션
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'cache', 'array_custom', ['cache_driver' => 'array_custom']);
|
||||
|
||||
$this->assertEquals('array', Config::get('cache.default'));
|
||||
|
||||
// array 드라이버로 실제 캐시 동작 검증
|
||||
Cache::store('array')->put('plugin_test', 'plugin_value', 60);
|
||||
$this->assertEquals('plugin_value', Cache::store('array')->get('plugin_test'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈/플러그인이 자체 캐시 키 네임스페이스로 독립적으로 캐시를 사용하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function cache_driver_module_plugin_isolation(): void
|
||||
{
|
||||
// 코어, 모듈, 플러그인이 동일 캐시 드라이버를 사용해도 키로 격리
|
||||
Cache::put('core:settings:version', '1.0', 60);
|
||||
Cache::put('module:sirsoft-ecommerce:products_count', 42, 60);
|
||||
Cache::put('plugin:sirsoft-payment:config', '{"key": "secret"}', 60);
|
||||
|
||||
$this->assertEquals('1.0', Cache::get('core:settings:version'));
|
||||
$this->assertEquals(42, Cache::get('module:sirsoft-ecommerce:products_count'));
|
||||
$this->assertEquals('{"key": "secret"}', Cache::get('plugin:sirsoft-payment:config'));
|
||||
|
||||
// 코어 캐시 삭제가 모듈/플러그인에 영향 없음
|
||||
Cache::forget('core:settings:version');
|
||||
$this->assertNull(Cache::get('core:settings:version'));
|
||||
$this->assertEquals(42, Cache::get('module:sirsoft-ecommerce:products_count'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Session 드라이버: 플러그인이 커스텀 세션 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 세션 드라이버를 등록하고 Config이 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function session_driver_plugin_registration_and_config(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_session_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_session',
|
||||
'label' => ['ko' => '커스텀 세션', 'en' => 'Custom Session'],
|
||||
'provider' => 'sirsoft-session_plugin',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('session', 'custom_session'));
|
||||
$this->assertFalse($this->driverRegistry->isCoreDriver('session', 'custom_session'));
|
||||
|
||||
// Config 적용 시뮬레이션
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'session' && $driver === 'custom_session') {
|
||||
Config::set('session.driver', 'array');
|
||||
Config::set('session.lifetime', 180);
|
||||
}
|
||||
});
|
||||
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'session', 'custom_session', []);
|
||||
|
||||
$this->assertEquals('array', Config::get('session.driver'));
|
||||
$this->assertEquals(180, Config::get('session.lifetime'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 드라이버 변경 후 실제 세션 읽기/쓰기가 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function session_driver_actual_read_write_after_config(): void
|
||||
{
|
||||
// array 드라이버로 세션 테스트 (외부 의존성 없음)
|
||||
Config::set('session.driver', 'array');
|
||||
|
||||
$session = app('session.store');
|
||||
$session->put('test_key', 'session_value');
|
||||
|
||||
$this->assertEquals('session_value', $session->get('test_key'));
|
||||
|
||||
$session->forget('test_key');
|
||||
$this->assertNull($session->get('test_key'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Queue 드라이버: 플러그인이 커스텀 큐 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 큐 드라이버를 등록하고 Config이 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function queue_driver_plugin_registration_and_config(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_queue_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_queue',
|
||||
'label' => ['ko' => '커스텀 큐', 'en' => 'Custom Queue'],
|
||||
'provider' => 'sirsoft-queue_plugin',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('queue', 'custom_queue'));
|
||||
|
||||
// Config 적용 시뮬레이션
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'queue' && $driver === 'custom_queue') {
|
||||
Config::set('queue.default', 'sync');
|
||||
}
|
||||
});
|
||||
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'queue', 'custom_queue', []);
|
||||
|
||||
$this->assertEquals('sync', Config::get('queue.default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* sync 큐 드라이버로 Config이 올바르게 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function queue_driver_sync_config_applied(): void
|
||||
{
|
||||
Config::set('queue.default', 'sync');
|
||||
|
||||
$this->assertEquals('sync', Config::get('queue.default'));
|
||||
|
||||
// sync 드라이버 커넥션이 유효한지 확인
|
||||
$connection = app('queue')->connection('sync');
|
||||
$this->assertNotNull($connection);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Log 드라이버: 플러그인이 커스텀 로그 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 로그 드라이버를 등록하고 Config이 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function log_driver_plugin_registration_and_config(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_log_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_log',
|
||||
'label' => ['ko' => '커스텀 로그', 'en' => 'Custom Log'],
|
||||
'provider' => 'sirsoft-log_plugin',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('log', 'custom_log'));
|
||||
|
||||
// 플러그인이 커스텀 채널 등록
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'log' && $driver === 'custom_log') {
|
||||
Config::set('logging.channels.custom_log', [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/custom_plugin.log'),
|
||||
'level' => 'debug',
|
||||
]);
|
||||
Config::set('logging.channels.stack.channels', ['custom_log']);
|
||||
}
|
||||
});
|
||||
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'log', 'custom_log', []);
|
||||
|
||||
$channels = Config::get('logging.channels.stack.channels');
|
||||
$this->assertContains('custom_log', $channels);
|
||||
$this->assertNotNull(Config::get('logging.channels.custom_log'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 로그 채널로 실제 로그 기록이 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function log_driver_actual_write_to_custom_channel(): void
|
||||
{
|
||||
$logPath = storage_path('logs/driver_test.log');
|
||||
|
||||
// 파일 정리
|
||||
if (file_exists($logPath)) {
|
||||
unlink($logPath);
|
||||
}
|
||||
|
||||
Config::set('logging.channels.driver_test', [
|
||||
'driver' => 'single',
|
||||
'path' => $logPath,
|
||||
'level' => 'debug',
|
||||
]);
|
||||
|
||||
Log::channel('driver_test')->info('플러그인 드라이버 로그 테스트', ['source' => 'integration_test']);
|
||||
|
||||
$this->assertFileExists($logPath);
|
||||
$logContent = file_get_contents($logPath);
|
||||
$this->assertStringContainsString('플러그인 드라이버 로그 테스트', $logContent);
|
||||
$this->assertStringContainsString('integration_test', $logContent);
|
||||
|
||||
// 정리
|
||||
unlink($logPath);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WebSocket 드라이버: 플러그인이 커스텀 웹소켓 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 웹소켓 드라이버를 등록하고 Config이 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function websocket_driver_plugin_registration_and_config(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_websocket_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_ws',
|
||||
'label' => ['ko' => '커스텀 웹소켓', 'en' => 'Custom WebSocket'],
|
||||
'provider' => 'sirsoft-ws_plugin',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('websocket', 'custom_ws'));
|
||||
|
||||
// 플러그인이 웹소켓 Config 적용
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'websocket' && $driver === 'custom_ws') {
|
||||
Config::set('broadcasting.default', 'custom_ws');
|
||||
Config::set('broadcasting.connections.custom_ws', [
|
||||
'driver' => 'pusher',
|
||||
'key' => 'custom-app-key',
|
||||
'options' => [
|
||||
'host' => 'ws.custom-provider.com',
|
||||
'port' => 6001,
|
||||
'scheme' => 'wss',
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'websocket', 'custom_ws', []);
|
||||
|
||||
$this->assertEquals('custom_ws', Config::get('broadcasting.default'));
|
||||
$this->assertEquals('ws.custom-provider.com', Config::get('broadcasting.connections.custom_ws.options.host'));
|
||||
$this->assertEquals(6001, Config::get('broadcasting.connections.custom_ws.options.port'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Mail 드라이버: 플러그인이 커스텀 메일 드라이버를 등록한 경우
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 플러그인이 메일 드라이버를 등록하고 Config이 적용되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function mail_driver_plugin_registration_and_config(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_mail_api',
|
||||
'label' => ['ko' => '커스텀 메일 API', 'en' => 'Custom Mail API'],
|
||||
'provider' => 'sirsoft-custom_mail',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->driverRegistry->isDriverAvailable('mail', 'custom_mail_api'));
|
||||
|
||||
// 플러그인이 메일 Config 적용
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'mail' && $driver === 'custom_mail_api') {
|
||||
Config::set('mail.default', 'custom_mail_api');
|
||||
Config::set('mail.mailers.custom_mail_api', [
|
||||
'transport' => 'log', // 테스트에서는 log transport 사용
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
HookManager::doAction('core.settings.apply_driver_config', 'mail', 'custom_mail_api', ['mailer' => 'custom_mail_api']);
|
||||
|
||||
$this->assertEquals('custom_mail_api', Config::get('mail.default'));
|
||||
$this->assertEquals('log', Config::get('mail.mailers.custom_mail_api.transport'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 메일 드라이버(log transport)로 실제 메일 발송이 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function mail_driver_actual_send_with_log_transport(): void
|
||||
{
|
||||
Config::set('mail.mailers.test_mailer', [
|
||||
'transport' => 'log',
|
||||
]);
|
||||
|
||||
// log transport는 실제 발송 없이 로그에 기록
|
||||
$mailer = app('mail.manager')->mailer('test_mailer');
|
||||
$this->assertNotNull($mailer);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 폴백 시나리오: 플러그인 드라이버 사용 불가 시 기본 드라이버로 복원
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 각 카테고리에서 플러그인 드라이버가 사용 불가능하면 기본 드라이버로 폴백하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function fallback_to_default_driver_for_all_categories(): void
|
||||
{
|
||||
$expectedDefaults = [
|
||||
'storage' => ['local', 'filesystems.default'],
|
||||
'cache' => ['file', 'cache.default'],
|
||||
'session' => ['database', 'session.driver'],
|
||||
'queue' => ['database', 'queue.default'],
|
||||
'log' => ['daily', null],
|
||||
'websocket' => ['', null],
|
||||
'mail' => ['smtp', 'mail.default'],
|
||||
];
|
||||
|
||||
foreach ($expectedDefaults as $category => [$defaultDriver, $configKey]) {
|
||||
// 등록되지 않은 드라이버 선택 시
|
||||
$unavailable = "nonexistent_{$category}_driver";
|
||||
$this->assertFalse(
|
||||
$this->driverRegistry->isDriverAvailable($category, $unavailable),
|
||||
"'{$unavailable}'가 {$category}에서 available로 잘못 판별됩니다."
|
||||
);
|
||||
|
||||
// 폴백 드라이버 확인
|
||||
$this->assertEquals(
|
||||
$defaultDriver,
|
||||
$this->driverRegistry->getDefaultDriver($category),
|
||||
"{$category} 카테고리의 기본 드라이버가 '{$defaultDriver}'가 아닙니다."
|
||||
);
|
||||
|
||||
// Config 폴백 적용 검증
|
||||
if ($configKey && $defaultDriver) {
|
||||
Config::set($configKey, $defaultDriver);
|
||||
$this->assertEquals($defaultDriver, Config::get($configKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스토리지 폴백 후 코어/모듈/플러그인 파일 조작이 여전히 정상 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function storage_fallback_preserves_file_operations(): void
|
||||
{
|
||||
// 존재하지 않는 드라이버에서 기본값 'local'로 폴백
|
||||
Config::set('filesystems.default', 'local');
|
||||
|
||||
$coreStorage = new CoreStorageDriver('local');
|
||||
$moduleStorage = new ModuleStorageDriver('test-driver-module', 'modules');
|
||||
$pluginStorage = new PluginStorageDriver('test-driver-plugin', 'plugins');
|
||||
|
||||
// 코어
|
||||
$coreStorage->put('temp', 'fallback-core.txt', 'core-fallback');
|
||||
$this->assertEquals('core-fallback', $coreStorage->get('temp', 'fallback-core.txt'));
|
||||
$coreStorage->delete('temp', 'fallback-core.txt');
|
||||
|
||||
// 모듈
|
||||
$moduleStorage->put('temp', 'fallback-module.txt', 'module-fallback');
|
||||
$this->assertEquals('module-fallback', $moduleStorage->get('temp', 'fallback-module.txt'));
|
||||
|
||||
// 플러그인
|
||||
$pluginStorage->put('temp', 'fallback-plugin.txt', 'plugin-fallback');
|
||||
$this->assertEquals('plugin-fallback', $pluginStorage->get('temp', 'fallback-plugin.txt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 폴백 후 캐시 조작이 여전히 정상 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function cache_fallback_preserves_cache_operations(): void
|
||||
{
|
||||
// 기본값 'file'로 폴백
|
||||
Config::set('cache.default', 'file');
|
||||
|
||||
Cache::put('fallback_test', 'fallback_value', 60);
|
||||
$this->assertEquals('fallback_value', Cache::get('fallback_test'));
|
||||
|
||||
Cache::forget('fallback_test');
|
||||
$this->assertNull(Cache::get('fallback_test'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 복수 플러그인 동시 등록
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 여러 플러그인이 같은 카테고리에 드라이버를 등록해도 모두 목록에 포함되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function multiple_plugins_can_register_drivers_in_same_category(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_cache_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_a_cache',
|
||||
'label' => ['ko' => 'A 캐시', 'en' => 'A Cache'],
|
||||
'provider' => 'sirsoft-plugin_a',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
HookManager::addFilter('core.settings.available_cache_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_b_cache',
|
||||
'label' => ['ko' => 'B 캐시', 'en' => 'B Cache'],
|
||||
'provider' => 'sirsoft-plugin_b',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$cacheDrivers = $this->driverRegistry->getAvailableDrivers('cache');
|
||||
$ids = array_column($cacheDrivers, 'id');
|
||||
|
||||
// 코어 + 플러그인 A + 플러그인 B 모두 포함
|
||||
$this->assertContains('file', $ids);
|
||||
$this->assertContains('redis', $ids);
|
||||
$this->assertContains('plugin_a_cache', $ids);
|
||||
$this->assertContains('plugin_b_cache', $ids);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helper Methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 테스트용 스토리지 플러그인을 등록합니다.
|
||||
*/
|
||||
private function registerStoragePlugin(): void
|
||||
{
|
||||
HookManager::addFilter('core.settings.available_storage_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'plugin_local_extended',
|
||||
'label' => ['ko' => '확장 로컬', 'en' => 'Extended Local'],
|
||||
'provider' => 'sirsoft-storage_ext',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
HookManager::addAction('core.settings.apply_driver_config', function (string $category, string $driver, array $settings) {
|
||||
if ($category === 'storage' && $driver === 'plugin_local_extended') {
|
||||
// 플러그인이 커스텀 디스크 등록 + filesystems.default 변경
|
||||
Config::set('filesystems.disks.plugin_local_extended', [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'throw' => true,
|
||||
]);
|
||||
Config::set('filesystems.default', 'plugin_local_extended');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 스토리지 플러그인의 Config을 적용합니다.
|
||||
*/
|
||||
private function applyPluginStorageConfig(): void
|
||||
{
|
||||
$category = 'storage';
|
||||
$selectedDriver = 'plugin_local_extended';
|
||||
|
||||
if ($this->driverRegistry->isDriverAvailable($category, $selectedDriver)) {
|
||||
HookManager::doAction('core.settings.apply_driver_config', $category, $selectedDriver, [
|
||||
'storage_driver' => $selectedDriver,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\DriverRegistryService;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* DriverRegistryService 단위 테스트
|
||||
*
|
||||
* 코어 드라이버 레지스트리의 핵심 기능을 검증합니다:
|
||||
* - 코어 드라이버 반환
|
||||
* - 필터 훅 통합
|
||||
* - 코어 드라이버 판별
|
||||
* - 사용 가능 여부 확인
|
||||
* - 기본 폴백 드라이버
|
||||
*/
|
||||
class DriverRegistryServiceTest extends TestCase
|
||||
{
|
||||
private DriverRegistryService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new DriverRegistryService();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 테스트에서 등록한 필터 훅 정리
|
||||
HookManager::resetAll();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 드라이버가 7개 카테고리를 모두 포함하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_all_seven_categories(): void
|
||||
{
|
||||
$categories = $this->service->getCategories();
|
||||
|
||||
$this->assertCount(7, $categories);
|
||||
$this->assertEquals(
|
||||
['storage', 'cache', 'session', 'queue', 'log', 'websocket', 'mail'],
|
||||
$categories
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 각 카테고리별 코어 드라이버가 올바르게 반환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_core_drivers_for_each_category(): void
|
||||
{
|
||||
$storageDrivers = $this->service->getAvailableDrivers('storage');
|
||||
$driverIds = array_column($storageDrivers, 'id');
|
||||
|
||||
$this->assertContains('local', $driverIds);
|
||||
$this->assertContains('s3', $driverIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 카테고리 코어 드라이버를 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_mail_core_drivers(): void
|
||||
{
|
||||
$mailDrivers = $this->service->getAvailableDrivers('mail');
|
||||
$driverIds = array_column($mailDrivers, 'id');
|
||||
|
||||
$this->assertContains('smtp', $driverIds);
|
||||
$this->assertContains('mailgun', $driverIds);
|
||||
$this->assertContains('ses', $driverIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 카테고리의 드라이버를 한 번에 반환하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_all_available_drivers_at_once(): void
|
||||
{
|
||||
$all = $this->service->getAllAvailableDrivers();
|
||||
|
||||
$this->assertArrayHasKey('storage', $all);
|
||||
$this->assertArrayHasKey('cache', $all);
|
||||
$this->assertArrayHasKey('session', $all);
|
||||
$this->assertArrayHasKey('queue', $all);
|
||||
$this->assertArrayHasKey('log', $all);
|
||||
$this->assertArrayHasKey('websocket', $all);
|
||||
$this->assertArrayHasKey('mail', $all);
|
||||
$this->assertCount(7, $all);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 드라이버 판별이 올바르게 동작하는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_identifies_core_drivers_correctly(): void
|
||||
{
|
||||
$this->assertTrue($this->service->isCoreDriver('storage', 'local'));
|
||||
$this->assertTrue($this->service->isCoreDriver('storage', 's3'));
|
||||
$this->assertTrue($this->service->isCoreDriver('mail', 'smtp'));
|
||||
|
||||
$this->assertFalse($this->service->isCoreDriver('storage', 'custom_storage'));
|
||||
$this->assertFalse($this->service->isCoreDriver('mail', 'custom_api'));
|
||||
$this->assertFalse($this->service->isCoreDriver('nonexistent', 'local'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 드라이버의 사용 가능 여부가 true로 반환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_reports_core_drivers_as_available(): void
|
||||
{
|
||||
$this->assertTrue($this->service->isDriverAvailable('cache', 'file'));
|
||||
$this->assertTrue($this->service->isDriverAvailable('cache', 'redis'));
|
||||
$this->assertTrue($this->service->isDriverAvailable('session', 'database'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록되지 않은 드라이버가 사용 불가로 판별되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_reports_unregistered_drivers_as_unavailable(): void
|
||||
{
|
||||
$this->assertFalse($this->service->isDriverAvailable('cache', 'custom_cache'));
|
||||
$this->assertFalse($this->service->isDriverAvailable('mail', 'custom_api'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 기본 폴백 드라이버가 올바르게 반환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_correct_default_drivers(): void
|
||||
{
|
||||
$this->assertEquals('local', $this->service->getDefaultDriver('storage'));
|
||||
$this->assertEquals('file', $this->service->getDefaultDriver('cache'));
|
||||
$this->assertEquals('database', $this->service->getDefaultDriver('session'));
|
||||
$this->assertEquals('database', $this->service->getDefaultDriver('queue'));
|
||||
$this->assertEquals('daily', $this->service->getDefaultDriver('log'));
|
||||
$this->assertEquals('', $this->service->getDefaultDriver('websocket'));
|
||||
$this->assertEquals('smtp', $this->service->getDefaultDriver('mail'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 존재하지 않는 카테고리의 기본 드라이버가 빈 문자열인지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_empty_string_for_unknown_category_default(): void
|
||||
{
|
||||
$this->assertEquals('', $this->service->getDefaultDriver('nonexistent'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 훅으로 플러그인 드라이버가 추가되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_includes_plugin_drivers_via_filter_hook(): void
|
||||
{
|
||||
// 플러그인이 메일 드라이버를 추가하는 필터 훅 등록
|
||||
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_api',
|
||||
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
|
||||
'provider' => 'sirsoft-custom_mail',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$mailDrivers = $this->service->getAvailableDrivers('mail');
|
||||
$driverIds = array_column($mailDrivers, 'id');
|
||||
|
||||
// 코어 드라이버 + 플러그인 드라이버
|
||||
$this->assertContains('smtp', $driverIds);
|
||||
$this->assertContains('mailgun', $driverIds);
|
||||
$this->assertContains('ses', $driverIds);
|
||||
$this->assertContains('custom_api', $driverIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 훅으로 추가된 플러그인 드라이버가 사용 가능으로 판별되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_reports_plugin_drivers_as_available_after_hook_registration(): void
|
||||
{
|
||||
$this->assertFalse($this->service->isDriverAvailable('cache', 'custom_cache'));
|
||||
|
||||
HookManager::addFilter('core.settings.available_cache_drivers', function (array $drivers) {
|
||||
$drivers[] = [
|
||||
'id' => 'custom_cache',
|
||||
'label' => ['ko' => '커스텀 캐시', 'en' => 'Custom Cache'],
|
||||
'provider' => 'sirsoft-custom_cache',
|
||||
];
|
||||
|
||||
return $drivers;
|
||||
});
|
||||
|
||||
$this->assertTrue($this->service->isDriverAvailable('cache', 'custom_cache'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 드라이버 데이터에 다국어 label이 포함되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_includes_localized_labels_in_driver_data(): void
|
||||
{
|
||||
$storageDrivers = $this->service->getAvailableDrivers('storage');
|
||||
|
||||
foreach ($storageDrivers as $driver) {
|
||||
$this->assertArrayHasKey('id', $driver);
|
||||
$this->assertArrayHasKey('label', $driver);
|
||||
$this->assertArrayHasKey('ko', $driver['label']);
|
||||
$this->assertArrayHasKey('en', $driver['label']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 설정 키 정보가 올바르게 반환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_settings_key_for_each_category(): void
|
||||
{
|
||||
$storageKey = $this->service->getSettingsKey('storage');
|
||||
$this->assertEquals(['category' => 'drivers', 'key' => 'storage_driver'], $storageKey);
|
||||
|
||||
$mailKey = $this->service->getSettingsKey('mail');
|
||||
$this->assertEquals(['category' => 'mail', 'key' => 'mailer'], $mailKey);
|
||||
|
||||
$this->assertNull($this->service->getSettingsKey('nonexistent'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 Laravel Config 키가 올바르게 반환되는지 검증합니다.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_returns_config_key_for_each_category(): void
|
||||
{
|
||||
$this->assertEquals('filesystems.default', $this->service->getConfigKey('storage'));
|
||||
$this->assertEquals('cache.default', $this->service->getConfigKey('cache'));
|
||||
$this->assertEquals('session.driver', $this->service->getConfigKey('session'));
|
||||
$this->assertEquals('queue.default', $this->service->getConfigKey('queue'));
|
||||
$this->assertEquals('mail.default', $this->service->getConfigKey('mail'));
|
||||
$this->assertNull($this->service->getConfigKey('nonexistent'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services\GeoIp;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Services\GeoIpDatabaseService;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* GeoIpDatabaseService 단위 테스트.
|
||||
*
|
||||
* HTTP 호출은 Http::fake로 모킹하고, 파일시스템 조작은 실제
|
||||
* storage/framework/testing/ 하위 임시 디렉토리에서 수행합니다.
|
||||
*/
|
||||
class GeoIpDatabaseServiceTest extends TestCase
|
||||
{
|
||||
private string $testGeoipDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->testGeoipDir = storage_path('framework/testing/geoip-test-'.uniqid());
|
||||
config([
|
||||
'geoip.database_path' => $this->testGeoipDir.'/GeoLite2-City.mmdb',
|
||||
// 테스트 시 retry 지연 최소화 (실제 운영은 config/geoip.php 기본값 사용)
|
||||
'geoip.download.retry_attempts' => 1,
|
||||
'geoip.download.retry_delay_ms' => 1,
|
||||
]);
|
||||
|
||||
if (File::isDirectory($this->testGeoipDir)) {
|
||||
File::deleteDirectory($this->testGeoipDir);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->testGeoipDir)) {
|
||||
File::deleteDirectory($this->testGeoipDir);
|
||||
}
|
||||
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 인스턴스를 생성합니다 (ConfigRepository는 mock).
|
||||
*/
|
||||
private function makeService(?ConfigRepositoryInterface $configRepo = null): GeoIpDatabaseService
|
||||
{
|
||||
$configRepo ??= Mockery::mock(ConfigRepositoryInterface::class)
|
||||
->shouldReceive('getCategory')->with('cache')->andReturn([])->byDefault()
|
||||
->shouldReceive('saveCategory')->andReturn(true)->byDefault()
|
||||
->getMock();
|
||||
|
||||
return new GeoIpDatabaseService($configRepo);
|
||||
}
|
||||
|
||||
public function test_missing_license_key_returns_error(): void
|
||||
{
|
||||
config(['geoip.license_key' => '']);
|
||||
|
||||
$service = $this->makeService();
|
||||
$result = $service->updateDatabase();
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('missing_license_key', $result['status']);
|
||||
}
|
||||
|
||||
public function test_is_license_key_configured_reflects_config(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
config(['geoip.license_key' => '']);
|
||||
$this->assertFalse($service->isLicenseKeyConfigured());
|
||||
|
||||
config(['geoip.license_key' => 'abcd1234']);
|
||||
$this->assertTrue($service->isLicenseKeyConfigured());
|
||||
}
|
||||
|
||||
public function test_build_download_url_masks_license_key(): void
|
||||
{
|
||||
config(['geoip.license_key' => 'secretKey1234567890']);
|
||||
$service = $this->makeService();
|
||||
|
||||
$masked = $service->buildDownloadUrlForDisplay(true);
|
||||
|
||||
$this->assertStringContainsString('secr', $masked);
|
||||
$this->assertStringNotContainsString('secretKey1234567890', $masked);
|
||||
$this->assertStringContainsString('****', $masked);
|
||||
}
|
||||
|
||||
public function test_mask_key_keeps_first_four_chars(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$this->assertEquals('abcd*****', $service->maskKey('abcd12345'));
|
||||
$this->assertEquals('****', $service->maskKey('abcd'));
|
||||
$this->assertEquals('**', $service->maskKey('ab'));
|
||||
}
|
||||
|
||||
public function test_update_skipped_when_recently_updated_without_force(): void
|
||||
{
|
||||
config(['geoip.license_key' => 'abcd1234']);
|
||||
|
||||
File::makeDirectory($this->testGeoipDir, 0755, true);
|
||||
$mmdbPath = $this->testGeoipDir.'/GeoLite2-City.mmdb';
|
||||
File::put($mmdbPath, 'dummy-mmdb-content');
|
||||
|
||||
$service = $this->makeService();
|
||||
$result = $service->updateDatabase(false);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('skipped', $result['status']);
|
||||
$this->assertArrayHasKey('data', $result);
|
||||
}
|
||||
|
||||
public function test_update_unauthorized_on_http_401(): void
|
||||
{
|
||||
config(['geoip.license_key' => 'invalid_key']);
|
||||
|
||||
Http::fake([
|
||||
'download.maxmind.com/*' => Http::response('', 401),
|
||||
]);
|
||||
|
||||
$service = $this->makeService();
|
||||
$result = $service->updateDatabase(true);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('unauthorized', $result['status']);
|
||||
$this->assertStringContainsString('401', $result['message']);
|
||||
}
|
||||
|
||||
public function test_update_download_failed_on_http_500(): void
|
||||
{
|
||||
config(['geoip.license_key' => 'validkey123']);
|
||||
|
||||
Http::fake([
|
||||
'download.maxmind.com/*' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
$service = $this->makeService();
|
||||
$result = $service->updateDatabase(true);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('download_failed', $result['status']);
|
||||
}
|
||||
|
||||
public function test_get_database_status_returns_missing_when_file_absent(): void
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$status = $service->getDatabaseStatus();
|
||||
|
||||
$this->assertFalse($status['exists']);
|
||||
$this->assertEquals(0, $status['file_size_bytes']);
|
||||
$this->assertNull($status['last_updated_at']);
|
||||
}
|
||||
|
||||
public function test_get_database_status_returns_metadata_when_file_exists(): void
|
||||
{
|
||||
File::makeDirectory($this->testGeoipDir, 0755, true);
|
||||
$mmdbPath = $this->testGeoipDir.'/GeoLite2-City.mmdb';
|
||||
File::put($mmdbPath, str_repeat('x', 1024));
|
||||
|
||||
$service = $this->makeService();
|
||||
$status = $service->getDatabaseStatus();
|
||||
|
||||
$this->assertTrue($status['exists']);
|
||||
$this->assertEquals(1024, $status['file_size_bytes']);
|
||||
$this->assertNotNull($status['last_updated_at']);
|
||||
}
|
||||
}
|
||||
@@ -1988,6 +1988,105 @@ class LayoutExtensionServiceTest extends TestCase
|
||||
$this->assertArrayHasKey('onAddressSelect', $injectedComponent['extensionPointProps']);
|
||||
}
|
||||
|
||||
/**
|
||||
* extension_point의 callbacks가 주입 컴포넌트에 extensionPointCallbacks로 전달되는지 테스트
|
||||
*/
|
||||
public function test_passes_extension_point_callbacks_to_injected_components(): void
|
||||
{
|
||||
LayoutExtension::create([
|
||||
'template_id' => $this->template->id,
|
||||
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
||||
'target_name' => 'editor_slot',
|
||||
'source_type' => LayoutSourceType::Plugin,
|
||||
'source_identifier' => 'sirsoft-test',
|
||||
'content' => [
|
||||
'extension_point' => 'editor_slot',
|
||||
'components' => [
|
||||
[
|
||||
'id' => 'editor_container',
|
||||
'type' => 'basic',
|
||||
'name' => 'Div',
|
||||
],
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'test_form',
|
||||
'components' => [
|
||||
[
|
||||
'id' => 'form_editor_slot',
|
||||
'type' => 'extension_point',
|
||||
'name' => 'editor_slot',
|
||||
'props' => [
|
||||
'readOnlyFields' => ['content'],
|
||||
],
|
||||
'callbacks' => [
|
||||
'onContentChange' => [
|
||||
'handler' => 'setState',
|
||||
'params' => ['target' => 'local', 'form.content' => '{{$event}}'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'data_sources' => [],
|
||||
];
|
||||
|
||||
$result = $this->service->applyExtensions($layout, $this->template->id);
|
||||
|
||||
$injectedComponent = $result['components'][0]['children'][0];
|
||||
|
||||
// props가 extensionPointProps로 전달되었는지 확인
|
||||
$this->assertArrayHasKey('extensionPointProps', $injectedComponent);
|
||||
$this->assertEquals(['content'], $injectedComponent['extensionPointProps']['readOnlyFields']);
|
||||
|
||||
// callbacks가 extensionPointCallbacks로 전달되었는지 확인
|
||||
$this->assertArrayHasKey('extensionPointCallbacks', $injectedComponent);
|
||||
$this->assertEquals('setState', $injectedComponent['extensionPointCallbacks']['onContentChange']['handler']);
|
||||
$this->assertEquals('{{$event}}', $injectedComponent['extensionPointCallbacks']['onContentChange']['params']['form.content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* callbacks가 없는 extension_point에서 extensionPointCallbacks가 생성되지 않는지 테스트
|
||||
*/
|
||||
public function test_no_callbacks_key_when_extension_point_has_no_callbacks(): void
|
||||
{
|
||||
LayoutExtension::create([
|
||||
'template_id' => $this->template->id,
|
||||
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
||||
'target_name' => 'simple_slot',
|
||||
'source_type' => LayoutSourceType::Plugin,
|
||||
'source_identifier' => 'sirsoft-test',
|
||||
'content' => [
|
||||
'extension_point' => 'simple_slot',
|
||||
'components' => [
|
||||
['id' => 'simple_component', 'type' => 'basic', 'name' => 'Div'],
|
||||
],
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'test_page',
|
||||
'components' => [
|
||||
[
|
||||
'id' => 'simple_ext_slot',
|
||||
'type' => 'extension_point',
|
||||
'name' => 'simple_slot',
|
||||
'props' => ['label' => 'test'],
|
||||
],
|
||||
],
|
||||
'data_sources' => [],
|
||||
];
|
||||
|
||||
$result = $this->service->applyExtensions($layout, $this->template->id);
|
||||
|
||||
$injectedComponent = $result['components'][0]['children'][0];
|
||||
$this->assertArrayHasKey('extensionPointProps', $injectedComponent);
|
||||
$this->assertArrayNotHasKey('extensionPointCallbacks', $injectedComponent);
|
||||
}
|
||||
|
||||
/**
|
||||
* components와 modals 양쪽에 동일 이름의 extension_point가 있을 때 모두 주입되는지 테스트
|
||||
*/
|
||||
|
||||
@@ -28,10 +28,7 @@ class LayoutResolverServiceTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
$this->layoutRepository = app(LayoutRepository::class);
|
||||
$this->resolverService = new LayoutResolverService(
|
||||
$this->layoutRepository,
|
||||
app(ModuleManager::class)
|
||||
);
|
||||
$this->resolverService = app(LayoutResolverService::class);
|
||||
|
||||
// 테스트용 템플릿 생성
|
||||
$this->template = Template::create([
|
||||
|
||||
@@ -65,7 +65,7 @@ class LayoutServiceCachingTest extends TestCase
|
||||
);
|
||||
|
||||
// Assert: 캐시에 저장되었는지 확인
|
||||
$cacheKey = "template.{$this->template->id}.layout.dashboard";
|
||||
$cacheKey = "g7:core:template.{$this->template->id}.layout.dashboard";
|
||||
$this->assertTrue(Cache::has($cacheKey));
|
||||
|
||||
// Act: 두 번째 호출 (캐시 히트)
|
||||
@@ -106,7 +106,7 @@ class LayoutServiceCachingTest extends TestCase
|
||||
// 캐시 생성
|
||||
$this->layoutService->loadAndMergeLayout($this->template->id, '_base');
|
||||
|
||||
$cacheKey = "template.{$this->template->id}.layout._base";
|
||||
$cacheKey = "g7:core:template.{$this->template->id}.layout._base";
|
||||
$this->assertTrue(Cache::has($cacheKey));
|
||||
|
||||
// Act: 캐시 무효화
|
||||
@@ -173,9 +173,9 @@ class LayoutServiceCachingTest extends TestCase
|
||||
$this->layoutService->loadAndMergeLayout($this->template->id, 'dashboard');
|
||||
$this->layoutService->loadAndMergeLayout($this->template->id, 'dashboard_custom');
|
||||
|
||||
$baseCacheKey = "template.{$this->template->id}.layout._base";
|
||||
$dashboardCacheKey = "template.{$this->template->id}.layout.dashboard";
|
||||
$customCacheKey = "template.{$this->template->id}.layout.dashboard_custom";
|
||||
$baseCacheKey = "g7:core:template.{$this->template->id}.layout._base";
|
||||
$dashboardCacheKey = "g7:core:template.{$this->template->id}.layout.dashboard";
|
||||
$customCacheKey = "g7:core:template.{$this->template->id}.layout.dashboard_custom";
|
||||
|
||||
$this->assertTrue(Cache::has($baseCacheKey));
|
||||
$this->assertTrue(Cache::has($dashboardCacheKey));
|
||||
@@ -241,7 +241,7 @@ class LayoutServiceCachingTest extends TestCase
|
||||
// Act: 첫 호출로 캐시 생성
|
||||
$result1 = $this->layoutService->loadAndMergeLayout($this->template->id, '_base');
|
||||
|
||||
$cacheKey = "template.{$this->template->id}.layout._base";
|
||||
$cacheKey = "g7:core:template.{$this->template->id}.layout._base";
|
||||
$this->assertTrue(Cache::has($cacheKey));
|
||||
|
||||
// 캐시 수동 삭제 (만료 시뮬레이션)
|
||||
@@ -295,8 +295,8 @@ class LayoutServiceCachingTest extends TestCase
|
||||
$this->layoutService->loadAndMergeLayout($this->template->id, 'dashboard');
|
||||
|
||||
// Assert: 부모와 자식 모두 캐싱됨
|
||||
$baseCacheKey = "template.{$this->template->id}.layout._base";
|
||||
$dashboardCacheKey = "template.{$this->template->id}.layout.dashboard";
|
||||
$baseCacheKey = "g7:core:template.{$this->template->id}.layout._base";
|
||||
$dashboardCacheKey = "g7:core:template.{$this->template->id}.layout.dashboard";
|
||||
|
||||
$this->assertTrue(Cache::has($baseCacheKey));
|
||||
$this->assertTrue(Cache::has($dashboardCacheKey));
|
||||
|
||||
@@ -1192,4 +1192,120 @@ class LayoutServiceTest extends TestCase
|
||||
// 첫 번째 DOT에서 매칭
|
||||
$this->assertTrue($method->invoke($this->layoutService, 'sirsoft-sample.admin.sub'));
|
||||
}
|
||||
|
||||
/**
|
||||
* transition_overlay 병합 - 자식이 wait_for 만 명시해도 부모의 spinner 설정이 보존되어야 함
|
||||
*
|
||||
* 이슈 #245 — engine-v1.30.0
|
||||
*/
|
||||
public function test_merge_transition_overlay_shallow_merge_preserves_parent_keys(): void
|
||||
{
|
||||
$parent = [
|
||||
'transition_overlay' => [
|
||||
'enabled' => true,
|
||||
'style' => 'spinner',
|
||||
'target' => 'main_content',
|
||||
'spinner' => ['component' => 'PageLoading'],
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$child = [
|
||||
'transition_overlay' => [
|
||||
'wait_for' => ['settings'],
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$result = $this->layoutService->mergeLayouts($parent, $child);
|
||||
|
||||
$this->assertSame(true, $result['transition_overlay']['enabled']);
|
||||
$this->assertSame('spinner', $result['transition_overlay']['style']);
|
||||
$this->assertSame('main_content', $result['transition_overlay']['target']);
|
||||
$this->assertSame(['component' => 'PageLoading'], $result['transition_overlay']['spinner']);
|
||||
$this->assertSame(['settings'], $result['transition_overlay']['wait_for']);
|
||||
}
|
||||
|
||||
/**
|
||||
* transition_overlay 병합 - 자식이 동일 키를 명시하면 자식이 우선
|
||||
*/
|
||||
public function test_merge_transition_overlay_child_overrides_parent_keys(): void
|
||||
{
|
||||
$parent = [
|
||||
'transition_overlay' => [
|
||||
'enabled' => true,
|
||||
'style' => 'spinner',
|
||||
'target' => 'main_content',
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$child = [
|
||||
'transition_overlay' => [
|
||||
'target' => 'tab_content',
|
||||
'wait_for' => ['tab_data'],
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$result = $this->layoutService->mergeLayouts($parent, $child);
|
||||
|
||||
$this->assertSame('tab_content', $result['transition_overlay']['target']);
|
||||
$this->assertSame('spinner', $result['transition_overlay']['style']);
|
||||
$this->assertSame(['tab_data'], $result['transition_overlay']['wait_for']);
|
||||
}
|
||||
|
||||
/**
|
||||
* transition_overlay 병합 - 부모만 정의된 경우 자식 폴백
|
||||
*/
|
||||
public function test_merge_transition_overlay_parent_only_fallback(): void
|
||||
{
|
||||
$parent = [
|
||||
'transition_overlay' => [
|
||||
'enabled' => true,
|
||||
'style' => 'spinner',
|
||||
'target' => 'main_content',
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$child = [
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$result = $this->layoutService->mergeLayouts($parent, $child);
|
||||
|
||||
$this->assertSame('main_content', $result['transition_overlay']['target']);
|
||||
}
|
||||
|
||||
/**
|
||||
* transition_overlay 병합 - boolean 케이스는 shallow merge 가 의미 없으므로 자식 우선
|
||||
*/
|
||||
public function test_merge_transition_overlay_boolean_uses_child_priority(): void
|
||||
{
|
||||
$parent = [
|
||||
'transition_overlay' => [
|
||||
'enabled' => true,
|
||||
'style' => 'spinner',
|
||||
],
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$child = [
|
||||
'transition_overlay' => false,
|
||||
'data_sources' => [],
|
||||
'components' => [],
|
||||
];
|
||||
|
||||
$result = $this->layoutService->mergeLayouts($parent, $child);
|
||||
|
||||
$this->assertSame(false, $result['transition_overlay']);
|
||||
}
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\MailSendStatus;
|
||||
use App\Models\MailSendLog;
|
||||
use App\Services\MailSendLogService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailSendLogService 테스트
|
||||
*
|
||||
* 메일 발송 이력 서비스의 로깅, 조회, 통계 메서드를 검증합니다.
|
||||
*/
|
||||
class MailSendLogServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private MailSendLogService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(MailSendLogService::class);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// logSent
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 성공 이력을 기록합니다
|
||||
*/
|
||||
public function test_log_sent_creates_sent_record(): void
|
||||
{
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'user@example.com',
|
||||
recipientName: 'John Doe',
|
||||
subject: 'Welcome',
|
||||
templateType: 'welcome',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
source: 'notification',
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(MailSendLog::class, $log);
|
||||
$this->assertTrue($log->exists);
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'recipient_name' => 'John Doe',
|
||||
'subject' => 'Welcome',
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => ExtensionOwnerType::Core->value,
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 기본값으로 extensionType=Core, extensionIdentifier='core', source=null을 사용
|
||||
*/
|
||||
public function test_log_sent_uses_default_values(): void
|
||||
{
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'test@example.com',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'recipient_name' => null,
|
||||
'subject' => null,
|
||||
'template_type' => null,
|
||||
'extension_type' => ExtensionOwnerType::Core->value,
|
||||
'extension_identifier' => 'core',
|
||||
'source' => null,
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 sent_at을 자동 설정
|
||||
*/
|
||||
public function test_log_sent_sets_sent_at(): void
|
||||
{
|
||||
$log = $this->service->logSent(recipientEmail: 'test@example.com');
|
||||
|
||||
$this->assertNotNull($log->sent_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 body를 기록합니다
|
||||
*/
|
||||
public function test_log_sent_records_body(): void
|
||||
{
|
||||
$body = '<p>Welcome to G7!</p>';
|
||||
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'user@example.com',
|
||||
subject: 'Welcome',
|
||||
body: $body,
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 body가 null일 수 있음
|
||||
*/
|
||||
public function test_log_sent_allows_null_body(): void
|
||||
{
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'test@example.com',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'body' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 발송자 정보를 기록합니다
|
||||
*/
|
||||
public function test_log_sent_records_sender(): void
|
||||
{
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'user@example.com',
|
||||
subject: 'Welcome',
|
||||
senderEmail: 'admin@example.com',
|
||||
senderName: 'G7 Admin',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'sender_email' => 'admin@example.com',
|
||||
'sender_name' => 'G7 Admin',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSent는 발송자 정보가 null일 수 있음
|
||||
*/
|
||||
public function test_log_sent_allows_null_sender(): void
|
||||
{
|
||||
$log = $this->service->logSent(
|
||||
recipientEmail: 'test@example.com',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'sender_email' => null,
|
||||
'sender_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// logFailed
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 실패 이력을 기록합니다
|
||||
*/
|
||||
public function test_log_failed_creates_failed_record(): void
|
||||
{
|
||||
$log = $this->service->logFailed(
|
||||
recipientEmail: 'user@example.com',
|
||||
subject: 'Order Confirmation',
|
||||
templateType: 'order_confirmed',
|
||||
extensionType: ExtensionOwnerType::Module,
|
||||
extensionIdentifier: 'sirsoft-ecommerce',
|
||||
source: 'notification',
|
||||
errorMessage: 'Connection timeout',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'subject' => 'Order Confirmation',
|
||||
'template_type' => 'order_confirmed',
|
||||
'extension_type' => ExtensionOwnerType::Module->value,
|
||||
'extension_identifier' => 'sirsoft-ecommerce',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
'error_message' => 'Connection timeout',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logFailed는 body를 기록합니다
|
||||
*/
|
||||
public function test_log_failed_records_body(): void
|
||||
{
|
||||
$body = '<p>Order Confirmation</p>';
|
||||
|
||||
$log = $this->service->logFailed(
|
||||
recipientEmail: 'user@example.com',
|
||||
subject: 'Order',
|
||||
errorMessage: 'SMTP error',
|
||||
body: $body,
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'body' => $body,
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logFailed는 발송자 정보를 기록합니다
|
||||
*/
|
||||
public function test_log_failed_records_sender(): void
|
||||
{
|
||||
$log = $this->service->logFailed(
|
||||
recipientEmail: 'user@example.com',
|
||||
errorMessage: 'SMTP error',
|
||||
senderEmail: 'noreply@example.com',
|
||||
senderName: 'G7 System',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'sender_email' => 'noreply@example.com',
|
||||
'sender_name' => 'G7 System',
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logFailed는 error_message가 null일 수 있음
|
||||
*/
|
||||
public function test_log_failed_allows_null_error_message(): void
|
||||
{
|
||||
$log = $this->service->logFailed(
|
||||
recipientEmail: 'test@example.com',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'test@example.com',
|
||||
'status' => MailSendStatus::Failed->value,
|
||||
'error_message' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// logSkipped
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 건너뛰기 이력을 기록합니다
|
||||
*/
|
||||
public function test_log_skipped_creates_skipped_record(): void
|
||||
{
|
||||
$log = $this->service->logSkipped(
|
||||
recipientEmail: 'user@example.com',
|
||||
recipientName: 'User',
|
||||
templateType: 'welcome',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
source: 'notification',
|
||||
errorMessage: '비활성 템플릿',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'recipient_name' => 'User',
|
||||
'subject' => null,
|
||||
'template_type' => 'welcome',
|
||||
'extension_type' => ExtensionOwnerType::Core->value,
|
||||
'extension_identifier' => 'core',
|
||||
'source' => 'notification',
|
||||
'status' => MailSendStatus::Skipped->value,
|
||||
'error_message' => '비활성 템플릿',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSkipped는 발송자 정보를 기록합니다
|
||||
*/
|
||||
public function test_log_skipped_records_sender(): void
|
||||
{
|
||||
$log = $this->service->logSkipped(
|
||||
recipientEmail: 'user@example.com',
|
||||
senderEmail: 'admin@example.com',
|
||||
senderName: 'G7 Admin',
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('mail_send_logs', [
|
||||
'recipient_email' => 'user@example.com',
|
||||
'sender_email' => 'admin@example.com',
|
||||
'sender_name' => 'G7 Admin',
|
||||
'status' => MailSendStatus::Skipped->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* logSkipped는 subject를 항상 null로 설정
|
||||
*/
|
||||
public function test_log_skipped_always_sets_null_subject(): void
|
||||
{
|
||||
$log = $this->service->logSkipped(
|
||||
recipientEmail: 'test@example.com',
|
||||
);
|
||||
|
||||
$this->assertNull($log->subject);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getLogs
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회
|
||||
*/
|
||||
public function test_get_logs_returns_paginated_result(): void
|
||||
{
|
||||
MailSendLog::factory()->count(5)->create();
|
||||
|
||||
$result = $this->service->getLogs();
|
||||
|
||||
$this->assertCount(5, $result->items());
|
||||
$this->assertEquals(5, $result->total());
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 필터 조회
|
||||
*/
|
||||
public function test_get_logs_applies_filters(): void
|
||||
{
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Core, 'core')->count(3)->create();
|
||||
MailSendLog::factory()->forExtension(ExtensionOwnerType::Module, 'sirsoft-ecommerce')->count(2)->create();
|
||||
|
||||
$result = $this->service->getLogs(['extension_type' => 'core']);
|
||||
|
||||
$this->assertCount(3, $result->items());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getStatistics
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 발송 통계 조회
|
||||
*/
|
||||
public function test_get_statistics_returns_correct_data(): void
|
||||
{
|
||||
MailSendLog::factory()->count(3)->create([
|
||||
'status' => MailSendStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
MailSendLog::factory()->failed()->count(1)->create([
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$stats = $this->service->getStatistics();
|
||||
|
||||
$this->assertEquals(4, $stats['total']);
|
||||
$this->assertEquals(3, $stats['sent']);
|
||||
$this->assertEquals(1, $stats['failed']);
|
||||
$this->assertEquals(4, $stats['today']);
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\User;
|
||||
use App\Services\MailTemplateService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* MailTemplateService 테스트
|
||||
*
|
||||
* 서비스의 CRUD, resolveTemplate 캐시, toggleActive, resetToDefault를 검증합니다.
|
||||
*/
|
||||
class MailTemplateServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private MailTemplateService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
$this->service = app(MailTemplateService::class);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getTemplatesForSettings
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 전체 템플릿 목록을 반환
|
||||
*/
|
||||
public function test_get_templates_for_settings_returns_all(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create();
|
||||
MailTemplate::factory()->withType('reset_password')->create();
|
||||
MailTemplate::factory()->withType('password_changed')->inactive()->create();
|
||||
|
||||
$result = $this->service->getTemplatesForSettings();
|
||||
|
||||
$this->assertCount(3, $result);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// updateTemplate
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 템플릿 수정이 올바르게 동작
|
||||
*/
|
||||
public function test_update_template_saves_data(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$template = MailTemplate::factory()->withType('welcome')->create();
|
||||
|
||||
$updated = $this->service->updateTemplate($template, [
|
||||
'subject' => ['ko' => '수정된 제목', 'en' => 'Updated Subject'],
|
||||
'body' => ['ko' => '<p>수정됨</p>', 'en' => '<p>Updated</p>'],
|
||||
'is_active' => true,
|
||||
], $user->id);
|
||||
|
||||
$this->assertEquals(['ko' => '수정된 제목', 'en' => 'Updated Subject'], $updated->subject);
|
||||
$this->assertFalse($updated->is_default);
|
||||
$this->assertEquals($user->id, $updated->updated_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 시 캐시가 무효화됨
|
||||
*/
|
||||
public function test_update_template_invalidates_cache(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withType('welcome')->create();
|
||||
Cache::put('mail_template:core:welcome', $template, 3600);
|
||||
|
||||
$this->service->updateTemplate($template, [
|
||||
'subject' => ['ko' => '새 제목'],
|
||||
'body' => ['ko' => '<p>새 본문</p>'],
|
||||
]);
|
||||
|
||||
$this->assertFalse(Cache::has('mail_template:core:welcome'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// toggleActive
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 활성 상태를 토글
|
||||
*/
|
||||
public function test_toggle_active_flips_state(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->create(['is_active' => true]);
|
||||
|
||||
$toggled = $this->service->toggleActive($template);
|
||||
|
||||
$this->assertFalse($toggled->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성→활성 토글
|
||||
*/
|
||||
public function test_toggle_active_activates_inactive(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->inactive()->create();
|
||||
|
||||
$toggled = $this->service->toggleActive($template);
|
||||
|
||||
$this->assertTrue($toggled->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토글 시 캐시가 무효화됨
|
||||
*/
|
||||
public function test_toggle_active_invalidates_cache(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withType('reset_password')->create();
|
||||
Cache::put('mail_template:core:reset_password', $template, 3600);
|
||||
|
||||
$this->service->toggleActive($template);
|
||||
|
||||
$this->assertFalse(Cache::has('mail_template:core:reset_password'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// resolveTemplate (캐시)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* resolveTemplate이 활성 템플릿을 반환
|
||||
*/
|
||||
public function test_resolve_template_returns_active(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create(['is_active' => true]);
|
||||
|
||||
$result = $this->service->resolveTemplate('welcome');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('welcome', $result->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveTemplate이 비활성 템플릿에 null 반환
|
||||
*/
|
||||
public function test_resolve_template_returns_null_for_inactive(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->inactive()->create();
|
||||
|
||||
$result = $this->service->resolveTemplate('welcome');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveTemplate이 결과를 캐싱
|
||||
*/
|
||||
public function test_resolve_template_caches_result(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create(['is_active' => true]);
|
||||
|
||||
$this->service->resolveTemplate('welcome');
|
||||
|
||||
$this->assertTrue(Cache::has('mail_template:core:welcome'));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// getPreview
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 미리보기가 변수를 placeholder로 치환
|
||||
*/
|
||||
public function test_get_preview_replaces_variables(): void
|
||||
{
|
||||
$data = [
|
||||
'subject' => 'Hello {name}',
|
||||
'body' => '<p>Welcome to {app_name}</p>',
|
||||
'variables' => [
|
||||
['key' => 'name'],
|
||||
['key' => 'app_name'],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->service->getPreview($data);
|
||||
|
||||
$this->assertEquals('Hello {name}', $result['subject']);
|
||||
$this->assertEquals('<p>Welcome to {app_name}</p>', $result['body']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// resetToDefault
|
||||
// ========================================================================
|
||||
|
||||
// ========================================================================
|
||||
// getTemplates (페이지네이션)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 페이지네이션된 목록을 반환
|
||||
*/
|
||||
public function test_get_templates_returns_paginated(): void
|
||||
{
|
||||
MailTemplate::factory()->count(5)->create();
|
||||
|
||||
$result = $this->service->getTemplates([], 2);
|
||||
|
||||
$this->assertEquals(5, $result->total());
|
||||
$this->assertCount(2, $result->items());
|
||||
$this->assertEquals(3, $result->lastPage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 필터가 동작
|
||||
*/
|
||||
public function test_get_templates_filters_by_search(): void
|
||||
{
|
||||
MailTemplate::factory()->withType('welcome')->create([
|
||||
'subject' => ['ko' => '환영 메일', 'en' => 'Welcome'],
|
||||
]);
|
||||
MailTemplate::factory()->withType('reset_password')->create([
|
||||
'subject' => ['ko' => '비밀번호 재설정', 'en' => 'Reset Password'],
|
||||
]);
|
||||
|
||||
$result = $this->service->getTemplates(['search' => '환영', 'search_type' => 'subject']);
|
||||
|
||||
$this->assertEquals(1, $result->total());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// resetToDefault
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 기본값으로 복원
|
||||
*/
|
||||
public function test_reset_to_default_restores_data(): void
|
||||
{
|
||||
$template = MailTemplate::factory()->withType('welcome')->create([
|
||||
'subject' => ['ko' => '사용자 수정'],
|
||||
'body' => ['ko' => '<p>수정됨</p>'],
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$defaultData = [
|
||||
'subject' => ['ko' => '원래 제목', 'en' => 'Original Subject'],
|
||||
'body' => ['ko' => '<p>원래 본문</p>', 'en' => '<p>Original Body</p>'],
|
||||
'variables' => [['key' => 'name', 'description' => '이름']],
|
||||
];
|
||||
|
||||
$result = $this->service->resetToDefault($template, $defaultData);
|
||||
|
||||
$this->assertEquals(['ko' => '원래 제목', 'en' => 'Original Subject'], $result->subject);
|
||||
$this->assertTrue($result->is_default);
|
||||
$this->assertTrue($result->is_active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\ModuleSettingsService;
|
||||
use App\Services\NotificationChannelService;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Services\SettingsService;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationChannelService 단위 테스트
|
||||
*
|
||||
* 확장(core/module/plugin) 단위 채널 전역 활성 여부 조회를 검증합니다.
|
||||
*/
|
||||
class NotificationChannelServiceTest extends TestCase
|
||||
{
|
||||
private NotificationChannelService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new NotificationChannelService;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 설정에서 채널이 활성화된 경우 true를 반환한다.
|
||||
*/
|
||||
public function test_core_channel_enabled_when_is_active_true(): void
|
||||
{
|
||||
$this->mockCoreSettings([
|
||||
['id' => 'mail', 'is_active' => true],
|
||||
['id' => 'database', 'is_active' => true],
|
||||
]);
|
||||
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 설정에서 채널이 비활성화된 경우 false를 반환한다.
|
||||
*/
|
||||
public function test_core_channel_disabled_when_is_active_false(): void
|
||||
{
|
||||
$this->mockCoreSettings([
|
||||
['id' => 'mail', 'is_active' => false],
|
||||
['id' => 'database', 'is_active' => true],
|
||||
]);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail')
|
||||
);
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'database')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정에 엔트리가 없으면 기본 활성(true) — 하위호환 + 신규 플러그인 채널 대응.
|
||||
*/
|
||||
public function test_core_channel_defaults_to_enabled_when_entry_missing(): void
|
||||
{
|
||||
$this->mockCoreSettings([
|
||||
['id' => 'mail', 'is_active' => true],
|
||||
]);
|
||||
|
||||
// slack은 설정에 없음 → 기본 true
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'slack')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* notifications.channels 키 자체가 없으면(빈 배열) 모든 채널 활성.
|
||||
*/
|
||||
public function test_core_channel_defaults_to_enabled_when_settings_empty(): void
|
||||
{
|
||||
$this->mockCoreSettings([]);
|
||||
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 경로: ModuleSettingsService를 통해 조회한다.
|
||||
*/
|
||||
public function test_module_channel_resolved_via_module_settings(): void
|
||||
{
|
||||
$moduleSettings = Mockery::mock(ModuleSettingsService::class);
|
||||
$moduleSettings->shouldReceive('get')
|
||||
->with('sirsoft-ecommerce', 'notifications.channels', [])
|
||||
->andReturn([
|
||||
['id' => 'mail', 'is_active' => false],
|
||||
['id' => 'database', 'is_active' => true],
|
||||
]);
|
||||
$this->app->instance(ModuleSettingsService::class, $moduleSettings);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->service->isChannelEnabledForExtension('module', 'sirsoft-ecommerce', 'mail')
|
||||
);
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('module', 'sirsoft-ecommerce', 'database')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 경로: PluginSettingsService를 통해 조회한다.
|
||||
*/
|
||||
public function test_plugin_channel_resolved_via_plugin_settings(): void
|
||||
{
|
||||
$pluginSettings = Mockery::mock(PluginSettingsService::class);
|
||||
$pluginSettings->shouldReceive('get')
|
||||
->with('sirsoft-slack', 'notifications.channels', [])
|
||||
->andReturn([
|
||||
['id' => 'slack', 'is_active' => true],
|
||||
]);
|
||||
$this->app->instance(PluginSettingsService::class, $pluginSettings);
|
||||
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('plugin', 'sirsoft-slack', 'slack')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 식별자가 비어 있으면 기본 활성 반환.
|
||||
*/
|
||||
public function test_module_without_identifier_returns_default_enabled(): void
|
||||
{
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('module', null, 'mail')
|
||||
);
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('module', '', 'mail')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 조합 반복 호출 시 캐시를 사용한다 (SettingsService 1회만 호출).
|
||||
*/
|
||||
public function test_memoizes_channel_enabled_lookup(): void
|
||||
{
|
||||
$settingsService = Mockery::mock(SettingsService::class);
|
||||
$settingsService->shouldReceive('getSetting')
|
||||
->once() // 캐시 검증
|
||||
->with('notifications.channels', [])
|
||||
->andReturn([
|
||||
['id' => 'mail', 'is_active' => false],
|
||||
]);
|
||||
$this->app->instance(SettingsService::class, $settingsService);
|
||||
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail');
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail');
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail');
|
||||
|
||||
$this->assertFalse(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* clearChannelEnabledCache() 호출 시 캐시가 초기화된다.
|
||||
*/
|
||||
public function test_clear_channel_enabled_cache(): void
|
||||
{
|
||||
$settingsService = Mockery::mock(SettingsService::class);
|
||||
$settingsService->shouldReceive('getSetting')
|
||||
->twice()
|
||||
->with('notifications.channels', [])
|
||||
->andReturn([['id' => 'mail', 'is_active' => true]]);
|
||||
$this->app->instance(SettingsService::class, $settingsService);
|
||||
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail');
|
||||
$this->service->clearChannelEnabledCache();
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail');
|
||||
|
||||
// 2회 호출 검증은 Mockery가 처리
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 조회 중 예외 발생 시 기본 활성(true) 반환.
|
||||
*/
|
||||
public function test_returns_default_enabled_on_settings_service_exception(): void
|
||||
{
|
||||
$settingsService = Mockery::mock(SettingsService::class);
|
||||
$settingsService->shouldReceive('getSetting')
|
||||
->andThrow(new \RuntimeException('DB unavailable'));
|
||||
$this->app->instance(SettingsService::class, $settingsService);
|
||||
|
||||
$this->assertTrue(
|
||||
$this->service->isChannelEnabledForExtension('core', 'core', 'mail')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 SettingsService 응답을 mocking 하는 헬퍼.
|
||||
*
|
||||
* @param array $channels
|
||||
*/
|
||||
private function mockCoreSettings(array $channels): void
|
||||
{
|
||||
$settingsService = Mockery::mock(SettingsService::class);
|
||||
$settingsService->shouldReceive('getSetting')
|
||||
->with('notifications.channels', [])
|
||||
->andReturn($channels);
|
||||
$this->app->instance(SettingsService::class, $settingsService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* NotificationDefinitionService 테스트
|
||||
*
|
||||
* 알림 정의 조회, 캐싱, 수정 동작을 검증합니다.
|
||||
*/
|
||||
class NotificationDefinitionServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private NotificationDefinitionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(NotificationDefinitionService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve()가 활성 정의를 반환하는지 확인
|
||||
*/
|
||||
public function test_resolve_returns_active_definition(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'welcome',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '환영', 'en' => 'Welcome'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.auth.after_register'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->service->invalidateCache('welcome');
|
||||
|
||||
$result = $this->service->resolve('welcome');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('welcome', $result->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve()가 비활성 정의를 반환하지 않는지 확인
|
||||
*/
|
||||
public function test_resolve_returns_null_for_inactive_definition(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'inactive_type',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '비활성', 'en' => 'Inactive'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'is_active' => false,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->service->invalidateCache('inactive_type');
|
||||
|
||||
$result = $this->service->resolve('inactive_type');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAllActive()가 활성 정의만 반환하는지 확인
|
||||
*/
|
||||
public function test_get_all_active_returns_only_active(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'active_one',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '활성1'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
NotificationDefinition::create([
|
||||
'type' => 'inactive_one',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '비활성1'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'is_active' => false,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$this->service->invalidateAllCache();
|
||||
|
||||
$result = $this->service->getAllActive();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('active_one', $result->first()->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* toggleActive()가 활성 상태를 반전시키는지 확인
|
||||
*/
|
||||
public function test_toggle_active_inverts_status(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'toggle_test',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '토글 테스트'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => [],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->toggleActive($definition);
|
||||
|
||||
$this->assertFalse($result->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateDefinition()이 채널과 훅을 수정하는지 확인
|
||||
*/
|
||||
public function test_update_definition_modifies_channels_and_hooks(): void
|
||||
{
|
||||
$definition = NotificationDefinition::create([
|
||||
'type' => 'update_test',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '수정 테스트'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.auth.after_register'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->updateDefinition($definition, [
|
||||
'channels' => ['mail', 'database'],
|
||||
'hooks' => ['core.auth.after_register', 'core.auth.after_login'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(['mail', 'database'], $result->channels);
|
||||
$this->assertEquals(['core.auth.after_register', 'core.auth.after_login'], $result->hooks);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user