ActivityLogType::Admin, 'action' => 'user.create', 'description_key' => 'activity_log.description.user_create', 'description_params' => ['user_id' => 'abc-123'], 'properties' => ['key' => 'value'], 'changes' => [['field' => 'name', 'old' => null, 'new' => 'John']], 'ip_address' => '127.0.0.1', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertArrayHasKey('id', $resource); $this->assertArrayHasKey('log_type', $resource); $this->assertArrayHasKey('log_type_label', $resource); $this->assertArrayHasKey('loggable_type', $resource); $this->assertArrayHasKey('loggable_id', $resource); $this->assertArrayHasKey('action', $resource); $this->assertArrayHasKey('action_label', $resource); $this->assertArrayHasKey('localized_description', $resource); $this->assertArrayHasKey('description_key', $resource); $this->assertArrayHasKey('properties', $resource); $this->assertArrayHasKey('changes', $resource); $this->assertArrayHasKey('bulk_changes', $resource); $this->assertArrayHasKey('actor_name', $resource); $this->assertArrayHasKey('user', $resource); $this->assertArrayHasKey('ip_address', $resource); $this->assertArrayHasKey('created_at', $resource); } /** * 리소스에 description 키가 아닌 localized_description이 포함됨 */ public function test_resource_uses_localized_description_not_description(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'test', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertArrayHasKey('localized_description', $resource); $this->assertArrayNotHasKey('description', $resource); } // ======================================================================== // i18n 필드 검증 // ======================================================================== /** * localized_description에 번역된 문자열이 반환됨 */ public function test_resource_returns_translated_description(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.description.user_create' => ':user_id 사용자가 생성되었습니다', ], $locale); $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.create', 'description_key' => 'activity_log.description.user_create', 'description_params' => ['user_id' => 'test-user'], ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('test-user 사용자가 생성되었습니다', $resource['localized_description']); } /** * description_key에 raw 키가 그대로 반환됨 */ public function test_resource_returns_raw_description_key(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.create', 'description_key' => 'activity_log.description.user_create', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('activity_log.description.user_create', $resource['description_key']); } /** * action_label에 번역된 액션 라벨이 반환됨 */ public function test_resource_returns_action_label(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.action.user.create' => '사용자 생성', ], $locale); $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.create', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('사용자 생성', $resource['action_label']); } // ======================================================================== // changes 필드 검증 // ======================================================================== /** * changes에 구조화된 변경 이력이 포함되고 label이 번역됨 */ public function test_resource_includes_changes_with_translated_label(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.email' => '이메일', 'activity_log.fields.name' => '이름', ], $locale); $changes = [ ['field' => 'email', 'label_key' => 'activity_log.fields.email', 'old' => 'old@test.com', 'new' => 'new@test.com', 'type' => 'text'], ['field' => 'name', 'label_key' => 'activity_log.fields.name', 'old' => 'Old Name', 'new' => 'New Name', 'type' => 'text'], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertCount(2, $resource['changes']); $this->assertEquals('이메일', $resource['changes'][0]['label']); $this->assertEquals('이름', $resource['changes'][1]['label']); $this->assertEquals('old@test.com', $resource['changes'][0]['old']); $this->assertEquals('new@test.com', $resource['changes'][0]['new']); } /** * changes에 enum 타입 필드의 old_label/new_label이 번역됨 */ public function test_resource_translates_enum_labels_in_changes(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.status' => '상태', 'enums.status.active' => '활성', 'enums.status.inactive' => '비활성', ], $locale); $changes = [ [ 'field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'inactive', 'new' => 'active', 'type' => 'enum', 'old_label_key' => 'enums.status.inactive', 'new_label_key' => 'enums.status.active', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('상태', $resource['changes'][0]['label']); $this->assertEquals('비활성', $resource['changes'][0]['old_label']); $this->assertEquals('활성', $resource['changes'][0]['new_label']); } /** * changes에 boolean 타입 필드의 old_label/new_label이 예/아니오로 변환됨 */ public function test_resource_translates_boolean_labels_in_changes(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.is_active' => '활성 여부', ], $locale); $changes = [ [ 'field' => 'is_active', 'label_key' => 'activity_log.fields.is_active', 'old' => false, 'new' => true, 'type' => 'boolean', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('활성 여부', $resource['changes'][0]['label']); $this->assertEquals(__('common.no'), $resource['changes'][0]['old_label']); $this->assertEquals(__('common.yes'), $resource['changes'][0]['new_label']); } /** * changes에 json 타입 필드(다국어)가 현재 로케일 값으로 변환됨 */ public function test_resource_extracts_locale_value_from_json_changes(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.option_name' => '옵션명', ], $locale); $changes = [ [ 'field' => 'option_name', 'label_key' => 'activity_log.fields.option_name', 'old' => ['en' => 'Brown', 'ko' => '하나하나'], 'new' => ['en' => 'Brown', 'ko' => '원'], 'type' => 'json', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'product_option.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('옵션명', $resource['changes'][0]['label']); // 현재 로케일(ko)에 맞는 값 추출 $this->assertEquals('하나하나', $resource['changes'][0]['old']); $this->assertEquals('원', $resource['changes'][0]['new']); } /** * changes에 json 타입 필드가 한국어 로케일로 변환됨 */ public function test_resource_extracts_korean_locale_value_from_json_changes(): void { app()->setLocale('ko'); app('translator')->addLines([ 'activity_log.fields.option_name' => '옵션명', ], 'ko'); $changes = [ [ 'field' => 'option_name', 'label_key' => 'activity_log.fields.option_name', 'old' => ['en' => 'Brown', 'ko' => '하나하나'], 'new' => ['en' => 'Brown', 'ko' => '원'], 'type' => 'json', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'product_option.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('하나하나', $resource['changes'][0]['old']); $this->assertEquals('원', $resource['changes'][0]['new']); // 로케일 복원 app()->setLocale(config('app.locale')); } /** * changes에 json 타입 필드의 old가 null이면 null 그대로 반환 */ public function test_resource_handles_null_json_change_values(): void { $changes = [ [ 'field' => 'option_name', 'label_key' => 'activity_log.fields.option_name', 'old' => null, 'new' => ['en' => 'New', 'ko' => '새로운'], 'type' => 'json', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'product_option.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertNull($resource['changes'][0]['old']); $this->assertIsString($resource['changes'][0]['new']); } /** * bulk_changes에서도 json 타입 필드가 로케일 변환됨 */ public function test_resource_extracts_locale_value_from_bulk_json_changes(): void { app()->setLocale('ko'); $bulkChanges = [ 503 => [ [ 'field' => 'option_name', 'label_key' => 'activity_log.fields.option_name', 'old' => ['en' => 'Brown', 'ko' => '하나하나'], 'new' => ['en' => 'Brown', 'ko' => '원'], 'type' => 'json', ], ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'product_option.bulk_update', 'changes' => $bulkChanges, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertNotNull($resource['bulk_changes']); $this->assertEquals('하나하나', $resource['bulk_changes'][0]['changes'][0]['old']); $this->assertEquals('원', $resource['bulk_changes'][0]['changes'][0]['new']); app()->setLocale(config('app.locale')); } /** * changes가 null이면 changes, bulk_changes 모두 null로 반환됨 */ public function test_resource_returns_null_changes_when_empty(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'login', 'changes' => null, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertNull($resource['changes']); $this->assertNull($resource['bulk_changes']); } /** * 단일 수정 changes는 changes에 반환, bulk_changes는 null */ public function test_resource_returns_single_changes_in_changes_field(): void { $changes = [ ['field' => 'name', 'label_key' => 'activity_log.fields.name', 'old' => 'Old', 'new' => 'New', 'type' => 'text'], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertNotNull($resource['changes']); $this->assertNull($resource['bulk_changes']); $this->assertCount(1, $resource['changes']); } /** * 일괄 수정 changes (모델ID 기준 그룹핑)는 bulk_changes에 반환, changes는 null */ public function test_resource_returns_bulk_changes_in_bulk_changes_field(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.status' => '상태', ], $locale); $bulkChanges = [ 1 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'active', 'new' => 'inactive', 'type' => 'text'], ], 2 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'active', 'new' => 'inactive', 'type' => 'text'], ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.bulk_update', 'changes' => $bulkChanges, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertNull($resource['changes']); $this->assertNotNull($resource['bulk_changes']); $this->assertCount(2, $resource['bulk_changes']); // 각 그룹에 model_id와 번역된 changes 포함 $this->assertEquals(1, $resource['bulk_changes'][0]['model_id']); $this->assertEquals(2, $resource['bulk_changes'][1]['model_id']); $this->assertEquals('상태', $resource['bulk_changes'][0]['changes'][0]['label']); } // ======================================================================== // 하위 호환: 기존 DB 레코드 처리 // ======================================================================== /** * 기존 DB 레코드에서 type이 'text'로 저장되었지만 모델의 현재 * $activityLogFields에서 'enum'으로 정의된 필드는 동적으로 enum 라벨을 해석함 */ public function test_resource_resolves_enum_labels_from_legacy_text_type(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.fields.status' => '상태', 'user.status.active' => '활성', 'user.status.blocked' => '차단', ], $locale); // 기존 DB 레코드: type이 'text'로 저장됨 (old_label_key/new_label_key 없음) $changes = [ [ 'field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'active', 'new' => 'blocked', 'type' => 'text', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'loggable_type' => User::class, 'loggable_id' => 1, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); // type이 동적으로 'enum'으로 보정됨 $this->assertEquals('enum', $resource['changes'][0]['type']); // enum 라벨이 동적으로 해석됨 $this->assertEquals('활성', $resource['changes'][0]['old_label']); $this->assertEquals('차단', $resource['changes'][0]['new_label']); } /** * 기존 DB 레코드에서 type이 'text'이고 모델에서도 'text'인 필드는 변경 없이 유지됨 */ public function test_resource_does_not_alter_genuine_text_type_fields(): void { $changes = [ [ 'field' => 'name', 'label_key' => 'activity_log.fields.name', 'old' => 'Old Name', 'new' => 'New Name', 'type' => 'text', ], ]; $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'loggable_type' => User::class, 'loggable_id' => 1, 'action' => 'user.update', 'changes' => $changes, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('text', $resource['changes'][0]['type']); $this->assertArrayNotHasKey('old_label', $resource['changes'][0]); $this->assertArrayNotHasKey('new_label', $resource['changes'][0]); } /** * 일괄 변경 로그에서 description_params에 count가 누락된 경우 * bulk changes 개수로 :count 플레이스홀더를 보정함 */ public function test_resource_resolves_unreplaced_count_from_bulk_changes(): void { $locale = app()->getLocale(); // namespaced key ('vendor::group.key') 는 addLines 의 3번째 인자 namespace 로 전달해야 함 app('translator')->addLines([ 'activity_log.description.order_bulk_status_update' => '주문 일괄 상태 변경 (:count건)', ], $locale, 'sirsoft-ecommerce'); $bulkChanges = [ 101 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'pending', 'new' => 'shipping', 'type' => 'text'], ], 102 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'pending', 'new' => 'shipping', 'type' => 'text'], ], 103 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'pending', 'new' => 'shipping', 'type' => 'text'], ], ]; // 기존 DB 레코드: description_params에 count 없음 $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'order.bulk_status_update', 'description_key' => 'sirsoft-ecommerce::activity_log.description.order_bulk_status_update', 'description_params' => ['order_id' => 'ORD-001'], 'changes' => $bulkChanges, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('주문 일괄 상태 변경 (3건)', $resource['localized_description']); } /** * description_params에 count가 이미 있으면 그대로 사용됨 (이중 치환 없음) */ public function test_resource_does_not_double_replace_count(): void { $locale = app()->getLocale(); app('translator')->addLines([ 'activity_log.description.order_bulk_status_update' => '주문 일괄 상태 변경 (:count건)', ], $locale, 'sirsoft-ecommerce'); $bulkChanges = [ 101 => [ ['field' => 'status', 'label_key' => 'activity_log.fields.status', 'old' => 'pending', 'new' => 'shipping', 'type' => 'text'], ], ]; // 새 DB 레코드: description_params에 count가 이미 있음 $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'order.bulk_status_update', 'description_key' => 'sirsoft-ecommerce::activity_log.description.order_bulk_status_update', 'description_params' => ['order_id' => 'ORD-001', 'count' => 5], 'changes' => $bulkChanges, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); // count가 이미 치환되어 5건으로 표시 (bulk_changes 개수 1이 아닌 5) $this->assertEquals('주문 일괄 상태 변경 (5건)', $resource['localized_description']); } // ======================================================================== // user 필드 검증 // ======================================================================== /** * user가 있으면 uuid, name, email이 포함된 객체 반환 */ public function test_resource_includes_user_info_when_user_exists(): void { $user = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'user.create', 'user_id' => $user->id, ]); $log->load('user'); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertIsArray($resource['user']); $this->assertEquals($user->uuid, $resource['user']['uuid']); $this->assertEquals('Test User', $resource['user']['name']); $this->assertEquals('test@example.com', $resource['user']['email']); } /** * user가 없으면 시스템 라벨만 포함된 객체 반환 */ public function test_resource_returns_system_user_when_no_user(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::System, 'action' => 'maintenance', 'user_id' => null, ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertIsArray($resource['user']); $this->assertEquals(__('common.system'), $resource['user']['name']); $this->assertArrayNotHasKey('uuid', $resource['user']); $this->assertArrayNotHasKey('email', $resource['user']); } // ======================================================================== // 기타 필드 // ======================================================================== /** * log_type에 enum value 문자열이 반환됨 */ public function test_resource_returns_log_type_as_string_value(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'test', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('admin', $resource['log_type']); } /** * ip_address가 올바르게 반환됨 */ public function test_resource_returns_ip_address(): void { $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'login', 'ip_address' => '192.168.1.100', ]); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('192.168.1.100', $resource['ip_address']); } /** * actor_name이 리소스에 포함됨 */ public function test_resource_includes_actor_name(): void { $user = User::factory()->create(['name' => 'Admin User']); $log = ActivityLog::create([ 'log_type' => ActivityLogType::Admin, 'action' => 'test', 'user_id' => $user->id, ]); $log->load('user'); $resource = (new ActivityLogResource($log))->toArray($this->makeRequest()); $this->assertEquals('Admin User', $resource['actor_name']); } }