v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -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);
}
}
-179
View File
@@ -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);
}
}
-242
View File
@@ -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']);
}
}