完善通知渠道稳定身份与保存交互 (#736)

This commit is contained in:
InfinityPacer
2026-08-31 07:41:33 +08:00
committed by GitHub
parent b512aee657
commit dd90d40703
8 changed files with 268 additions and 105 deletions
+2
View File
@@ -1839,6 +1839,8 @@ export interface DownloaderConf {
// 通知配置 // 通知配置
export interface NotificationConf { export interface NotificationConf {
// 稳定通知渠道身份;显示名称变化时保持不变,用于配置保存和运行态引用
id: string
// 名称 // 名称
name: string name: string
// 类型 telegram/wechat/vocechat/synologychat // 类型 telegram/wechat/vocechat/synologychat
@@ -47,6 +47,7 @@ const notificationInfoDialog = computed({
// 通知详情 // 通知详情
const notificationInfo = ref<NotificationConf>({ const notificationInfo = ref<NotificationConf>({
id: '',
name: '', name: '',
type: '', type: '',
enabled: false, enabled: false,
@@ -183,9 +184,12 @@ async function updateWechatClawBotQrImage(status?: WechatClawBotStatus | null) {
/** 组装微信客服状态接口所需的请求参数。 */ /** 组装微信客服状态接口所需的请求参数。 */
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) { function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
const config = notificationInfo.value.config || {} const config = notificationInfo.value.config || {}
// 配置身份优先于显示名称;名称仅作为旧配置尚未归一化时的明确读取回退。
const source = notificationInfo.value.id || notificationInfo.value.name
const fallbackSource = props.notification.id || props.notification.name
return { return {
source: notificationInfo.value.name, source,
fallback_source: props.notification.name, fallback_source: fallbackSource,
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL, WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET, WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS, WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
@@ -225,19 +229,24 @@ function openNotificationInfoDialog() {
/** 保存通知渠道编辑结果并通知父级刷新。 */ /** 保存通知渠道编辑结果并通知父级刷新。 */
function saveNotificationInfo() { function saveNotificationInfo() {
// 为空不保存,跳出警告框 // 为空不保存,跳出警告框
if (!notificationInfo.value.name) { const normalizedName = notificationInfo.value.name.trim()
if (!normalizedName) {
$toast.error(t('notification.name') + t('common.required')) $toast.error(t('notification.name') + t('common.required'))
return return
} }
// 重名判断 // 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) { const duplicate = props.notifications.some(
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists')) item => item.id !== props.notification.id && item.name.trim().toLowerCase() === normalizedName.toLowerCase(),
)
if (duplicate) {
$toast.error(t('notification.channel') + `${normalizedName}` + t('common.exists'))
return return
} }
notificationInfo.value.name = normalizedName
ensureWechatConfigDefaults(notificationInfo.value) ensureWechatConfigDefaults(notificationInfo.value)
ensureWechatClawBotConfigDefaults(notificationInfo.value) ensureWechatClawBotConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = false notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name) emit('change', notificationInfo.value, props.notification.id)
emit('done') emit('done')
} }
@@ -37,14 +37,14 @@ describe('SendMessageAction', () => {
expect(getSelectItems(container, '渠道')).toEqual([]) expect(getSelectItems(container, '渠道')).toEqual([])
}) })
it('maps unwrapped admin notification channels to name options', async () => { it('maps admin notification channels to name options while preserving workflow values', async () => {
mocks.apiGet.mockResolvedValue({ mocks.apiGet.mockResolvedValue({
success: true, success: true,
message: '', message: '',
data: { data: {
value: [ value: [
{ name: 'Telegram', type: 'telegram', enabled: true }, { id: 'telegram-channel', name: 'Telegram', type: 'telegram', enabled: true },
{ name: '企业微信', type: 'wechat', enabled: false }, { id: 'wechat-channel', name: '企业微信', type: 'wechat', enabled: false },
], ],
}, },
}) })
+2
View File
@@ -13,6 +13,7 @@ export default {
loading: 'Loading', loading: 'Loading',
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
exists: 'Exists',
openInNewWindow: 'Open in new window', openInNewWindow: 'Open in new window',
download: 'Download', download: 'Download',
uploadSpeed: 'Upload speed', uploadSpeed: 'Upload speed',
@@ -2522,6 +2523,7 @@ export default {
notification: { notification: {
channels: 'Notification Channels', channels: 'Notification Channels',
channelsDesc: 'Set message sending channel parameters', channelsDesc: 'Set message sending channel parameters',
channelsLoadFailed: 'Failed to load notification channels. Refresh and try again.',
organizeSuccess: 'Media Import', organizeSuccess: 'Media Import',
downloadAdded: 'Download Added', downloadAdded: 'Download Added',
subscribeAdded: 'Subscribe Added', subscribeAdded: 'Subscribe Added',
+2
View File
@@ -13,6 +13,7 @@ export default {
loading: '加载中', loading: '加载中',
success: '成功', success: '成功',
error: '错误', error: '错误',
exists: '已存在',
openInNewWindow: '在新窗口中打开', openInNewWindow: '在新窗口中打开',
download: '下载', download: '下载',
inputMessage: '输入消息或命令', inputMessage: '输入消息或命令',
@@ -2475,6 +2476,7 @@ export default {
notification: { notification: {
channels: '通知渠道', channels: '通知渠道',
channelsDesc: '设置消息发送渠道参数', channelsDesc: '设置消息发送渠道参数',
channelsLoadFailed: '加载通知渠道失败,请刷新后重试',
organizeSuccess: '资源入库', organizeSuccess: '资源入库',
downloadAdded: '资源下载', downloadAdded: '资源下载',
subscribeAdded: '添加订阅', subscribeAdded: '添加订阅',
+2
View File
@@ -13,6 +13,7 @@ export default {
loading: '加載中', loading: '加載中',
success: '成功', success: '成功',
error: '錯誤', error: '錯誤',
exists: '已存在',
openInNewWindow: '在新窗口中打開', openInNewWindow: '在新窗口中打開',
download: '下載', download: '下載',
inputMessage: '輸入消息或命令', inputMessage: '輸入消息或命令',
@@ -2474,6 +2475,7 @@ export default {
notification: { notification: {
channels: '通知渠道', channels: '通知渠道',
channelsDesc: '設置消息發送渠道參數', channelsDesc: '設置消息發送渠道參數',
channelsLoadFailed: '載入通知渠道失敗,請刷新後重試',
organizeSuccess: '資源入庫', organizeSuccess: '資源入庫',
downloadAdded: '資源下載', downloadAdded: '資源下載',
subscribeAdded: '添加訂閱', subscribeAdded: '添加訂閱',
+132 -64
View File
@@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import { manageNotificationChannel } from '@/api/manage'
import type { NotificationConf, NotificationSwitchConf } from '@/api/types' import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue' import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -79,6 +78,19 @@ const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dar
// //
const notifications = ref<NotificationConf[]>([]) const notifications = ref<NotificationConf[]>([])
type NotificationConfigInput = Partial<NotificationConf> & {
id?: unknown
}
interface NotificationConfigResponse {
value?: NotificationConfigInput[]
}
type NotificationLoadState = 'idle' | 'loading' | 'ready' | 'error'
const notificationLoadState = ref<NotificationLoadState>('idle')
const notificationSaveLoading = ref(false)
// //
const $toast = useToast() const $toast = useToast()
@@ -132,10 +144,75 @@ const notificationTime = ref({
end: '23:59', end: '23:59',
}) })
const wechatClawBotRenameMap = ref<Record<string, string>>({})
let editorDialogController: ReturnType<typeof openSharedDialog> | null = null let editorDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 创建仅用于新建渠道的稳定身份;保存后以后端归一化身份为准。 */
function createNotificationId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
/** 为旧配置建立与后端一致的可重复身份,避免在保存前退回到显示名称作为列表 key。 */
function createLegacyNotificationId(notification: NotificationConfigInput, index: number) {
const type = typeof notification.type === 'string' ? notification.type : 'notification'
const name = typeof notification.name === 'string' ? notification.name.trim() : ''
return `legacy-${type}-${name || String(index)}`
}
/** 统一通知配置的身份、名称和可选字段,供 GET、编辑和保存共用。 */
function normalizeNotification(notification: NotificationConfigInput, index: number): NotificationConf {
const rawId = notification.id
const id =
typeof rawId === 'string' && rawId.trim()
? rawId.trim()
: typeof rawId === 'number'
? String(rawId)
: createLegacyNotificationId(notification, index)
const name = typeof notification.name === 'string' ? notification.name.trim() : ''
const type = typeof notification.type === 'string' ? notification.type : ''
const config = notification.config && typeof notification.config === 'object' ? notification.config : {}
return {
...notification,
id,
name,
type,
config,
enabled: Boolean(notification.enabled),
}
}
function normalizeNotificationList(value: NotificationConfigInput[] = []) {
return value.map((notification, index) => normalizeNotification(notification, index))
}
function notificationNameKey(name: string) {
return name.trim().toLowerCase()
}
/** 检查名称是否为空或与另一个渠道重复,名称比较不区分大小写。 */
function validateNotificationNames(value: NotificationConf[]) {
const names = new Set<string>()
for (const notification of value) {
const name = notification.name.trim()
if (!name) {
$toast.error(t('notification.nameRequired'))
return false
}
const key = notificationNameKey(name)
if (names.has(key)) {
$toast.error(`${t('notification.channel')}${name}${t('common.exists')}`)
return false
}
names.add(key)
}
return true
}
const canSaveNotifications = computed(() => notificationLoadState.value === 'ready' && !notificationSaveLoading.value)
// //
function closeTemplateEditorDialog() { function closeTemplateEditorDialog() {
editorDialogOpen.value = false editorDialogOpen.value = false
@@ -184,11 +261,15 @@ watch(editorTheme, theme => {
// //
function addNotification(notification: string) { function addNotification(notification: string) {
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}` const prefix = t('setting.notification.channel')
while (notifications.value.some(item => item.name === name)) { let index = notifications.value.length + 1
name = `${t('setting.notification.channel')}${parseInt(name.split(t('setting.notification.channel'))[1]) + 1}` let name = `${prefix}${index}`
while (notifications.value.some(item => notificationNameKey(item.name) === notificationNameKey(name))) {
index += 1
name = `${prefix}${index}`
} }
notifications.value.push({ notifications.value.push({
id: createNotificationId(),
name: name, name: name,
type: notification, type: notification,
enabled: false, enabled: false,
@@ -198,59 +279,21 @@ function addNotification(notification: string) {
// //
function removeNotification(notification: NotificationConf) { function removeNotification(notification: NotificationConf) {
const index = notifications.value.indexOf(notification) const index = notifications.value.findIndex(item => item.id === notification.id)
if (index > -1) notifications.value.splice(index, 1) if (index > -1) notifications.value.splice(index, 1)
} }
function trackWechatClawBotRename(oldName: string, newName: string) {
if (!oldName || !newName || oldName === newName) {
return
}
const renameMap = { ...wechatClawBotRenameMap.value }
let chainedRename = false
//
for (const [source, target] of Object.entries(renameMap)) {
if (target === oldName) {
renameMap[source] = newName
chainedRename = true
}
}
if (!chainedRename) {
renameMap[oldName] = newName
}
wechatClawBotRenameMap.value = Object.fromEntries(
Object.entries(renameMap).filter(([source, target]) => source && target && source !== target),
)
}
async function migrateWechatClawBotRenames() {
const activeWechatClawBotNames = new Set(
notifications.value.filter(item => item.type === 'wechatclawbot').map(item => item.name),
)
const renameEntries = Object.entries(wechatClawBotRenameMap.value).filter(
([oldName, newName]) => oldName && newName && oldName !== newName && activeWechatClawBotNames.has(newName),
)
for (const [oldName, newName] of renameEntries) {
await manageNotificationChannel(
'WechatClawBot',
'migrate_cache',
{
old_name: oldName,
new_name: newName,
},
{ feedback: 'silent' },
)
}
}
// API // API
async function loadNotificationSetting() { async function loadNotificationSetting() {
notificationLoadState.value = 'loading'
try { try {
const result = await api.get<{ value?: NotificationConf[] }>('system/setting/Notifications') const result = await api.get<NotificationConfigResponse>('system/setting/Notifications')
notifications.value = result.value ?? [] notifications.value = normalizeNotificationList(result.value)
wechatClawBotRenameMap.value = {} notificationLoadState.value = 'ready'
} catch (error) { } catch (error) {
console.log(error) console.error(error)
//
notificationLoadState.value = 'error'
} }
} }
@@ -311,14 +354,22 @@ async function loadNotificationTime() {
// API // API
async function saveNotificationSetting() { async function saveNotificationSetting() {
if (!canSaveNotifications.value || !validateNotificationNames(notifications.value)) return
notificationSaveLoading.value = true
try { try {
await migrateWechatClawBotRenames() const payload = notifications.value.map((notification, index) => normalizeNotification(notification, index))
await api.post('system/setting/Notifications', notifications.value, { feedback: 'silent' }) notifications.value = payload
wechatClawBotRenameMap.value = {} const result = await api.post<NotificationConfigResponse>('notification/config', payload, { feedback: 'silent' })
if (result?.value) {
notifications.value = normalizeNotificationList(result.value)
}
$toast.success(t('setting.notification.saveSuccess')) $toast.success(t('setting.notification.saveSuccess'))
} catch (error) { } catch (error) {
console.log(error) console.error(error)
$toast.error(t('setting.notification.saveFailed')) $toast.error(t('setting.notification.saveFailed'))
} finally {
notificationSaveLoading.value = false
} }
} }
@@ -334,14 +385,22 @@ async function saveNotificationTime() {
} }
// //
function changNotificationSetting(notification: NotificationConf, name: string) { function changNotificationSetting(notification: NotificationConf, id: string) {
const index = notifications.value.findIndex(item => item.name === name) const normalizedNotification = normalizeNotification(notification, notifications.value.length)
const index = notifications.value.findIndex(item => item.id === id)
if (index !== -1) { if (index !== -1) {
const previous = notifications.value[index] if (!normalizedNotification.name) {
notifications.value[index] = notification $toast.error(t('notification.nameRequired'))
if (previous?.type === 'wechatclawbot' && previous.name !== notification.name) { return
trackWechatClawBotRename(previous.name, notification.name)
} }
const duplicate = notifications.value.some(
item => item.id !== id && notificationNameKey(item.name) === notificationNameKey(normalizedNotification.name),
)
if (duplicate) {
$toast.error(`${t('notification.channel')}${normalizedNotification.name}${t('common.exists')}`)
return
}
notifications.value[index] = normalizedNotification
} }
} }
@@ -410,10 +469,13 @@ useSilentSettingRefresh(loadPageData, {
<VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle> <VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>
</VCardItem> </VCardItem>
<VCardText> <VCardText>
<VAlert v-if="notificationLoadState === 'error'" type="error" variant="tonal" class="mb-4">
{{ t('setting.notification.channelsLoadFailed') }}
</VAlert>
<Draggable <Draggable
v-model="notifications" v-model="notifications"
handle=".cursor-move" handle=".cursor-move"
item-key="name" item-key="id"
tag="div" tag="div"
:component-data="{ 'class': 'grid gap-3 grid-app-card' }" :component-data="{ 'class': 'grid gap-3 grid-app-card' }"
> >
@@ -430,7 +492,13 @@ useSilentSettingRefresh(loadPageData, {
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4"> <div class="d-flex flex-wrap gap-4 mt-4">
<VBtn mtype="submit" @click="saveNotificationSetting" prepend-icon="mdi-content-save"> <VBtn
mtype="submit"
:loading="notificationSaveLoading"
:disabled="!canSaveNotifications"
@click="saveNotificationSetting"
prepend-icon="mdi-content-save"
>
{{ t('common.save') }} {{ t('common.save') }}
</VBtn> </VBtn>
<VBtn color="success" variant="tonal"> <VBtn color="success" variant="tonal">
@@ -43,7 +43,7 @@ vi.mock('@/components/cards/NotificationChannelCard.vue', async () => {
<input <input
:aria-label="'name-' + notification.name" :aria-label="'name-' + notification.name"
:value="notification.name" :value="notification.name"
@input="$emit('change', { ...notification, name: $event.target.value }, notification.name)" @input="$emit('change', { ...notification, name: $event.target.value }, notification.id)"
/> />
<button :aria-label="'remove-' + notification.name" @click="$emit('close')">remove</button> <button :aria-label="'remove-' + notification.name" @click="$emit('close')">remove</button>
</section> </section>
@@ -73,9 +73,15 @@ vi.mock('vuedraggable', async () => {
} }
}) })
const notificationsFixture = [ const notificationsFixture: Array<{
{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } }, id?: string
{ name: '通知3', type: 'telegram', enabled: false, config: {} }, name: string
type: string
enabled: boolean
config: Record<string, unknown>
}> = [
{ id: 'channel-alpha', name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } },
{ id: 'channel-three', name: '通知3', type: 'telegram', enabled: false, config: {} },
] ]
const templateFixture = { const templateFixture = {
@@ -85,10 +91,10 @@ const templateFixture = {
subscribeComplete: '{}', subscribeComplete: '{}',
} }
function mockLoadedSettings() { function mockLoadedSettings(channels = notificationsFixture) {
mocks.apiGet.mockImplementation((endpoint: string) => { mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/setting/Notifications') { if (endpoint === 'system/setting/Notifications') {
return { success: true, data: { value: structuredClone(notificationsFixture) } } return { success: true, data: { value: structuredClone(channels) } }
} }
if (endpoint === 'system/setting/NotificationSwitchs') { if (endpoint === 'system/setting/NotificationSwitchs') {
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } } return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
@@ -156,7 +162,23 @@ describe('AccountSettingNotification', () => {
expect(refreshOptions.active.value).toBe(false) expect(refreshOptions.active.value).toBe(false)
}) })
it('creates a unique automatic channel name, removes channels, and saves the current order', async () => { it('keeps the backend legacy identity when renaming a channel without an id', async () => {
mockLoadedSettings([{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: {} }])
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith('notification/config', [
expect.objectContaining({ id: 'legacy-wechatclawbot-Alpha', name: 'Beta' }),
]),
)
})
it('creates a unique automatic channel name, removes channels, and saves the current order once', async () => {
const user = userEvent.setup() const user = userEvent.setup()
await renderNotificationSettings() await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot') await screen.findByText('Alpha / wechatclawbot')
@@ -172,12 +194,80 @@ describe('AccountSettingNotification', () => {
await user.click(channelCard.getByRole('button', { name: '保存' })) await user.click(channelCard.getByRole('button', { name: '保存' }))
await waitFor(() => { await waitFor(() => {
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Notifications', [ expect(mocks.apiPost).toHaveBeenCalledWith('notification/config', [
expect.objectContaining({ name: '通知4', type: 'wechat' }), expect.objectContaining({ id: expect.any(String), name: '通知4', type: 'wechat' }),
expect.objectContaining({ name: 'Alpha', type: 'wechatclawbot' }), expect.objectContaining({ id: 'channel-alpha', name: 'Alpha', type: 'wechatclawbot' }),
]) ])
}) })
expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功') expect(mocks.apiPost).toHaveBeenCalledTimes(1)
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功'))
})
it('trims channel names and rejects case-insensitive duplicates before saving', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('name-Alpha'), ' 通知3 ')
expect(mocks.toastError).toHaveBeenCalledWith('通知渠道【通知3】已存在')
expect(screen.getByText('Alpha / wechatclawbot')).toBeInTheDocument()
await fireEvent.update(screen.getByLabelText('name-Alpha'), ' Beta ')
expect(screen.getByText('Beta / wechatclawbot')).toBeInTheDocument()
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'notification/config',
expect.arrayContaining([expect.objectContaining({ id: 'channel-alpha', name: 'Beta' })]),
),
)
})
it('keeps the current channels and disables saving when loading fails', async () => {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/setting/Notifications') throw new Error('notifications unavailable')
if (endpoint === 'system/setting/NotificationSwitchs') {
return { success: true, data: { value: [{ type: '资源下载', action: 'user' }] } }
}
if (endpoint === 'system/setting/NotificationSendTime') {
return { success: true, data: { value: { start: '08:30', end: '22:00' } } }
}
if (endpoint === 'system/setting/NotificationTemplates') {
return { success: true, data: { value: structuredClone(templateFixture) } }
}
throw new Error(`Unexpected GET ${endpoint}`)
})
const user = userEvent.setup()
await renderNotificationSettings()
expect(await screen.findByText('加载通知渠道失败,请刷新后重试')).toBeInTheDocument()
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
expect(save).toBeDisabled()
await user.click(save)
expect(mocks.apiPost).not.toHaveBeenCalled()
})
it('prevents duplicate channel saves while the request is pending', async () => {
let resolveSave!: (value: unknown) => void
mocks.apiPost.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSave = resolve
}),
)
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
await user.click(save)
await user.click(save)
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
resolveSave({ success: true, data: { value: structuredClone(notificationsFixture) } })
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功'))
}) })
it('offers DingTalk as a native notification channel', async () => { it('offers DingTalk as a native notification channel', async () => {
@@ -192,7 +282,7 @@ describe('AccountSettingNotification', () => {
expect(screen.getByText('通知4 / dingtalk')).toBeInTheDocument() expect(screen.getByText('通知4 / dingtalk')).toBeInTheDocument()
}) })
it('compresses chained ClawBot renames and migrates the original source before saving channels', async () => { it('submits chained ClawBot renames and migration cleanup in one configuration request', async () => {
const user = userEvent.setup() const user = userEvent.setup()
await renderNotificationSettings() await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot') await screen.findByText('Alpha / wechatclawbot')
@@ -201,27 +291,21 @@ describe('AccountSettingNotification', () => {
await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma') await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma')
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' })) await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2)) await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', { expect(mocks.apiPost).toHaveBeenCalledWith(
target: 'WechatClawBot', 'notification/config',
action: 'migrate_cache',
params: { old_name: 'Alpha', new_name: 'Gamma' },
})
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'system/setting/Notifications',
expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]), expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]),
) )
}) })
it('keeps a failed ClawBot migration pending and retries it before saving channels', async () => { it('keeps a failed configuration request editable and retries it as one request', async () => {
const user = userEvent.setup() const user = userEvent.setup()
await renderNotificationSettings() await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot') await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta') await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
const save = getCard('通知渠道').getByRole('button', { name: '保存' }) const save = getCard('通知渠道').getByRole('button', { name: '保存' })
mocks.apiPost.mockResolvedValueOnce({ success: false, message: 'migration failed' }) mocks.apiPost.mockRejectedValueOnce(new Error('configuration failed'))
await user.click(save) await user.click(save)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!')) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!'))
expect(mocks.apiPost).toHaveBeenCalledTimes(1) expect(mocks.apiPost).toHaveBeenCalledTimes(1)
@@ -229,15 +313,9 @@ describe('AccountSettingNotification', () => {
mocks.apiPost.mockClear() mocks.apiPost.mockClear()
mocks.apiPost.mockResolvedValue({ success: true }) mocks.apiPost.mockResolvedValue({ success: true })
await user.click(save) await user.click(save)
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2)) await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(1))
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/manage', { expect(mocks.apiPost).toHaveBeenCalledWith(
target: 'WechatClawBot', 'notification/config',
action: 'migrate_cache',
params: { old_name: 'Alpha', new_name: 'Beta' },
})
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'system/setting/Notifications',
expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]), expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]),
) )
}) })