fix(message_bizppurio): 알림 채널 미저장 시 발송 차단 + 토글 저장·노출 정정

관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정.

- 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의
 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정
- 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을
 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스
- 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해
 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지)
This commit is contained in:
chym1217 committed 2026-07-28 12:36:29 +09:00
1 parent 92f36e86b1
commit eb0c72c32b
9 files changed
+309 -6

No files matched your search

@@ -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);
});
});
@@ -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"
},
@@ -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);
});
});
@@ -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"
},