conversationSearchDelete.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
  3. import { shallowRef } from 'vue'
  4. import { createMemoryHistory, createRouter, matchedRouteKey } from 'vue-router'
  5. import ConversationsView from '../../src/client/views/ConversationsView.vue'
  6. import { apiRequest } from '../../src/client/api/client'
  7. import type { WorkspaceLifecyclePreview } from '../../src/shared/workspaceLifecycle'
  8. vi.mock('../../src/client/api/client', () => ({ apiRequest: vi.fn(), ApiError: class extends Error {} }))
  9. vi.mock('../../src/client/stores/auth', () => ({ useAuthStore: () => ({ user: { id: 'owner' }, profiles: [], refreshProfileAvatars: async () => {} }) }))
  10. vi.mock('../../src/client/stores/theme', () => ({ useThemeStore: () => ({}) }))
  11. let wrapper: VueWrapper | undefined
  12. afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.mocked(apiRequest).mockReset() })
  13. async function setup(kind: 'direct' | 'group', remove: () => Promise<void> = async () => {}, groups: WorkspaceLifecyclePreview['groups'] = [], initiallyArchived = true) {
  14. let archived = [{ id: 'archived', name: '旧聊天', kind, archived: initiallyArchived, memberIds: ['bot'], updatedAt: 1 }]
  15. const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/:id?', component: { template: '<div />' } }] })
  16. await router.push('/conversations'); await router.isReady()
  17. vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
  18. vi.mocked(apiRequest).mockImplementation(async (path, options) => {
  19. if (path === '/api/app/workspace/snapshot') return { agents: [{ id: 'bot', name: '旧聊天', archived: true }], conversations: archived, details: [], cursor: 1 } as never
  20. if (path.endsWith('/lifecycle')) {
  21. if (options?.method !== 'POST') return { name: '旧聊天', kind, groups, confirmationToken: 'a'.repeat(64) } as never
  22. await remove()
  23. const action = (options.body as { action: string }).action
  24. if (action === 'delete') archived = []
  25. else archived = archived.map(item => ({ ...item, archived: action === 'archive' }))
  26. return {} as never
  27. }
  28. if (path === '/api/app/agents') return { agents: [{ id: 'bot', name: '旧聊天', archived: true }] } as never
  29. if (path === '/api/app/conversations') return { conversations: archived, cursor: 1 } as never
  30. if (path.startsWith('/api/app/events')) return { events: [], cursor: 1 } as never
  31. return {} as never
  32. })
  33. wrapper = shallowMount(ConversationsView, { attachTo: document.body, global: {
  34. provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) },
  35. plugins: [router], stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, FloatingResourceSearch: false, Teleport: false },
  36. } })
  37. await flushPromises()
  38. document.dispatchEvent(new CustomEvent('hermes-yaoyao:sidebar-search', { detail: { section: 'groups' } }))
  39. await flushPromises()
  40. expect(document.querySelector('.search-delete')).toBeNull()
  41. document.querySelector<HTMLButtonElement>('[role="tab"][aria-label="已归档"]')!.click()
  42. await flushPromises()
  43. return router
  44. }
  45. describe('archived chat deletion from search', () => {
  46. it('keeps the archived result and route when confirmation is cancelled', async () => {
  47. const router = await setup('direct')
  48. const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
  49. document.querySelector<HTMLButtonElement>('.search-delete')!.click()
  50. await flushPromises()
  51. expect(confirm).toHaveBeenCalledWith(expect.stringContaining('永久删除 Bot「旧聊天」'))
  52. expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
  53. expect(router.currentRoute.value.path).toBe('/conversations')
  54. expect(document.querySelector('[role="dialog"]')?.textContent).toContain('旧聊天')
  55. })
  56. it.each(['direct', 'group'] as const)('deletes an archived %s with the confirmed snapshot and updates the open search', async kind => {
  57. await setup(kind)
  58. vi.spyOn(window, 'confirm').mockReturnValue(true)
  59. const button = document.querySelector<HTMLButtonElement>('.search-delete')!
  60. button.focus(); button.click()
  61. await flushPromises()
  62. expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/archived/lifecycle', { method: 'POST', body: { action: 'delete', confirmationToken: 'a'.repeat(64) } })
  63. expect(document.querySelector('[role="dialog"]')?.textContent).toContain('没有已归档聊天')
  64. expect(document.querySelector('[role="tab"][aria-label="已归档"]')?.textContent).toContain('0')
  65. expect(document.activeElement).toBe(document.querySelector('[role="dialog"] input'))
  66. })
  67. it('disables repeated deletion and keeps server errors inside search', async () => {
  68. let reject!: (error: Error) => void
  69. await setup('direct', () => new Promise<void>((_, fail) => { reject = fail }))
  70. vi.spyOn(window, 'confirm').mockReturnValue(true)
  71. const button = document.querySelector<HTMLButtonElement>('.search-delete')!
  72. button.click(); await flushPromises()
  73. expect(button.disabled).toBe(true)
  74. button.click()
  75. expect(vi.mocked(apiRequest).mock.calls.filter(([, options]) => options?.method === 'POST')).toHaveLength(1)
  76. reject(new Error('此 Bot 仍是群聊成员'))
  77. await flushPromises()
  78. expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toBe('此 Bot 仍是群聊成员')
  79. expect(button.disabled).toBe(false)
  80. expect(document.querySelector('[role="dialog"]')?.textContent).toContain('旧聊天')
  81. })
  82. it('restores a chat without opening it and updates both archive counts', async () => {
  83. const router = await setup('direct')
  84. document.querySelector<HTMLButtonElement>('.search-restore')!.click()
  85. await flushPromises()
  86. expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/archived/lifecycle', { method: 'POST', body: { action: 'restore' } })
  87. expect(router.currentRoute.value.path).toBe('/conversations')
  88. expect(document.querySelector('[role="tab"][aria-label="未归档"]')?.textContent).toContain('1')
  89. expect(document.querySelector('[role="tab"][aria-label="已归档"]')?.textContent).toContain('0')
  90. })
  91. it.each(['archive', 'delete'] as const)('warns about group membership before %s from the chat menu', async operation => {
  92. await setup('direct', async () => {}, [{ id: 'group', name: '设计群', administrator: false }], false)
  93. const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
  94. wrapper!.findComponent({ name: 'ConversationList' }).vm.$emit(operation, 'archived')
  95. await flushPromises()
  96. expect(confirm).toHaveBeenCalledWith(expect.stringContaining('「设计群」'))
  97. expect(confirm).toHaveBeenCalledWith(expect.stringContaining('先从这些群聊移除'))
  98. expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
  99. })
  100. it('blocks a group administrator and asks for a transfer instead of deleting', async () => {
  101. await setup('direct', async () => {}, [{ id: 'group', name: '管理群', administrator: true }])
  102. const confirm = vi.spyOn(window, 'confirm')
  103. document.querySelector<HTMLButtonElement>('.search-delete')!.click()
  104. await flushPromises()
  105. expect(confirm).not.toHaveBeenCalled()
  106. expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toContain('「管理群」的管理者,请先转交管理权')
  107. expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'POST')).toBe(false)
  108. })
  109. it('shows menu failures outside the chat pane so the list-only layout can display them', async () => {
  110. await setup('direct', async () => {}, [{ id: 'group', name: '管理群', administrator: true }], false)
  111. document.querySelector<HTMLButtonElement>('[aria-label="关闭搜索"]')!.click()
  112. wrapper!.findComponent({ name: 'ConversationList' }).vm.$emit('archive', 'archived')
  113. await flushPromises()
  114. const alert = document.querySelector('.lifecycle-error')!
  115. expect(alert.textContent).toContain('请先转交管理权')
  116. expect(alert.parentElement).toBe(document.body)
  117. document.querySelector<HTMLButtonElement>('[aria-label="关闭聊天操作提示"]')!.click()
  118. await flushPromises()
  119. expect(document.querySelector('.lifecycle-error')).toBeNull()
  120. })
  121. })