fix: improve maintenance and synchronize live conversations
This commit is contained in:
1 parent
bb541bd212
commit
ef86a5d3af
297 files changed
+2983
-1274
No files matched your search
@@ -40,6 +40,9 @@ const mountStep = async () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const buttonByText = (wrapper: ReturnType<typeof mount>, label: string) =>
|
||||
wrapper.findAll('button').find((button) => button.text() === label)!
|
||||
|
||||
const clickContinue = async (wrapper: any) => {
|
||||
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
|
||||
await cont.trigger('click')
|
||||
@@ -48,7 +51,7 @@ const clickContinue = async (wrapper: any) => {
|
||||
|
||||
const typeAndAdd = async (wrapper: any, url: string) => {
|
||||
await wrapper.find('input[type="text"]').setValue(url)
|
||||
await wrapper.findAll('button').find((b: any) => b.text() === '添加')!.trigger('click')
|
||||
await buttonByText(wrapper, '添加').trigger('click')
|
||||
}
|
||||
|
||||
const staged = (wrapper: any) =>
|
||||
@@ -206,13 +209,13 @@ describe('StepTeach - a website that fails fast', () => {
|
||||
expect(wrapper.find('.crawl-failure').text()).toContain("无法读取 https://erfanpv.com。This site could not be found")
|
||||
expect(wrapper.find('.btn-accent').text()).toContain('跳过并继续')
|
||||
|
||||
await wrapper.findAll('button').find((b: any) => b.text().includes('Paste some text instead'))!.trigger('click')
|
||||
await buttonByText(wrapper, '改为粘贴文本').trigger('click')
|
||||
addText.mockResolvedValue({ message: 'ok' })
|
||||
await wrapper.find('textarea').setValue('We sell handmade candles and ship across India within five days.')
|
||||
await wrapper.findAll('button').find((b: any) => b.text() === 'Add this text')!.trigger('click')
|
||||
await buttonByText(wrapper, '添加文本').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), expect.stringContaining('handmade candles'), 'agent-1')
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), expect.stringContaining('handmade candles'), 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -246,7 +249,7 @@ describe('StepTeach - a website that fails fast', () => {
|
||||
expect(wrapper.find('.btn-accent').text()).toContain('正在读取网站')
|
||||
expect(wrapper.find('.btn-accent').attributes('disabled')).toBeDefined()
|
||||
for (const label of ['添加', '上传 PDF', '返回']) {
|
||||
expect(wrapper.findAll('button').find((b: any) => b.text() === label)!.attributes('disabled')).toBeDefined()
|
||||
expect(buttonByText(wrapper, label).attributes('disabled')).toBeDefined()
|
||||
}
|
||||
expect(wrapper.find('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.source-remove').attributes('disabled')).toBeDefined()
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('StepTeach - paste text instead', () => {
|
||||
await wrapper.find('textarea').setValue(longText)
|
||||
await button('添加文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), longText, 'agent-1')
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), longText, 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
// Keep the real components and composables; replace only their external edges.
|
||||
vi.mock('webfontloader', () => ({
|
||||
default: { load: ({ active }: { active?: () => void }) => active?.() },
|
||||
}))
|
||||
vi.mock('@/services/widget', () => ({
|
||||
widgetService: { getWidgets: vi.fn().mockResolvedValue([{ id: 'widget-1', agent_id: 'agent-1' }]) },
|
||||
}))
|
||||
vi.mock('@/services/groups', () => ({ listGroups: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/services/jira', () => ({
|
||||
checkJiraConnection: vi.fn().mockResolvedValue({ connected: false }),
|
||||
getAgentJiraConfig: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
getJiraProjects: vi.fn().mockResolvedValue([]),
|
||||
getJiraIssueTypes: vi.fn().mockResolvedValue([]),
|
||||
saveAgentJiraConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: { getAgentById: vi.fn(async () => agent) },
|
||||
}))
|
||||
|
||||
import AgentChatPreviewPanel from '@/components/agent/AgentChatPreviewPanel.vue'
|
||||
import AgentDetail from '@/components/agent/AgentDetail.vue'
|
||||
import { useAgentChat } from '@/composables/useAgentChat'
|
||||
|
||||
const agent = {
|
||||
id: 'agent-1', name: '小鲸', display_name: '小鲸', agent_type: 'CUSTOMER_SUPPORT',
|
||||
is_active: true, organization_id: 'org-1', use_workflow: false, require_token_auth: false,
|
||||
instructions: [], groups: [], customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' },
|
||||
}
|
||||
|
||||
const previewProps = {
|
||||
isActive: true, agentId: 'agent-1', agentName: '小鲸', agentType: 'CUSTOMER_SUPPORT',
|
||||
customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' as const },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('agent preview and close', () => {
|
||||
it('keeps sample messages, disabled composer and style-switch restoration', async () => {
|
||||
const wrapper = mount(AgentChatPreviewPanel, { props: previewProps })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('你好!有什么可以帮你?')
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
expect(wrapper.text()).toContain('支持——所有商品 30 天内免费退货。')
|
||||
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('.chat-input button').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ customization: { ...previewProps.customization, chat_style: 'ASK_ANYTHING' } })
|
||||
expect(wrapper.text()).toContain('欢迎来到 小鲸')
|
||||
expect(wrapper.text()).not.toContain('你们支持免费退货吗?')
|
||||
expect(wrapper.get('.welcome-message-input input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('.welcome-message-input button').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ customization: previewProps.customization })
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('still shows the offline state without enabling the preview composer', () => {
|
||||
const wrapper = mount(AgentChatPreviewPanel, { props: { ...previewProps, isActive: false } })
|
||||
expect(wrapper.text()).toContain('客服已离线')
|
||||
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves the chat composable public initialization, send and cleanup interface', async () => {
|
||||
const chat = useAgentChat('agent-1')
|
||||
chat.initChat()
|
||||
expect(chat.messages.value).toEqual([{ role: 'bot', content: '你好!有什么可以帮你的吗?' }])
|
||||
chat.currentInput.value = '测试消息'
|
||||
await chat.sendMessage('测试消息')
|
||||
expect(chat.messages.value.at(-1)).toEqual({ role: 'user', content: '测试消息' })
|
||||
expect(chat.currentInput.value).toBe('')
|
||||
expect(() => chat.cleanup()).not.toThrow()
|
||||
})
|
||||
|
||||
it('emits close from the detail button and cleans the mounted preview on unmount', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [] })
|
||||
const wrapper = mount(AgentDetail, {
|
||||
props: { agent },
|
||||
shallow: true,
|
||||
global: { plugins: [router], stubs: { AgentChatPreviewPanel: false, 'font-awesome-icon': true } },
|
||||
})
|
||||
await flushPromises()
|
||||
const tab = wrapper.findAll('button').find((button) => button.text() === '聊天自定义')
|
||||
expect(tab).toBeDefined()
|
||||
await tab!.trigger('click')
|
||||
expect(wrapper.findComponent(AgentChatPreviewPanel).exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
expect(document.getElementById('chat-panel-font-agent-1')).not.toBeNull()
|
||||
|
||||
await wrapper.get('button[aria-label="返回客服列表"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toEqual([[]])
|
||||
wrapper.unmount()
|
||||
expect(document.getElementById('chat-panel-font-agent-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AISetup from '@/components/ai/AISetup.vue'
|
||||
|
||||
const { loadModule, fetchPlans, availablePlans } = vi.hoisted(() => ({
|
||||
loadModule: vi.fn(),
|
||||
fetchPlans: vi.fn(),
|
||||
availablePlans: [] as Array<{ id: string; name: string; max_messages: number }>,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
hasEnterpriseModule: true,
|
||||
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
|
||||
loadModule,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
useSubscriptionStorage: () => ({
|
||||
getCurrentSubscription: () => null,
|
||||
getAvailablePlans: () => availablePlans,
|
||||
isSubscriptionActive: () => false,
|
||||
hasFeature: () => false,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useAISetup', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useAISetup: () => ({
|
||||
isLoading: ref(false),
|
||||
error: ref(''),
|
||||
providers: ref([]),
|
||||
setupConfig: ref({ provider: 'openai', model: 'gpt-4o-mini', apiKey: '', baseUrl: '' }),
|
||||
saveAISetup: vi.fn(),
|
||||
updateAISetup: vi.fn(),
|
||||
hasExistingConfig: ref(false),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AISetup subscription plan loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
availablePlans.splice(0)
|
||||
fetchPlans.mockImplementation(async () => {
|
||||
availablePlans.push({ id: 'starter', name: 'Starter', max_messages: 500 })
|
||||
})
|
||||
loadModule.mockResolvedValue({ subscriptionStore: { fetchPlans } })
|
||||
})
|
||||
|
||||
it('loads the optional enterprise catalog once during initialization', async () => {
|
||||
const wrapper = mount(AISetup)
|
||||
await flushPromises()
|
||||
|
||||
expect(loadModule).toHaveBeenCalledTimes(1)
|
||||
expect(fetchPlans).toHaveBeenCalledTimes(1)
|
||||
expect(availablePlans).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { AxiosError } from 'axios'
|
||||
import AIAgentSetup from '@/components/aiagent/AIAgentSetup.vue'
|
||||
|
||||
const { getOrganizationAgents, getOrganizationConfig, setAgents, route } = vi.hoisted(() => ({
|
||||
getOrganizationAgents: vi.fn(),
|
||||
getOrganizationConfig: vi.fn(),
|
||||
setAgents: vi.fn(),
|
||||
route: { query: {} as Record<string, string> },
|
||||
}))
|
||||
|
||||
vi.mock('@/services/agent', () => ({ agentService: { getOrganizationAgents } }))
|
||||
vi.mock('@/services/ai', () => ({
|
||||
aiService: {
|
||||
getOrganizationConfig,
|
||||
getProviders: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
useAgentStorage: () => ({ setAgents }),
|
||||
useSubscriptionStorage: () => ({
|
||||
getCurrentSubscription: () => null,
|
||||
isSubscriptionActive: () => false,
|
||||
hasFeature: () => false,
|
||||
getAvailablePlans: () => [],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
hasEnterpriseModule: false,
|
||||
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
|
||||
loadModule: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}))
|
||||
describe('AIAgentSetup initial configuration check', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getOrganizationAgents.mockResolvedValue([{ id: 'agent-1', name: 'Test agent' }])
|
||||
getOrganizationConfig.mockResolvedValue({ provider: 'openai' })
|
||||
})
|
||||
|
||||
it('checks existing agents once and shows the configured agent list', async () => {
|
||||
const wrapper = mount(AIAgentSetup, {
|
||||
props: { model: 'openai' },
|
||||
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
|
||||
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
|
||||
expect(setAgents).toHaveBeenCalledWith([{ id: 'agent-1', name: 'Test agent' }])
|
||||
expect(wrapper.text()).not.toContain('配置 AI 服务商')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens AI setup when an existing organization has no AI configuration', async () => {
|
||||
const notFound = new AxiosError('AI configuration not found')
|
||||
Object.defineProperty(notFound, 'response', { value: { status: 404 } })
|
||||
getOrganizationConfig.mockRejectedValue(notFound)
|
||||
|
||||
const wrapper = mount(AIAgentSetup, {
|
||||
props: { model: 'openai' },
|
||||
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
|
||||
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('配置 AI 服务商')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import ChatMessage from '../../../components/aiagent/ChatMessage.vue'
|
||||
import robotIcon from '@/assets/robot.png'
|
||||
import userIcon from '@/assets/user.png'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
let wrapper: VueWrapper
|
||||
@@ -143,4 +142,4 @@ describe('ChatMessage', () => {
|
||||
|
||||
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('AI 助手')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import FaqWorkspace from '@/components/faq/FaqWorkspace.vue'
|
||||
|
||||
const { updateSettingsMock, settingsSeed, persistedSettings } = vi.hoisted(() => ({
|
||||
updateSettingsMock: vi.fn(),
|
||||
persistedSettings: { value: null as Record<string, unknown> | null },
|
||||
settingsSeed: {
|
||||
enabled: true,
|
||||
slug: 'help-center',
|
||||
title: 'Support',
|
||||
description: null,
|
||||
logo_url: null,
|
||||
favicon_url: null,
|
||||
brand_color: '#000000',
|
||||
header_links: [] as Array<{ label: string; url: string }>,
|
||||
cta_text: 'Contact us',
|
||||
cta_url: '',
|
||||
cta_enabled: true,
|
||||
auto_generate: false,
|
||||
agent_id: null,
|
||||
ai_search_enabled: false,
|
||||
chat_widget_enabled: false,
|
||||
live_url: 'https://support.example.test/help-center',
|
||||
published_count: 0,
|
||||
plan_allowed: true,
|
||||
agents: [],
|
||||
domain: null,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFaqWorkspace', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const noop = vi.fn()
|
||||
const noopAsync = vi.fn(async () => {})
|
||||
|
||||
return {
|
||||
useFaqWorkspace: () => ({
|
||||
faqs: ref([]),
|
||||
job: ref(null),
|
||||
settings: ref({ ...settingsSeed, header_links: [...settingsSeed.header_links] }),
|
||||
estimate: ref(null),
|
||||
fetchEstimate: noopAsync,
|
||||
sourceCount: ref(0),
|
||||
pageCount: ref(0),
|
||||
phase: ref('empty'),
|
||||
barPhase: ref('idle'),
|
||||
publishedCount: ref(0),
|
||||
groupedFaqs: ref(new Map()),
|
||||
searchQuery: ref(''),
|
||||
categoryFilter: ref(null),
|
||||
statusFilter: ref('all'),
|
||||
categories: ref([]),
|
||||
hasActiveFilters: ref(false),
|
||||
filteredCount: ref(0),
|
||||
resetFilters: noop,
|
||||
toggleCategory: noop,
|
||||
isCategoryOpen: () => true,
|
||||
editingId: ref(null),
|
||||
isNewFaq: ref(false),
|
||||
draftQuestion: ref(''),
|
||||
draftAnswer: ref(''),
|
||||
draftCategory: ref('General'),
|
||||
draftSlug: ref(''),
|
||||
draftUrlPath: ref(''),
|
||||
draftMetaTitle: ref(''),
|
||||
draftMetaDescription: ref(''),
|
||||
isSaving: ref(false),
|
||||
selectedIds: ref(new Set()),
|
||||
selectionActive: ref(false),
|
||||
toggleSelect: noop,
|
||||
setSelected: noop,
|
||||
clearSelection: noop,
|
||||
bulkSetStatus: noopAsync,
|
||||
bulkDelete: noopAsync,
|
||||
refresh: noopAsync,
|
||||
startPolling: noop,
|
||||
stopPolling: noop,
|
||||
startGeneration: noopAsync,
|
||||
submitImport: noopAsync,
|
||||
submitPdfImport: noopAsync,
|
||||
togglePublish: noopAsync,
|
||||
startEdit: noop,
|
||||
startAdd: noop,
|
||||
cancelEdit: noop,
|
||||
saveEdit: noopAsync,
|
||||
deleteFaq: noopAsync,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/faq', () => ({
|
||||
faqService: {
|
||||
updateSettings: updateSettingsMock,
|
||||
uploadLogo: vi.fn(),
|
||||
removeLogo: vi.fn(),
|
||||
uploadFavicon: vi.fn(),
|
||||
removeFavicon: vi.fn(),
|
||||
setDomain: vi.fn(),
|
||||
verifyDomain: vi.fn(),
|
||||
removeDomain: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Help Center appearance updates', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
persistedSettings.value = { ...settingsSeed, header_links: [] }
|
||||
updateSettingsMock.mockImplementation(async (patch: Record<string, unknown>) => {
|
||||
persistedSettings.value = { ...persistedSettings.value, ...patch }
|
||||
return { ...persistedSettings.value }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function openAppearance() {
|
||||
wrapper = mount(FaqWorkspace, {
|
||||
global: {
|
||||
stubs: {
|
||||
FaqGenerateBar: true,
|
||||
FaqEmptyState: true,
|
||||
FaqGenerationProgress: true,
|
||||
FaqCategoryGroup: true,
|
||||
FaqCard: true,
|
||||
HelpCenterPublic: true,
|
||||
FaqImportModal: true,
|
||||
FaqSourcePicker: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const customTab = wrapper.findAll('button.tab').find((button) => button.text().includes('自定义'))
|
||||
expect(customTab).toBeDefined()
|
||||
await customTab!.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('updates the parent draft immediately and saves text after the existing debounce', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaUrl.setValue('app.example.test')
|
||||
|
||||
expect((ctaUrl.element as HTMLInputElement).value).toBe('app.example.test')
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(799)
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({ cta_url: 'app.example.test' })
|
||||
})
|
||||
|
||||
it('merges text edits made inside one debounce window into the saved patch', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaText.setValue('Talk to our team')
|
||||
await ctaUrl.setValue('app.example.test')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
cta_text: 'Talk to our team',
|
||||
cta_url: 'app.example.test',
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes pending text edits together with an immediate color save', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
await ctaText.setValue('Talk to our team')
|
||||
await wrapper.findAll('button.swatch')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
cta_text: 'Talk to our team',
|
||||
brand_color: '#4338CA',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a newer draft when an earlier save response arrives in flight', async () => {
|
||||
await openAppearance()
|
||||
|
||||
let resolveFirstSave!: (settings: Record<string, unknown>) => void
|
||||
const firstSaveResponse = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveFirstSave = resolve
|
||||
})
|
||||
updateSettingsMock.mockImplementationOnce(async (patch: Record<string, unknown>) => {
|
||||
persistedSettings.value = { ...persistedSettings.value, ...patch }
|
||||
const responseSnapshot = { ...persistedSettings.value }
|
||||
await firstSaveResponse
|
||||
return responseSnapshot
|
||||
})
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaText.setValue('First saved value')
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
|
||||
await ctaUrl.setValue('newer.example.test')
|
||||
expect((ctaUrl.element as HTMLInputElement).value).toBe('newer.example.test')
|
||||
resolveFirstSave({ ...persistedSettings.value })
|
||||
await flushPromises()
|
||||
|
||||
expect((wrapper.get('input[placeholder="app.yoursite.com"]').element as HTMLInputElement).value)
|
||||
.toBe('newer.example.test')
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledTimes(2)
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({ cta_url: 'newer.example.test' })
|
||||
expect(persistedSettings.value).toMatchObject({
|
||||
cta_text: 'First saved value',
|
||||
cta_url: 'newer.example.test',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves header-link edits through parent updates and custom colors immediately', async () => {
|
||||
await openAppearance()
|
||||
|
||||
await wrapper.get('button.add-link').trigger('click')
|
||||
const linkLabel = wrapper.get('.link-rows .text-input--label')
|
||||
await linkLabel.setValue('Documentation')
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({
|
||||
header_links: [{ label: 'Documentation', url: '' }],
|
||||
})
|
||||
|
||||
await wrapper.findAll('button.swatch')[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({ brand_color: '#4338CA' })
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
|
||||
import { useKnowledgeExplorer, type AddSourcePayload } from '../../../composables/useKnowledgeExplorer'
|
||||
|
||||
const knowledge = vi.hoisted(() => ({
|
||||
addUrls: vi.fn().mockResolvedValue({}),
|
||||
getKnowledgeByOrganization: vi.fn().mockResolvedValue({ knowledge: [] }),
|
||||
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [] }),
|
||||
getOrgQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
|
||||
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/knowledge', () => ({ knowledgeService: knowledge }))
|
||||
|
||||
const mountModal = async (url: string) => {
|
||||
const wrapper = mount(KnowledgeAddSourceModal)
|
||||
@@ -28,9 +39,15 @@ const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('.scope__title').map((node) => node.text())
|
||||
|
||||
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
|
||||
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
|
||||
wrapper.findAll('button[aria-pressed]').find((node) => node.text().startsWith(title))
|
||||
|
||||
const submitWebsite = async (wrapper: ReturnType<typeof mount>) => {
|
||||
await wrapper.findAll('button').find((button) => button.text() === '加入抓取队列')!.trigger('click')
|
||||
}
|
||||
|
||||
describe('KnowledgeAddSourceModal crawl scope', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('offers no section option for a homepage URL', async () => {
|
||||
// "Pages under / " is not a section — it is the whole site, which the next
|
||||
// option already covers.
|
||||
@@ -47,25 +64,39 @@ describe('KnowledgeAddSourceModal crawl scope', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to this-site when the section option disappears', async () => {
|
||||
it('falls back to this-site and submits it to the organization knowledge base', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
|
||||
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
await submitWebsite(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
|
||||
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
|
||||
expect(payload).toEqual({
|
||||
type: 'website',
|
||||
url: 'https://help.example.com',
|
||||
scope: 'host',
|
||||
})
|
||||
const explorer = useKnowledgeExplorer('org', undefined, 'org-1')
|
||||
await expect(explorer.submitSource(payload)).resolves.toBe(true)
|
||||
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com'], undefined, undefined, undefined, 'host')
|
||||
expect(knowledge.getKnowledgeByOrganization).toHaveBeenCalledWith('org-1', 1, 100)
|
||||
expect(knowledge.getOrgQueueItems).toHaveBeenCalledWith('org-1')
|
||||
expect(knowledge.getKnowledgeByAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the selected scope', async () => {
|
||||
it('submits the selected section for the agent knowledge base', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
await submitWebsite(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
|
||||
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
|
||||
expect(payload).toEqual({ type: 'website', url: 'https://help.example.com/hc/en', scope: 'path' })
|
||||
const explorer = useKnowledgeExplorer('agent', 'agent-1', 'org-1')
|
||||
await expect(explorer.submitSource(payload)).resolves.toBe(true)
|
||||
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com/hc/en'], 'agent-1', undefined, undefined, 'path')
|
||||
expect(knowledge.getKnowledgeByAgent).toHaveBeenCalledWith('agent-1', 1, 100)
|
||||
expect(knowledge.getAgentQueueItems).toHaveBeenCalledWith('agent-1')
|
||||
expect(knowledge.getKnowledgeByOrganization).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@ vi.mock('@/pwa/register', () => ({
|
||||
|
||||
const setNotificationPermission = (permission: string | null) => {
|
||||
if (permission === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
delete (window as any).Notification
|
||||
} else {
|
||||
vi.stubGlobal('Notification', { permission })
|
||||
@@ -53,7 +53,7 @@ describe('EnablePushPrompt', () => {
|
||||
setNotificationPermission('default')
|
||||
const wrapper = await mountPrompt()
|
||||
expect(wrapper.find('.push-prompt').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Enable notifications')
|
||||
expect(wrapper.text()).toContain('开启通知')
|
||||
})
|
||||
|
||||
it('stays hidden when permission is already granted', async () => {
|
||||
@@ -95,7 +95,7 @@ describe('EnablePushPrompt', () => {
|
||||
setNotificationPermission(null)
|
||||
vi.stubGlobal('navigator', { userAgent: 'iPhone Safari' })
|
||||
const wrapper = await mountPrompt()
|
||||
expect(wrapper.text()).toContain('Add ReplyPier to your Home Screen')
|
||||
expect(wrapper.text()).toContain('请先把 ReplyPier 添加到主屏幕')
|
||||
expect(wrapper.find('.primary-btn').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,6 @@ describe('TicketInvestigationPanel guided re-run', () => {
|
||||
const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false })
|
||||
await denied.find('.rerun-btn').trigger('click')
|
||||
expect(denied.find('.rerun-scope a').exists()).toBe(false)
|
||||
expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings')
|
||||
expect(denied.find('.rerun-scope').text()).toContain('工单设置')
|
||||
})
|
||||
})
|
||||
@@ -61,7 +61,7 @@ describe('TicketSidePanel CSAT card', () => {
|
||||
)
|
||||
expect(card.text()).toContain('4/5')
|
||||
expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5)
|
||||
expect(card.text()).toContain('Rated by the customer')
|
||||
expect(card.text()).toContain('客户已在关联会话中评分')
|
||||
})
|
||||
|
||||
it('shows pending while the ask is out', () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ChatDetail } from '@/types/chat'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (value?: any) => void>()
|
||||
const reconnect = new Set<() => void>()
|
||||
const acknowledgements = new Map<string, () => void>()
|
||||
return {
|
||||
handlers, reconnect, acknowledgements,
|
||||
getChatDetail: vi.fn(),
|
||||
socket: {
|
||||
connect: vi.fn(),
|
||||
on: vi.fn((name: string, handler: (value?: any) => void) => handlers.set(name, handler)),
|
||||
off: vi.fn((name: string) => handlers.delete(name)),
|
||||
onReconnect: vi.fn((handler: () => void) => reconnect.add(handler)),
|
||||
offReconnect: vi.fn((handler: () => void) => reconnect.delete(handler)),
|
||||
emit: vi.fn((event: string, value: { session_id: string }, ack?: () => void) => {
|
||||
if (event === 'join_room' && ack) acknowledgements.set(value.session_id, ack)
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/socket', () => ({ socketService: mocks.socket }))
|
||||
vi.mock('@/services/chat', () => ({ chatService: { getChatDetail: mocks.getChatDetail } }))
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: {
|
||||
getUserId: () => 'user-1',
|
||||
getCurrentUser: () => ({ organization_id: 'org-1', role: { permissions: [{ name: 'view_unassigned_chats' }] } }),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useConversationsList } from '@/composables/useConversationsList'
|
||||
|
||||
const detail = (sessionId: string, messages: ChatDetail['messages'] = []): ChatDetail => ({
|
||||
session_id: sessionId,
|
||||
customer: { id: 'customer', email: '' },
|
||||
agent: { id: 'agent', name: 'AI', display_name: null },
|
||||
messages, status: 'open', user_id: null, group_id: null,
|
||||
created_at: '2026-10-29T00:00:00Z', updated_at: '2026-10-29T00:00:00Z',
|
||||
})
|
||||
|
||||
const message = (id: number, text: string) => ({
|
||||
id, message: text, message_type: id % 2 ? 'user' : 'bot',
|
||||
created_at: '2026-10-29T00:00:00Z', session_id: 's1',
|
||||
})
|
||||
|
||||
function setup() {
|
||||
let model!: ReturnType<typeof useConversationsList>
|
||||
const emit = vi.fn()
|
||||
const props = reactive({ conversations: [], loading: false, error: '' })
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() { model = useConversationsList(props, emit); return () => h('div') },
|
||||
}))
|
||||
return { model, emit, wrapper }
|
||||
}
|
||||
|
||||
describe('conversation realtime synchronization', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mocks.handlers.clear()
|
||||
mocks.reconnect.clear()
|
||||
mocks.acknowledgements.clear()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('refreshes a selected AI conversation after its authorized watch is acknowledged', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, '你好'), message(2, '您好')]))
|
||||
await model.loadChatDetail('s1')
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('join_room', { session_id: 'chat_watch_s1' }, expect.any(Function))
|
||||
mocks.acknowledgements.get('chat_watch_s1')!()
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['你好', '您好'])
|
||||
expect(emit).toHaveBeenCalledWith('chatUpdated', expect.objectContaining({ session_id: 's1' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('coalesces thousands of updates and performs only one trailing read while a read is in flight', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
let finish!: (value: ChatDetail) => void
|
||||
const delayed = new Promise<ChatDetail>(resolve => { finish = resolve })
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockReturnValueOnce(delayed).mockResolvedValueOnce(detail('s1', [message(1, 'latest')]))
|
||||
await model.loadChatDetail('s1')
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
mocks.handlers.get('inbox_changed')!()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
for (let i = 0; i < 5000; i++) mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
expect(emit.mock.calls.filter(call => call[0] === 'liveRefresh')).toHaveLength(1)
|
||||
finish(detail('s1'))
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(3)
|
||||
expect(model.selectedChat.value?.messages[0].message).toBe('latest')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not replace a newer selection with a slow older response', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
let finish!: (value: ChatDetail) => void
|
||||
mocks.getChatDetail.mockReturnValueOnce(new Promise<ChatDetail>(resolve => { finish = resolve })).mockResolvedValueOnce(detail('s2'))
|
||||
const first = model.loadChatDetail('s1')
|
||||
await model.loadChatDetail('s2')
|
||||
finish(detail('s1'))
|
||||
await first
|
||||
expect(model.selectedChat.value?.session_id).toBe('s2')
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejoins and resynchronizes after a connection drops, then removes its listeners', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
mocks.getChatDetail.mockResolvedValue(detail('s1'))
|
||||
await model.loadChatDetail('s1')
|
||||
for (const callback of mocks.reconnect) callback()
|
||||
mocks.acknowledgements.get('chat_watch_s1')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
|
||||
expect(mocks.handlers.has('chat_changed')).toBe(false)
|
||||
expect(mocks.reconnect.size).toBe(0)
|
||||
})
|
||||
|
||||
it('eventually reconciles if a broker notification is missed without a disconnect', async () => {
|
||||
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, 'recovered')]))
|
||||
await model.loadChatDetail('s1')
|
||||
await vi.advanceTimersByTimeAsync(46000)
|
||||
expect(model.selectedChat.value?.messages[0].message).toBe('recovered')
|
||||
expect(emit).toHaveBeenCalledWith('liveRefresh')
|
||||
wrapper.unmount()
|
||||
random.mockRestore()
|
||||
})
|
||||
|
||||
it('drops a conversation when the server revokes access', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockRejectedValueOnce({ response: { status: 404 } })
|
||||
await model.loadChatDetail('s1')
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(model.selectedChat.value).toBeNull()
|
||||
expect(emit).toHaveBeenCalledWith('chatAccessLost')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps two same-tick messages and ignores a duplicate row', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
mocks.getChatDetail.mockResolvedValue(detail('s1'))
|
||||
await model.loadChatDetail('s1')
|
||||
const reply = mocks.handlers.get('chat_reply')!
|
||||
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
reply({ session_id: 's1', message_id: 2, message: 'b', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
await Promise.resolve()
|
||||
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['a', 'b'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { io, socket, emitter } = vi.hoisted(() => {
|
||||
const socket = {
|
||||
connected: false,
|
||||
active: true,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
io: vi.fn(() => socket),
|
||||
socket,
|
||||
emitter: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('socket.io-client', () => ({ io }))
|
||||
vi.mock('@/utils/emitter', () => ({ default: emitter }))
|
||||
vi.mock('@/config/api', () => ({ getWsUrl: () => 'wss://support.example.test' }))
|
||||
|
||||
import { socketService } from '@/services/socket'
|
||||
|
||||
describe('SocketService connection lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
socketService.disconnect()
|
||||
vi.clearAllMocks()
|
||||
socket.connected = false
|
||||
socket.active = true
|
||||
io.mockReturnValue(socket)
|
||||
})
|
||||
|
||||
it('reuses a pending or reconnecting socket instead of replacing its listeners', () => {
|
||||
socketService.connect()
|
||||
socketService.on('chat_reply', vi.fn())
|
||||
|
||||
socketService.connect()
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
expect(socket.on).toHaveBeenCalledTimes(5)
|
||||
expect(socket.disconnect).not.toHaveBeenCalled()
|
||||
expect(socket.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconnects an existing inactive socket without creating a second instance', () => {
|
||||
socketService.connect()
|
||||
socket.active = false
|
||||
|
||||
socketService.connect()
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
expect(socket.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows a fresh socket after an explicit disconnect', () => {
|
||||
const nextSocket = {
|
||||
...socket,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
io.mockReturnValueOnce(socket).mockReturnValueOnce(nextSocket)
|
||||
|
||||
socketService.connect('/agent')
|
||||
socketService.disconnect()
|
||||
socketService.connect('/agent')
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(io).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateWidgetFormField } from '@/utils/widgetFormValidation'
|
||||
|
||||
describe('validateWidgetFormField', () => {
|
||||
it('uses the server-trimmed value for required and text length rules', () => {
|
||||
const field = { name: 'name', label: 'Name', type: 'text', minLength: 2 }
|
||||
|
||||
expect(validateWidgetFormField(field, ' A ')).toBe('Name 至少需要 2 个字符')
|
||||
expect(validateWidgetFormField(field, ' AB ')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, required: true }, ' ')).toBe('Name 为必填项')
|
||||
})
|
||||
|
||||
it('counts text by Unicode code point like Python len()', () => {
|
||||
const field = { name: 'name', label: 'Name', type: 'text' }
|
||||
|
||||
expect(validateWidgetFormField({ ...field, minLength: 2 }, '🙂'))
|
||||
.toBe('Name 至少需要 2 个字符')
|
||||
expect(validateWidgetFormField({ ...field, maxLength: 1 }, '🙂')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a zero text maxLength as disabled, matching the server contract', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'message', label: 'Message', type: 'text', maxLength: 0 },
|
||||
'hello',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps numeric zero and zero bounds meaningful', () => {
|
||||
const field = { name: 'amount', label: 'Amount', type: 'number', maxLength: 0, required: true }
|
||||
|
||||
expect(validateWidgetFormField(field, 0)).toBeNull()
|
||||
expect(validateWidgetFormField(field, '1')).toBe('Amount 不能大于 0')
|
||||
expect(validateWidgetFormField({ name: 'age', label: 'Age', type: 'number' }, '-1'))
|
||||
.toBe('Age 不能小于 0')
|
||||
})
|
||||
|
||||
it('rejects numeric text that Python float() rejects', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'age', label: 'Age', type: 'number' },
|
||||
'12abc',
|
||||
)).toBe('Age 必须是有效数字')
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'age', label: 'Age', type: 'number', minLength: 18 },
|
||||
'2.5e1',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts Python float() decimal forms and preserves explicit null bounds', () => {
|
||||
const field = { name: 'amount', label: 'Amount', type: 'number' }
|
||||
|
||||
expect(validateWidgetFormField(field, '3.')).toBeNull()
|
||||
expect(validateWidgetFormField(field, '1.e3')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, minLength: null }, '-1')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, maxLength: null }, '1')).toBeNull()
|
||||
})
|
||||
|
||||
it('trims email values before applying the existing email rule', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
' person@example.test ',
|
||||
)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,8 @@ describe('403 View', () => {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/403', name: 'forbidden', component: ForbiddenView }
|
||||
{ path: '/403', name: 'forbidden', component: ForbiddenView },
|
||||
{ path: '/settings/user', name: 'user-settings', component: { template: '<div>Settings</div>' } }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -54,10 +55,6 @@ describe('403 View', () => {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
template: '<a :href="to" class="router-link"><slot /></a>',
|
||||
props: ['to']
|
||||
},
|
||||
DashboardLayout: {
|
||||
template: '<div class="dashboard-layout"><slot /></div>'
|
||||
}
|
||||
@@ -68,8 +65,8 @@ describe('403 View', () => {
|
||||
|
||||
it('renders properly', () => {
|
||||
expect(wrapper.find('.unauthorized').exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Access Denied')
|
||||
expect(wrapper.find('p').text()).toBe('You don\'t have permission to access this page.')
|
||||
expect(wrapper.find('h1').text()).toBe('访问被拒绝')
|
||||
expect(wrapper.find('p').text()).toBe('你没有权限访问此页面。')
|
||||
})
|
||||
|
||||
it('is wrapped in DashboardLayout', () => {
|
||||
@@ -95,14 +92,11 @@ describe('403 View', () => {
|
||||
expect(backLink.classes()).toContain('back-link')
|
||||
})
|
||||
|
||||
it('navigates to home when clicking the back link', async () => {
|
||||
it('navigates to the accessible page when clicking the back link', async () => {
|
||||
const backLink = wrapper.find('.back-link')
|
||||
await backLink.trigger('click')
|
||||
|
||||
// Simulate router navigation
|
||||
await router.push('/')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/settings/user')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,12 +84,14 @@ vi.mock('@/services/api', () => ({
|
||||
// Mock agent service
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: {
|
||||
getAgentRoster: vi.fn().mockResolvedValue([]),
|
||||
getOrganizationAgents: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
// Import the mocked modules
|
||||
import { chatService } from '@/services/chat'
|
||||
import { agentService } from '@/services/agent'
|
||||
|
||||
describe('ConversationsView', () => {
|
||||
let wrapper: VueWrapper
|
||||
@@ -138,6 +140,20 @@ describe('ConversationsView', () => {
|
||||
expect(conversationsList.props('loading')).toBe(false)
|
||||
})
|
||||
|
||||
it('loads the lightweight agent roster used by the inbox filter', async () => {
|
||||
const roster = [{ id: 'agent-1', name: 'Ada', display_name: 'Ada Lovelace' }]
|
||||
vi.mocked(agentService.getAgentRoster).mockResolvedValue(roster)
|
||||
|
||||
wrapper = mount(ConversationsView)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const filters = wrapper.findComponent({ name: 'ConversationFilters' })
|
||||
expect(filters.props('agents')).toEqual(roster)
|
||||
})
|
||||
|
||||
expect(agentService.getAgentRoster).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading error', async () => {
|
||||
const errorMessage = '加载会话失败'
|
||||
;(chatService.getRecentChats as any).mockRejectedValue(new Error('API Error'))
|
||||
@@ -186,4 +202,4 @@ describe('ConversationsView', () => {
|
||||
|
||||
expect(chatService.getRecentChats).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,30 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { flushPromises, mount, VueWrapper } from '@vue/test-utils'
|
||||
import HumanAgentView from '../../views/HumanAgentView.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const serviceMocks = vi.hoisted(() => ({
|
||||
listUsers: vi.fn(),
|
||||
getTeamOverview: vi.fn(),
|
||||
listGroups: vi.fn(),
|
||||
listRoles: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/users', () => ({
|
||||
listUsers: serviceMocks.listUsers,
|
||||
getTeamOverview: serviceMocks.getTeamOverview
|
||||
}))
|
||||
|
||||
vi.mock('@/services/groups', () => ({
|
||||
listGroups: serviceMocks.listGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/services/roles', () => ({
|
||||
listRoles: serviceMocks.listRoles
|
||||
}))
|
||||
|
||||
// Mock the components
|
||||
vi.mock('@/layouts/DashboardLayout.vue', () => ({
|
||||
default: {
|
||||
@@ -51,9 +71,26 @@ vi.mock('@/components/human-agent/RoleList.vue', () => ({
|
||||
describe('HumanAgentView', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
serviceMocks.listUsers.mockResolvedValue([{
|
||||
id: 'agent-1',
|
||||
email: 'agent@example.test',
|
||||
full_name: 'Test Agent',
|
||||
organization_id: 'org-test',
|
||||
is_online: true,
|
||||
role: { id: 'role-agent', name: 'Agent' }
|
||||
}])
|
||||
serviceMocks.getTeamOverview.mockResolvedValue(null)
|
||||
serviceMocks.listGroups.mockResolvedValue([{
|
||||
id: 'group-1',
|
||||
name: 'Support',
|
||||
organization_id: 'org-test'
|
||||
}])
|
||||
serviceMocks.listRoles.mockResolvedValue([{ id: 'role-agent', name: 'Agent' }])
|
||||
wrapper = mount(HumanAgentView)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('renders properly', () => {
|
||||
@@ -77,6 +114,15 @@ describe('HumanAgentView', () => {
|
||||
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loads the roster and tab counts through the local service boundary', () => {
|
||||
expect(serviceMocks.listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.getTeamOverview).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.listGroups).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.listRoles).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.tab-pill .count').map((badge) => badge.text())).toEqual(['1', '1', '1'])
|
||||
expect(wrapper.find('.stat-value').text()).toBe('1')
|
||||
})
|
||||
|
||||
it('switches to GroupList when Groups tab is clicked', async () => {
|
||||
const groupsTab = wrapper.findAll('.tab-pill')[1]
|
||||
await groupsTab.trigger('click')
|
||||
@@ -121,4 +167,4 @@ describe('HumanAgentView', () => {
|
||||
const dashboardLayout = wrapper.findComponent({ name: 'DashboardLayout' })
|
||||
expect(dashboardLayout.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SetupView from '../../views/SetupView.vue'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -58,13 +58,17 @@ describe('SetupView', () => {
|
||||
let wrapper: VueWrapper
|
||||
let router: any
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh pinia instance
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Create router instance
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } }
|
||||
@@ -85,6 +89,7 @@ describe('SetupView', () => {
|
||||
})
|
||||
|
||||
// Wait for initial organization check
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
@@ -102,6 +107,7 @@ describe('SetupView', () => {
|
||||
vi.mocked(getSetupStatus).mockResolvedValueOnce(true)
|
||||
|
||||
// Remount component
|
||||
wrapper.unmount()
|
||||
wrapper = mount(SetupView, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
@@ -288,4 +294,4 @@ describe('SetupView', () => {
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').classes()).toContain('loading')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import TicketsView from '@/views/TicketsView.vue'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
|
||||
const { router } = vi.hoisted(() => ({
|
||||
router: { push: vi.fn(), replace: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => router,
|
||||
}))
|
||||
vi.mock('@/layouts/DashboardLayout.vue', () => ({
|
||||
default: { name: 'DashboardLayout', template: '<div><slot /></div>' },
|
||||
}))
|
||||
vi.mock('@/composables/useTicketSocket', () => ({ useTicketSocket: vi.fn() }))
|
||||
vi.mock('@/composables/useUsers', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useUsers: () => ({ users: ref([]), fetchUsers: vi.fn().mockResolvedValue([]) }) }
|
||||
})
|
||||
vi.mock('@/services/tickets', () => ({
|
||||
ticketService: {
|
||||
listTickets: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ticket filter updates through the workspace', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(ticketService.listTickets).mockResolvedValue({
|
||||
tickets: [],
|
||||
pagination: { page: 1, per_page: 20, total: 0, total_pages: 0 },
|
||||
})
|
||||
vi.mocked(ticketService.getStats).mockResolvedValue({
|
||||
open: 0,
|
||||
awaiting_approval: 0,
|
||||
sla_breaching: 0,
|
||||
ai_resolved_pct_7d: null,
|
||||
csat_avg: null,
|
||||
csat_ai_avg: null,
|
||||
csat_human_avg: null,
|
||||
csat_responses: 0,
|
||||
csat_window_days: 7,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('updates parent filters, URL state, and the debounced search request', async () => {
|
||||
wrapper = mount(TicketsView)
|
||||
await flushPromises()
|
||||
vi.mocked(ticketService.listTickets).mockClear()
|
||||
|
||||
const statusButton = wrapper.findAll('button.pill').find((button) => button.text() === '待处理')
|
||||
expect(statusButton).toBeDefined()
|
||||
await statusButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.replace).toHaveBeenCalledWith({ query: { status: 'open' } })
|
||||
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation',
|
||||
}))
|
||||
|
||||
vi.mocked(ticketService.listTickets).mockClear()
|
||||
const search = wrapper.get('input.search-input')
|
||||
await search.setValue('refund')
|
||||
await vi.advanceTimersByTimeAsync(349)
|
||||
expect(ticketService.listTickets).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.replace).toHaveBeenLastCalledWith({ query: { status: 'open', search: 'refund' } })
|
||||
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({ search: 'refund' }))
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,7 @@ describe('the composer while a reply is in flight', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const composer = (wrapper: any) =>
|
||||
@@ -185,4 +186,51 @@ describe('the composer while a reply is in flight', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('observes dynamic inputs without a runtime Node global', async () => {
|
||||
vi.stubGlobal('Node', undefined)
|
||||
const errors: string[] = []
|
||||
const onWindowError = (event: ErrorEvent) => errors.push(event.message)
|
||||
window.addEventListener('error', onWindowError)
|
||||
|
||||
const wrapper = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const hostInput = document.createElement('input')
|
||||
hostInput.type = 'text'
|
||||
document.body.appendChild(hostInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(errors).toEqual([])
|
||||
hostInput.remove()
|
||||
window.removeEventListener('error', onWindowError)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('removes the same host-message listener before remounting', () => {
|
||||
const addListener = vi.spyOn(window, 'addEventListener')
|
||||
const removeListener = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
const firstMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const messageListeners = () => addListener.mock.calls
|
||||
.filter(([type]) => type === 'message')
|
||||
.map(([, listener]) => listener)
|
||||
|
||||
const firstHostListener = messageListeners()[0]
|
||||
expect(firstHostListener).toBeDefined()
|
||||
firstMount.unmount()
|
||||
expect(removeListener.mock.calls.some(
|
||||
([type, listener]) => type === 'message' && listener === firstHostListener
|
||||
)).toBe(true)
|
||||
|
||||
const secondMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const listenersAfterRemount = messageListeners()
|
||||
const secondHostListener = listenersAfterRemount[2]
|
||||
|
||||
expect(listenersAfterRemount).toHaveLength(4)
|
||||
expect(secondHostListener).not.toBe(firstHostListener)
|
||||
expect(removeListener.mock.calls.some(
|
||||
([type, listener]) => type === 'message' && listener === secondHostListener
|
||||
)).toBe(false)
|
||||
|
||||
secondMount.unmount()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user