From eb0c72c32b00599528aa760f591e95a0644eab2a Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 22 Jul 2026 16:12:17 +0900 Subject: [PATCH] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=B1=84=EB=84=90=20=EB=AF=B8=EC=A0=80=EC=9E=A5=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=9C=EC=86=A1=20=EC=B0=A8=EB=8B=A8=20+=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=A0=80=EC=9E=A5=C2=B7=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정. - 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정 - 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스 - 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지) --- ...board-notification-channel-toggle.test.tsx | 45 +++++++++ .../_tab_notification_definitions.json | 5 +- ...commerceNotificationChannelToggle.test.tsx | 44 +++++++++ .../_tab_notification_definitions.json | 4 +- .../RegisterNotificationChannelsListener.php | 95 +++++++++++++++++++ .../ChannelRegistrationHookTest.php | 67 +++++++++++++ ...gisterNotificationChannelsListenerTest.php | 11 +++ ...admin-notification-channel-labels.test.tsx | 40 ++++++++ .../_tab_notification_definitions.json | 4 +- 9 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx new file mode 100644 index 00000000..843dcfdf --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx @@ -0,0 +1,45 @@ +// e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 게시판 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * + * @description + * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 + * `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨. + * 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 + * 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json'; + +const tabJson = JSON.stringify(tab); + +describe('게시판 알림 채널 토글 저장 upsert (#28)', () => { + it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect( + /\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson), + ).toBe(true); + }); + + it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => { + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true); + }); +}); + +describe('게시판 하단 서브탭 필터 source 분기 (#28)', () => { + it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 624e4a21..9f800b06 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -1,6 +1,7 @@ { "meta": { "is_partial": true, + "_e2e_allow": "e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28)", "description": "게시판 알림 설정 탭 — 채널 토글 + 2단 탭 + 채널별 템플릿 목록" }, "id": "tab_content_notification_definitions", @@ -115,7 +116,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +140,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx new file mode 100644 index 00000000..1965c9de --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx @@ -0,0 +1,44 @@ +/** + * 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * + * @description + * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 + * `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨. + * 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 + * 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +const tabJson = JSON.stringify(tab); + +describe('이커머스 알림 채널 토글 저장 upsert (#28)', () => { + it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect( + /\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson), + ).toBe(true); + }); + + it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => { + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true); + }); +}); + +describe('이커머스 하단 서브탭 필터 source 분기 (#28)', () => { + it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 3b5ebb93..20ee5d5e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -115,7 +115,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 239e94c8..85c61807 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Listeners; use App\Contracts\Extension\HookListenerInterface; +use App\Services\ModuleSettingsService; use App\Services\PluginSettingsService; +use App\Services\SettingsService; /** * 비즈뿌리오 문자·알림톡 채널을 코어 알림 시스템에 등록하는 필터 리스너. @@ -70,6 +72,11 @@ class RegisterNotificationChannelsListener implements HookListenerInterface 'priority' => 20, 'type' => 'filter', ], + 'core.notification.channel_enabled' => [ + 'method' => 'gateChannelEnabled', + 'priority' => 20, + 'type' => 'filter', + ], ]; foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) { @@ -144,6 +151,94 @@ class RegisterNotificationChannelsListener implements HookListenerInterface }; } + /** + * 우리 채널(sms/alimtalk)의 "미저장=비활성(OFF)" 정책을 적용합니다 + * (core.notification.channel_enabled 필터). + * + * 코어의 기본 판정은 "채널 설정 엔트리가 없으면 활성(true)" 이다(하위호환). 하지만 + * sms·alimtalk 은 플러그인이 나중에 주입한 채널이라, 관리자가 명시적으로 켜기 전에는 + * 발송·기록하지 않아야 한다(opt-in). 게스트 발송 정책 isChannelGuestAllowed 의 + * "확장 채널은 미선언=차단" 과 같은 방향이다. + * + * 따라서 우리 채널이면서 해당 확장의 notifications.channels 저장소에 엔트리가 없을 때만 + * false 로 덮어쓴다. 엔트리가 저장되어 있으면(켜짐/꺼짐 모두) 코어가 이미 그 값을 반영해 + * $enabled 로 넘겨주므로 그대로 통과시킨다. 우리 채널이 아니면 항상 원본을 통과시킨다. + * + * @param bool $enabled 코어가 계산한 활성 여부 + * @param string $extensionType 확장 타입 (core/module/plugin) + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 최종 활성 여부 + */ + public function gateChannelEnabled( + bool $enabled, + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + if (! in_array($channelId, self::CHANNEL_IDS, true)) { + return $enabled; + } + + // 저장 엔트리가 있으면 코어 판정($enabled)을 존중, 없으면 미저장 → OFF + if ($this->hasSavedChannelEntry($extensionType, $extensionIdentifier, $channelId)) { + return $enabled; + } + + return false; + } + + /** + * 해당 확장의 notifications.channels 저장소에 특정 채널 엔트리가 존재하는지 확인합니다. + * + * 저장소는 확장 타입별로 분리됩니다: + * - core → SettingsService + * - module → ModuleSettingsService + * - plugin → PluginSettingsService + * + * 조회 실패(예외)나 미지원 타입은 "미저장"(false)으로 간주해 안전측(OFF)으로 처리한다. + * + * @param string $extensionType 확장 타입 + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 저장 엔트리 존재 여부 + */ + private function hasSavedChannelEntry( + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + try { + $channels = match ($extensionType) { + 'core' => app(SettingsService::class) + ->getSetting('notifications.channels', []), + 'module' => empty($extensionIdentifier) + ? [] + : app(ModuleSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + 'plugin' => empty($extensionIdentifier) + ? [] + : app(PluginSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + default => [], + }; + } catch (\Throwable $e) { + return false; + } + + if (! is_array($channels)) { + return false; + } + + foreach ($channels as $entry) { + if (is_array($entry) && ($entry['id'] ?? null) === $channelId) { + return true; + } + } + + return false; + } + /** * 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php index 04a7c231..c104b193 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php @@ -6,6 +6,7 @@ use App\Extension\HookManager; use App\Services\ChannelReadinessService; use App\Services\NotificationChannelService; use App\Services\PluginSettingsService; +use App\Services\SettingsService; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -48,6 +49,26 @@ class ChannelRegistrationHookTest extends PluginTestCase fn ($result, $channelId) => $listener->checkReadiness($result, $channelId), 20, ); + HookManager::addFilter( + 'core.notification.channel_enabled', + fn ($enabled, $extType, $extId, $channelId) => $listener->gateChannelEnabled($enabled, $extType, $extId, $channelId), + 20, + ); + } + + /** + * 코어 SettingsService 의 notifications.channels 저장값을 지정합니다. + * + * @param array> $channels + */ + private function setCoreChannels(array $channels): void + { + $settings = \Mockery::mock(SettingsService::class); + $settings->shouldReceive('getSetting') + ->with('notifications.channels', []) + ->andReturn($channels); + $this->app->instance(SettingsService::class, $settings); + app(NotificationChannelService::class)->clearChannelEnabledCache(); } public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void @@ -94,4 +115,50 @@ class ChannelRegistrationHookTest extends PluginTestCase $this->assertTrue($result['ready']); } + + public function test_channel_enabled_미저장_sms는_off로_덮인다(): void + { + $this->registerListener(); + // mail/database 만 저장, sms/alimtalk 엔트리 없음(미저장) + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'database', 'is_active' => true], + ]); + + $service = app(NotificationChannelService::class); + + // 우리 채널: 미저장 → OFF (opt-in) + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'sms')); + $service->clearChannelEnabledCache(); + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'alimtalk')); + // 코어 기본 채널: 미저장이어도 ON 유지(하위호환) + $service->clearChannelEnabledCache(); + $this->assertTrue($service->isChannelEnabledForExtension('core', 'core', 'mail')); + } + + public function test_channel_enabled_저장된_on_sms는_on이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => true], + ]); + + $this->assertTrue( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } + + public function test_channel_enabled_저장된_off_sms는_off이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => false], + ]); + + $this->assertFalse( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php index 4e31bd6f..eb6d8a7a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -204,6 +204,7 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertArrayHasKey('core.notification.filter_available_channels', $hooks); $this->assertArrayHasKey('core.notification.channel_readiness', $hooks); + $this->assertArrayHasKey('core.notification.channel_enabled', $hooks); $this->assertArrayHasKey('core.auth.notification.channels', $hooks); $this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks); $this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks); @@ -213,4 +214,14 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertSame('filter', $config['type']); } } + + public function test_channel_enabled_우리채널이_아니면_원본을_통과시킨다(): void + { + $listener = $this->makeListener(); + + // mail/database 등 타 채널은 저장소 조회 없이 코어 판정($enabled)을 그대로 반환 + $this->assertTrue($listener->gateChannelEnabled(true, 'core', 'core', 'mail')); + $this->assertFalse($listener->gateChannelEnabled(false, 'core', 'core', 'database')); + $this->assertTrue($listener->gateChannelEnabled(true, 'module', 'sirsoft-board', 'slack')); + } } diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx index a99df300..da015400 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx @@ -82,3 +82,43 @@ describe('알림 채널 라벨 — registry payload string contract 회귀 가 expect(stringContractBindings.length).toBeGreaterThan(0); }); }); + +/** + * 채널 토글 저장 + 하단 서브탭 필터 회귀 가드 (#28) + * + * 회귀 시나리오: + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 `.some(...) ? .map(...) : [...arr, {id...}]` + * 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨 + * (사이트내 알림처럼 사라지지 않음). 코어 기본 채널(source==='core')은 미저장 시 노출 유지, + * 확장 채널은 미저장 시 숨김이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + */ +describe('채널 토글 저장 upsert + 하단 서브탭 필터 (#28)', () => { + // 표현식은 text 뿐 아니라 setState params 키 값 / iteration.source 등에도 있으므로 + // 레이아웃 전체를 직렬화해 표현식 문자열을 검사한다. + const tabJson = JSON.stringify(notifTab); + + it('채널 토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect(/\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson)).toBe( + true, + ); + }); + + it('토글 저장이 map-only(기존 엔트리만 변형) 형태가 아니다', () => { + // 옛 표현식: {{(...channels ?? []).map(...)}} 단독. some/spread-add 가 없으면 회귀. + const hasUpsert = /\.some\(c => c\.id === ch\.id\)/.test(tabJson); + expect(hasUpsert, 'map-only 저장이면 upsert(.some) 표현식이 없다 → 회귀').toBe(true); + }); + + it('하단 서브탭 필터가 코어기본/확장 채널을 source 로 분기한다', () => { + // 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 노출 + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json index 9b77bf77..22449485 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json @@ -115,7 +115,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" },