feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

@@ -0,0 +1,156 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
// FaqCard → MarkdownEditor → faqService → api → router → views → firebase,
// which cannot initialize in the test environment.
vi.mock('@/services/firebase', () => ({
messaging: {},
requestNotificationPermission: vi.fn(),
}))
vi.mock('@/services/faq', () => ({
faqService: { uploadImage: vi.fn() },
}))
import FaqCard from '../../../components/faq/FaqCard.vue'
import MarkdownEditor from '../../../components/faq/MarkdownEditor.vue'
import type { FaqItem } from '../../../types/faq'
const faq: FaqItem = {
id: 'f1',
question: 'How does billing work?',
answer: 'Plans are billed per seat, monthly or yearly.',
category: 'Billing',
status: 'published',
knowledge_id: 1,
source_label: 'Billing & refunds policy',
}
const createWrapper = (props = {}) =>
mount(FaqCard, {
props: {
faq,
editing: false,
draftQuestion: '',
draftAnswer: '',
...props,
},
})
describe('FaqCard display mode', () => {
it('renders question, answer and source label', () => {
const wrapper = createWrapper()
expect(wrapper.find('.faq-card__question').text()).toBe('How does billing work?')
expect(wrapper.find('.faq-card__answer').text()).toBe('Plans are billed per seat, monthly or yearly.')
expect(wrapper.find('.faq-card__source').text()).toContain('Billing & refunds policy')
})
it('falls back to Generated when there is no source label', () => {
const wrapper = createWrapper({ faq: { ...faq, source_label: null } })
expect(wrapper.find('.faq-card__source').text()).toContain('自动生成')
})
it('shows the publish pill state', () => {
const published = createWrapper()
expect(published.find('.pill').classes()).toContain('pill--published')
expect(published.find('.pill').text()).toContain('已发布')
const draft = createWrapper({ faq: { ...faq, status: 'draft' } })
expect(draft.find('.pill').classes()).toContain('pill--draft')
expect(draft.find('.pill').text()).toContain('草稿')
})
it('emits toggle-status when the pill is clicked', async () => {
const wrapper = createWrapper()
await wrapper.find('.pill').trigger('click')
expect(wrapper.emitted('toggle-status')).toHaveLength(1)
})
it('emits edit and delete from the icon buttons', async () => {
const wrapper = createWrapper()
await wrapper.find('.icon-btn--edit').trigger('click')
await wrapper.find('.icon-btn--delete').trigger('click')
expect(wrapper.emitted('edit')).toHaveLength(1)
expect(wrapper.emitted('delete')).toHaveLength(1)
})
it('hides edit and delete and disables the pill when locked', () => {
const wrapper = createWrapper({ locked: true })
expect(wrapper.find('.icon-btn--edit').exists()).toBe(false)
expect(wrapper.find('.icon-btn--delete').exists()).toBe(false)
expect(wrapper.find('.pill').attributes('disabled')).toBeDefined()
})
})
describe('FaqCard edit mode', () => {
it('renders inputs bound to the drafts', () => {
const wrapper = createWrapper({
editing: true,
draftQuestion: 'Draft question?',
draftAnswer: 'Draft answer.',
})
expect((wrapper.find('.edit-question').element as HTMLInputElement).value).toBe('Draft question?')
// The answer is edited through the MarkdownEditor component.
expect(wrapper.findComponent(MarkdownEditor).props('modelValue')).toBe('Draft answer.')
expect(wrapper.find('.edit-label').text()).toBe('编辑中')
})
it('labels a new FAQ', () => {
const wrapper = createWrapper({ editing: true, isNew: true })
expect(wrapper.find('.edit-label').text()).toBe('新建常见问题')
})
it('emits draft updates on input', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.edit-question').setValue('New question')
wrapper.findComponent(MarkdownEditor).vm.$emit('update:modelValue', 'New answer')
expect(wrapper.emitted('update:draftQuestion')?.[0]).toEqual(['New question'])
expect(wrapper.emitted('update:draftAnswer')?.[0]).toEqual(['New answer'])
})
it('renders the topic input with existing topics as autocomplete options', () => {
const wrapper = createWrapper({
editing: true,
draftCategory: 'Billing',
categories: ['Billing', 'Getting started'],
})
expect((wrapper.find('.edit-topic__input').element as HTMLInputElement).value).toBe('Billing')
const options = wrapper.findAll('#faq-topic-options option')
expect(options.map((o) => o.attributes('value'))).toEqual(['Billing', 'Getting started'])
})
it('emits topic draft updates on input', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.edit-topic__input').setValue('Refunds')
expect(wrapper.emitted('update:draftCategory')?.[0]).toEqual(['Refunds'])
})
it('emits save and cancel', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.btn-save').trigger('click')
await wrapper.find('.btn-cancel').trigger('click')
expect(wrapper.emitted('save')).toHaveLength(1)
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('disables Save while saving', () => {
const wrapper = createWrapper({ editing: true, saving: true })
expect(wrapper.find('.btn-save').attributes('disabled')).toBeDefined()
expect(wrapper.find('.btn-save').text()).toBe('保存中…')
})
})
@@ -0,0 +1,77 @@
/*
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqGenerateBar from '../../../components/faq/FaqGenerateBar.vue'
const createWrapper = (props = {}) =>
mount(FaqGenerateBar, {
props: {
phase: 'idle' as const,
sourceCount: 5,
pageCount: 9,
faqCount: 12,
publishedCount: 7,
...props,
},
})
describe('FaqGenerateBar', () => {
it('shows knowledge base counts and Generate when idle', () => {
const wrapper = createWrapper()
expect(wrapper.find('.generate-bar__title').text()).toBe('生成常见问题')
expect(wrapper.find('.generate-bar__sub').text()).toContain('读取 5 个来源 · 9 个页面')
expect(wrapper.find('.btn--generate').text()).toContain('生成')
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeUndefined()
})
it('shows FAQ counts and Regenerate when ready', () => {
const wrapper = createWrapper({ phase: 'ready' })
expect(wrapper.find('.generate-bar__title').text()).toBe('常见问题待发布')
expect(wrapper.find('.generate-bar__sub').text()).toContain('12 条常见问题 · 7 条已发布')
expect(wrapper.find('.btn--generate').text()).toContain('重新生成')
})
it('disables the generate button while generating', () => {
const wrapper = createWrapper({ phase: 'generating' })
expect(wrapper.find('.generate-bar__title').text()).toBe('正在生成常见问题…')
expect(wrapper.find('.btn--generate').text()).toContain('生成中…')
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
})
it('respects the disabled prop', () => {
const wrapper = createWrapper({ disabled: true })
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
})
it('emits generate, import and add', async () => {
const wrapper = createWrapper()
await wrapper.find('.btn--generate').trigger('click')
const ghostButtons = wrapper.findAll('.btn--ghost')
await ghostButtons[0].trigger('click')
await ghostButtons[1].trigger('click')
expect(wrapper.emitted('generate')).toHaveLength(1)
expect(wrapper.emitted('import')).toHaveLength(1)
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit generate when disabled', async () => {
const wrapper = createWrapper({ phase: 'generating' })
await wrapper.find('.btn--generate').trigger('click')
expect(wrapper.emitted('generate')).toBeUndefined()
})
})
@@ -0,0 +1,74 @@
/*
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqGenerationProgress from '../../../components/faq/FaqGenerationProgress.vue'
import type { FaqGenerationJob, FaqJobStage } from '../../../types/faq'
const job = (stage: FaqJobStage): FaqGenerationJob => ({
id: 1,
job_type: 'generate_all',
status: 'processing',
stage,
progress_percentage: 40,
faqs_created: 0,
source_url: null,
error: null,
})
const stepStates = (stage: FaqJobStage) => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job(stage) } })
return wrapper.findAll('.step').map((step) => {
if (step.classes().includes('done')) return 'done'
if (step.classes().includes('active')) return 'active'
return 'todo'
})
}
describe('FaqGenerationProgress', () => {
it('renders four steps with the expected labels', () => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job('analyzing_sources') } })
const labels = wrapper.findAll('.step__label').map((el) => el.text())
expect(labels).toEqual([
'分析你的来源',
'从内容中提取问题',
'撰写有依据的回答',
'按分类整理',
])
})
it('marks earlier stages done, the current stage active, later stages todo', () => {
expect(stepStates('drafting')).toEqual(['done', 'done', 'active', 'todo'])
})
it('starts with the first stage active', () => {
expect(stepStates('analyzing_sources')).toEqual(['active', 'todo', 'todo', 'todo'])
expect(stepStates('not_started')).toEqual(['active', 'todo', 'todo', 'todo'])
})
it('marks everything done when completed', () => {
expect(stepStates('completed')).toEqual(['done', 'done', 'done', 'done'])
})
it('shows pulsing dots only on the active step', () => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job('drafting') } })
const steps = wrapper.findAll('.step')
expect(steps[2].find('.step__pulse').exists()).toBe(true)
expect(steps[0].find('.step__pulse').exists()).toBe(false)
expect(steps[3].find('.step__pulse').exists()).toBe(false)
})
})
@@ -0,0 +1,97 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqImportModal from '../../../components/faq/FaqImportModal.vue'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const createWrapper = () =>
mount(FaqImportModal, {
props: { open: true },
global: {
stubs: {
Modal: { template: '<div><slot name="title" /><slot name="content" /></div>' },
FaqOrb: true,
},
},
})
/** Pick an import mode by its radio value. */
const chooseMode = (wrapper: ReturnType<typeof createWrapper>, mode: string) =>
wrapper.find(`input[value="${mode}"]`).setValue(true)
describe('FaqImportModal', () => {
it('offers the preserve-URLs option only for an article migration', async () => {
const wrapper = createWrapper()
// Q&A and PDF modes extract answers from one document — there are no
// per-article source URLs to preserve.
expect(wrapper.find('.preserve').exists()).toBe(false)
await chooseMode(wrapper, 'articles')
expect(wrapper.find('.preserve').exists()).toBe(true)
await chooseMode(wrapper, 'pdf')
expect(wrapper.find('.preserve').exists()).toBe(false)
})
it('preserves URLs by default, since that is what a migration wants', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual([
'https://support.acme.com/help',
'articles',
true,
])
})
it('submits the opt-out when the box is unticked', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
await wrapper.find('.preserve__box').setValue(false)
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]?.[2]).toBe(false)
})
it('never claims to preserve URLs in a mode that has none', async () => {
const wrapper = createWrapper()
await wrapper.find('#faq-import-url').setValue('support.acme.com/faq')
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual(['https://support.acme.com/faq', 'qa', false])
})
it('resets the choice when the mode changes, so it cannot go stale', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('.preserve__box').setValue(false)
await chooseMode(wrapper, 'qa')
await chooseMode(wrapper, 'articles')
expect((wrapper.find('.preserve__box').element as HTMLInputElement).checked).toBe(true)
})
})
@@ -0,0 +1,90 @@
/*
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqSeoFields from '../../../components/faq/FaqSeoFields.vue'
const createWrapper = (props = {}) => mount(FaqSeoFields, { props })
// The meta-title input is the only plain .seo__input without a modifier class.
const titleInput = (wrapper: ReturnType<typeof createWrapper>) =>
wrapper.findAll('input.seo__input').filter((i) => !i.classes().some((c) => c.includes('--')))[0]
describe('FaqSeoFields', () => {
it('stays collapsed until opened when nothing is customised', async () => {
const wrapper = createWrapper()
expect(wrapper.find('.seo__fields').exists()).toBe(false)
await wrapper.find('.seo__toggle').trigger('click')
expect(wrapper.find('.seo__fields').exists()).toBe(true)
})
it('opens itself when the article already has overrides', () => {
// A customised value must never be hidden behind a collapsed panel.
const wrapper = createWrapper({ metaTitle: 'Custom title' })
expect(wrapper.find('.seo__fields').exists()).toBe(true)
expect(wrapper.find('.seo__badge').exists()).toBe(true)
})
it('can still be collapsed after opening itself', async () => {
const wrapper = createWrapper({ metaTitle: 'Custom title' })
await wrapper.find('.seo__toggle').trigger('click')
expect(wrapper.find('.seo__fields').exists()).toBe(false)
})
it('does not treat a slug alone as customised', () => {
// Every article gets a generated slug, so its presence says nothing about
// whether the org edited anything.
const wrapper = createWrapper({ slug: 'auto-generated-slug' })
expect(wrapper.find('.seo__badge').exists()).toBe(false)
})
it('emits updates for each field', async () => {
const wrapper = createWrapper({ metaTitle: 'x' })
await wrapper.find('.seo__input--slug').setValue('custom-slug')
await wrapper.find('.seo__input--path').setValue('/hc/articles/1')
await titleInput(wrapper).setValue('New title')
await wrapper.find('textarea').setValue('New description')
expect(wrapper.emitted('update:slug')?.[0]).toEqual(['custom-slug'])
expect(wrapper.emitted('update:urlPath')?.[0]).toEqual(['/hc/articles/1'])
expect(wrapper.emitted('update:metaTitle')?.at(-1)).toEqual(['New title'])
expect(wrapper.emitted('update:metaDescription')?.[0]).toEqual(['New description'])
})
it('treats a preserved URL path as a customisation', () => {
// A migrated article's URL must not be hidden behind a collapsed panel.
const wrapper = createWrapper({ urlPath: '/hc/en-us/articles/360012-reset' })
expect(wrapper.find('.seo__fields').exists()).toBe(true)
expect(wrapper.find('.seo__badge').exists()).toBe(true)
expect((wrapper.find('.seo__input--path').element as HTMLInputElement).value).toBe(
'/hc/en-us/articles/360012-reset',
)
})
it('flags a title past the length search engines truncate at', () => {
const wrapper = createWrapper({ metaTitle: 'a'.repeat(61) })
expect(wrapper.find('.seo__count--over').exists()).toBe(true)
})
it('shows the question as the title placeholder so the default is visible', () => {
const wrapper = createWrapper({ metaTitle: 'x', question: 'How do I sign up?' })
expect(titleInput(wrapper).attributes('placeholder')).toBe('How do I sign up?')
})
})
@@ -0,0 +1,53 @@
/*
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 { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { toast } from 'vue-sonner'
import CopyField from '../../../components/integrations/CopyField.vue'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const mountField = () => mount(CopyField, {
props: { fieldId: 'verify-token', label: 'Verify token', name: 'Verify token', value: 'tok-123' },
})
describe('CopyField', () => {
beforeEach(() => vi.clearAllMocks())
it('labels its input', () => {
const wrapper = mountField()
expect(wrapper.find('label').attributes('for')).toBe('verify-token')
expect(wrapper.find('input').attributes('id')).toBe('verify-token')
})
it('copies the value', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
await mountField().find('button').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('tok-123')
expect(toast.success).toHaveBeenCalledWith('Verify token已复制')
})
it('says so when the browser will not copy', async () => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('insecure')) } })
await mountField().find('button').trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
/*
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MessengerPagePicker from '../../../components/integrations/MessengerPagePicker.vue'
const PAGES = [
{ id: 'PAGE1', name: 'Acme Support' },
{ id: 'PAGE2', name: 'Acme Sales' },
]
const mountPicker = (connecting = false) =>
mount(MessengerPagePicker, {
props: { pages: PAGES, connecting },
global: { stubs: { 'font-awesome-icon': true } },
})
describe('MessengerPagePicker', () => {
it('renders one row per Page', () => {
const wrapper = mountPicker()
expect(wrapper.findAll('.picker-row')).toHaveLength(2)
expect(wrapper.text()).toContain('Acme Support')
expect(wrapper.text()).toContain('Acme Sales')
})
it('emits select with the picked Page id', async () => {
const wrapper = mountPicker()
await wrapper.findAll('.picker-row')[1].trigger('click')
expect(wrapper.emitted('select')).toEqual([['PAGE2']])
})
it('locks every row while a connect is in flight', () => {
const wrapper = mountPicker(true)
const rows = wrapper.findAll('.picker-row')
expect(rows.every((row) => row.attributes('disabled') !== undefined)).toBe(true)
})
})
@@ -0,0 +1,78 @@
/*
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 { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
import channelsService from '../../../services/channels'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('../../../services/channels', () => ({
default: { connectWhatsApp: vi.fn(), connectMessenger: vi.fn(), connectInstagram: vi.fn() },
}))
vi.mock('../../../services/agent', () => ({ agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([]) } }))
// The manual form, as when Meta's one-click signup isn't offered
vi.mock('../../../composables/useMetaSignup', () => ({
useMetaSignup: () => ({
signupEnabled: ref(false), signingUp: ref(false), showManualForm: ref(true), signupPages: ref([]),
connectingPage: ref(false), copy: { intro: '', cta: '' }, startSignup: vi.fn(), onPageSelected: vi.fn(),
}),
}))
const OWN_APP_ACCOUNT = {
id: 'acc-1', channel_type: 'whatsapp', external_account_id: 'PN9', is_active: true, display_name: 'Acme',
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1', webhook_verify_token: 'tok', webhook_auto_configured: false,
}
const fill = async (wrapper: ReturnType<typeof mount>, values: Record<string, string>) => {
for (const [key, value] of Object.entries(values)) {
await wrapper.find(`#meta-${key}`).setValue(value)
}
await wrapper.findAll('button').find(b => b.text() === '连接')!.trigger('click')
await flushPromises()
}
describe('MetaChannelConnect app secret', () => {
beforeEach(() => vi.clearAllMocks())
it('connects without a secret and sends none', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...OWN_APP_ACCOUNT, webhook_url: undefined } as any)
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x' })
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith({ phone_number_id: 'PN9', access_token: 'EAAG-x' })
expect(wrapper.text()).not.toContain('回调 URL')
})
it('sends the secret and shows where webhooks go', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue(OWN_APP_ACCOUNT as any)
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: ' s3cret ' })
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
{ phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: 's3cret' })
expect(wrapper.text()).toContain('回调 URL')
expect(wrapper.text()).toContain('WhatsApp → 配置')
})
it('offers the secret on Messenger too, not Instagram', () => {
const has = (channel: 'messenger' | 'instagram') =>
mount(MetaChannelConnect, { props: { channel }, global: { stubs: { 'font-awesome-icon': true } } })
.find('#meta-app_secret').exists()
expect(has('messenger')).toBe(true)
expect(has('instagram')).toBe(false)
})
})
@@ -0,0 +1,80 @@
/*
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 { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/services/agent', () => ({
agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([{ id: 'ag1', name: 'Bot' }]) },
}))
vi.mock('@/services/channels', () => ({
default: {
getEmbeddedSignupConfig: vi.fn().mockResolvedValue({ enabled: false }),
connectWhatsApp: vi.fn(),
},
}))
import channelsService from '@/services/channels'
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
import type { ChannelAccount } from '@/services/channels'
const ACCOUNT: ChannelAccount = {
id: 'acc1', channel_type: 'whatsapp', external_account_id: 'PN1',
display_name: 'Test Number', is_active: true, agent_id: 'ag1',
}
const mountModal = (existingAccount: ChannelAccount) =>
mount(MetaChannelConnect, {
props: { channel: 'whatsapp', existingAccount },
global: { stubs: { 'font-awesome-icon': true } },
})
describe('MetaChannelConnect reconnect prompt', () => {
beforeEach(() => vi.clearAllMocks())
it('does not offer a reconnect for a healthy account', async () => {
const wrapper = mountModal(ACCOUNT)
await flushPromises()
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
expect(wrapper.text()).toContain('已连接')
})
it('offers a reconnect when Meta stopped accepting the account', async () => {
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
await flushPromises()
expect(wrapper.find('.meta-reconnect').text()).toContain('请重新连接以恢复回复')
})
it('Reconnect opens the credentials form, and connecting again clears the prompt', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...ACCOUNT, needs_reconnect: false })
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
await flushPromises()
await wrapper.find('.meta-reconnect button').trigger('click')
expect(wrapper.find('#meta-phone_number_id').exists()).toBe(true)
await wrapper.find('#meta-phone_number_id').setValue('PN1')
await wrapper.find('#meta-access_token').setValue('new-token')
await wrapper.findAll('.meta-actions .meta-btn-primary').at(-1)!.trigger('click')
await flushPromises()
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
expect.objectContaining({ phone_number_id: 'PN1', access_token: 'new-token' }))
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
expect(wrapper.text()).toContain('已连接')
})
})
@@ -0,0 +1,52 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import MetaOwnAppWebhook from '../../../components/integrations/MetaOwnAppWebhook.vue'
import type { ChannelAccount } from '../../../services/channels'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const account = (channel_type: 'whatsapp' | 'messenger', webhook_auto_configured?: boolean): ChannelAccount => ({
id: 'acc-1',
channel_type,
external_account_id: 'PN1',
is_active: true,
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1',
webhook_verify_token: 'tok-123',
webhook_auto_configured,
})
const text = (a: ChannelAccount) => mount(MetaOwnAppWebhook, { props: { account: a } }).text()
describe('MetaOwnAppWebhook', () => {
it('shows the callback URL and verify token', () => {
const inputs = mount(MetaOwnAppWebhook, { props: { account: account('whatsapp', true) } }).findAll('input')
expect(inputs.map(i => (i.element as HTMLInputElement).value)).toEqual([
'https://api.example.com/api/v1/webhooks/meta/acc-1', 'tok-123'])
})
it('says a WhatsApp number is set up only when Meta took the callback', () => {
expect(text(account('whatsapp', true))).toContain('无需额外配置')
expect(text(account('whatsapp', false))).toContain('WhatsApp → 配置')
})
it('tells Messenger where the values go', () => {
expect(text(account('messenger'))).toContain('Messenger → 设置 → Webhooks')
})
})
@@ -0,0 +1,89 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import BottomNav from '@/components/layout/BottomNav.vue'
// Nav visibility comes from the real permission checks reading the cached
// user, so give this spec a user who holds everything.
vi.mock('@/services/user', async () => {
const { userWithPermissions } = await import('../../fixtures/permissions')
const user = userWithPermissions([
'manage_agents', 'manage_users', 'view_all_chats', 'manage_all_chats', 'view_people',
'manage_knowledge', 'view_analytics', 'view_tickets', 'manage_organization',
'manage_ai_config', 'manage_subscription',
])
return { userService: { getCurrentUser: () => user } }
})
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
}))
const routes = ['/conversations', '/people', '/ai-agents', '/analytics', '/knowledge'].map(
(path) => ({ path, component: { template: '<div />' } })
)
routes.push({ path: '/', component: { template: '<div />' } })
const makeRouter = () => createRouter({ history: createWebHistory(), routes })
const mountNav = async (props = {}, path = '/conversations') => {
const router = makeRouter()
await router.push(path)
await router.isReady()
return mount(BottomNav, { props, global: { plugins: [router] } })
}
describe('BottomNav', () => {
it('renders the four primary items plus More', async () => {
const wrapper = await mountNav()
const labels = wrapper.findAll('.bottom-nav-label').map((n) => n.text())
expect(labels).toEqual(['会话', '联系人', 'AI 客服', '数据分析', '更多'])
})
it('marks the current route active', async () => {
const wrapper = await mountNav({}, '/analytics')
const active = wrapper.findAll('.bottom-nav-item.active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('数据分析')
})
it('marks More active when on an overflow route', async () => {
const wrapper = await mountNav({}, '/knowledge')
const active = wrapper.findAll('.bottom-nav-item.active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('更多')
})
it('shows an unread badge on More and caps it at 99+', async () => {
const wrapper = await mountNav({ unreadCount: 3 })
expect(wrapper.find('.bottom-nav-badge').text()).toBe('3')
await wrapper.setProps({ unreadCount: 120 })
expect(wrapper.find('.bottom-nav-badge').text()).toBe('99+')
await wrapper.setProps({ unreadCount: 0 })
expect(wrapper.find('.bottom-nav-badge').exists()).toBe(false)
})
it('emits more when the More button is tapped', async () => {
const wrapper = await mountNav()
await wrapper.find('button.bottom-nav-item').trigger('click')
expect(wrapper.emitted('more')).toHaveLength(1)
})
})
@@ -0,0 +1,177 @@
/*
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 { describe, it, expect, vi, beforeEach } from 'vitest'
// Drive the REAL permission checks from a cached user, rather than mocking
// permissionChecks. Nav visibility and the router now read the same map, and a
// mock of the checks cannot catch the two disagreeing — which is exactly the
// bug this suite missed: People was listed for anyone with a chat grant while
// the route required view_people, so the link bounced.
const currentUser = await vi.hoisted(async () => ({ value: null as unknown }))
vi.mock('@/services/user', () => ({
userService: { getCurrentUser: () => currentUser.value },
}))
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
}))
import { useNavItems, PRIMARY_NAV_PATHS } from '@/components/layout/navItems'
import { userWithPermissions, HUMAN_AGENT_PERMISSIONS } from '../../fixtures/permissions'
const ALL_PERMISSIONS = [
'manage_agents', 'view_agents', 'manage_users', 'view_all_chats', 'manage_all_chats',
'view_assigned_chats', 'manage_assigned_chats', 'view_unassigned_chats', 'view_people',
'manage_knowledge', 'view_knowledge', 'view_analytics', 'view_tickets', 'manage_tickets',
'manage_organization', 'view_organization', 'manage_ai_config', 'view_ai_config',
'manage_subscription', 'view_subscription',
]
const asUser = (permissions: string[]) => {
currentUser.value = userWithPermissions(permissions)
}
describe('useNavItems', () => {
beforeEach(() => asUser(ALL_PERMISSIONS))
it('splits primary (bottom-nav) and overflow (More sheet) items', () => {
const { primaryNavItems, moreNavItems } = useNavItems()
expect(primaryNavItems.value.map((i) => i.to)).toEqual([
'/conversations',
'/people',
'/ai-agents',
'/analytics',
])
const morePaths = moreNavItems.value.map((i) => i.to)
expect(morePaths).toContain('/human-agents')
expect(morePaths).toContain('/knowledge')
expect(morePaths).toContain('/settings/user')
morePaths.forEach((p) => expect(PRIMARY_NAV_PATHS).not.toContain(p))
moreNavItems.value.forEach((i) => expect(i.section).toBeUndefined())
})
it('hides permission-gated items for restricted users', () => {
asUser(['manage_agents'])
const { primaryNavItems, moreNavItems, navItems } = useNavItems()
const primaryPaths = primaryNavItems.value.map((i) => i.to)
expect(primaryPaths).not.toContain('/conversations')
expect(primaryPaths).not.toContain('/people')
expect(primaryPaths).not.toContain('/analytics')
expect(primaryPaths).toContain('/ai-agents')
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/human-agents')
// User settings is always available
expect(navItems.value.map((i) => i.to)).toContain('/settings/user')
})
// The product requirement, as a test: a Human Agent sees the inbox, the
// people they talk to, and their own settings. Nothing else.
it('shows a Human Agent exactly Inbox, People and User Settings', () => {
asUser(HUMAN_AGENT_PERMISSIONS)
const { navItems } = useNavItems()
expect(navItems.value.filter((i) => i.to).map((i) => i.to)).toEqual([
'/conversations',
'/people',
'/settings/user',
])
})
// A role with only view_people gets the People link; one with only a chat
// grant does not. The nav used to gate People on chat permissions while the
// route required view_people, so both cases were wrong in opposite ways.
it('gates People on the same permissions as its route', () => {
asUser(['view_people'])
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
asUser(['view_assigned_chats'])
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
asUser(['manage_knowledge'])
expect(useNavItems().navItems.value.map((i) => i.to)).not.toContain('/people')
})
// super_admin bypasses every check on the backend; a frontend check that
// does not bypass gives that role an almost-empty sidebar.
it('shows everything to a super_admin', () => {
asUser(['super_admin'])
const paths = useNavItems().navItems.value.filter((i) => i.to).map((i) => i.to)
expect(paths).toContain('/ai-agents')
expect(paths).toContain('/human-agents')
expect(paths).toContain('/knowledge')
expect(paths).toContain('/settings/organization')
})
it('excludes enterprise subscription when enterprise module is absent', () => {
const { moreNavItems } = useNavItems()
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/settings/subscription')
})
// Guards the whole point of the More sheet: mobile must expose exactly the
// same destinations as the desktop sidebar, so adding a nav item can never
// silently leave phones without it.
it('surfaces every sidebar destination across the bottom nav and More sheet', () => {
const { navItems, primaryNavItems, moreNavGroups } = useNavItems()
const sidebarPaths = navItems.value.filter((i) => i.to).map((i) => i.to)
const mobilePaths = [
...primaryNavItems.value.map((i) => i.to),
...moreNavGroups.value.flatMap((g) => g.items.map((i) => i.to)),
]
expect(mobilePaths.slice().sort()).toEqual(sidebarPaths.slice().sort())
})
it('keeps the sidebar section headings in the More sheet', () => {
const { moreNavGroups } = useNavItems()
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置'])
// No empty groups — a section fully covered by the bottom nav is dropped
moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0))
})
// User Settings is always visible while the Settings heading used to be
// permission-gated separately, which orphaned the item into Main Menu.
it('keeps always-visible settings items under Settings for restricted users', () => {
// Knowledge, so Main Menu still has a non-primary item to group; no
// settings grants, so Settings holds only the always-visible entry.
asUser(['manage_knowledge'])
const { moreNavGroups } = useNavItems()
const settings = moreNavGroups.value.find((g) => g.section === '设置')
expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user'])
expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to))
.not.toContain('/settings/user')
})
it('drops a section whose every item is permission-hidden', () => {
asUser([])
const { moreNavGroups, navItems } = useNavItems()
// Only User Settings survives, so Main Menu disappears entirely
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置'])
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置'])
})
})
@@ -0,0 +1,98 @@
/*
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, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import InvestigationNoteForm from '@/components/tickets/InvestigationNoteForm.vue'
// attachTo puts the component in the real document so focus can be asserted,
// and VTU only takes it back out on unmount — without this the forms pile up
// and whichever mounted last owns document.activeElement.
const mounted: VueWrapper[] = []
afterEach(() => {
while (mounted.length) mounted.pop()!.unmount()
})
const mountForm = (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
const wrapper = mount(InvestigationNoteForm, {
props: { placeholder: 'Why?', submitLabel: 'Go', ...props },
slots,
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
describe('InvestigationNoteForm', () => {
it('trims the note before emitting', async () => {
const wrapper = mountForm()
await wrapper.find('textarea').setValue(' use fields.order_ref ')
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual(['use fields.order_ref'])
})
it('allows an empty note', async () => {
// Re-running unchanged is legitimate, and the reject flow has always
// permitted a reason-less rejection.
const wrapper = mountForm()
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual([''])
})
it('submits on Cmd+Enter and Ctrl+Enter', async () => {
const meta = mountForm()
await meta.find('textarea').setValue('a')
await meta.find('textarea').trigger('keydown', { key: 'Enter', metaKey: true })
expect(meta.emitted('submit')?.[0]).toEqual(['a'])
const ctrl = mountForm()
await ctrl.find('textarea').setValue('b')
await ctrl.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
expect(ctrl.emitted('submit')?.[0]).toEqual(['b'])
})
it('cancels on Escape', async () => {
const wrapper = mountForm()
await wrapper.find('textarea').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('focuses the textarea so the operator can just type', () => {
// Mounted alone in this test, so activeElement can only be this one.
const wrapper = mountForm()
expect(document.activeElement).toBe(wrapper.find('textarea').element)
})
it('blocks a second submit while one is in flight', async () => {
const wrapper = mountForm({ busy: true })
expect(wrapper.find('.submit-btn').attributes('disabled')).toBeDefined()
})
it('colours the action by tone', () => {
expect(mountForm({ tone: 'danger' }).find('.submit-btn').classes()).toContain('danger')
// Accent is the default, so the panel's re-run needs no tone prop.
expect(mountForm().find('.submit-btn').classes()).toContain('accent')
})
it('renders extra controls passed by the caller', () => {
const wrapper = mountForm({}, { default: '<label class="extra">Re-run too</label>' })
expect(wrapper.find('.extra').exists()).toBe(true)
})
})
@@ -0,0 +1,120 @@
/*
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import TicketConnectorsSection from '@/components/tickets/TicketConnectorsSection.vue'
import { mcpService } from '@/services/mcp'
import type { MCPTool } from '@/types/mcp'
vi.mock('@/services/mcp', () => ({
mcpService: {
getOrganizationMCPTools: vi.fn(),
createMCPTool: vi.fn(),
updateMCPTool: vi.fn(),
deleteMCPTool: vi.fn(),
getMCPToolReferences: vi.fn(),
},
}))
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const CONNECTOR: MCPTool = {
id: 7,
name: 'Elastic prod',
transport_type: 'http',
enabled: true,
url: 'https://kibana/api/agent_builder/mcp',
usage_guidance: 'Order id is fields.order_ref.',
organization_id: 'org-1',
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
}
function mountIt(connectors: MCPTool[] = []) {
vi.mocked(mcpService.getOrganizationMCPTools).mockResolvedValue(connectors)
return mount(TicketConnectorsSection, {
props: { selectedIds: [] },
global: { stubs: { 'font-awesome-icon': true } },
})
}
describe('TicketConnectorsSection guidance', () => {
beforeEach(() => vi.clearAllMocks())
it('sends the guidance the operator wrote when creating a connector', async () => {
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
await wrapper.find('.field-input').setValue('Elastic prod')
await wrapper.find('.guidance-input').setValue('Order id is fields.order_ref.')
await wrapper.find('.connect-btn').trigger('click')
await flushPromises()
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0]).toMatchObject({
usage_guidance: 'Order id is fields.order_ref.',
})
})
it('sends null rather than an empty string when no guidance is written', async () => {
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
await wrapper.find('.field-input').setValue('Bare connector')
await wrapper.find('.connect-btn').trigger('click')
await flushPromises()
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0].usage_guidance).toBeNull()
})
it('never seeds guidance from a preset description', async () => {
/**
* A preset's `desc` is a one-line marketing subtitle ("Errors, issues and
* traces"). Letting it become prompt text is exactly the failure this
* field exists to fix, so it must stay empty even though `description`
* is filled.
*/
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile')[0].trigger('click')
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe('')
expect((wrapper.find('.field-input').element as HTMLInputElement).value).toBe('Grafana')
})
it('loads existing guidance into the edit form and marks the row as guided', async () => {
const wrapper = mountIt([CONNECTOR])
await flushPromises()
expect(wrapper.find('.guided-tag').exists()).toBe(true)
await wrapper.find('.row-btn').trigger('click')
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe(
'Order id is fields.order_ref.',
)
})
it('shows no guided chip for an undocumented connector', async () => {
const wrapper = mountIt([{ ...CONNECTOR, usage_guidance: null }])
await flushPromises()
expect(wrapper.find('.guided-tag').exists()).toBe(false)
})
})
@@ -0,0 +1,214 @@
/*
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 { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TicketInvestigationPanel from '../../../components/tickets/TicketInvestigationPanel.vue'
import type { InvestigationDetail, InvestigationRun } from '../../../types/ticket'
const baseRun: InvestigationRun = {
id: 'r1',
run_type: 'investigation',
status: 'completed',
trigger: 'manual',
tool_calls_used: 0,
}
const mountPanel = (run: Partial<InvestigationRun>, props: Record<string, unknown> = {}) =>
mount(TicketInvestigationPanel, {
props: {
investigation: {
run: { ...baseRun, ...run },
hypotheses: [],
events: [],
} as InvestigationDetail,
...props,
},
global: {
stubs: {
'font-awesome-icon': true,
'router-link': { template: '<a><slot /></a>' },
},
},
})
describe('TicketInvestigationPanel connector warning', () => {
it('warns when the run loaded fewer connectors than configured', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 0,
failed: [{ name: 'Elasticsearch', error: "[Errno 2] No such file or directory: 'npx'" }],
},
})
const warning = wrapper.find('.connector-warning')
expect(warning.exists()).toBe(true)
expect(warning.text()).toContain('0 / 1 个已配置连接器')
expect(warning.text()).toContain('Elasticsearch')
expect(warning.text()).toContain('npx')
})
it('stays silent when every configured connector loaded', () => {
const wrapper = mountPanel({
connector_status: { configured: 2, loaded: 2, failed: [] },
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
it('warns when every connector loaded but the provider refused their tools', () => {
// The #303 case: nothing "failed", so the loaded-vs-configured check alone
// reports a clean run that gathered no evidence at all.
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
provider_errors: ["The model provider rejected a connected tool's schema."],
},
})
const warning = wrapper.find('.connector-warning')
expect(warning.exists()).toBe(true)
expect(warning.text()).toContain("无法使用")
expect(warning.text()).toContain('rejected a connected tool')
})
it('stays silent when the run has no connector status (older runs)', () => {
const wrapper = mountPanel({})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
})
describe('TicketInvestigationPanel errored tool calls', () => {
/**
* A connector can come up, load its tools, and then error on every query.
* That reports loaded === configured with no provider errors, so before
* #318 the run rendered completely clean with a confident wrong answer.
*/
it('warns when every tool call errored, even with all connectors loaded', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
tool_calls: 3,
tool_calls_failed: 3,
},
})
const text = wrapper.find('.connector-warning').text()
expect(text).toContain('3 / 3 次工具调用出错')
expect(text).toContain('此处结论均无证据支撑')
})
it('reports a partial failure without claiming nothing was gathered', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
tool_calls: 4,
tool_calls_failed: 1,
},
})
const text = wrapper.find('.connector-warning').text()
expect(text).toContain('1 / 4 次工具调用出错')
expect(text).not.toContain('evidence-backed')
})
it('stays silent on a clean run', () => {
const wrapper = mountPanel({
connector_status: {
configured: 2,
loaded: 2,
failed: [],
tool_calls: 5,
tool_calls_failed: 0,
},
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
it('handles a run recorded before these counts existed', () => {
const wrapper = mountPanel({
connector_status: { configured: 1, loaded: 1, failed: [] },
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
})
describe('TicketInvestigationPanel guided re-run', () => {
/**
* Below autonomy 2 no proposal is created, so the L2 approval banner never
* renders — and it held the only reject-with-feedback path. An L1 operator
* had no way to correct a run short of editing the ticket description.
*/
it('offers a guided re-run when the user can start one', () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
})
it('is absent for a user who cannot manage tickets', () => {
expect(mountPanel({}, { canReinvestigate: false }).find('.panel-foot').exists()).toBe(false)
expect(mountPanel({}).find('.panel-foot').exists()).toBe(false)
})
it('disables the button with the reason a run is unavailable', () => {
const wrapper = mountPanel({}, {
canReinvestigate: true,
reinvestigateBlockedReason: 'This ticket is resolved — reopen it to investigate again',
})
const button = wrapper.find('.rerun-btn')
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toContain('reopen it')
})
it('emits the trimmed note and closes the form', async () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
await wrapper.find('.rerun-btn').trigger('click')
await wrapper.find('.note-input').setValue(' Use fields.order_ref, not order_id. ')
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('reinvestigate')?.[0]).toEqual(['Use fields.order_ref, not order_id.'])
// Back to the button, so a second run needs a deliberate click.
expect(wrapper.find('.note-input').exists()).toBe(false)
})
it('lets the operator back out without starting a run', async () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
await wrapper.find('.rerun-btn').trigger('click')
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('reinvestigate')).toBeUndefined()
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
})
it('links to settings only for someone who can edit them', async () => {
const allowed = mountPanel({}, { canReinvestigate: true, canEditSettings: true })
await allowed.find('.rerun-btn').trigger('click')
expect(allowed.find('.rerun-scope a').exists()).toBe(true)
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')
})
})
@@ -0,0 +1,95 @@
/*
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 { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/composables/useUsers', () => ({
useUsers: () => ({ users: { value: [] }, fetchUsers: vi.fn().mockResolvedValue([]) }),
}))
import TicketSidePanel from '../../../components/tickets/TicketSidePanel.vue'
import type { Ticket } from '../../../types/ticket'
const baseTicket = {
id: 't1',
ticket_number: 12,
display_number: 'TKT-12',
organization_id: 'org1',
title: 'Refund never arrived',
status: 'closed',
priority: 'medium',
source: 'chat_ai',
reopened_count: 0,
} as Ticket
const createWrapper = (ticket: Partial<Ticket> = {}, linkedSessionIds: string[] = ['s1']) =>
mount(TicketSidePanel, {
props: {
ticket: { ...baseTicket, ...ticket } as Ticket,
linkedSessionIds,
canManage: false,
},
global: { stubs: { 'font-awesome-icon': true } },
})
const csatCard = (wrapper: ReturnType<typeof createWrapper>) =>
wrapper.findAll('.panel-card').find((card) => card.text().startsWith('CSAT'))!
describe('TicketSidePanel CSAT card', () => {
it('shows the score once the customer has rated', () => {
const card = csatCard(
createWrapper({
csat_requested_at: '2026-07-20T10:00:00Z',
csat_responded_at: '2026-07-20T11:00:00Z',
csat_score: 4,
}),
)
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')
})
it('shows pending while the ask is out', () => {
const card = csatCard(createWrapper({ csat_requested_at: '2026-07-20T10:00:00Z' }))
expect(card.text()).toContain('待评价')
expect(card.find('.csat-stars').exists()).toBe(false)
})
it('goes back to pending when a reopened ticket is asked again', () => {
const card = csatCard(
createWrapper({
csat_score: 2,
csat_responded_at: '2026-07-20T11:00:00Z',
csat_requested_at: '2026-07-21T09:00:00Z',
}),
)
expect(card.text()).toContain('待评价')
expect(card.text()).not.toContain('2/5')
})
it('shows not requested before close', () => {
const card = csatCard(createWrapper())
expect(card.text()).toContain('未邀请评价')
expect(card.text()).toContain('工单关闭时会自动邀请评价')
})
it('explains why a ticket with no conversation is never asked', () => {
const card = csatCard(createWrapper({}, []))
expect(card.text()).toContain('没有可评价的关联会话')
})
})
@@ -0,0 +1,72 @@
/*
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 { describe, it, expect } from 'vitest'
import { accountToManage, reconnectWarning } from '@/utils/channelAccounts'
import type { ChannelAccount } from '@/services/channels'
const account = (overrides: Partial<ChannelAccount> = {}): ChannelAccount => ({
id: 'a1', channel_type: 'whatsapp', external_account_id: 'PN1',
display_name: 'Support (+1 555)', is_active: true, ...overrides,
})
describe('reconnectWarning', () => {
it('is silent when no account needs reconnecting', () => {
expect(reconnectWarning([])).toBeUndefined()
expect(reconnectWarning([account(), account({ needs_reconnect: false })])).toBeUndefined()
})
it('names the account that needs reconnecting', () => {
expect(reconnectWarning([account({ needs_reconnect: true })]))
.toBe('以下账号的访问权限已过期:Support (+1 555),请重新连接以恢复回复。')
})
it('names only the expired ones when several are connected', () => {
const warning = reconnectWarning([
account({ id: 'a1', display_name: 'Sales', needs_reconnect: true }),
account({ id: 'a2', display_name: 'Healthy' }),
account({ id: 'a3', display_name: 'Billing', needs_reconnect: true }),
])
expect(warning).toBe('以下账号的访问权限已过期:Sales, Billing,请重新连接以恢复回复。')
})
it('falls back to the platform id when the account has no name', () => {
expect(reconnectWarning([account({ display_name: undefined, needs_reconnect: true })]))
.toContain('以下账号的访问权限已过期:PN1')
})
})
describe('accountToManage', () => {
it('opens the account that needs reconnecting, even when it is not first', () => {
const healthy = account({ id: 'a1' })
const expired = account({ id: 'a2', needs_reconnect: true })
expect(accountToManage([healthy, expired])).toBe(expired)
})
it('opens the first expired one when several need reconnecting', () => {
const first = account({ id: 'a2', needs_reconnect: true })
expect(accountToManage([account({ id: 'a1' }), first, account({ id: 'a3', needs_reconnect: true })])).toBe(first)
})
it('opens the first account when every one is healthy', () => {
const first = account({ id: 'a1' })
expect(accountToManage([first, account({ id: 'a2' })])).toBe(first)
})
it('opens nothing when no account is connected', () => {
expect(accountToManage([])).toBeNull()
})
})
@@ -0,0 +1,144 @@
/*
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 { describe, it, expect, vi, afterEach } from 'vitest'
import {
parseSignupMessage,
signupLoginOptions,
runInstagramLogin,
runBusinessLogin,
} from '../../utils/metaSdk'
const message = (data: unknown, origin = 'https://www.facebook.com') =>
({ origin, data: typeof data === 'string' ? data : JSON.stringify(data) }) as MessageEvent
const FINISH = {
type: 'WA_EMBEDDED_SIGNUP',
event: 'FINISH',
data: { waba_id: 'WABA9', phone_number_id: 'PN555' },
}
describe('parseSignupMessage', () => {
it('reads the WABA and phone number from a finished signup', () => {
expect(parseSignupMessage(message(FINISH))).toEqual({
waba_id: 'WABA9',
phone_number_id: 'PN555',
})
})
it('accepts the other Facebook origins the flow can post from', () => {
for (const origin of ['https://web.facebook.com', 'https://business.facebook.com']) {
expect(parseSignupMessage(message(FINISH, origin))).not.toBeNull()
}
})
it('rejects a lookalike origin', () => {
// Meta's own sample uses origin.endsWith('facebook.com'), which these pass.
for (const origin of ['https://evil-facebook.com', 'https://facebook.com.attacker.io']) {
expect(parseSignupMessage(message(FINISH, origin))).toBeNull()
}
})
it('rejects a plain http facebook origin', () => {
expect(parseSignupMessage(message(FINISH, 'http://www.facebook.com'))).toBeNull()
})
it('ignores unrelated messages on a trusted origin', () => {
expect(parseSignupMessage(message({ type: 'SOMETHING_ELSE' }))).toBeNull()
expect(parseSignupMessage(message('not json at all'))).toBeNull()
})
it('ignores a signup that was abandoned rather than finished', () => {
expect(parseSignupMessage(message({ ...FINISH, event: 'CANCEL' }))).toBeNull()
})
it('ignores a finish missing either id', () => {
expect(parseSignupMessage(message({ ...FINISH, data: { waba_id: 'WABA9' } }))).toBeNull()
expect(parseSignupMessage(message({ ...FINISH, data: {} }))).toBeNull()
})
})
describe('signupLoginOptions', () => {
it('asks for a code rather than the default token response', () => {
expect(signupLoginOptions('CFG1')).toEqual({
config_id: 'CFG1',
response_type: 'code',
override_default_response_type: true,
extras: { setup: {} },
})
})
it('sends the WhatsApp setup extra for WhatsApp', () => {
expect(signupLoginOptions('CFG1', 'whatsapp').extras).toEqual({ setup: {} })
})
it('omits the setup extra for Facebook Login for Business', () => {
// extras.setup opens Embedded Signup; Messenger/Instagram must not send it.
expect(signupLoginOptions('CFG1', 'messenger')).not.toHaveProperty('extras')
expect(signupLoginOptions('CFG1', 'instagram')).not.toHaveProperty('extras')
})
})
describe('OAuth login popups', () => {
afterEach(() => vi.unstubAllGlobals())
/** Capture the URL the popup is opened with, without completing the login. */
const capturePopupUrl = (run: () => Promise<unknown>) => {
let opened = ''
vi.stubGlobal('open', (url: string) => {
opened = url
return { closed: false, close: () => {} }
})
// The promise stays pending (no callback message); we only want the URL.
void run().catch(() => {})
return new URL(opened)
}
it('sends Instagram to its own authorize endpoint, not a Facebook dialog', () => {
const url = capturePopupUrl(() =>
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }))
expect(url.origin + url.pathname).toBe('https://www.instagram.com/oauth/authorize')
expect(url.searchParams.get('client_id')).toBe('IGAPP')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb.html')
expect(url.searchParams.get('force_reauth')).toBe('true')
expect(url.searchParams.get('scope')).toContain('instagram_business_manage_messages')
// Least privilege: we only read the account and handle DMs, so no
// comments/publishing/insights scopes to enlarge App Review.
expect(url.searchParams.get('scope')).not.toContain('content_publish')
expect(url.searchParams.get('state')).toBeTruthy()
})
it('sends Messenger to the Facebook dialog with its configuration', () => {
const url = capturePopupUrl(() =>
runBusinessLogin({
appId: 'FBAPP', configId: 'CFG1', graphVersion: 'v21.0',
redirectUri: 'https://app.test/cb.html',
}))
expect(url.hostname).toBe('www.facebook.com')
expect(url.searchParams.get('config_id')).toBe('CFG1')
expect(url.searchParams.get('client_id')).toBe('FBAPP')
})
it('rejects when the browser blocks the popup', async () => {
vi.stubGlobal('open', () => null)
await expect(
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }),
).rejects.toThrow(/弹窗被拦截/)
})
})