fix(message_bizppurio): 알림 채널 미저장 시 발송 차단 + 토글 저장·노출 정정
관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정. - 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정 - 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스 - 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지)
This commit is contained in:
+45
@@ -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);
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -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"
|
||||
},
|
||||
|
||||
+44
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
+95
@@ -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 을 더합니다.
|
||||
*
|
||||
|
||||
+67
@@ -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<int, array<string, mixed>> $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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user