menuRepository = Mockery::mock(MenuRepositoryInterface::class); $this->roleRepository = Mockery::mock(RoleRepositoryInterface::class); $this->helper = new ExtensionMenuSyncHelper($this->menuRepository, $this->roleRepository); } /** * Eloquent 모델 Mock 생성 헬퍼 * * makePartial()로 Eloquent의 setAttribute가 정상 동작하도록 합니다. * * @param string $modelClass 모델 클래스명 * @param array $attributes 설정할 속성 * @return \Mockery\MockInterface */ private function createModelMock(string $modelClass, array $attributes = []): \Mockery\MockInterface { $mock = Mockery::mock($modelClass)->makePartial(); foreach ($attributes as $key => $value) { $mock->{$key} = $value; } return $mock; } // ========== syncMenu 테스트 ========== /** * 신규 메뉴 생성 시 user_overrides 없이 생성되는지 테스트 */ public function test_sync_menu_creates_new_menu_without_user_overrides(): void { $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->with('board-management', ExtensionOwnerType::Module, 'sirsoft-board') ->once() ->andReturn(null); $name = ['ko' => '게시판 관리', 'en' => 'Board Management']; $newMenu = $this->createModelMock(Menu::class, ['id' => 1]); $this->menuRepository ->shouldReceive('updateOrCreate') ->once() ->with( Mockery::on(fn ($conditions) => $conditions['slug'] === 'board-management' && $conditions['extension_type'] === ExtensionOwnerType::Module && $conditions['extension_identifier'] === 'sirsoft-board'), Mockery::on(fn ($values) => $values['name'] === $name && $values['icon'] === 'fas fa-list' && $values['order'] === 10 && $values['url'] === '/admin/boards' && $values['is_active'] === true && ! array_key_exists('user_overrides', $values)) ) ->andReturn($newMenu); $result = $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $name, 'icon' => 'fas fa-list', 'order' => 10, 'url' => '/admin/boards', ], ); $this->assertSame($newMenu, $result); } /** * user_overrides에 "name"이 있으면 name 갱신을 건너뛰는지 테스트 */ public function test_sync_menu_skips_user_modified_name(): void { $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '우리 게시판', 'en' => 'Our Board'], 'icon' => 'fas fa-list', 'order' => 10, 'url' => '/admin/boards', 'user_overrides' => ['name'], // 유저가 name을 수정한 적 있음 ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(function ($data) { // name은 user_overrides에 있으므로 포함되지 않아야 함 return ! array_key_exists('name', $data) && $data['icon'] === 'fas fa-clipboard' && $data['order'] === 20 && $data['url'] === '/admin/boards/v2' && $data['parent_id'] === null && $data['is_active'] === true; }) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => ['ko' => 'Board 관리', 'en' => 'Board Admin'], 'icon' => 'fas fa-clipboard', 'order' => 20, 'url' => '/admin/boards/v2', ], ); } /** * user_overrides에 "icon"이 있으면 icon 갱신을 건너뛰는지 테스트 */ public function test_sync_menu_skips_user_modified_icon(): void { $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '게시판', 'en' => 'Board'], 'icon' => 'fas fa-star', 'order' => 10, 'url' => '/admin/boards', 'user_overrides' => ['icon'], ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $newName = ['ko' => 'Board 관리', 'en' => 'Board Admin']; $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(function ($data) use ($newName) { return $data['name'] === $newName && ! array_key_exists('icon', $data) && $data['order'] === 20 && $data['url'] === '/admin/boards/v2'; }) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $newName, 'icon' => 'fas fa-clipboard', 'order' => 20, 'url' => '/admin/boards/v2', ], ); } /** * user_overrides에 "order"가 있으면 order 갱신을 건너뛰는지 테스트 */ public function test_sync_menu_skips_user_modified_order(): void { $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '게시판', 'en' => 'Board'], 'icon' => 'fas fa-list', 'order' => 99, 'url' => '/admin/boards', 'user_overrides' => ['order'], ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $newName = ['ko' => 'Board 관리', 'en' => 'Board Admin']; $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(function ($data) use ($newName) { return $data['name'] === $newName && $data['icon'] === 'fas fa-clipboard' && ! array_key_exists('order', $data) && $data['url'] === '/admin/boards/v2'; }) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $newName, 'icon' => 'fas fa-clipboard', 'order' => 20, 'url' => '/admin/boards/v2', ], ); } /** * user_overrides에 "url"이 있으면 url 갱신을 건너뛰는지 테스트 */ public function test_sync_menu_skips_user_modified_url(): void { $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '게시판', 'en' => 'Board'], 'icon' => 'fas fa-list', 'order' => 10, 'url' => '/admin/custom-boards', 'user_overrides' => ['url'], ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $newName = ['ko' => 'Board 관리', 'en' => 'Board Admin']; $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(function ($data) use ($newName) { return $data['name'] === $newName && $data['icon'] === 'fas fa-clipboard' && $data['order'] === 20 && ! array_key_exists('url', $data); }) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $newName, 'icon' => 'fas fa-clipboard', 'order' => 20, 'url' => '/admin/boards/v2', ], ); } /** * user_overrides에 없는 필드만 갱신되는지 테스트 */ public function test_sync_menu_updates_unmodified_fields(): void { $newName = ['ko' => 'Board 관리', 'en' => 'Board Admin']; $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '게시판 관리', 'en' => 'Board Management'], 'icon' => 'fas fa-list', 'order' => 10, 'url' => '/admin/boards', 'user_overrides' => [], // 아무것도 수정 안 함 ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(fn ($data) => $data['name'] === $newName && $data['icon'] === 'fas fa-clipboard' && $data['order'] === 20 && $data['url'] === '/admin/boards/v2' && $data['parent_id'] === null && $data['is_active'] === true) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $newName, 'icon' => 'fas fa-clipboard', 'order' => 20, 'url' => '/admin/boards/v2', ], ); } /** * parent_id와 is_active는 항상 확장 정의값으로 업데이트되는지 테스트 */ public function test_sync_menu_always_updates_parent_and_active(): void { $name = ['ko' => '게시판 관리', 'en' => 'Board Management']; $existingMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'name' => ['ko' => '커스텀', 'en' => 'Custom'], 'icon' => 'fas fa-star', 'order' => 99, 'url' => '/admin/custom', 'user_overrides' => ['name', 'icon', 'order', 'url'], // 모든 필드 수정됨 ]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn($existingMenu); $this->menuRepository ->shouldReceive('update') ->once() ->with( $existingMenu, Mockery::on(function ($data) { // 보호된 필드는 포함되지 않아야 함 $noName = ! array_key_exists('name', $data); $noIcon = ! array_key_exists('icon', $data); $noOrder = ! array_key_exists('order', $data); $noUrl = ! array_key_exists('url', $data); // parent_id와 is_active는 항상 포함 $hasParent = $data['parent_id'] === 5; $hasActive = $data['is_active'] === true; return $noName && $noIcon && $noOrder && $noUrl && $hasParent && $hasActive; }) ) ->andReturn(true); $freshMenu = $this->createModelMock(Menu::class, ['id' => 1]); $existingMenu->shouldReceive('fresh')->once()->andReturn($freshMenu); $this->helper->syncMenu( slug: 'board-management', extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', newAttributes: [ 'name' => $name, 'icon' => 'fas fa-list', 'order' => 10, 'url' => '/admin/boards', ], parentId: 5, ); } // ========== syncMenuRecursive 테스트 ========== /** * 다국어 문자열 name을 배열로 역호환 변환하는지 테스트 */ public function test_sync_menu_recursive_converts_string_name_to_array(): void { $newMenu = $this->createModelMock(Menu::class, ['id' => 1]); $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->once() ->andReturn(null); $this->menuRepository ->shouldReceive('updateOrCreate') ->once() ->with( Mockery::any(), Mockery::on(function ($values) { // 문자열 name이 다국어 배열로 변환되었는지 확인 return is_array($values['name']) && $values['name']['ko'] === '게시판 관리' && $values['name']['en'] === '게시판 관리'; }) ) ->andReturn($newMenu); $result = $this->helper->syncMenuRecursive( menuData: [ 'slug' => 'board-management', 'name' => '게시판 관리', // 문자열 name 'icon' => 'fas fa-list', 'order' => 10, ], extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertSame($newMenu, $result); } /** * children 재귀 처리 테스트 */ public function test_sync_menu_recursive_handles_children(): void { $parentMenu = $this->createModelMock(Menu::class, ['id' => 10]); $childMenu = $this->createModelMock(Menu::class, ['id' => 11]); // 부모 메뉴 생성 $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->with('board-management', ExtensionOwnerType::Module, 'sirsoft-board') ->once() ->andReturn(null); $this->menuRepository ->shouldReceive('updateOrCreate') ->once() ->with( Mockery::on(fn ($c) => $c['slug'] === 'board-management'), Mockery::any() ) ->andReturn($parentMenu); // 자식 메뉴 생성 (parent_id = 10) $this->menuRepository ->shouldReceive('findBySlugAndExtension') ->with('board-list', ExtensionOwnerType::Module, 'sirsoft-board') ->once() ->andReturn(null); $this->menuRepository ->shouldReceive('updateOrCreate') ->once() ->with( Mockery::on(fn ($c) => $c['slug'] === 'board-list'), Mockery::on(fn ($v) => $v['parent_id'] === 10) ) ->andReturn($childMenu); $result = $this->helper->syncMenuRecursive( menuData: [ 'slug' => 'board-management', 'name' => ['ko' => '게시판', 'en' => 'Board'], 'icon' => 'fas fa-list', 'order' => 10, 'children' => [ [ 'slug' => 'board-list', 'name' => ['ko' => '게시판 목록', 'en' => 'Board List'], 'url' => '/admin/boards', 'order' => 1, ], ], ], extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertEquals(10, $result->id); } // ========== cleanupStaleMenus 테스트 ========== /** * stale 메뉴가 삭제되는지 테스트 (자식 포함) */ public function test_cleanup_stale_menus_deletes_stale_menus_with_children(): void { // stale 자식 메뉴 $staleChildRoles = Mockery::mock(BelongsToMany::class); $staleChildRoles->shouldReceive('detach')->once(); $staleChild = $this->createModelMock(Menu::class, [ 'id' => 11, 'slug' => 'stale-child', ]); $staleChild->shouldReceive('roles')->once()->andReturn($staleChildRoles); // stale 부모 메뉴 $staleMenuRoles = Mockery::mock(BelongsToMany::class); $staleMenuRoles->shouldReceive('detach')->once(); $staleMenuChildren = new Collection([$staleChild]); $staleMenu = $this->createModelMock(Menu::class, [ 'id' => 10, 'slug' => 'stale-menu', ]); $staleMenu->shouldReceive('roles')->once()->andReturn($staleMenuRoles); $staleMenu->children = $staleMenuChildren; // 현재 유효한 메뉴 $activeMenu = $this->createModelMock(Menu::class, [ 'id' => 1, 'slug' => 'active-menu', ]); $this->menuRepository ->shouldReceive('getMenusByExtension') ->with(ExtensionOwnerType::Module, 'sirsoft-board') ->once() ->andReturn(new Collection([$activeMenu, $staleMenu])); $this->menuRepository ->shouldReceive('delete') ->with($staleChild) ->once() ->andReturn(true); $this->menuRepository ->shouldReceive('delete') ->with($staleMenu) ->once() ->andReturn(true); $deleted = $this->helper->cleanupStaleMenus( ExtensionOwnerType::Module, 'sirsoft-board', ['active-menu'], ); // stale 부모 1 + stale 자식 1 = 2 $this->assertEquals(2, $deleted); } /** * 현재 유효한 메뉴는 보존되는지 테스트 */ public function test_cleanup_stale_menus_preserves_current_menus(): void { $menu1 = $this->createModelMock(Menu::class, ['id' => 1, 'slug' => 'menu-a']); $menu2 = $this->createModelMock(Menu::class, ['id' => 2, 'slug' => 'menu-b']); $this->menuRepository ->shouldReceive('getMenusByExtension') ->once() ->andReturn(new Collection([$menu1, $menu2])); // delete는 호출되지 않아야 함 $this->menuRepository->shouldNotReceive('delete'); $deleted = $this->helper->cleanupStaleMenus( ExtensionOwnerType::Module, 'sirsoft-board', ['menu-a', 'menu-b'], ); $this->assertEquals(0, $deleted); } // ========== collectSlugsRecursive 테스트 ========== /** * 재귀 slug 수집 테스트 */ public function test_collect_slugs_recursive(): void { $menuDataArray = [ [ 'slug' => 'board-management', 'name' => ['ko' => '게시판', 'en' => 'Board'], 'children' => [ [ 'slug' => 'board-list', 'name' => ['ko' => '목록', 'en' => 'List'], ], [ 'slug' => 'board-settings', 'name' => ['ko' => '설정', 'en' => 'Settings'], ], ], ], [ 'slug' => 'product-management', 'name' => ['ko' => '상품', 'en' => 'Product'], ], ]; $slugs = $this->helper->collectSlugsRecursive($menuDataArray); $this->assertCount(4, $slugs); $this->assertContains('board-management', $slugs); $this->assertContains('board-list', $slugs); $this->assertContains('board-settings', $slugs); $this->assertContains('product-management', $slugs); } /** * slug 없는 경우 name에서 slug 추출 테스트 */ public function test_collect_slugs_recursive_falls_back_to_name(): void { $menuDataArray = [ [ 'name' => ['ko' => '게시판', 'en' => 'Board'], // slug 없음 → name 배열의 첫 번째 값 사용 ], ]; $slugs = $this->helper->collectSlugsRecursive($menuDataArray); $this->assertCount(1, $slugs); $this->assertEquals('게시판', $slugs[0]); } /** * 문자열 name에서 slug 추출 (역호환성) */ public function test_collect_slugs_recursive_with_string_name(): void { $menuDataArray = [ [ 'name' => 'Board Management', // slug 없음 → 문자열 name 기반 배열 변환 후 첫 번째 값 ], ]; $slugs = $this->helper->collectSlugsRecursive($menuDataArray); $this->assertCount(1, $slugs); $this->assertEquals('Board Management', $slugs[0]); } }