test(settings): cover notification configuration (#674)

This commit is contained in:
InfinityPacer
2026-08-13 10:08:58 +08:00
committed by GitHub
parent b3b9248af6
commit 9654bd9852
6 changed files with 379 additions and 8 deletions
@@ -283,6 +283,22 @@ describe('MediaCard', () => {
})
})
it('keeps the local-exists marker hidden when the exists request reports a business failure', async () => {
const media = createMediaInfo({ title: '存在查询失败电影', tmdb_id: 9103 })
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('9103', {}),
mediaExistsHandler({ data: {}, message: '查询失败', success: false }, 200, existsRequest),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(existsRequest).toHaveBeenCalledOnce())
await waitFor(() => expect(console.error).toHaveBeenCalled())
expect(container.querySelector('.bg-green-500')).toBeNull()
})
it.each([
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), '9201', '3'],
[
@@ -292,6 +292,27 @@ describe('AddSubtitleDownloadDialog submissions', () => {
})
})
it('reveals advanced options and submits the selected media source identity', async () => {
const submitted = vi.fn()
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ mediaId: '84', mediaSource: 'themoviedb' })
expect(screen.getByLabelText('识别数据源')).not.toBeVisible()
await user.click(screen.getByRole('button', { name: '显示高级选项' }))
await user.selectOptions(screen.getByLabelText('识别数据源'), 'imdb')
await user.clear(screen.getByLabelText('IMDb ID'))
await user.type(screen.getByLabelText('IMDb ID'), 'tt0111161')
await user.click(screen.getByRole('button', { name: '下载字幕' }))
await waitFor(() => expect(submitted).toHaveBeenCalledOnce())
expect(submitted.mock.calls[0][0]).toMatchObject({
media_id: 'tt0111161',
media_source: 'imdb',
})
})
it('preserves the signed enclosure in download/subtitle and prevents duplicate submission', async () => {
const deferred = createDeferred<JsonBodyType>()
const submitted = vi.fn()
@@ -206,14 +206,15 @@ function trackWechatClawBotRename(oldName: string, newName: string) {
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 (renameMap[oldName]) {
renameMap[oldName] = newName
} else {
if (!chainedRename) {
renameMap[oldName] = newName
}
wechatClawBotRenameMap.value = Object.fromEntries(
@@ -325,6 +326,7 @@ async function saveNotificationTime() {
$toast.success(t('setting.notification.timeSaveSuccess'))
} catch (error) {
console.log(error)
$toast.error(t('setting.notification.timeSaveFailed'))
}
}
@@ -367,6 +369,7 @@ async function saveNotificationSwitchs() {
$toast.success(t('setting.notification.switchSaveSuccess'))
} catch (error) {
console.log(error)
$toast.error(t('setting.notification.switchSaveFailed'))
}
}
@@ -0,0 +1,329 @@
import vuetify from '@/plugins/vuetify'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
useSilentSettingRefresh: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({ get: mocks.apiGet, post: mocks.apiPost }),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: mocks.openSharedDialog,
}))
vi.mock('@/composables/useSilentSettingRefresh', () => ({
useSilentSettingRefresh: mocks.useSilentSettingRefresh,
}))
vi.mock('@/components/cards/NotificationChannelCard.vue', async () => {
const { defineComponent } = await import('vue')
return {
default: defineComponent({
name: 'NotificationChannelCardStub',
props: { notification: { type: Object, required: true } },
emits: ['change', 'close'],
template: `
<section :aria-label="'channel-' + notification.name">
<span>{{ notification.name }} / {{ notification.type }}</span>
<input
:aria-label="'name-' + notification.name"
:value="notification.name"
@input="$emit('change', { ...notification, name: $event.target.value }, notification.name)"
/>
<button :aria-label="'remove-' + notification.name" @click="$emit('close')">remove</button>
</section>
`,
}),
}
})
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DraggableStub',
props: { modelValue: { type: Array, default: () => [] } },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
const reverse = () => emit('update:modelValue', [...props.modelValue].reverse())
return () => {
const items = props.modelValue as Array<{ name?: string }>
return h('div', [
h('button', { 'aria-label': 'reverse-channels', onClick: reverse }, 'reverse'),
...items.map(element => slots.item?.({ element })),
])
}
},
}),
}
})
const notificationsFixture = [
{ name: 'Alpha', type: 'wechatclawbot', enabled: true, config: { token: 'fixture-token' } },
{ name: '通知3', type: 'telegram', enabled: false, config: {} },
]
const templateFixture = {
organizeSuccess: '{"title":"organized"}',
downloadAdded: '{"title":"downloaded"}',
subscribeAdded: '{}',
subscribeComplete: '{}',
}
function mockLoadedSettings() {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/setting/Notifications') {
return { success: true, data: { value: structuredClone(notificationsFixture) } }
}
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}`)
})
mocks.apiPost.mockResolvedValue({ success: true })
}
function createDialogController() {
return { close: vi.fn(), id: 1, updateProps: vi.fn() }
}
async function renderNotificationSettings() {
return renderWithProviders(AccountSettingNotification)
}
function getCard(title: string) {
const card = screen.getByText(title).closest('.v-card')
expect(card).not.toBeNull()
return within(card as HTMLElement)
}
function getDialogEvents() {
return mocks.openSharedDialog.mock.calls.at(-1)?.[2] as {
close: () => void
save: (value: string) => Promise<void>
'update:content': (value: string) => void
'update:modelValue': (value: boolean) => void
}
}
describe('AccountSettingNotification', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.openSharedDialog.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.useSilentSettingRefresh.mockReset()
mocks.openSharedDialog.mockImplementation(() => createDialogController())
mockLoadedSettings()
})
it('loads owned settings, adds missing switch defaults, and follows active refresh state', async () => {
const { rerender } = await renderNotificationSettings()
expect(await screen.findByText('Alpha / wechatclawbot')).toBeInTheDocument()
expect(screen.getByText('通知3 / telegram')).toBeInTheDocument()
expect(screen.getByLabelText('开始时间')).toHaveValue('08:30')
expect(screen.getByLabelText('结束时间')).toHaveValue('22:00')
expect(getCard('通知发送范围').getAllByRole('radiogroup')).toHaveLength(9)
const refreshOptions = mocks.useSilentSettingRefresh.mock.calls[0]?.[1]
expect(refreshOptions.active.value).toBe(true)
await rerender({ active: false })
expect(refreshOptions.active.value).toBe(false)
})
it('creates a unique automatic channel name, removes channels, and saves the current order', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
const channelCard = getCard('通知渠道')
const buttons = channelCard.getAllByRole('button')
await user.click(buttons.at(-1)!)
await user.click(await screen.findByText('企业微信', { selector: '.v-list-item-title' }))
expect(screen.getByText('通知4 / wechat')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'remove-通知3' }))
await user.click(screen.getByRole('button', { name: 'reverse-channels' }))
await user.click(channelCard.getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/Notifications', [
expect.objectContaining({ name: '通知4', type: 'wechat' }),
expect.objectContaining({ name: 'Alpha', type: 'wechatclawbot' }),
])
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('通知设置保存成功')
})
it('compresses chained ClawBot renames and migrates the original source before saving channels', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
await fireEvent.update(await screen.findByLabelText('name-Beta'), 'Gamma')
await user.click(getCard('通知渠道').getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/wechatclawbot/migrate', null, {
params: { old_source: 'Alpha', new_source: 'Gamma' },
})
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'system/setting/Notifications',
expect.arrayContaining([expect.objectContaining({ name: 'Gamma', type: 'wechatclawbot' })]),
)
})
it('keeps a failed ClawBot migration pending and retries it before saving channels', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('name-Alpha'), 'Beta')
const save = getCard('通知渠道').getByRole('button', { name: '保存' })
mocks.apiPost.mockResolvedValueOnce({ success: false, message: 'migration failed' })
await user.click(save)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知设置保存失败!'))
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
mocks.apiPost.mockClear()
mocks.apiPost.mockResolvedValue({ success: true })
await user.click(save)
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledTimes(2))
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'notification/wechatclawbot/migrate', null, {
params: { old_source: 'Alpha', new_source: 'Beta' },
})
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'system/setting/Notifications',
expect.arrayContaining([expect.objectContaining({ name: 'Beta' })]),
)
})
it('loads, edits, and saves a template while pausing refresh and following the active theme', async () => {
const previousTheme = vuetify.theme.global.name.value
const controller = createDialogController()
mocks.openSharedDialog.mockReturnValue(controller)
try {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
const templateButton = screen.getByRole('button', { name: /资源入库/ })
await user.click(templateButton)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ content: templateFixture.organizeSuccess, editorTheme: 'github_light_default' }),
)
expect(mocks.useSilentSettingRefresh.mock.calls[0]?.[1].active.value).toBe(false)
getDialogEvents()['update:content']('{"title":"updated"}')
vuetify.theme.global.name.value = 'dark'
await waitFor(() => expect(controller.updateProps).toHaveBeenCalledWith({ editorTheme: 'github_dark' }))
await getDialogEvents().save('{"title":"updated"}')
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/NotificationTemplates', {
...templateFixture,
organizeSuccess: '{"title":"updated"}',
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('模板保存成功')
expect(controller.close).toHaveBeenCalledOnce()
} finally {
vuetify.theme.global.name.value = previousTheme
}
})
it('keeps the template editor open when saving fails and reports load failures', async () => {
const user = userEvent.setup()
const controller = createDialogController()
mocks.openSharedDialog.mockReturnValue(controller)
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
const templateButton = screen.getByRole('button', { name: /资源下载/ })
await user.click(templateButton)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
await getDialogEvents().save('{"title":"failed"}')
expect(mocks.toastError).toHaveBeenCalledWith('模板保存失败!')
expect(controller.close).not.toHaveBeenCalled()
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint === 'system/setting/NotificationTemplates') throw new Error('offline')
return mockLoadedSettings()
})
mocks.openSharedDialog.mockClear()
await user.click(screen.getByRole('button', { name: /添加订阅/ }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('模板加载失败!'))
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('saves edited notification time and merged message scope payloads', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
await fireEvent.update(screen.getByLabelText('开始时间'), '09:15')
await fireEvent.update(screen.getByLabelText('结束时间'), '21:45')
await user.click(getCard('通知发送时间').getByRole('button', { name: '保存' }))
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/NotificationSendTime', {
start: '09:15',
end: '21:45',
})
const scopeCard = getCard('通知发送范围')
await user.click(scopeCard.getAllByRole('radio', { name: '仅管理员' })[0])
await user.click(scopeCard.getByRole('button', { name: '保存' }))
expect(mocks.apiPost).toHaveBeenCalledWith(
'system/setting/NotificationSwitchs',
expect.arrayContaining([
{ type: '资源下载', action: 'admin' },
{ type: '智能体', action: 'admin' },
{ type: '其它', action: 'admin' },
]),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('通知发送时间保存成功')
expect(mocks.toastSuccess).toHaveBeenCalledWith('消息类型开关保存成功')
})
it('reports HTTP failures when saving notification time or message scopes', async () => {
const user = userEvent.setup()
await renderNotificationSettings()
await screen.findByText('Alpha / wechatclawbot')
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
await user.click(getCard('通知发送时间').getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('通知发送时间保存失败!'))
mocks.apiPost.mockRejectedValueOnce(new Error('offline'))
await user.click(getCard('通知发送范围').getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('消息类型开关保存失败!'))
})
})