feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
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(/弹窗被拦截/)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user