From 308332eb94717e7154751203f4ac5e96874081c9 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 21 Oct 2026 21:40:00 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BA=A4=E4=BB=98=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E3=80=81=E7=BB=B4=E6=8A=A4=E6=8C=87=E5=BC=95=E4=B8=8E=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。 --- AGENT_HANDOFF.md | 82 + FROM_ZERO_PLAN.md | 326 + HELP_CENTER_INFRA.md | 194 + README.md | 60 + backend/temp/test.pdf | 1 + backend/temp/test.txt | 1 + backend/tests_live/README.md | 87 + backend/tests_live/conftest.py | 265 + backend/tests_live/test_behaviours_live.py | 205 + backend/tests_live/test_guardrails_live.py | 197 + .../tests_live/test_vulnerabilities_live.py | 297 + docs/images/analytics-dashboard.webp | Bin 0 -> 92522 bytes docs/images/chat-widget.webp | Bin 0 -> 61878 bytes docs/images/inbox-human-handoff.webp | Bin 0 -> 97910 bytes docs/images/workflow-builder.webp | Bin 0 -> 74072 bytes e2e/.env.e2e | 79 + e2e/.gitignore | 13 + e2e/AUTHORING.md | 171 + e2e/FEATURES.md | 73 + e2e/a11y-baseline/ai-agents-light.json | 1 + e2e/a11y-baseline/ai-agents.json | 1 + e2e/a11y-baseline/ai-config-light.json | 1 + e2e/a11y-baseline/ai-config.json | 1 + e2e/a11y-baseline/inbox-light.json | 1 + e2e/a11y-baseline/inbox.json | 1 + e2e/a11y-baseline/login.json | 1 + e2e/a11y-baseline/people-light.json | 1 + e2e/a11y-baseline/people.json | 1 + e2e/a11y-baseline/tickets-light.json | 1 + e2e/a11y-baseline/tickets.json | 1 + e2e/a11y-baseline/widget-dark.json | 1 + e2e/a11y-baseline/widget-light.json | 1 + e2e/eslint.config.js | 47 + e2e/package-lock.json | 3118 +++ e2e/package.json | 35 + e2e/playwright.config.ts | 115 + e2e/scripts/api-fuzz.sh | 27 + e2e/scripts/api-types.sh | 29 + e2e/scripts/stack.sh | 365 + e2e/scripts/upgrade-check.sh | 72 + e2e/services/customer-site/server.ts | 99 + e2e/services/fake-externals/limits.ts | 95 + e2e/services/fake-externals/server.ts | 548 + e2e/services/fake-llm/contract_check.py | 60 + e2e/services/fake-llm/protocol.ts | 77 + e2e/services/fake-llm/server.ts | 285 + e2e/services/fake-mcp/server.ts | 88 + e2e/specs/a11y/a11y.spec.ts | 45 + e2e/specs/agents/agent-editor.spec.ts | 25 + e2e/specs/analytics/analytics.spec.ts | 25 + e2e/specs/auth/login.spec.ts | 38 + e2e/specs/auth/sessions.spec.ts | 88 + e2e/specs/channels/delivery.spec.ts | 72 + e2e/specs/channels/email.spec.ts | 38 + e2e/specs/channels/forged-webhooks.spec.ts | 100 + e2e/specs/channels/handover.spec.ts | 37 + e2e/specs/channels/needs-reconnect.spec.ts | 94 + e2e/specs/channels/own-meta-app.spec.ts | 80 + e2e/specs/channels/replies.spec.ts | 19 + e2e/specs/channels/reply-buttons.spec.ts | 21 + e2e/specs/channels/retries.spec.ts | 23 + e2e/specs/crm/hubspot.org.spec.ts | 63 + e2e/specs/crm/pipedrive.org.spec.ts | 57 + e2e/specs/handover/after-hours.org.spec.ts | 26 + e2e/specs/handover/attachments.spec.ts | 30 + e2e/specs/handover/handover.spec.ts | 30 + e2e/specs/handover/nobody-online.spec.ts | 20 + e2e/specs/help-center/publishing.org.spec.ts | 31 + e2e/specs/integrations/jira.org.spec.ts | 60 + e2e/specs/integrations/shopify.org.spec.ts | 62 + e2e/specs/knowledge/knowledge-answer.spec.ts | 27 + .../knowledge/knowledge-lifecycle.spec.ts | 41 + e2e/specs/knowledge/knowledge-pdf.spec.ts | 24 + e2e/specs/leads/lead-capture.spec.ts | 33 + e2e/specs/leads/people.spec.ts | 58 + e2e/specs/mcp/connectors.spec.ts | 72 + e2e/specs/perf/budgets.spec.ts | 40 + .../security/agent-socket-origin.spec.ts | 33 + e2e/specs/security/headers.spec.ts | 38 + .../security/org-domain-cors.org.spec.ts | 66 + e2e/specs/settings/ai-config.org.spec.ts | 47 + e2e/specs/setup/org.setup.ts | 47 + e2e/specs/team/permissions.spec.ts | 76 + e2e/specs/team/team.spec.ts | 31 + e2e/specs/tickets/ai-ticket.spec.ts | 83 + e2e/specs/visual/widget.spec.ts | 25 + e2e/specs/widget/ai-failures.spec.ts | 49 + e2e/specs/widget/chat-styles.spec.ts | 58 + .../widget/conversation-ownership.spec.ts | 148 + e2e/specs/widget/iframe-snippet.spec.ts | 14 + e2e/specs/widget/js-api.spec.ts | 25 + e2e/specs/widget/placement.spec.ts | 49 + e2e/specs/widget/rating.spec.ts | 26 + e2e/specs/widget/token-auth.spec.ts | 61 + e2e/specs/widget/widget-chat.spec.ts | 41 + e2e/specs/workflows/workflows.spec.ts | 107 + e2e/src/api/agent-socket.ts | 58 + e2e/src/api/agents.ts | 47 + e2e/src/api/ai.ts | 39 + e2e/src/api/auth.ts | 25 + e2e/src/api/channels.ts | 72 + e2e/src/api/chats.ts | 16 + e2e/src/api/client.ts | 78 + e2e/src/api/crm.ts | 26 + e2e/src/api/customization.ts | 6 + e2e/src/api/help-center.ts | 23 + e2e/src/api/integrations.ts | 70 + e2e/src/api/knowledge.ts | 47 + e2e/src/api/mcp.ts | 18 + e2e/src/api/organizations.ts | 36 + e2e/src/api/people.ts | 16 + e2e/src/api/schema.d.ts | 18324 ++++++++++++++++ e2e/src/api/team.ts | 72 + e2e/src/api/tickets.ts | 9 + e2e/src/api/types.ts | 8 + e2e/src/api/widget-apps.ts | 22 + e2e/src/api/widgets.ts | 23 + e2e/src/api/workflows.ts | 19 + e2e/src/channels/chat.ts | 145 + e2e/src/channels/email.ts | 60 + e2e/src/channels/index.ts | 35 + e2e/src/channels/line.ts | 43 + e2e/src/channels/meta.ts | 232 + e2e/src/channels/slack.ts | 70 + e2e/src/channels/sms.ts | 72 + e2e/src/channels/telegram.ts | 140 + e2e/src/components/chat-widget.ts | 250 + e2e/src/components/sidebar.ts | 20 + e2e/src/env.ts | 58 + e2e/src/fakes/externals.ts | 79 + e2e/src/fakes/llm.ts | 138 + e2e/src/fakes/mailbox.ts | 79 + e2e/src/fakes/mcp.ts | 22 + e2e/src/fixtures/diagnostics.ts | 60 + e2e/src/fixtures/index.ts | 274 + e2e/src/fixtures/network.ts | 13 + e2e/src/index.ts | 61 + e2e/src/org-setup.ts | 58 + e2e/src/org-state.ts | 38 + e2e/src/pages/agents.page.ts | 57 + e2e/src/pages/ai-config.page.ts | 42 + e2e/src/pages/dashboard.page.ts | 24 + e2e/src/pages/human-agents.page.ts | 41 + e2e/src/pages/inbox.page.ts | 65 + e2e/src/pages/integrations.page.ts | 50 + e2e/src/pages/login.page.ts | 27 + e2e/src/pages/people.page.ts | 28 + e2e/src/pages/setup-wizard.page.ts | 109 + e2e/src/pages/setup.page.ts | 38 + e2e/src/pages/tickets.page.ts | 46 + e2e/src/support/a11y.ts | 52 + e2e/src/support/db.ts | 18 + e2e/src/support/ids.ts | 13 + e2e/src/support/pdf.ts | 26 + e2e/src/support/step.ts | 10 + e2e/src/support/timeouts.ts | 9 + e2e/src/support/unchanged.ts | 18 + e2e/src/support/workers.ts | 17 + e2e/tsconfig.json | 22 + frontend/src/__tests__/App.spec.ts | 60 + .../__tests__/components/BaseModal.spec.ts | 143 + .../components/UserForm.chatScope.spec.ts | 152 + .../components/WhatsAppTemplateSelect.spec.ts | 128 + .../common/GuidanceTextarea.spec.ts | 88 + .../notifications/EnablePushPrompt.spec.ts | 101 + .../composables/useBreakpoint.spec.ts | 82 + .../composables/useConversationToken.spec.ts | 180 + .../composables/useCrawlCheck.spec.ts | 62 + .../__tests__/composables/useCurrency.spec.ts | 43 + .../useFaqWorkspace.category.spec.ts | 95 + .../__tests__/composables/useMCPTools.spec.ts | 125 + .../useNotificationSettings.spec.ts | 73 + .../composables/useOrgInboxRoom.spec.ts | 61 + .../useWidgetCustomization.spec.ts | 53 + .../useWidgetSocket.reconnect.spec.ts | 230 + .../src/__tests__/fixtures/permissions.ts | 75 + .../src/__tests__/fixtures/widget-snippet.txt | 5 + .../__tests__/layouts/DashboardLayout.spec.ts | 220 + frontend/src/__tests__/pwa/register.spec.ts | 117 + .../__tests__/router/routePermissions.spec.ts | 142 + frontend/src/__tests__/services/api.spec.ts | 239 + .../services/firebase-unregister.spec.ts | 89 + .../__tests__/services/installUrls.spec.ts | 120 + .../services/knowledge.errors.spec.ts | 15 + frontend/src/__tests__/stores/counter.spec.ts | 45 + frontend/src/__tests__/stubs/pwa-register.ts | 18 + .../src/__tests__/utils/aiDisclaimer.spec.ts | 50 + frontend/src/__tests__/utils/apiError.spec.ts | 22 + .../src/__tests__/utils/chatState.spec.ts | 111 + frontend/src/__tests__/utils/color.spec.ts | 53 + frontend/src/__tests__/utils/endChat.spec.ts | 71 + .../__tests__/utils/knowledgePages.spec.ts | 111 + frontend/src/__tests__/utils/mcp.spec.ts | 69 + .../src/__tests__/utils/messageMatch.spec.ts | 67 + .../src/__tests__/utils/permissions.spec.ts | 84 + .../src/__tests__/utils/planLimits.spec.ts | 41 + frontend/src/__tests__/utils/sanitize.spec.ts | 146 + .../__tests__/utils/swOauthCallback.spec.ts | 73 + frontend/src/__tests__/utils/text.spec.ts | 47 + .../src/__tests__/utils/timezones.spec.ts | 46 + .../src/__tests__/utils/uploadUrl.spec.ts | 129 + .../src/__tests__/utils/validators.spec.ts | 101 + .../__tests__/utils/whatsappLanguages.spec.ts | 86 + .../__tests__/utils/whatsappTemplates.spec.ts | 297 + .../src/__tests__/utils/widgetEmbed.spec.ts | 70 + .../src/__tests__/utils/widgetTheme.spec.ts | 66 + frontend/src/__tests__/views/403.spec.ts | 108 + .../views/settings/AIConfigSettings.spec.ts | 96 + .../settings/OrganizationSettings.spec.ts | 94 + .../webclient/chattermateIdentity.spec.ts | 267 + .../webclient/chattermateLifecycle.spec.ts | 272 + .../__tests__/webclient/hostBridge.spec.ts | 107 + .../webclient/widgetComposer.spec.ts | 188 + .../webclient/widgetStartupRefusal.spec.ts | 163 + temp/test.pdf | 1 + temp/test.txt | 1 + 216 files changed, 37038 insertions(+) create mode 100644 AGENT_HANDOFF.md create mode 100644 FROM_ZERO_PLAN.md create mode 100644 HELP_CENTER_INFRA.md create mode 100644 README.md create mode 100644 backend/temp/test.pdf create mode 100644 backend/temp/test.txt create mode 100644 backend/tests_live/README.md create mode 100644 backend/tests_live/conftest.py create mode 100644 backend/tests_live/test_behaviours_live.py create mode 100644 backend/tests_live/test_guardrails_live.py create mode 100644 backend/tests_live/test_vulnerabilities_live.py create mode 100644 docs/images/analytics-dashboard.webp create mode 100644 docs/images/chat-widget.webp create mode 100644 docs/images/inbox-human-handoff.webp create mode 100644 docs/images/workflow-builder.webp create mode 100644 e2e/.env.e2e create mode 100644 e2e/.gitignore create mode 100644 e2e/AUTHORING.md create mode 100644 e2e/FEATURES.md create mode 100644 e2e/a11y-baseline/ai-agents-light.json create mode 100644 e2e/a11y-baseline/ai-agents.json create mode 100644 e2e/a11y-baseline/ai-config-light.json create mode 100644 e2e/a11y-baseline/ai-config.json create mode 100644 e2e/a11y-baseline/inbox-light.json create mode 100644 e2e/a11y-baseline/inbox.json create mode 100644 e2e/a11y-baseline/login.json create mode 100644 e2e/a11y-baseline/people-light.json create mode 100644 e2e/a11y-baseline/people.json create mode 100644 e2e/a11y-baseline/tickets-light.json create mode 100644 e2e/a11y-baseline/tickets.json create mode 100644 e2e/a11y-baseline/widget-dark.json create mode 100644 e2e/a11y-baseline/widget-light.json create mode 100644 e2e/eslint.config.js create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100755 e2e/scripts/api-fuzz.sh create mode 100755 e2e/scripts/api-types.sh create mode 100755 e2e/scripts/stack.sh create mode 100755 e2e/scripts/upgrade-check.sh create mode 100644 e2e/services/customer-site/server.ts create mode 100644 e2e/services/fake-externals/limits.ts create mode 100644 e2e/services/fake-externals/server.ts create mode 100644 e2e/services/fake-llm/contract_check.py create mode 100644 e2e/services/fake-llm/protocol.ts create mode 100644 e2e/services/fake-llm/server.ts create mode 100644 e2e/services/fake-mcp/server.ts create mode 100644 e2e/specs/a11y/a11y.spec.ts create mode 100644 e2e/specs/agents/agent-editor.spec.ts create mode 100644 e2e/specs/analytics/analytics.spec.ts create mode 100644 e2e/specs/auth/login.spec.ts create mode 100644 e2e/specs/auth/sessions.spec.ts create mode 100644 e2e/specs/channels/delivery.spec.ts create mode 100644 e2e/specs/channels/email.spec.ts create mode 100644 e2e/specs/channels/forged-webhooks.spec.ts create mode 100644 e2e/specs/channels/handover.spec.ts create mode 100644 e2e/specs/channels/needs-reconnect.spec.ts create mode 100644 e2e/specs/channels/own-meta-app.spec.ts create mode 100644 e2e/specs/channels/replies.spec.ts create mode 100644 e2e/specs/channels/reply-buttons.spec.ts create mode 100644 e2e/specs/channels/retries.spec.ts create mode 100644 e2e/specs/crm/hubspot.org.spec.ts create mode 100644 e2e/specs/crm/pipedrive.org.spec.ts create mode 100644 e2e/specs/handover/after-hours.org.spec.ts create mode 100644 e2e/specs/handover/attachments.spec.ts create mode 100644 e2e/specs/handover/handover.spec.ts create mode 100644 e2e/specs/handover/nobody-online.spec.ts create mode 100644 e2e/specs/help-center/publishing.org.spec.ts create mode 100644 e2e/specs/integrations/jira.org.spec.ts create mode 100644 e2e/specs/integrations/shopify.org.spec.ts create mode 100644 e2e/specs/knowledge/knowledge-answer.spec.ts create mode 100644 e2e/specs/knowledge/knowledge-lifecycle.spec.ts create mode 100644 e2e/specs/knowledge/knowledge-pdf.spec.ts create mode 100644 e2e/specs/leads/lead-capture.spec.ts create mode 100644 e2e/specs/leads/people.spec.ts create mode 100644 e2e/specs/mcp/connectors.spec.ts create mode 100644 e2e/specs/perf/budgets.spec.ts create mode 100644 e2e/specs/security/agent-socket-origin.spec.ts create mode 100644 e2e/specs/security/headers.spec.ts create mode 100644 e2e/specs/security/org-domain-cors.org.spec.ts create mode 100644 e2e/specs/settings/ai-config.org.spec.ts create mode 100644 e2e/specs/setup/org.setup.ts create mode 100644 e2e/specs/team/permissions.spec.ts create mode 100644 e2e/specs/team/team.spec.ts create mode 100644 e2e/specs/tickets/ai-ticket.spec.ts create mode 100644 e2e/specs/visual/widget.spec.ts create mode 100644 e2e/specs/widget/ai-failures.spec.ts create mode 100644 e2e/specs/widget/chat-styles.spec.ts create mode 100644 e2e/specs/widget/conversation-ownership.spec.ts create mode 100644 e2e/specs/widget/iframe-snippet.spec.ts create mode 100644 e2e/specs/widget/js-api.spec.ts create mode 100644 e2e/specs/widget/placement.spec.ts create mode 100644 e2e/specs/widget/rating.spec.ts create mode 100644 e2e/specs/widget/token-auth.spec.ts create mode 100644 e2e/specs/widget/widget-chat.spec.ts create mode 100644 e2e/specs/workflows/workflows.spec.ts create mode 100644 e2e/src/api/agent-socket.ts create mode 100644 e2e/src/api/agents.ts create mode 100644 e2e/src/api/ai.ts create mode 100644 e2e/src/api/auth.ts create mode 100644 e2e/src/api/channels.ts create mode 100644 e2e/src/api/chats.ts create mode 100644 e2e/src/api/client.ts create mode 100644 e2e/src/api/crm.ts create mode 100644 e2e/src/api/customization.ts create mode 100644 e2e/src/api/help-center.ts create mode 100644 e2e/src/api/integrations.ts create mode 100644 e2e/src/api/knowledge.ts create mode 100644 e2e/src/api/mcp.ts create mode 100644 e2e/src/api/organizations.ts create mode 100644 e2e/src/api/people.ts create mode 100644 e2e/src/api/schema.d.ts create mode 100644 e2e/src/api/team.ts create mode 100644 e2e/src/api/tickets.ts create mode 100644 e2e/src/api/types.ts create mode 100644 e2e/src/api/widget-apps.ts create mode 100644 e2e/src/api/widgets.ts create mode 100644 e2e/src/api/workflows.ts create mode 100644 e2e/src/channels/chat.ts create mode 100644 e2e/src/channels/email.ts create mode 100644 e2e/src/channels/index.ts create mode 100644 e2e/src/channels/line.ts create mode 100644 e2e/src/channels/meta.ts create mode 100644 e2e/src/channels/slack.ts create mode 100644 e2e/src/channels/sms.ts create mode 100644 e2e/src/channels/telegram.ts create mode 100644 e2e/src/components/chat-widget.ts create mode 100644 e2e/src/components/sidebar.ts create mode 100644 e2e/src/env.ts create mode 100644 e2e/src/fakes/externals.ts create mode 100644 e2e/src/fakes/llm.ts create mode 100644 e2e/src/fakes/mailbox.ts create mode 100644 e2e/src/fakes/mcp.ts create mode 100644 e2e/src/fixtures/diagnostics.ts create mode 100644 e2e/src/fixtures/index.ts create mode 100644 e2e/src/fixtures/network.ts create mode 100644 e2e/src/index.ts create mode 100644 e2e/src/org-setup.ts create mode 100644 e2e/src/org-state.ts create mode 100644 e2e/src/pages/agents.page.ts create mode 100644 e2e/src/pages/ai-config.page.ts create mode 100644 e2e/src/pages/dashboard.page.ts create mode 100644 e2e/src/pages/human-agents.page.ts create mode 100644 e2e/src/pages/inbox.page.ts create mode 100644 e2e/src/pages/integrations.page.ts create mode 100644 e2e/src/pages/login.page.ts create mode 100644 e2e/src/pages/people.page.ts create mode 100644 e2e/src/pages/setup-wizard.page.ts create mode 100644 e2e/src/pages/setup.page.ts create mode 100644 e2e/src/pages/tickets.page.ts create mode 100644 e2e/src/support/a11y.ts create mode 100644 e2e/src/support/db.ts create mode 100644 e2e/src/support/ids.ts create mode 100644 e2e/src/support/pdf.ts create mode 100644 e2e/src/support/step.ts create mode 100644 e2e/src/support/timeouts.ts create mode 100644 e2e/src/support/unchanged.ts create mode 100644 e2e/src/support/workers.ts create mode 100644 e2e/tsconfig.json create mode 100644 frontend/src/__tests__/App.spec.ts create mode 100644 frontend/src/__tests__/components/BaseModal.spec.ts create mode 100644 frontend/src/__tests__/components/UserForm.chatScope.spec.ts create mode 100644 frontend/src/__tests__/components/WhatsAppTemplateSelect.spec.ts create mode 100644 frontend/src/__tests__/components/common/GuidanceTextarea.spec.ts create mode 100644 frontend/src/__tests__/components/notifications/EnablePushPrompt.spec.ts create mode 100644 frontend/src/__tests__/composables/useBreakpoint.spec.ts create mode 100644 frontend/src/__tests__/composables/useConversationToken.spec.ts create mode 100644 frontend/src/__tests__/composables/useCrawlCheck.spec.ts create mode 100644 frontend/src/__tests__/composables/useCurrency.spec.ts create mode 100644 frontend/src/__tests__/composables/useFaqWorkspace.category.spec.ts create mode 100644 frontend/src/__tests__/composables/useMCPTools.spec.ts create mode 100644 frontend/src/__tests__/composables/useNotificationSettings.spec.ts create mode 100644 frontend/src/__tests__/composables/useOrgInboxRoom.spec.ts create mode 100644 frontend/src/__tests__/composables/useWidgetCustomization.spec.ts create mode 100644 frontend/src/__tests__/composables/useWidgetSocket.reconnect.spec.ts create mode 100644 frontend/src/__tests__/fixtures/permissions.ts create mode 100644 frontend/src/__tests__/fixtures/widget-snippet.txt create mode 100644 frontend/src/__tests__/layouts/DashboardLayout.spec.ts create mode 100644 frontend/src/__tests__/pwa/register.spec.ts create mode 100644 frontend/src/__tests__/router/routePermissions.spec.ts create mode 100644 frontend/src/__tests__/services/api.spec.ts create mode 100644 frontend/src/__tests__/services/firebase-unregister.spec.ts create mode 100644 frontend/src/__tests__/services/installUrls.spec.ts create mode 100644 frontend/src/__tests__/services/knowledge.errors.spec.ts create mode 100644 frontend/src/__tests__/stores/counter.spec.ts create mode 100644 frontend/src/__tests__/stubs/pwa-register.ts create mode 100644 frontend/src/__tests__/utils/aiDisclaimer.spec.ts create mode 100644 frontend/src/__tests__/utils/apiError.spec.ts create mode 100644 frontend/src/__tests__/utils/chatState.spec.ts create mode 100644 frontend/src/__tests__/utils/color.spec.ts create mode 100644 frontend/src/__tests__/utils/endChat.spec.ts create mode 100644 frontend/src/__tests__/utils/knowledgePages.spec.ts create mode 100644 frontend/src/__tests__/utils/mcp.spec.ts create mode 100644 frontend/src/__tests__/utils/messageMatch.spec.ts create mode 100644 frontend/src/__tests__/utils/permissions.spec.ts create mode 100644 frontend/src/__tests__/utils/planLimits.spec.ts create mode 100644 frontend/src/__tests__/utils/sanitize.spec.ts create mode 100644 frontend/src/__tests__/utils/swOauthCallback.spec.ts create mode 100644 frontend/src/__tests__/utils/text.spec.ts create mode 100644 frontend/src/__tests__/utils/timezones.spec.ts create mode 100644 frontend/src/__tests__/utils/uploadUrl.spec.ts create mode 100644 frontend/src/__tests__/utils/validators.spec.ts create mode 100644 frontend/src/__tests__/utils/whatsappLanguages.spec.ts create mode 100644 frontend/src/__tests__/utils/whatsappTemplates.spec.ts create mode 100644 frontend/src/__tests__/utils/widgetEmbed.spec.ts create mode 100644 frontend/src/__tests__/utils/widgetTheme.spec.ts create mode 100644 frontend/src/__tests__/views/403.spec.ts create mode 100644 frontend/src/__tests__/views/settings/AIConfigSettings.spec.ts create mode 100644 frontend/src/__tests__/views/settings/OrganizationSettings.spec.ts create mode 100644 frontend/src/__tests__/webclient/chattermateIdentity.spec.ts create mode 100644 frontend/src/__tests__/webclient/chattermateLifecycle.spec.ts create mode 100644 frontend/src/__tests__/webclient/hostBridge.spec.ts create mode 100644 frontend/src/__tests__/webclient/widgetComposer.spec.ts create mode 100644 frontend/src/__tests__/webclient/widgetStartupRefusal.spec.ts create mode 100644 temp/test.pdf create mode 100644 temp/test.txt diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md new file mode 100644 index 0000000..399ed7b --- /dev/null +++ b/AGENT_HANDOFF.md @@ -0,0 +1,82 @@ +# ReplyPier 开发与维护指引 + +日常登录、操作和启停见 [LOCAL_DEPLOY.md](LOCAL_DEPLOY.md),开发排期见 [FROM_ZERO_PLAN.md](FROM_ZERO_PLAN.md)。 + +## 产品目标 + +- 网站右下角悬浮按钮,点击展开聊天。 +- AI 根据公司知识库回答,支持同会话转人工。 +- 多个人工客服协作、会话分配与访问隔离。 +- 服务星级评分和文字评价。 +- 管理员查看聊天记录、接待情况和评价。 + +## 维护约定 + +- 不主动撰写或归档项目来源、旧 Git 状态、操作时间线、验收 JSON 或验收截图。 +- 正常程序日志、业务聊天记录和测试代码按既有机制保留;功能测试按任务需要执行。 +- 保护源码中的版权声明以及 `LICENSE`、`NOTICE`。 +- 不公开环境密钥、账号文件或客户数据。 + +## 本机环境 + +项目目录:`/Users/a11/1020-1030/01-projects/replypier`。 + +后端使用 Python 3.12 虚拟环境,知识库 worker 独立运行,前端使用 Vite preview。数据库与缓存通过 Colima 中的独立容器提供。 + +| 入口 / 服务 | 地址 | +| --- | --- | +| 后台登录 | | +| 悬浮客服演示页 | | +| 会话工作台 | | +| API / Widget / Socket.IO | `127.0.0.1:8018` | +| PostgreSQL 16 + pgvector | `127.0.0.1:15432` | +| Redis | `127.0.0.1:16380` | + +Docker context 为 `colima-bytedesk`,Compose 项目名为 `chattermate-local`,数据库卷为 `chattermate-local_postgres_data`。它们是运行配置,不能为整理名称而改动或删除。Colima 虚拟机承载数据库与缓存,不要单独关闭。 + +## 配置与数据保护 + +- 账号密码保存在 `deploy/local/.state/test-accounts.json`,日常使用见本机说明。 +- `deploy/local/.state/test-environment.json` 保存组织、AI 客服、Widget、分组及人工客服 ID,构建脚本需要使用。 +- `backend/.env`、`frontend/.env`、`deploy/local/.env` 是私有配置。 +- 模型 Key 加密保存在数据库。不要为文档或排查而导出、解密或写入源码与日志。 +- 恢复时同时保留数据库与后端加密配置;不要用重置管理员、覆盖密钥或删除卷的方式重新开始。 + +## 维护入口 + +| 文件 | 用途 | +| --- | --- | +| [deploy/local/local.sh](deploy/local/local.sh) | 启停、状态、依赖安装、构建与日志 | +| [deploy/local/compose.yaml](deploy/local/compose.yaml) | PostgreSQL/pgvector 与 Redis 容器、数据卷 | +| [deploy/local/init_env.py](deploy/local/init_env.py) | 仅生成缺失配置,保留现有密钥 | +| [deploy/local/requirements.lock.txt](deploy/local/requirements.lock.txt) | 后端依赖锁定 | +| [deploy/local/seed_test.py](deploy/local/seed_test.py) | 使用已有管理员及账号文件准备演示配置,不是空环境初始化器 | +| [deploy/local/demo.template.html](deploy/local/demo.template.html) | 演示页模板 | + +## 会话权限与接待规则 + +会话管理必须统一检查角色、组织边界和会话可见性。普通客服不能读取、接管、重新分配或订阅其他人负责的会话;合法转交后应同步更新访问权限。 + +- 管理操作:[session_to_agent.py](backend/app/api/session_to_agent.py)。 +- Widget 与客服连接、房间、消息和评价:[widget_chat.py](backend/app/api/widget_chat.py)。 +- 人工路由:[human_routing.py](backend/app/services/human_routing.py)。 +- 定向回归:[test_session_to_agent.py](backend/tests/api/test_session_to_agent.py)。 + +## RAG 与 Agent 实现位置 + +- [knowledge_base.py](backend/app/knowledge/knowledge_base.py):资料导入、切分、向量索引。 +- [embedder.py](backend/app/knowledge/embedder.py):本地 FastEmbed / ONNX 嵌入,按进程缓存模型;默认模型为 `BAAI/bge-small-en-v1.5`。 +- [knowledge_search_byagent.py](backend/app/tools/knowledge_search_byagent.py):限定关联知识源,补充相邻块并收集来源。 +- [chat_agent.py](backend/app/agents/chat_agent.py):Agno Agent、模型调用、工具与结构化回复。 +- [history_memory.py](backend/app/agents/history_memory.py):裁剪交给模型的近期历史,保留数据库中的会话记录。 +- [transfer_agent.py](backend/app/agents/transfer_agent.py):接待时间、人员可用性及转接说明。 +- [workflow_execution.py](backend/app/services/workflow_execution.py):消息、LLM、条件、表单与转人工等节点执行。 + +知识检索使用向量搜索;不要将其描述为已配置混合检索或 reranker。知识更新不是微调模型,多个人工客服也不等于多个自治 AI Agent。 + +## 待接入与待配置项 + +- 真实网站、HTTPS、CORS 和 Widget 的部署地址,需要对应网站与部署权限。 +- 固定客户长期负责人的身份映射与自动路由,需要独立定制。 +- 中文知识效果需要真实业务资料评估,不能用虚构示例替代。 +- 邮件邀请、重置密码、Firebase 离线推送和生产容量需在相应任务中处理。 diff --git a/FROM_ZERO_PLAN.md b/FROM_ZERO_PLAN.md new file mode 100644 index 0000000..0553028 --- /dev/null +++ b/FROM_ZERO_PLAN.md @@ -0,0 +1,326 @@ +# ReplyPier 从零开发计划:9 月 21 日—10 月 21 日 + +## 1. 目标与投入 + +在 **31 天(含起止两日)**内,从空工程开发一套可嵌入个人或公司网站的 AI 客服系统,完成以下业务闭环: + +> 网站右下角悬浮入口 → 公司知识库问答 → 转人工 → 分配给对应客服 → 人工双向交流 → 结束服务并评价 → 管理员回看记录与接待情况。 + +计划针对 **ReplyPier**,参考本目录中的 Python/FastAPI、Vue 和 Socket.IO 实现。日期表示从零开发的任务顺序,不是实际进度记录,不指定年份或星期。 + +### 适用前提 + +- **人力:1 人,每天 6–8 小时,共 186–248 小时。**包括源码阅读、开发、测试、联调、文档和缓冲。 +- **能力:**已掌握 Python、HTTP、SQL,能够开发基础 Vue 页面。“从零”指空工程开发;如果还需要从零学习编程,应延长周期。 +- **开发方法:**复用 FastAPI、SQLAlchemy、Socket.IO、Agno 等成熟组件,自己实现身份、会话、知识范围、人工路由、评价和界面,不从零重写模型框架或通信协议。 +- **参考环境保留:**在独立实现目录、数据库、端口和测试数据中开发,不清空当前 ReplyPier,不覆盖现有环境密钥、账号、模型配置或聊天记录。 +- **部署目标:**先交付可复现的单节点版本;真实网站上线以用户提供网站、域名、部署目标和相关授权为条件。 + +**完成边界:本计划完整覆盖上述客服核心需求,不将所有渠道、工作流、AI 工单调查和企业扩展都塞入 31 天。** + +## 2. 项目分析:应学习什么,难点在哪里 + +### 2.1 实际技术结构 + +| 部分 | 本地实现 | 对计划的影响 | +| --- | --- | --- | +| HTTP 后端 | Python、FastAPI、SQLAlchemy、Alembic | 身份、配置、历史、管理接口与数据库迁移是一条基础主线 | +| 实时通信 | `python-socketio`、前端 `socket.io-client`,区分 `/widget`、`/agent` | 不能把原生 WebSocket 当作可直接替代的客户端;访客和客服身份要分别校验 | +| 前端 | Vue 3、TypeScript、Vite、Pinia、Vue Router | 同时安排后台、工作台、Widget 与 loader 的开发和构建 | +| AI 编排 | Agno;依赖文件固定 `agno==1.7.6` | Agent 通过工具获取知识,并输出转人工、结束等结构化字段;业务动作仍由服务端校验执行 | +| 模型调用 | 本机配置为 DeepSeek | 第一版只接一个模型供应商;调用需要预算,不能将模型 Key 放到网页 | +| 知识检索 | FastEmbed/ONNX 本地嵌入、PostgreSQL/pgvector 向量搜索 | 不需要额外搭建 Elasticsearch;先验证中文检索,再调提示词 | +| 知识处理 | `KnowledgeQueue` 持久化任务、独立 knowledge worker | 文件上传成功不等于知识可检索,需要展示处理中、成功和失败 | +| 缓存与事件 | Redis、Socket.IO Redis manager 等 | 用于连接事件与短期状态,聊天历史仍以数据库为依据 | +| 测试 | pytest、Vitest、Playwright E2E | 逻辑测试、界面流程测试和真实模型效果评测要分开判断 | + +本地 `backend/app` 约有 350 个 Python 文件,前端有 158 个 Vue 组件。适合按业务链路取舍,不适合逐文件复制。 + +依据:[后端依赖](backend/requirements.txt)、[前端依赖及构建脚本](frontend/package.json)、[后端入口](backend/app/main.py)、[Socket.IO 配置](backend/app/core/socketio.py)、[知识处理 worker](backend/app/workers/knowledge_processor.py)。 + +### 2.2 先分清 AI 客服、人工客服和网站客户 + +三个角色在代码里不是同一种对象: + +- **`Agent`:AI 客服配置。**包括模型、指令、知识关联和转人工设置。 +- **`User`:管理员或人工客服。**通过角色、分组和会话可见范围控制权限。 +- **`Customer`:网站访客或已识别客户。**需要结合组织及可信身份识别,不能只凭网页传来的邮箱确认身份。 + +`SessionToAgent` 同时关联 AI Agent、人工 User、Customer 和组织。评分也区分 AI 客服与人工客服字段。开发时必须明确字段语义,避免将 AI Agent ID 当成人工客服 ID,或把评价统计到错误的人。 + +参考:[会话模型](backend/app/models/session_to_agent.py)、[客户模型](backend/app/models/customer.py)、[评价模型](backend/app/models/rating.py)。 + +### 2.3 一次完整对话跨越哪些模块 + +```text +网站 loader → Vue Widget → 访客凭证 → Socket.IO /widget + │ + 会话与消息持久化 + │ + Agno ChatAgent 调用知识工具 + │ + FastEmbed 查询向量 → pgvector 检索 + │ + 模型回答 + 来源 / 转人工意图 + │ + 服务端路由 → 等待接管 / 指定负责人 + │ + Socket.IO /agent → 人工回复 → 访客 + │ + 结束会话 → 星级文字评价 → 管理后台 +``` + +主要阅读入口: + +- Widget 授权、聊天、历史、人工消息、房间与评价:[widget_chat.py](backend/app/api/widget_chat.py)。 +- Vue Widget 挂载:[widget.ts](frontend/src/webclient/widget.ts);连接状态与重连:[useWidgetSocket.ts](frontend/src/composables/useWidgetSocket.ts)。 +- 嵌入代码与后端地址:[widgetEmbed.ts](frontend/src/utils/widgetEmbed.ts)。 +- Agent 与结构化回复:[chat_agent.py](backend/app/agents/chat_agent.py);历史预算:[history_memory.py](backend/app/agents/history_memory.py)。 +- 知识导入:[knowledge_base.py](backend/app/knowledge/knowledge_base.py);本地嵌入:[embedder.py](backend/app/knowledge/embedder.py);按 Agent 检索:[knowledge_search_byagent.py](backend/app/tools/knowledge_search_byagent.py)。 +- 转人工与转交权限:[human_routing.py](backend/app/services/human_routing.py)、[session_to_agent.py](backend/app/api/session_to_agent.py)。 + +### 2.4 最影响进度与质量的六个问题 + +#### ① 悬浮按钮只是入口,真正要完成的是可嵌入产品 + +除了后台页面,还要交付 loader、Widget 页面、JS/CSS 构建、后端地址配置、来源域名检查、移动端布局,以及加载失败的反馈。不能只在开发服务器首页放一个聊天框就算完成网站接入。 + +#### ② RAG 是工具式检索,不是训练 DeepSeek + +当前实现由 Agent 调用知识检索工具,检索关联资料、补充相邻块并收集来源。知识更新改变检索数据,不是微调模型。 + +本地配置默认嵌入模型为 `BAAI/bge-small-en-v1.5`,当前代码使用向量搜索。中文业务效果需要用自己的资料和问句验证;不能直接宣称已经具备混合检索、reranker 或中文大规模效果。若更换嵌入模型,应处理向量维度、索引版本与重新嵌入,不能直接混用新旧向量。 + +#### ③ 转人工不是让模型说一句“已为你转接” + +参考实现的主要状态是 `OPEN`、`TRANSFERRED`、`CLOSED`,还要结合 `user_id` 判断由谁处理: + +- `TRANSFERRED` 且没有人工负责人:等待人工。 +- 已有人工负责人:由对应客服接待。 +- `CLOSED`:结束,不能继续按活跃会话发送普通消息。 + +转接必须更新服务端状态并通知正确的人;AI 已经发出的请求晚返回时,也不能在人工接管后继续插话。等待、接管、转交、结束都要在同一会话中保留上下文。 + +#### ④ 多客服隔离必须覆盖读、写、转交和 Socket 房间 + +不能只在列表中隐藏别人的会话。客服 B 即使猜到 A 的会话 ID,也不能读取历史、加入房间、发送消息、接管或将会话重新分配给自己。 + +`reassign_chat` 等会话管理操作必须统一检查角色、组织和会话可见性,不能只检查“属于同一公司”。 + +参考:[权限实现](backend/app/api/session_to_agent.py)、[对应回归测试](backend/tests/api/test_session_to_agent.py)。 + +#### ⑤ 固定客户负责人不等于当前会话负责人 + +队列接管、单次会话转交与长期客户负责人映射是不同能力。长期映射与自动路由需要单独实现。 + +本计划将其作为明确开发项,区分: + +- **长期负责人:**组织 + 可信网站客户身份 → 负责客服。 +- **本次接待人:**当前会话由谁处理,可以经授权临时转交。 + +一次 A→B 会话转交,默认不自动修改客户长期负责人。匿名访客也不能通过自填邮箱冒充已绑定客户。 + +#### ⑥ 结束、评分和管理回看必须闭环 + +结束服务后应出现 1–5 星及文字评价入口;重复提交不能让统计重复计数,文字不能成为脚本注入入口。评价需要关联真实会话和结案接待人,管理员可以回看原对话和反馈。 + +本轮建议采用:同一会话保留一条有效评价,重提更新该评价;人工评价归属结案时的接待客服,并保存归属,不能随客户长期负责人变更而漂移。 + +### 2.5 接入与功能待办 + +1. 真实网站与公网部署接入,需要网站身份来源、域名与部署权限。 +2. 固定客户到负责客服的业务映射与自动路由,需要独立定制。 +3. 中文知识检索需要实际业务资料评估,不能用虚构示例代替。 +4. 单节点部署需补充适用负载下的验证;Vite preview 不作为生产静态资源服务。 + +## 3. 交付范围与业务规则 + +### 3.1 必须交付 + +- 管理员、至少两个人工客服、组织与客服分组管理。 +- 可配置的右下角悬浮入口,支持展开、收起和移动端使用。 +- 匿名访客与可信网站客户两种身份;会话与历史不能串到他人名下。 +- 文本双向消息、持久化、发送确认、消息幂等、断线重连和历史恢复。 +- 知识文本维护,TXT/Markdown、非扫描 PDF 导入;异步切块、向量化、状态展示、失败重试和删除同步。 +- 一个模型供应商的 Agent、知识检索工具、来源展示、有限历史和失败反馈。 +- 同会话 AI 转人工、分组待接管、人工接管、人工转交、结束服务。 +- 固定客户到负责客服的绑定与路由;普通客服不能修改全局绑定或抢走他人负责客户。 +- 星级和文字评价;管理员查看对话、接待人、评价与基础统计。 +- 关键用户界面的中文文案、可重复构建部署、备份恢复说明与相关测试。 + +### 3.2 固定客户路由采用的默认规则 + +| 场景 | 本轮实现规则 | +| --- | --- | +| 匿名或未绑定客户转人工 | 进入对应客服组待接管列表,同组有权限的客服可以接管 | +| 已绑定客户,负责人在线 | 在发起转人工时分配给该负责人,AI 阶段不提前占用人工接待身份 | +| 已绑定客户,负责人离线 | 展示等待/留言状态,待接待项仅负责人和管理员可见,不静默换给其他客服 | +| 需要其他客服临时接待 | 管理员或有权的当前负责人明确转交,保存记录 | +| 修改长期负责人 | 由管理员操作,影响后续路由;既有会话权限按其当前接待人判断 | +| 客户跨设备登录 | 使用网站后端确认的稳定客户身份恢复,不以浏览器随机 ID 或自填邮箱代替 | + +这些是计划采用的业务默认值,涉及公司实际分工时在第一天确认。 + +### 3.3 本轮不做 + +多模型供应商后台、完整可视化工作流、AI 工单自动调查、MCP/SQL 外部工具、Shopify/Jira/Slack/WhatsApp 等第三方集成、复杂网站爬取、扫描 PDF/OCR、聊天文件附件、完整 Help Center、离线推送、复杂邮件系统、集群高可用与大规模并发。 + +这些能力可以在核心验收后独立排期,不应替代评分、多客服隔离或固定负责人等本项目的实际需求。 + +## 4. 实现路线与外部输入 + +### 工程路线 + +1. 一个 FastAPI 应用,按身份、会话、Agent/知识、人工接待、评价和管理统计划分业务边界。 +2. 一个 Vue 工程,分别构建管理后台、客服工作台和嵌入 Widget;loader 作为网站接入入口。 +3. PostgreSQL + pgvector 保存业务数据与知识向量;Alembic 管理迁移。 +4. 独立 knowledge worker 处理数据库中的知识任务;先采用一个 worker 和有限并发,避免本机模型重复加载挤占内存。 +5. Redis 用于短期在线状态、限流和必要的 Socket.IO 事件分发,不替代数据库历史。 +6. 自动化流程测试用可控模型桩;真实知识效果在授权预算内用少量真实模型调用验证。模型桩通过不等于 RAG 有效。 +7. 先提供确定可运行的核心流程,再丰富界面;不从零开发流程设计器作为聊天的前置条件。 + +### 需要用户提供的输入 + +| 输入 | 影响的工作 | 未具备时的安排 | +| --- | --- | --- | +| 真实知识资料、FAQ 与不允许编造的政策 | 知识检索及问答验收 | 用明确标记的测试资料开发,但真实业务效果仍待资料到位后验证 | +| 客服名单、负责关系、离线处理规则 | 固定客户路由 | 使用两位测试客服和测试客户完成实现,不冒充已配置真实客户关系 | +| 网站技术栈、登录身份来源及接入权限 | 可信客户身份、跨设备恢复 | 用独立测试宿主站验证签发流程;真实网站接入仍列为待完成 | +| 域名、可部署环境与上线授权 | HTTPS、反向代理、公开访问 | 完成本机/独立测试环境部署;公网发布由输入与授权到位后推进 | +| 模型配置及允许的测试预算 | 真实 Agent/RAG 验收 | 常规测试继续使用模型桩;真实调用需按实际授权进行,不从旧环境导出密钥 | + +这些依赖不阻塞独立模块开发,但受影响的真实接入项不能在缺少输入时标记完成。 + +## 5. 31 天逐日安排 + +每日均包含实现、定向阅读和相关测试。下表的“完成标准”针对从空工程开发的版本。 + +### 第一阶段:9 月 21 日—9 月 27 日,身份、Widget 与消息基础 + +| 日期 | 当日工作与产物 | 当日完成标准 | +| --- | --- | --- | +| 09/21 · D01 | 确认六项核心需求和固定负责人规则;画角色、会话状态、数据关系图;整理网站与知识输入 | 形成需求边界、验收用例和输入清单;明确 AI Agent、人工 User、Customer 的区别 | +| 09/22 · D02 | 新建最小 FastAPI/Vue 工程;接 PostgreSQL/pgvector、Redis;锁定依赖;建立健康检查、构建及测试入口 | 前后端可构建、启动;数据库扩展可用;开发环境与参考环境的端口、数据和密钥隔离 | +| 09/23 · D03 | 实现组织、用户、角色、分组、客户、会话与消息基础模型;Alembic 迁移;实现一次性初始管理员创建 | 空库可初始化;重复启动不重复创建管理员;准备两个组织、每组织两位测试客服 | +| 09/24 · D04 | 实现后台登录、凭证校验、管理员/客服权限、会话访问策略;完成登录与人员管理基础页 | 普通客服不能修改管理员配置;不同组织数据隔离;会话权限由后端判断 | +| 09/25 · D05 | 实现 Widget 配置、loader、右下角悬浮按钮与展开收起;建立独立宿主测试网页 | 嵌入脚本能加载自己的服务;桌面和窄屏正常显示;错误地址或加载失败有反馈 | +| 09/26 · D06 | 实现匿名凭证和 Socket.IO `/widget`、`/agent` 连接鉴权;消息持久化、发送确认与客户端消息 ID | 合法访客/客服可连接;伪造身份被拒绝;同一消息重复提交只产生一条业务记录 | +| 09/27 · D07 | 完成最小聊天界面、人工回复、历史查询与重连;用预先授权的测试会话贯通两端 | 访客与测试客服可双向交流;刷新/断线后能补拉已确认消息;权限拒绝不导致消息入库 | + +**阶段出口:Widget、后台和实时消息不再是彼此独立的原型。** + +### 第二阶段:9 月 28 日—10 月 4 日,知识库与客服 Agent + +| 日期 | 当日工作与产物 | 当日完成标准 | +| --- | --- | --- | +| 09/28 · D08 | 实现知识源、AI Agent 配置及二者关联;完成文本/FAQ 维护页面;准备真实或明确标记的测试资料 | 不同组织和不同 Agent 的知识范围明确;可以编辑资料并指定供哪个 AI 客服使用 | +| 09/29 · D09 | 实现 TXT/Markdown、非扫描 PDF 导入,文件限制、文本解析、切块与来源元数据 | 可查看提取文本和切块;空文件、超限、无文本 PDF 有明确反馈;不会读取任意本机文件 | +| 09/30 · D10 | 实现数据库知识任务队列、独立 worker、FastEmbed 和 pgvector 索引;处理失败与重试 | 待处理—处理中—完成/失败状态可见;任务可重试;重复消费不重复建立有效切块 | +| 10/01 · D11 | 实现按组织、Agent 和知识源范围检索;建立调试页;先用中文问题核对召回,再决定是否调整嵌入模型 | 能展示命中原文与来源;未关联资料不被搜出;修正召回失败案例,换模型时不混用旧向量 | +| 10/02 · D12 | 接入单一模型供应商和 Agno 知识工具;加密保存模型密钥;定义结构化回复、历史预算、超时与工具调用上限 | 工具调用与回复字段可测试;Key 只在后端使用且不明文入库;历史超限和模型失败都有明确处理 | +| 10/03 · D13 | 在 Widget 展示知识回答、来源、等待状态和失败提示;保存完整会话上下文;加入知识更新/删除同步 | 回答依据可追溯;刷新后历史保留;删除或替换的知识不再作为有效来源被召回 | +| 10/04 · D14 | 固定 30 个知识问答用例,区分检索与生成问题;进行获准预算内的真实模型验收并修复主要问题 | 逐题核对知识依据与模型行为;明确资料外问题的拒答或转人工行为 | + +**阶段出口:AI 能基于指定知识回答,而不是只靠模型常识生成看似正确的回复。** + +### 第三阶段:10 月 5 日—10 月 11 日,转人工、多客服隔离与固定负责人 + +| 日期 | 当日工作与产物 | 当日完成标准 | +| --- | --- | --- | +| 10/05 · D15 | 实现主动请求与 Agent 结构化意图触发的转人工;统一服务端会话状态与上下文交接 | 用户点击或明确要求转人工后,真实进入待人工状态;不是仅返回一段转接话术 | +| 10/06 · D16 | 实现客服组待接管列表、在线/离线状态、接待时间、认领与无人在线提示 | 同组可接未分配会话;两个客服并发认领只有一个成功;无人在线时可等待或留言 | +| 10/07 · D17 | 完成人工工作台接管与双向消息;加入 AI 运行结果的状态/版本检查 | 接管保留 AI 历史;人工期间不再触发普通 AI 回复;接管前的慢模型请求晚返回也不插话 | +| 10/08 · D18 | 强化多客服权限:列表、详情、接管、重新分配、发消息、加入房间;覆盖会话可见性与重新分配的越权用例 | B 无法读取、抢占、重分配或订阅 A 的会话;跨组织访问被拒绝;非法消息不入库 | +| 10/09 · D19 | 实现合法 A→B 转交、负责人变更事件、旧房间权限回收和前端列表同步 | 转交后 B 获得权限、A 失去权限;A 不能靠既有连接继续收到新消息;变更可追溯 | +| 10/10 · D20 | 实现可信网站客户身份签发、稳定客户 ID 与长期负责人映射;完成管理员绑定界面 | 网站后端确认的客户可绑定 A/B;更换设备仍识别同一客户;匿名自填邮箱不能继承他人权限 | +| 10/11 · D21 | 将长期负责人接入人工路由;实现在线指定、离线等待与授权转交;完成多角色联调 | 绑定 A 的客户转人工只交给 A;A 离线不静默分给 B;临时转交不擅自修改长期绑定 | + +**阶段出口:多客服协作、会话隔离和“客户找自己的负责客服”都能验证。** + +### 第四阶段:10 月 12 日—10 月 15 日,评价、管理员回看与网站接入 + +| 日期 | 当日工作与产物 | 当日完成标准 | +| --- | --- | --- | +| 10/12 · D22 | 实现结束服务、请求评分、1–5 星及文字反馈;完成身份校验、文本安全处理、重复提交规则 | 只有真实会话访客可以评价;非法分值被拒绝;重复提交不重复计数;保存正确接待人 | +| 10/13 · D23 | 完成管理员聊天记录、接待人、评价明细与基础统计;定义评分与接待数量口径 | 管理员可回看 AI→人工全过程;A/B 会话与评分归属正确;普通客服仍不能查看他人记录 | +| 10/14 · D24 | 完成关键界面中文化、ReplyPier 品牌、窄屏布局、输入反馈及错误提示;检查真实渲染 | 悬浮窗不遮挡关键操作;等待、转人工、接管、结束、评分状态清楚;主要旅程可用键盘操作 | +| 10/15 · D25 | 构建后台、Widget 与 loader;配置宿主网站地址、来源校验和代理路径;冻结功能 | 跨来源测试宿主站完成嵌入与身份签发;输入与授权齐备时验证真实网站;不把测试页等同线上接入 | + +**阶段出口:六项核心需求完整可演示;真实接入尚缺的输入与责任人明确。** + +### 第五阶段:10 月 16 日—10 月 21 日,可靠性、部署与交付缓冲 + +| 日期 | 当日工作与产物 | 当日完成标准 | +| --- | --- | --- | +| 10/16 · D26 | 运行关键 pytest/Vitest/Playwright 回归;覆盖匿名/登录、A/B 客服、管理员、评分及攻击路径 | 核心自动化用例通过;分别检查模型桩流程与真实模型行为;模型/知识未变时避免重复真实调用 | +| 10/17 · D27 | 验证重连、消息幂等、并发接管、重启、worker 中断、模型超时及小规模负载 | 已确认消息不丢失;重试不重复入库;AI/索引故障不会让人工服务失效;检查负载下的消息完整性与故障恢复 | +| 10/18 · D28 | 部署自己构建的后端、worker、静态前端和代理;验证空库初始化、健康检查、备份恢复 | 新环境按文档启动;Socket.IO 与 Widget 正常;恢复数据库及加密配置后能读取历史和模型配置 | +| 10/19 · D29 | 第一缓冲日:修复身份、消息、负责人路由、会话权限和评价等阻塞问题 | 必须功能无阻塞缺陷;每个关键修复有定向回归;不加入新渠道或复杂工作流 | +| 10/20 · D30 | 第二缓冲日:继续修复;整理 README、配置示例、API/事件说明、部署恢复和演示脚本 | 能按文档独立安装并演示;配置示例不含真实 Key、密码或客户数据;未完成接入项清楚列出 | +| 10/21 · D31 | 执行第 6 节最终验收;整理源码、迁移、构建和使用说明 | 完整走通“悬浮入口—知识问答—转人工—指定客服—评价—管理员回看”;仅对实际通过项打勾 | + +## 6. 最终验收清单 + +### 6.1 用户真正要用的六项能力 + +- [ ] 网站右下角按钮可打开/收起客服,桌面与手机布局正常。 +- [ ] AI 根据指定公司知识回答,展示可核对来源;知识不足时明确反馈。 +- [ ] AI 与人工在同一会话中交接,历史保留,人工接管后 AI 不插话。 +- [ ] 至少 A/B 两位人工客服可协作;无权访问对方已分配会话。 +- [ ] 结束服务后能提交星级与文字评价,重复提交不会制造重复统计。 +- [ ] 管理员可看聊天记录、实际接待人、评分、反馈和基础接待统计。 + +### 6.2 固定客户负责关系 + +- [ ] 两名已认证客户分别绑定 A 和 B,转人工后分到对应人。 +- [ ] 同一已认证客户在不同浏览器/设备上仍能正确识别,不能恢复其他客户的私密历史。 +- [ ] 负责人离线时按既定规则等待/留言,不静默换人,不向无权客服公开该客户对话。 +- [ ] 临时转交与长期绑定分开,普通客服不能篡改全局负责关系。 +- [ ] 伪造客户 ID、自填他人邮箱或篡改组织参数均不能冒充已认证客户。 + +### 6.3 权限与可靠性 + +- [ ] 跨组织的会话、知识、评价、客服配置全部隔离。 +- [ ] B 猜到 A 的会话 ID 后,读取、加入房间、发消息、接管、重新分配均被拒绝。 +- [ ] 合法 A→B 转交后,旧连接不再收到后续会话内容。 +- [ ] 同一客户端消息 ID 重试 10 次,只保存一条业务消息。 +- [ ] 断网、刷新、应用重启后,已确认的历史可完整补拉,前端不重复展示。 +- [ ] AI 请求晚返回、模型报错、知识 worker 失败均有可见状态和恢复路径。 +- [ ] 评分越权、非法分值、恶意反馈文本及重复提交均有测试。 + +**建议的小规模消息负载:**独立测试环境,20 个访客 Socket.IO 连接、2 个客服,总文本发送速率 2 条/秒,持续 5 分钟。使用可控模型桩隔离模型费用和延迟;检查成功确认数、持久化数、业务重复数、补拉缺失数和延迟分位数。该负载用于单节点核心版验收,不推导生产容量。 + +### 6.4 真实中文知识效果 + +- [ ] 固定 30 个问题:20 个知识库内问题、10 个知识库外问题,逐题核对预期依据。 +- [ ] 至少 18/20 个库内问题检索到正确依据,并得到有来源支持的回答;分别检查召回和最终生成结果。 +- [ ] 10 个库外问题均明确表示资料不足或提供人工入口,不凭空编造业务政策。 +- [ ] 额外检查跨组织、未关联给当前 AI Agent、已删除或已替换的知识,均不得违规引用。 +- [ ] 按选定的嵌入模型、聊天模型和知识版本核对真实问答;不用模型桩或单个测试政策代替真实资料效果。 + +### 6.5 可安装、可恢复、可接入 + +- [ ] 源码可按文档构建,后台、Widget、loader 都来自自研工程。 +- [ ] 空环境可以迁移数据库、创建初始管理员并配置 AI、客服与知识,不依赖本机既有账号文件。 +- [ ] 备份包括数据库及必要加密配置,恢复后历史和加密保存的模型配置可用。 +- [ ] 开发密钥、账号文件、模型缓存和测试数据中的私密信息不进入公开交付物。 +- [ ] 测试宿主网站完成跨来源嵌入;真实网站上线如仍缺域名、身份接口或授权,明确列为待完成项。 +- [ ] 具备 5–10 分钟演示,覆盖六项核心能力及固定负责人规则。 + +## 7. 里程碑与执行原则 + +| 检查点 | 应达到的结果 | 进度落后时优先处理 | +| --- | --- | --- | +| 9 月 27 日 | 身份、Widget、两端消息与历史可用 | 构建、鉴权、消息持久化,不提前堆模型功能 | +| 10 月 4 日 | 知识导入、检索、Agent 回答和来源可验证 | 区分解析/召回/模型问题,不靠继续调提示词掩盖检索错误 | +| 10 月 11 日 | 转人工、多客服隔离、指定负责人贯通 | 身份映射、接管竞态和权限,不能用手动转交冒充长期绑定 | +| 10 月 15 日 | 核心功能与界面完整,冻结范围 | 只补缺陷和真实接入依赖,不追加工作流或第三方渠道 | +| 10 月 21 日 | 验收与交付材料齐备 | 未通过的必须项明确保留;必要时调整人力或工期,不虚报完成 | + +执行时坚持: + +1. 按业务旅程推进,不按目录数量平均分配天数。 +2. 让 AI Agent、人工客服、客户身份各司其职,权限由服务端执行。 +3. 优先做好用户明确需要的评分、隔离和负责关系,而不是先开发 AI 工单等扩展。 +4. 每天完成可运行产物和针对性测试;常规回归使用模型桩,真实调用按预算进行。 +5. 10 月 19 日和 10 月 20 日保留为真实缓冲,不提前排满增强功能。 diff --git a/HELP_CENTER_INFRA.md b/HELP_CENTER_INFRA.md new file mode 100644 index 0000000..7931c95 --- /dev/null +++ b/HELP_CENTER_INFRA.md @@ -0,0 +1,194 @@ +# Help Center hosting — infrastructure runbook + +The public help center is served by the backend itself (host-dispatched SSR — +see `backend/app/core/help_center_host.py`). This runbook covers the +deploy-time pieces that live **outside** the repo: DNS, TLS and the host +nginx on the production VPS. `frontend/nginx.conf` and the docker-compose +files need **no changes**. + +> **As deployed on the ChatterMate cloud VPS (2026-07-14):** the base domain is +> **`help.chattermate.chat`** (orgs serve at `{slug}.help.chattermate.chat`), not +> the `chattermate.help` this doc originally planned — that domain was never +> registered, and the org only owns `chattermate.chat`. DNS is **Route53**, so the +> wildcard cert is issued and auto-renewed with the `dns-route53` plugin. +> Placeholders (``, ``, …) stand in for the live values; the +> real ones live on the VPS and in ops notes, not in this public repo. + +## 1. One-time platform setup + +### DNS (Route53) +Point a wildcard under the domain you own at the VPS. On the cloud box this is +the `chattermate.chat` hosted zone (``): +- `*.help.chattermate.chat. A ` +- `*.help.chattermate.chat. AAAA ` + +One wildcard record covers every org — no per-org DNS. Create them via the API: +```bash +aws route53 change-resource-record-sets --hosted-zone-id \ + --change-batch file://wildcard.json # UPSERT A + AAAA for *.help. +``` + +Backend env (`backend.env` on the VPS): +``` +HELP_CENTER_BASE_DOMAIN=help.chattermate.chat +HELP_CENTER_TARGET_IPS=[""] # JSON array — see gotcha below +``` +> **Gotcha:** `HELP_CENTER_TARGET_IPS` is a `frozenset` setting, so +> pydantic-settings JSON-parses the env value. A bare IP or comma list +> (``) fails validation and crash-loops the backend at startup — +> it **must** be a JSON array: `[""]`. (Powers the per-org +> custom-domain A-record verification in §2; unused by the wildcard site.) + +### Wildcard certificate (DNS-01 via Route53) +HTTP-01 cannot issue wildcards. A dedicated IAM user with the standard certbot +Route53 policy (`route53:ListHostedZones` + `GetChange` on `*`, +`ChangeResourceRecordSets` on the zone) has its creds at `~/.aws/credentials` +(mode 600) on the box. Issue once: +```bash +docker run --rm \ + -v ~/chattermate/certbot/conf:/etc/letsencrypt \ + -v ~/.aws:/root/.aws:ro \ + certbot/dns-route53 certonly --dns-route53 \ + -d '*.help.chattermate.chat' -d 'help.chattermate.chat' \ + --cert-name help.chattermate.chat \ + --non-interactive --agree-tos -m +``` + +**Auto-renewal:** a daily cron runs the compose `certbot` service as a +**one-shot**, then reloads nginx only if a cert actually changed: +```bash +docker compose -f docker-compose.hostinger.yml \ + run --rm --entrypoint certbot certbot \ + renew --quiet --deploy-hook "echo reload-needed > /var/www/certbot/.reload" +# ...then, if the flag file appeared: rm it && docker compose exec -T nginx nginx -s reload +``` +It uses the `certbot/dns-route53` image and mounts +`/home/chattermate/.aws:/root/.aws:ro`, so the wildcard renews unattended (the +renewal conf remembers `authenticator = dns-route53`). Existing HTTP-01 webroot +certs still renew — the dns-route53 image is a superset of `certbot/certbot`. +`renew` covers every cert in the shared `certbot/conf`, not just the wildcard. + +> **The `certbot` service must stay one-shot — never give it a long-running +> loop entrypoint.** It previously used +> `sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait ${!}; done;'`, +> which broke renewal in two non-obvious ways: +> +> 1. **`compose run` inherits the entrypoint.** The "one-off" cron run never +> exited, so `--rm` never fired (a container leaked *every night*) and the +> cron's chained `&& nginx -s reload` never ran at all. +> 2. **`sh -c ' + + +` +} + +createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + if (url.pathname === '/health') { + res.writeHead(200, { 'content-type': 'text/plain' }).end('ok') + return + } + if (url.pathname !== '/') { + res.writeHead(404).end() + return + } + const params = url.searchParams + let html: string + try { + html = page({ + widgetId: params.get('widget') ?? '', + config: params.get('config'), + token: params.get('token'), + trigger: params.get('trigger') === '1', + iframe: params.get('snippet') === 'iframe', + }) + } catch { + res.writeHead(400, { 'content-type': 'text/plain' }).end('config is not valid JSON') + return + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html) +}).listen(port, '127.0.0.1', () => console.log(`[customer-site] http://127.0.0.1:${port}/?widget=`)) diff --git a/e2e/services/fake-externals/limits.ts b/e2e/services/fake-externals/limits.ts new file mode 100644 index 0000000..f892913 --- /dev/null +++ b/e2e/services/fake-externals/limits.ts @@ -0,0 +1,95 @@ +// What each provider rejects, from its own published limits — deliberately NOT +// the backend's constants (backend/app/channels/interactive_limits.py), so a +// backend that drifts past a real limit gets the same 400 it would in production. +// +// Each check returns the provider-style error message, or null when the payload +// would be accepted. + +type Body = Record | null +// Provider payloads are walked loosely; typing every Graph/Bot API shape buys nothing here. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Loose = any + +const len = (value: unknown): number => (typeof value === 'string' ? [...value].length : 0) +const bytes = (value: unknown): number => (typeof value === 'string' ? Buffer.byteLength(value) : 0) + +function tooLong(field: string, value: unknown, max: number): string | null { + return len(value) > max ? `${field} is ${len(value)} characters; the limit is ${max}` : null +} + +function first(...checks: Array): string | null { + return checks.find((c) => c !== null) ?? null +} + +// https://developers.facebook.com/docs/whatsapp/cloud-api/messages +function whatsapp(body: Body): string | null { + if (!body || body.messaging_product !== 'whatsapp' || !body.type) return null // receipts, typing + if (body.type === 'text') return tooLong('text.body', (body.text as Body)?.body, 4096) + if (body.type !== 'interactive') return null + const interactive = body.interactive as Record + const bodyText = tooLong('interactive.body.text', interactive?.body?.text, 1024) + if (interactive?.type === 'button') { + const buttons: Loose[] = interactive.action?.buttons ?? [] + if (buttons.length < 1 || buttons.length > 3) return `interactive buttons must number 1-3, got ${buttons.length}` + return first(bodyText, ...buttons.map((b) => first( + tooLong('button title', b.reply?.title, 20), + bytes(b.reply?.id) > 256 ? 'button id is over 256 bytes' : null, + ))) + } + if (interactive?.type === 'list') { + const sections: Loose[] = interactive.action?.sections ?? [] + const rows = sections.flatMap((s) => s.rows ?? []) + if (sections.length > 10) return `a list allows 10 sections, got ${sections.length}` + if (rows.length < 1 || rows.length > 10) return `a list allows 1-10 rows in total, got ${rows.length}` + return first(bodyText, tooLong('list button', interactive.action?.button, 20), ...rows.map((r) => first( + tooLong('row title', r.title, 24), + tooLong('row description', r.description, 72), + bytes(r.id) > 200 ? 'row id is over 200 bytes' : null, + ))) + } + return bodyText +} + +// https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies +function pageSend(textMax: number) { + return (body: Body): string | null => { + const message = body?.message as Record | undefined + if (!message) return null // sender_action (typing) + const replies: Loose[] = message.quick_replies ?? [] + if (replies.length > 13) return `quick_replies allows 13, got ${replies.length}` + return first(tooLong('message.text', message.text, textMax), ...replies.map((q) => tooLong('quick reply title', q.title, 20))) + } +} + +// https://core.telegram.org/bots/api#sendmessage +function telegram(action: string, body: Body): string | null { + if (action !== 'sendMessage') return null + const keyboard: Loose[] = (body?.reply_markup as Record | undefined)?.inline_keyboard?.flat() ?? [] + return first( + tooLong('text', body?.text, 4096), + ...keyboard.map((b) => (bytes(b.callback_data) > 64 ? 'callback_data is over 64 bytes' : null)), + ) +} + +// https://developers.line.biz/en/reference/messaging-api/#send-push-message +function line(action: string, body: Body): string | null { + if (action !== 'push') return null + const messages: Loose[] = (body?.messages as Loose[]) ?? [] + if (messages.length < 1 || messages.length > 5) return `messages must number 1-5, got ${messages.length}` + return first(...messages.map((m) => tooLong('text', m.text, 5000))) +} + +/** The provider's rejection for this call, or null. `provider` is the fake's path prefix. */ +export function rejection(provider: string, action: string, method: string, body: Body): string | null { + if (method !== 'POST') return null + switch (provider) { + case 'graph': return action === 'messages' ? (body?.messaging_product ? whatsapp(body) : pageSend(2000)(body)) : null + case 'graph-instagram': return action === 'messages' ? pageSend(1000)(body) : null + case 'telegram': return telegram(action, body) + case 'line': return line(action, body) + case 'twilio': return action === 'Messages.json' ? tooLong('Body', body?.Body, 1600) : null + case 'plivo': return action === 'Message' ? tooLong('text', body?.text, 1600) : null + case 'slack': return action === 'chat.postMessage' || action === 'chat.update' ? tooLong('text', body?.text, 40000) : null + default: return null + } +} diff --git a/e2e/services/fake-externals/server.ts b/e2e/services/fake-externals/server.ts new file mode 100644 index 0000000..d536a73 --- /dev/null +++ b/e2e/services/fake-externals/server.ts @@ -0,0 +1,548 @@ +// A recording stand-in for the third-party APIs the backend calls on its own: +// Telegram, Meta Graph (WhatsApp / Messenger / Instagram), Slack, LINE, SMS +// providers, HubSpot, Pipedrive, Jira, Shopify. The backend reaches it through the *_API_BASE overrides in +// .env.e2e (see backend/app/core/external_urls.py); each provider is a path prefix. +// +// Every request is recorded (method, path, query, headers, body, the answer) and +// answered like the provider would: +// - the smallest success the adapter accepts, by default; +// - the provider's own rejection when a payload breaks its published limits +// (limits.ts), so a backend that drifts past a real limit fails here too; +// - a scripted answer — a failure, or a reply for a provider with no built-in +// answers — while one the test registered matches (PUT /__faults). +// Tests then assert on what the backend sent ("the bot replied on Telegram with …") +// and read values the provider would have learned, such as the webhook secret +// Telegram receives in setWebhook. +// +// Where a provider tells the backend who the account is, the answer is derived +// from what the test sent, so every test gets its own account: +// Meta tokens `owns:[,…]:` own those nodes (pages, numbers, IG +// accounts). Graph refuses such a token a +// node another token owns, and debug_token +// reports its first id as the page +// Meta tokens `…:app=.:…` were issued by the customer's own Meta +// app: GET /app answers that id and checks +// appsecret_proof against that secret (any +// other token is ChatterMate's app) +// Meta POST / {webhook_configuration} verifies the override callback the way +// Meta does — a GET with hub.verify_token +// that must echo hub.challenge — before +// accepting it +// Meta GET / id = +// LINE GET /info userId derived from the channel access token +// Slack oauth.v2.access team id and bot token derived from the OAuth code +// HubSpot oauth/v1/token an access token derived from the code; introspecting it +// (oauth/v1/access-tokens/) gives a hub id from it +// Pipedrive oauth/token tokens derived from the code, and this fake as the +// company's api_domain; users/me gives a company id +// derived from the token +// Jira authorize consent granted at once: redirects to redirect_uri with a +// fresh code and the state; the token is derived from the +// code and accessible-resources gives a site derived from it +// Shopify orders the customer's order with the number they searched for +// (#1001 if none), shipped with a tracking number +// +// A call's `action` is the provider's method (Telegram, Slack) or else the last +// non-empty path segment (`/Account//Message/` → `Message`). +// +// Control API (src/fakes/externals.ts): +// GET /__calls?provider=&action=&contains=&since= recorded calls, oldest first; +// contains = substring of path or body +// DELETE /__calls forget every call +// PUT /__faults/:id { provider, action?, contains?, status, body?, times? } +// DELETE /__faults/:id +// +// Run: node services/fake-externals/server.ts --port 14020 + +import { createHash, createHmac } from 'node:crypto' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { rejection } from './limits.ts' + +export interface RecordedCall { + at: string + provider: string + /** Provider-level operation: Telegram/Slack method name, else the last path segment. */ + action: string + method: string + path: string + query: Record + /** Lower-cased request headers — e.g. authorization, to tell which account sent it. */ + headers: Record + body: unknown + /** HTTP status the fake answered with. */ + status: number + /** What the fake answered — e.g. the message_id Telegram assigned. */ + response: unknown +} + +export interface Fault { + provider: string + action?: string + /** Only calls whose path or body contains this — scope a fault to one test's account or customer. */ + contains?: string + status: number + body?: unknown + /** How many matching calls fail; omitted = until the fault is deleted. */ + times?: number +} + +const MAX_CALLS = 5000 +const calls: RecordedCall[] = [] +const faults = new Map() +/** Every node some `owns:` token has claimed — what other tokens are refused. */ +const claimed = new Set() +let counter = 0 + +class HttpError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +function send(res: ServerResponse, status: number, body: unknown, headers: Record = {}) { + if (status === 204) { + res.writeHead(status, headers) + res.end() + return + } + res.writeHead(status, { 'content-type': 'application/json', ...headers }) + res.end(JSON.stringify(body)) +} + +async function readRaw(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') +} + +function parseBody(raw: string, contentType: string): unknown { + if (!raw) return null + if (contentType.includes('application/x-www-form-urlencoded')) return Object.fromEntries(new URLSearchParams(raw)) + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +/** A stable numeric id per bot token, so reconnecting the same token finds the same bot. */ +function botIdFor(token: string): number { + return parseInt(digest(token).slice(0, 10), 16) +} + +function bearer(headers: Record): string { + return (headers.authorization ?? '').replace(/^Bearer\s+/i, '') +} + +// ---------- per-provider answers ---------- + +interface Call { + provider: string + /** Where the backend reached this fake, e.g. http://127.0.0.1:14020. */ + origin: string + action: string + method: string + url: URL + headers: Record + body: Record | null +} + +interface Answer { + status: number + body: unknown + headers?: Record +} + +const ok = (body: unknown): Answer => ({ status: 200, body }) + +function telegram(action: string, token: string, body: Record | null): Answer { + switch (action) { + case 'getMe': { + const id = botIdFor(token) + return ok({ ok: true, result: { id, is_bot: true, first_name: 'E2E Bot', username: `e2e_bot_${id}` } }) + } + case 'sendMessage': + return ok({ ok: true, result: { message_id: counter, date: Math.floor(Date.now() / 1000), chat: { id: body?.chat_id }, text: body?.text } }) + default: + // setWebhook, deleteWebhook, sendChatAction, answerCallbackQuery, editMessageReplyMarkup, … + return ok({ ok: true, result: true }) + } +} + +/** The node ids a Meta token owns, or null for a token that makes no claim. */ +function ownedNodes(token: string): string[] | null { + const match = /^owns:([^:]+):/.exec(token) + if (!match) return null + const nodes = match[1].split(',') + for (const node of nodes) claimed.add(node) + return nodes +} + +function graphError(message: string, code = 100): Answer { + return { status: 400, body: { error: { message, type: 'OAuthException', code, fbtrace_id: 'e2e' } } } +} + +/** ChatterMate's own Meta app; its secret mirrors META_APP_SECRET in .env.e2e. */ +const CHATTERMATE_APP = { id: 'e2e-chattermate-app', secret: 'e2e-meta-app-secret' } + +/** The Meta app that issued a token. */ +function appOf(token: string): { id: string; secret: string } { + const match = /:app=([^.:]+)\.([^:]+)/.exec(token) + return match ? { id: match[1], secret: match[2] } : CHATTERMATE_APP +} + +/** Meta checks an override callback before taking it: the GET must echo the challenge. */ +async function overrideCallback(config: Record): Promise { + const uri = String(config.override_callback_uri ?? '') + if (!uri) return ok({ success: true }) // back to the app's own callback + const challenge = `e2e-challenge-${counter}` + const query = new URLSearchParams({ 'hub.mode': 'subscribe', 'hub.verify_token': String(config.verify_token ?? ''), 'hub.challenge': challenge }) + const check = await fetch(`${uri}?${query}`, { signal: AbortSignal.timeout(5_000) }).catch(() => null) + if (!check || (await check.text()) !== challenge) { + return graphError('The callback URL or verify token couldn\'t be validated. Please verify the provided information or try again later.', 2200) + } + return ok({ success: true }) +} + +async function graph({ action, method, url, headers, body }: Call): Promise { + if (action === 'app' && method === 'GET') { + const token = bearer(headers) + const app = appOf(token) + const proof = url.searchParams.get('appsecret_proof') + if (proof !== null && proof !== createHmac('sha256', app.secret).update(token).digest('hex')) { + return graphError('Invalid appsecret_proof provided in the API argument') + } + return ok({ id: app.id, name: `E2E app ${app.id}` }) + } + if (action === 'debug_token') { + const inspected = ownedNodes(url.searchParams.get('input_token') ?? '') + return ok({ data: { is_valid: Boolean(inspected), type: 'PAGE', profile_id: inspected?.[0] ?? '' } }) + } + // `//v21.0/[/]`. A token can't reach a page, number or IG + // account another token owns; anything unclaimed (a customer's PSID, `me`) is fine. + const owned = ownedNodes(bearer(headers)) + const node = url.pathname.split('/').filter(Boolean)[2] ?? '' + if (owned && claimed.has(node) && !owned.includes(node)) { + return graphError(`Unsupported request - object with ID '${node}' does not exist, cannot be loaded due to missing permissions, or does not support this operation`) + } + const webhookConfiguration = body?.webhook_configuration + if (method === 'POST' && webhookConfiguration && typeof webhookConfiguration === 'object') { + return overrideCallback(webhookConfiguration as Record) + } + if (method !== 'GET') { + return ok({ messages: [{ id: `wamid.e2e.${counter}` }], message_id: `m_e2e_${counter}`, recipient_id: body?.recipient ?? null, success: true }) + } + return ok({ id: action, name: `E2E ${action}`, username: `e2e_${action}`, data: [] }) +} + +function answer(call: Call): Answer | Promise { + const { provider, action, headers, body } = call + switch (provider) { + case 'graph': + case 'graph-instagram': + return graph(call) + case 'slack': + if (action === 'oauth.v2.access') { + const code = String(body?.code ?? '') + return ok({ ok: true, access_token: `xoxb-e2e-${digest(code).slice(0, 12)}`, bot_user_id: 'U0E2EBOT', team: { id: `T${digest(code).slice(0, 10).toUpperCase()}`, name: 'E2E Workspace' } }) + } + return ok({ ok: true, ts: `${Math.floor(Date.now() / 1000)}.${String(counter).padStart(6, '0')}`, channel: body?.channel ?? 'C0E2E' }) + case 'line': + // GET /info is the bot's own profile; everything else (push, webhook endpoint) answers an empty 200. + return ok(action === 'info' ? { userId: `U${digest(bearer(headers)).slice(0, 32)}`, displayName: 'E2E LINE Bot', basicId: '@e2e' } : {}) + case 'twilio': + return ok({ sid: `SM${String(counter).padStart(32, '0')}`, status: 'queued' }) + case 'plivo': + return ok({ message_uuid: [`e2e-${counter}`], api_id: `e2e-${counter}` }) + case 'hubspot': + return hubspot(call) + case 'pipedrive-oauth': + return pipedriveOauth(call) + case 'pipedrive': + return pipedrive(call) + case 'jira-auth': + return jiraAuth(call) + case 'jira': + return jira(call) + case 'shopify': + return shopify(call) + default: + return ok({}) + } +} + +function hubspot({ method, url, body }: Call): Answer { + const path = url.pathname + if (path.endsWith('/oauth/v1/token')) { + const seed = String(body?.code ?? body?.refresh_token ?? '') + return ok({ access_token: `hs-${digest(seed).slice(0, 16)}`, refresh_token: `hs-refresh-${digest(seed).slice(0, 8)}`, expires_in: 1800, token_type: 'bearer' }) + } + const introspected = /\/oauth\/v1\/access-tokens\/([^/]+)$/.exec(path) + if (introspected) return ok({ hub_id: parseInt(digest(introspected[1]).slice(0, 7), 16), hub_domain: 'e2e.hubspot.com', token: introspected[1] }) + if (path.endsWith('/contacts/batch/upsert')) return ok({ status: 'COMPLETE', results: [{ id: String(counter), new: true }] }) + return ok(method === 'GET' ? { results: [] } : { id: String(counter) }) +} + +/** Insert-ordered, so the oldest entry goes first once a map outgrows `max`. */ +function remember(map: Map, key: string, value: V, max = 1000) { + map.set(key, value) + if (map.size > max) map.delete(map.keys().next().value as string) +} + +// Pipedrive: OAuth on pipedrive-oauth (oauth.pipedrive.com); the API on the +// company's own host, which the token response names — here, this fake. + +/** Consent granted at once: back to the app with a fresh code and the state. */ +function consentRedirect(url: URL): Answer { + const redirect = url.searchParams.get('redirect_uri') + if (!redirect) return { status: 400, body: { error: 'invalid_request', error_description: 'redirect_uri is required' } } + const back = new URL(redirect) + back.searchParams.set('code', `code-${counter}`) + back.searchParams.set('state', url.searchParams.get('state') ?? '') + return { status: 302, body: {}, headers: { location: back.toString() } } +} + +function pipedriveOauth({ action, url, origin, body }: Call): Answer { + if (action === 'authorize') return consentRedirect(url) + if (action === 'revoke') return ok({}) + const seed = String(body?.code ?? body?.refresh_token ?? '') + return ok({ + access_token: `pd-${digest(seed).slice(0, 16)}`, refresh_token: `pd-refresh-${digest(seed).slice(0, 8)}`, + token_type: 'Bearer', expires_in: 3600, scope: 'base,contacts:full,leads:full', api_domain: `${origin}/pipedrive`, + }) +} + +function pipedrive({ method, url, headers, body }: Call): Answer { + const path = url.pathname.replace(/^\/pipedrive/, '') + if (path === '/api/v1/users/me') { + return ok({ success: true, data: { id: 1, company_id: parseInt(digest(bearer(headers)).slice(0, 7), 16), company_name: 'E2E Pipedrive Co' } }) + } + // Nothing exists yet: searches find nobody, and every create gets a fresh id. + if (path.endsWith('/search')) return ok({ success: true, data: { items: [] } }) + if (path === '/api/v1/leads' && method === 'GET') return ok({ success: true, data: [] }) + if (method === 'POST' && path === '/api/v1/leads') return ok({ success: true, data: { id: `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}`, title: body?.title } }) + if (method === 'POST' || method === 'PATCH') return ok({ success: true, data: { id: counter, ...(body ?? {}) } }) + return { status: 404, body: { success: false, error: `e2e fake has no answer for ${method} ${path}` } } +} + +// Jira Cloud: OAuth on jira-auth (auth.atlassian.com), the REST API on jira (api.atlassian.com). + +function jiraAuth({ action, url, body }: Call): Answer { + if (action === 'authorize') return consentRedirect(url) + // oauth/token, for both the code grant and a refresh. + const seed = String(body?.code ?? body?.refresh_token ?? '') + return ok({ access_token: `jira-${digest(seed).slice(0, 16)}`, refresh_token: `jira-refresh-${digest(seed).slice(0, 8)}`, token_type: 'Bearer', expires_in: 3600, scope: 'read:jira-work write:jira-work read:jira-user offline_access' }) +} + +const JIRA_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'].map((name, i) => ({ id: String(i + 1), name })) +const JIRA_ISSUE_TYPES = [ + { id: '10001', name: 'Task', subtask: false, description: 'A small, distinct piece of work.' }, + { id: '10002', name: 'Sub-task', subtask: true, description: 'A part of a task.' }, +] +const jiraIssues = new Map }>() + +function jiraNotFound(message: string): Answer { + return { status: 404, body: { errorMessages: [message], errors: {} } } +} + +function jira({ method, url, headers, body }: Call): Answer { + if (url.pathname.endsWith('/oauth/token/accessible-resources')) { + const site = digest(bearer(headers)).slice(0, 12) + return ok([{ id: `cloud-${site}`, url: `https://e2e-${site}.atlassian.net`, name: `e2e-${site}`, scopes: ['read:jira-work', 'write:jira-work'], avatarUrl: '' }]) + } + // /jira/ex/jira//rest/api/3/ + const resource = /\/ex\/jira\/[^/]+\/rest\/api\/3\/(.+)$/.exec(url.pathname)?.[1] ?? '' + if (resource === 'project') return ok([{ id: '10000', key: 'SUP', name: 'Support' }]) + if (resource.startsWith('project/')) return ok({ id: '10000', key: resource.slice('project/'.length), name: 'Support', issueTypes: JIRA_ISSUE_TYPES }) + if (resource === 'priority') return ok(JIRA_PRIORITIES) + if (resource === 'issue/createmeta') { + return ok({ projects: [{ key: url.searchParams.get('projectKeys'), issuetypes: [{ id: url.searchParams.get('issuetypeIds'), fields: { summary: {}, description: {}, priority: {} } }] }] }) + } + if (resource === 'issue' && method === 'POST') { + const fields = (body?.fields ?? {}) as { project?: { key?: string } } + const key = `${fields.project?.key ?? 'SUP'}-${counter}` + remember(jiraIssues, key, { key, fields: { ...fields, status: { name: 'To Do' } } }) + return { status: 201, body: { id: String(counter), key, self: `${url.origin}${url.pathname}/${counter}` } } + } + const issueKey = /^issue\/([^/]+)$/.exec(resource)?.[1] + if (issueKey) { + const issue = jiraIssues.get(issueKey) + if (!issue) return jiraNotFound('Issue does not exist or you do not have permission to see it.') + if (method === 'PUT') { + Object.assign(issue.fields, (body?.fields ?? {}) as Record) + return { status: 204, body: null } + } + return ok({ id: issueKey, ...issue }) + } + return jiraNotFound(`e2e fake has no answer for ${method} ${resource}`) +} + +// Shopify Admin API: /shopify/.myshopify.com/admin/... + +interface ShopifyOrder { id: string; number: string; email: string } +const shopifyOrders = new Map() + +function money(amount: string) { + return { shopMoney: { amount, currencyCode: 'USD' } } +} + +function shopifyOrderNode(order: ShopifyOrder) { + return { + id: `gid://shopify/Order/${order.id}`, + name: `#${order.number}`, + email: order.email, + phone: null, + createdAt: '2026-10-01T10:00:00Z', + processedAt: '2026-10-01T10:00:00Z', + updatedAt: '2026-10-02T10:00:00Z', + cancelledAt: null, + cancelReason: null, + displayFinancialStatus: 'PAID', + displayFulfillmentStatus: 'FULFILLED', + subtotalLineItemsQuantity: 1, + currentSubtotalPriceSet: money('40.00'), + currentTotalPriceSet: money('45.00'), + originalTotalPriceSet: money('45.00'), + customer: { id: `gid://shopify/Customer/${order.id}`, firstName: 'E2E', lastName: 'Customer', email: order.email, phone: null }, + shippingAddress: { address1: '1 Test Street', address2: null, city: 'Testville', provinceCode: 'CA', zip: '94000', country: 'United States', phone: null, name: 'E2E Customer' }, + fulfillments: [{ status: 'SUCCESS', trackingInfo: [{ company: 'UPS', number: `1ZE2E${order.id}`, url: `https://tracking.e2e.test/1ZE2E${order.id}` }] }], + lineItems: { edges: [{ node: { id: `gid://shopify/LineItem/${order.id}`, title: 'E2E Mug', name: 'E2E Mug', quantity: 1, originalUnitPriceSet: money('40.00'), variant: { id: 'gid://shopify/ProductVariant/1', title: 'Default', sku: 'E2E-MUG', image: null } } }] }, + } +} + +function shopify({ action, body }: Call): Answer { + if (action === 'access_token') { + const seed = String(body?.subject_token ?? body?.code ?? '') + return ok({ access_token: `shpat_${digest(seed).slice(0, 24)}`, scope: 'read_orders,read_products' }) + } + const query = String(body?.query ?? '') + const variables = (body?.variables ?? {}) as Record + if (query.includes('query SearchOrders')) { + // `email:[ AND name:]`, as the backend builds it. + const search = String(variables.query ?? '') + const email = /email:(\S+)/.exec(search)?.[1] ?? '' + const number = /name:(\S+)/.exec(search)?.[1] ?? '1001' + const order = { id: String(parseInt(digest(`${email}#${number}`).slice(0, 8), 16)), number, email } + remember(shopifyOrders, order.id, order) + return ok({ data: { orders: { edges: email ? [{ node: shopifyOrderNode(order) }] : [], pageInfo: { hasNextPage: false, endCursor: null } } } }) + } + if (query.includes('query GetOrder')) { + const order = shopifyOrders.get(String(variables.id ?? '').split('/').at(-1) ?? '') + return ok({ data: { order: order ? shopifyOrderNode(order) : null } }) + } + return ok({ errors: [{ message: 'e2e fake has no answer for this query' }] }) +} + +/** How each provider says "bad request", so the backend's error handling sees the real shape. */ +function providerRejection(provider: string, message: string): Answer { + switch (provider) { + case 'graph': + case 'graph-instagram': return graphError(message) + case 'telegram': return { status: 400, body: { ok: false, error_code: 400, description: `Bad Request: ${message}` } } + case 'slack': return ok({ ok: false, error: 'msg_too_long', detail: message }) + case 'twilio': return { status: 400, body: { code: 21617, message, status: 400 } } + default: return { status: 400, body: { message, error: message } } + } +} + +// ---------- faults ---------- + +function matchingFault(provider: string, action: string, haystack: string): Fault | undefined { + for (const [id, fault] of faults) { + if (fault.provider !== provider) continue + if (fault.action && fault.action !== action) continue + if (fault.contains && !haystack.includes(fault.contains)) continue + if (fault.times !== undefined) { + fault.times -= 1 + if (fault.times <= 0) faults.delete(id) + } + return fault + } + return undefined +} + +// ---------- http ---------- + +async function route(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? '/', 'http://fake-externals') + const path = url.pathname + + if (path === '/health') return send(res, 200, { status: 'ok', calls: calls.length, faults: faults.size }) + if (path === '/__calls' && req.method === 'GET') { + const q = url.searchParams + const since = q.get('since') + const contains = q.get('contains') + return send(res, 200, calls.filter((c) => + (!q.get('provider') || c.provider === q.get('provider')) + && (!q.get('action') || c.action === q.get('action')) + && (!since || c.at >= since) + && (!contains || `${c.path}\n${JSON.stringify(c.body)}`.includes(contains)))) + } + if (path === '/__calls' && req.method === 'DELETE') { + calls.length = 0 + return send(res, 200, { ok: true }) + } + const faultPath = /^\/__faults\/([\w.-]+)$/.exec(path) + if (faultPath && req.method === 'PUT') { + const fault = parseBody(await readRaw(req), 'application/json') as Fault | null + if (!fault?.provider || !fault.status) throw new HttpError(400, 'a fault needs provider and status') + faults.set(faultPath[1], fault) + return send(res, 200, { ok: true }) + } + if (faultPath && req.method === 'DELETE') { + faults.delete(faultPath[1]) + return send(res, 200, { ok: true }) + } + + const [, provider = '', ...rest] = path.split('/') + const raw = await readRaw(req) + const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v ?? ''])) + const body = parseBody(raw, headers['content-type'] ?? '') as Record | null + const method = req.method ?? 'GET' + counter += 1 + + let action = rest.filter(Boolean).at(-1) ?? '' + let token = '' + if (provider === 'telegram') { + // /telegram/bot/ + token = (rest[0] ?? '').replace(/^bot/, '') + action = rest[1] ?? '' + } + + const fault = matchingFault(provider, action, `${path}\n${raw}`) + const rejected = fault ? null : rejection(provider, action, method, body) + let result: Answer + if (fault) result = { status: fault.status, body: fault.body ?? {} } + else if (rejected) result = providerRejection(provider, rejected) + else if (provider === 'telegram') result = telegram(action, token, body) + else result = await answer({ provider, origin: `http://${headers.host ?? '127.0.0.1'}`, action, method, url, headers, body }) + + calls.push({ + at: new Date().toISOString(), + provider, action, method, path, + query: Object.fromEntries(url.searchParams), + headers, body, + status: result.status, + response: result.body, + }) + if (calls.length > MAX_CALLS) calls.splice(0, calls.length - MAX_CALLS) + return send(res, result.status, result.body, result.headers) +} + +const portArg = process.argv.indexOf('--port') +const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14020) + +createServer((req, res) => { + route(req, res).catch((err: unknown) => { + const status = err instanceof HttpError ? err.status : 500 + const message = err instanceof Error ? err.message : String(err) + console.error(`[fake-externals] ${req.method} ${req.url} -> ${status}: ${message}`) + send(res, status, { error: message }) + }) +}).listen(port, '127.0.0.1', () => console.log(`[fake-externals] listening on http://127.0.0.1:${port}`)) diff --git a/e2e/services/fake-llm/contract_check.py b/e2e/services/fake-llm/contract_check.py new file mode 100644 index 0000000..9cde142 --- /dev/null +++ b/e2e/services/fake-llm/contract_check.py @@ -0,0 +1,60 @@ +"""Checks the fake LLM against the backend's real agno code path. + +The e2e suite only works if agno accepts what the fake returns. This runs the same +calls the backend makes — the AI-setup key check and a ChatAgent-style structured +reply — through agno against a running fake, so an agno upgrade that changes the +request or response shape fails here with a clear message instead of as flaky e2e +tests. CI runs it right after the stack starts. + + cd backend && PYTHONPATH=. ENV_FILE=../e2e/.env.e2e NO_ENTERPRISE=1 \\ + python ../e2e/services/fake-llm/contract_check.py [base_url] +""" + +import asyncio +import json +import sys +import urllib.request + +from agno.agent import Agent + +from app.models.schemas.chat import ChatResponse +from app.utils.agno_utils import create_model, test_model_api_key + +BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:14010/v1" +CONTROL = BASE_URL.removesuffix("/v1") +SCENARIO = "contract-check" + + +def put_scenario() -> None: + spec = {"rules": [ + {"match": {"source": "human", "flags": "i"}, + "reply": {"message": "Connecting you.", "fields": {"transfer_to_human": True}}}, + {"reply": {"message": "Happy to help!"}}, + ]} + req = urllib.request.Request(f"{CONTROL}/__scenarios/{SCENARIO}", data=json.dumps(spec).encode(), + method="PUT", headers={"content-type": "application/json"}) + urllib.request.urlopen(req).read() + + +async def main() -> int: + put_scenario() + assert await test_model_api_key("e2e-key", "OPENAI_COMPATIBLE", "e2e-fake", base_url=BASE_URL), \ + "AI-setup key check failed against the fake" + + model = create_model("OPENAI_COMPATIBLE", "e2e-key", "e2e-fake", max_tokens=1000, base_url=BASE_URL) + agent = Agent(model=model, instructions=f"Contract check. [[e2e:{SCENARIO}]]", + response_model=ChatResponse, structured_outputs=True, system_message_role="system") + + handover = (await agent.arun("I want a human")).content + assert isinstance(handover, ChatResponse), f"structured reply not parsed: {handover!r}" + assert handover.transfer_to_human is True and handover.message == "Connecting you.", handover + + plain = (await agent.arun("hello")).content + assert plain.transfer_to_human is False and plain.message == "Happy to help!", plain + + print("fake LLM contract OK") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/e2e/services/fake-llm/protocol.ts b/e2e/services/fake-llm/protocol.ts new file mode 100644 index 0000000..643cef8 --- /dev/null +++ b/e2e/services/fake-llm/protocol.ts @@ -0,0 +1,77 @@ +// Wire types shared by the fake LLM server and the test-side client (src/fakes/llm.ts). +// Type-only: the server imports this with `import type`, so Node's type stripping +// needs nothing at runtime. + +/** A serialisable RegExp. */ +export interface Pattern { + source: string + flags: string +} + +/** + * What the model "says" for one matched turn. + * + * - `message` / `fields` fill a structured (json_schema) reply — e.g. ChatAgent's + * ChatResponse. `fields` overlays any schema field: transfer_to_human, create_ticket, … + * - `text` is used for plain-text requests (the transfer agent, the key check). + * Falls back to `message`. + */ +export interface Reply { + message?: string + fields?: Record + text?: string + /** Token counts to report, for metering. Default 1/1. */ + usage?: { prompt_tokens: number; completion_tokens: number } +} + +/** How the turn goes wrong instead of (or before) answering. */ +export interface Fault { + /** Answer with this HTTP status and an OpenAI-style error instead of a completion. */ + status?: number + message?: string + /** Wait this long first — past AGENT_RUN_TIMEOUT to exercise the backend's timeout. */ + delayMs?: number + /** Return content that is not the JSON the request's schema asks for. */ + malformed?: boolean +} + +export interface ToolCall { + name: string + arguments: Record +} + +export interface ScenarioRule { + /** Tested against the last user message. Omitted = matches anything (otherwise). */ + match?: Pattern + /** Ask the backend to run a tool first; `reply` is sent after the tool result comes back. */ + toolCall?: ToolCall + reply: Reply + fault?: Fault +} + +export interface ScenarioSpec { + /** + * Some backend calls never see the agent's instructions (the transfer agent's prompt + * has no `[[e2e:]]` marker), so a scenario can also match on the unique agent name. + */ + agentName?: string + rules: ScenarioRule[] +} + +export interface RecordedRequest { + at: string + system: string + lastUser: string + lastRole: string + /** 'json_schema:' | 'json_object' | null */ + responseFormat: string | null + tools: string[] + /** Contents of the tool-result messages in this request — e.g. what knowledge search returned. */ + toolResults: string[] + /** What the fake answered: 'tool_call:' | 'structured' | 'text' | 'error:' | 'malformed' */ + answeredWith: string + /** The model the backend asked for. */ + model: string + /** The last 6 characters of the API key it sent — which key the org's config used. */ + keySuffix: string +} diff --git a/e2e/services/fake-llm/server.ts b/e2e/services/fake-llm/server.ts new file mode 100644 index 0000000..bec21aa --- /dev/null +++ b/e2e/services/fake-llm/server.ts @@ -0,0 +1,285 @@ +// A deterministic stand-in for an OpenAI-compatible model, for e2e tests. +// +// The backend's agents are pointed here (AI config model_type OPENAI_COMPATIBLE, +// base_url http://127.0.0.1:14010/v1). Each test registers a *scenario* — scripted +// replies keyed by a `[[e2e:]]` marker in its agent's instructions or by the +// agent's unique name — so parallel tests in one org never see each other's script. +// +// It speaks only what ChatterMate uses: non-streaming /v1/chat/completions, with or +// without a strict json_schema response_format (ChatAgent's ChatResponse), and tool +// calls. Anything else fails loudly instead of returning something plausible. +// +// Control API (used by src/fakes/llm.ts): +// PUT /__scenarios/:id register/replace a ScenarioSpec +// GET /__scenarios/:id/requests what the backend sent for that scenario +// DELETE /__scenarios forget everything (manual use only: scenario +// ids are unique per test, and a reset would +// wipe any other run sharing this fake) +// +// An API key starting with `e2e-invalid` is rejected with 401, like a real provider. +// A rule can also fail on purpose (fault: status, delay, malformed output). +// +// Run: node services/fake-llm/server.ts --port 14010 (Node >= 22.18 strips the types) + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import type { Fault, RecordedRequest, Reply, ScenarioRule, ScenarioSpec } from './protocol.ts' + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json } +// JSON Schema is walked generically (properties, $ref, anyOf, …); typing every +// keyword buys nothing here. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type JsonSchema = { [key: string]: any } + +interface ChatMessage { + role: string + content?: string | Array<{ type: string; text?: string }> | null + tool_calls?: unknown[] +} + +interface ChatRequest { + model?: string + messages: ChatMessage[] + stream?: boolean + response_format?: { type: string; json_schema?: { name?: string; schema?: JsonSchema } } + tools?: Array<{ type: string; function: { name: string } }> +} + +const MARKER = /\[\[e2e:([\w-]+)\]\]/ +const DEFAULT_SCENARIO = '__default' +const DEFAULT_REPLY: Reply = { message: 'Default reply from the e2e fake LLM.', text: 'OK' } +const MAX_RECORDED = 50 +// Oldest scenarios are forgotten past this, so a fake that outlives many runs stays small. +const MAX_SCENARIOS = 5000 + +const scenarios = new Map() +const recorded = new Map() + +// ---------- request helpers ---------- + +function textOf(content: ChatMessage['content']): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) return content.map((part) => part.text ?? '').join('\n') + return '' +} + +function selectScenario(req: ChatRequest): string { + const all = req.messages.map((m) => textOf(m.content)).join('\n') + const marker = MARKER.exec(all) + if (marker && scenarios.has(marker[1])) return marker[1] + for (const [id, spec] of scenarios) { + if (spec.agentName && all.includes(spec.agentName)) return id + } + return DEFAULT_SCENARIO +} + +function selectRule(spec: ScenarioSpec | undefined, lastUser: string): ScenarioRule | undefined { + if (!spec) return undefined + return spec.rules.find((rule) => !rule.match || new RegExp(rule.match.source, rule.match.flags).test(lastUser)) +} + +// ---------- structured output ---------- + +function resolveRef(schema: JsonSchema, root: JsonSchema): JsonSchema { + if (typeof schema.$ref !== 'string') return schema + const path = schema.$ref.replace(/^#\//, '').split('/') + return path.reduce((node: JsonSchema, key: string) => node?.[key], root) ?? {} +} + +function isNullable(schema: JsonSchema): boolean { + if (Array.isArray(schema.type)) return schema.type.includes('null') + const variants = schema.anyOf ?? schema.oneOf + return Array.isArray(variants) && variants.some((v: JsonSchema) => v.type === 'null') +} + +/** The minimal value that satisfies `schema` — the base every structured reply is built on. */ +function defaultFor(rawSchema: JsonSchema, root: JsonSchema): Json { + const schema = resolveRef(rawSchema, root) + if ('default' in schema) return schema.default as Json + if (isNullable(schema)) return null + if (Array.isArray(schema.enum)) return schema.enum[0] as Json + const variants = schema.anyOf ?? schema.oneOf ?? schema.allOf + if (Array.isArray(variants) && variants.length) return defaultFor(variants[0], root) + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type + switch (type) { + case 'string': return '' + case 'boolean': return false + case 'number': + case 'integer': return 0 + case 'array': return [] + case 'object': + default: { + const out: { [key: string]: Json } = {} + for (const [key, prop] of Object.entries(schema.properties ?? {})) out[key] = defaultFor(prop, root) + return out + } + } +} + +function structuredContent(req: ChatRequest, reply: Reply): string { + const schema = req.response_format?.json_schema?.schema + const base = schema ? (defaultFor(schema, schema) as { [key: string]: Json }) : {} + const message = reply.message ?? DEFAULT_REPLY.message + return JSON.stringify({ ...base, message, ...(reply.fields as { [key: string]: Json } | undefined) }) +} + +// ---------- completion ---------- + +function completion(req: ChatRequest, message: { content: string | null; tool_calls?: unknown[] }, usage = { prompt_tokens: 1, completion_tokens: 1 }) { + return { + id: `chatcmpl-e2e-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: req.model ?? 'e2e-fake', + choices: [{ + index: 0, + message: { role: 'assistant', ...message }, + finish_reason: message.tool_calls ? 'tool_calls' : 'stop', + }], + usage: { ...usage, total_tokens: usage.prompt_tokens + usage.completion_tokens }, + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** A scripted failure: an OpenAI-style error status, or garbage where JSON was asked for. */ +function faulted(req: ChatRequest, fault: Fault, record: RecordedRequest) { + if (fault.status) { + record.answeredWith = `error:${fault.status}` + throw new HttpError(fault.status, fault.message ?? 'The e2e fake LLM failed on purpose.') + } + record.answeredWith = 'malformed' + return completion(req, { content: '{"message": "this reply is cut off' }) +} + +async function answer(req: ChatRequest, authorization: string) { + const scenarioId = selectScenario(req) + const lastUserMsg = [...req.messages].reverse().find((m) => m.role === 'user') + const lastUser = textOf(lastUserMsg?.content) + const lastRole = req.messages.at(-1)?.role ?? '' + const rule = selectRule(scenarios.get(scenarioId), lastUser) + const tools = (req.tools ?? []).map((t) => t.function.name) + const format = req.response_format + const record: RecordedRequest = { + at: new Date().toISOString(), + system: req.messages.filter((m) => m.role === 'system' || m.role === 'developer').map((m) => textOf(m.content)).join('\n'), + lastUser, + lastRole, + responseFormat: format ? (format.type === 'json_schema' ? `json_schema:${format.json_schema?.name ?? ''}` : format.type) : null, + tools, + toolResults: req.messages.filter((m) => m.role === 'tool').map((m) => textOf(m.content)), + answeredWith: '', + model: req.model ?? '', + keySuffix: authorization.replace(/^Bearer\s+/i, '').slice(-6), + } + const list = recorded.get(scenarioId) ?? [] + list.push(record) + recorded.set(scenarioId, list.slice(-MAX_RECORDED)) + + if (rule?.fault?.delayMs) await sleep(rule.fault.delayMs) + if (rule?.fault && (rule.fault.status || rule.fault.malformed)) return faulted(req, rule.fault, record) + + const usage = rule?.reply.usage + let body + if (rule?.toolCall && lastRole !== 'tool') { + if (!tools.includes(rule.toolCall.name)) { + throw new HttpError(422, `scenario '${scenarioId}' wants tool '${rule.toolCall.name}' but the request only offers [${tools.join(', ')}]`) + } + record.answeredWith = `tool_call:${rule.toolCall.name}` + body = completion(req, { + content: null, + tool_calls: [{ + id: `call_e2e_${Date.now()}`, + type: 'function', + function: { name: rule.toolCall.name, arguments: JSON.stringify(rule.toolCall.arguments) }, + }], + }) + } else if (format && format.type !== 'text') { + record.answeredWith = 'structured' + body = completion(req, { content: structuredContent(req, rule?.reply ?? DEFAULT_REPLY) }, usage) + } else { + const reply = rule?.reply ?? DEFAULT_REPLY + record.answeredWith = 'text' + body = completion(req, { content: reply.text ?? reply.message ?? DEFAULT_REPLY.text! }, usage) + } + return body +} + +// ---------- http ---------- + +class HttpError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const raw = Buffer.concat(chunks).toString('utf8') + if (!raw) return {} + try { + return JSON.parse(raw) + } catch { + throw new HttpError(400, 'body is not JSON') + } +} + +function send(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +async function route(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? '/', 'http://fake-llm') + const path = url.pathname.replace(/\/+$/, '') + const scenarioPath = /^\/__scenarios\/([\w-]+)(\/requests)?$/.exec(path) + + if (req.method === 'GET' && path === '/health') return send(res, 200, { status: 'ok', scenarios: scenarios.size }) + if (req.method === 'GET' && path === '/v1/models') return send(res, 200, { object: 'list', data: [{ id: 'e2e-fake', object: 'model' }] }) + + if (req.method === 'POST' && path === '/v1/chat/completions') { + // Lets a test exercise the "invalid API key" path: the backend live-tests keys. + if (/^Bearer e2e-invalid/.test(req.headers.authorization ?? '')) { + throw new HttpError(401, 'Incorrect API key provided.') + } + const body = (await readJson(req)) as ChatRequest + if (body.stream) throw new HttpError(400, 'the e2e fake LLM does not stream — ChatterMate never asks it to; if it does now, extend services/fake-llm') + if (!Array.isArray(body.messages)) throw new HttpError(400, 'messages[] is required') + return send(res, 200, await answer(body, req.headers.authorization ?? '')) + } + + if (scenarioPath && req.method === 'PUT' && !scenarioPath[2]) { + const spec = (await readJson(req)) as ScenarioSpec + if (!Array.isArray(spec.rules)) throw new HttpError(400, 'rules[] is required') + scenarios.set(scenarioPath[1], spec) + recorded.delete(scenarioPath[1]) + while (scenarios.size > MAX_SCENARIOS) { + const oldest = scenarios.keys().next().value! + scenarios.delete(oldest) + recorded.delete(oldest) + } + return send(res, 200, { ok: true }) + } + if (scenarioPath && req.method === 'GET' && scenarioPath[2]) return send(res, 200, recorded.get(scenarioPath[1]) ?? []) + if (req.method === 'DELETE' && path === '/__scenarios') { + scenarios.clear() + recorded.clear() + return send(res, 200, { ok: true }) + } + + throw new HttpError(404, `no route for ${req.method} ${url.pathname}`) +} + +const portArg = process.argv.indexOf('--port') +const port = Number(portArg > -1 ? process.argv[portArg + 1] : process.env.FAKE_LLM_PORT ?? 14010) + +createServer((req, res) => { + route(req, res).catch((err: unknown) => { + const status = err instanceof HttpError ? err.status : 500 + const message = err instanceof Error ? err.message : String(err) + console.error(`[fake-llm] ${req.method} ${req.url} -> ${status}: ${message}`) + send(res, status, { error: { message, type: 'e2e_fake_llm_error' } }) + }) +}).listen(port, '127.0.0.1', () => console.log(`[fake-llm] listening on http://127.0.0.1:${port}`)) diff --git a/e2e/services/fake-mcp/server.ts b/e2e/services/fake-mcp/server.ts new file mode 100644 index 0000000..5394dd7 --- /dev/null +++ b/e2e/services/fake-mcp/server.ts @@ -0,0 +1,88 @@ +// A real MCP server (streamable HTTP, stateless) for the MCP-connector journeys: +// the backend connects to it exactly as it would to a customer's server, lists +// its tools and calls them when the (fake) model asks. +// +// POST /mcp the MCP endpoint +// GET /__requests the headers each MCP request arrived with, oldest first +// DELETE /__requests forget them +// +// Tools: lookup_order(order_id) → "Order : shipped on 2 May via DHL". +// +// Run: node services/fake-mcp/server.ts --port 14030 + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { z } from 'zod' + +interface SeenRequest { + at: string + method: string + authorization: string | null + rpcMethod: string | null +} + +const seen: SeenRequest[] = [] + +function mcpServer(): McpServer { + const server = new McpServer({ name: 'e2e-orders', version: '1.0.0' }) + server.registerTool('lookup_order', { + description: 'Look up an order by its id and return its shipping status.', + inputSchema: { order_id: z.string().describe('The order id, e.g. A123') }, + }, async ({ order_id }) => ({ + content: [{ type: 'text', text: `Order ${order_id}: shipped on 2 May via DHL` }], + })) + return server +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const raw = Buffer.concat(chunks).toString('utf8') + return raw ? JSON.parse(raw) : undefined +} + +function send(res: ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +async function route(req: IncomingMessage, res: ServerResponse) { + const path = new URL(req.url ?? '/', 'http://fake-mcp').pathname + if (path === '/health') return send(res, 200, { status: 'ok' }) + if (path === '/__requests' && req.method === 'GET') return send(res, 200, seen) + if (path === '/__requests' && req.method === 'DELETE') { + seen.length = 0 + return send(res, 200, { ok: true }) + } + if (path !== '/mcp') return send(res, 404, { error: `no route for ${path}` }) + if (req.method !== 'POST') return send(res, 405, { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }) + + const body = await readJson(req) + seen.push({ + at: new Date().toISOString(), + method: req.method, + authorization: req.headers.authorization ?? null, + rpcMethod: (body as { method?: string } | undefined)?.method ?? null, + }) + // Stateless: a fresh server and transport per request, as the SDK recommends. + const server = mcpServer() + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) + res.on('close', () => { + void transport.close() + void server.close() + }) + await server.connect(transport) + await transport.handleRequest(req, res, body) +} + +const portArg = process.argv.indexOf('--port') +const port = Number(portArg > -1 ? process.argv[portArg + 1] : 14030) + +createServer((req, res) => { + route(req, res).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + console.error(`[fake-mcp] ${req.method} ${req.url} -> 500: ${message}`) + if (!res.headersSent) send(res, 500, { error: message }) + }) +}).listen(port, '127.0.0.1', () => console.log(`[fake-mcp] listening on http://127.0.0.1:${port}/mcp`)) diff --git a/e2e/specs/a11y/a11y.spec.ts b/e2e/specs/a11y/a11y.spec.ts new file mode 100644 index 0000000..a564bb9 --- /dev/null +++ b/e2e/specs/a11y/a11y.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, expectNoNewA11yViolations, InboxPage } from '@e2e' + +// Serious and critical axe findings, per screen, held to a baseline +// (a11y-baseline/): today's known issues pass, anything new fails, and the +// baseline shrinks as they are fixed. The widget is checked in both themes. + +test.describe('accessibility', () => { + test('sign-in page @a11y', async ({ anonymousPage }) => { + await anonymousPage.goto('/login') + await expect(anonymousPage.getByRole('button', { name: /sign in/i })).toBeVisible() + await expectNoNewA11yViolations(anonymousPage, 'login') + }) + + // The dashboard is dark unless the user picks light (stored as cm-theme); both are checked. + for (const scheme of ['dark', 'light'] as const) { + const suffix = scheme === 'dark' ? '' : '-light' + + test(`dashboard screens, ${scheme} theme @a11y`, async ({ page, agent, agentsPage, aiConfigPage, ticketsPage, peoplePage }) => { + // An agent of its own, so the agents screen is the list (with a card to check), not first-run setup. + expect(agent.id).toBeTruthy() + await page.addInitScript((theme) => localStorage.setItem('cm-theme', theme), scheme) + await new InboxPage(page).goto() + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe(scheme) + await expectNoNewA11yViolations(page, `inbox${suffix}`) + await agentsPage.goto() + await expectNoNewA11yViolations(page, `ai-agents${suffix}`) + await aiConfigPage.goto() + await expectNoNewA11yViolations(page, `ai-config${suffix}`) + await ticketsPage.goto() + await expectNoNewA11yViolations(page, `tickets${suffix}`) + await peoplePage.goto() + await expectNoNewA11yViolations(page, `people${suffix}`) + }) + } + + for (const scheme of ['light', 'dark'] as const) { + test(`widget, ${scheme} theme @a11y @widget`, async ({ widget }) => { + await widget.page.emulateMedia({ colorScheme: scheme }) + await widget.embed() + await widget.open() + await expect(widget.messageInput).toBeEditable() + await expectNoNewA11yViolations(widget.page, `widget-${scheme}`) + }) + } +}) diff --git a/e2e/specs/agents/agent-editor.spec.ts b/e2e/specs/agents/agent-editor.spec.ts new file mode 100644 index 0000000..09f0ad7 --- /dev/null +++ b/e2e/specs/agents/agent-editor.spec.ts @@ -0,0 +1,25 @@ +import { test, expect, scenarioMarker, unique, AI_REPLY } from '@e2e' + +test.describe('agent editor', () => { + test('an agent created and edited in the dashboard answers with its new instructions @agents', async ({ makeAgent, agentsPage, makeWidget, llm }) => { + // With at least one agent, /ai-agents shows the list rather than onboarding. + await makeAgent() + const scenarioId = unique('scn') + const name = `UI Agent ${scenarioId}` + + await agentsPage.goto() + const agentId = await agentsPage.createAgent(name) + await agentsPage.openAgent(agentId) + await agentsPage.setInstructions(`Always sign off as the Acme team. ${scenarioMarker(scenarioId)}`) + + await llm.script({ name, scenarioId }, (s) => s.otherwise().reply('Hello from the Acme team!')) + const widget = await makeWidget(agentId) + await widget.embed() + await widget.open() + await widget.send('Hi there') + + await expect(widget.lastAgentMessage()).toHaveText('Hello from the Acme team!', AI_REPLY) + const prompts = (await llm.requests({ name, scenarioId })).map((request) => request.system) + expect(prompts.join('\n')).toContain('Always sign off as the Acme team.') + }) +}) diff --git a/e2e/specs/analytics/analytics.spec.ts b/e2e/specs/analytics/analytics.spec.ts new file mode 100644 index 0000000..b69189c --- /dev/null +++ b/e2e/specs/analytics/analytics.spec.ts @@ -0,0 +1,25 @@ +import { test, expect, AI_REPLY } from '@e2e' + +interface AgentPerformance { bot_agents: Array<{ id: string; total_chats: number }> } + +test.describe('analytics', () => { + test('a conversation shows up in the agent\'s numbers, and the dashboard renders without errors @analytics', async ({ agent, widget, llm, adminApi, page }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help!')) + await widget.embed() + await widget.open() + await widget.send('Hello') + await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY) + + const chatsOf = async () => (await adminApi.get('/analytics/agent-performance', { params: { time_range: '24h' } })) + .bot_agents.find((a) => a.id === agent.id)?.total_chats + await expect.poll(chatsOf).toBe(1) + + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.goto('/analytics') + await expect(page.getByRole('main').getByRole('heading', { name: 'Total Conversations' })).toBeVisible() + // The shared test org has hundreds of agents; the dashboard lists the busiest, + // so this one's row is checked through the API above. + expect(errors).toEqual([]) + }) +}) diff --git a/e2e/specs/auth/login.spec.ts b/e2e/specs/auth/login.spec.ts new file mode 100644 index 0000000..92ad5c5 --- /dev/null +++ b/e2e/specs/auth/login.spec.ts @@ -0,0 +1,38 @@ +import { test, expect, storageStatePath } from '@e2e' + +test.describe('signed out', () => { + test.use({ storageState: { cookies: [], origins: [] } }) + + test('admin signs in and lands on the dashboard @smoke @auth', async ({ loginPage, org, page }) => { + await loginPage.goto() + await loginPage.signIn(org.admin.email, org.admin.password) + await expect(page).toHaveURL(/\/ai-agents/) + }) + + test('a wrong password keeps you on the sign-in page with an error @auth', async ({ loginPage, org, page }) => { + await loginPage.goto() + await loginPage.signIn(org.admin.email, 'not-the-password') + await expect(loginPage.error).toHaveText('Incorrect email or password') + await expect(page).toHaveURL(/\/login/) + }) + + test('signing out returns to the sign-in page @auth', async ({ loginPage, dashboard, org, page }) => { + // A fresh session, not the shared admin state — logging out must not affect other tests. + await loginPage.goto() + await loginPage.signIn(org.admin.email, org.admin.password) + await expect(page).toHaveURL(/\/ai-agents/) + await dashboard.logout() + await page.goto('/conversations') + await expect(page).toHaveURL(/\/login/) + }) +}) + +test.describe('as a human agent', () => { + // A path, not a read: spec files load before the setup project writes the state. + test.use({ storageState: storageStatePath('human-agent') }) + + test('the Agent role cannot open AI settings @auth', async ({ dashboard, page }) => { + await page.goto('/settings/ai-config') + await expect(dashboard.accessDenied).toBeVisible() + }) +}) diff --git a/e2e/specs/auth/sessions.spec.ts b/e2e/specs/auth/sessions.spec.ts new file mode 100644 index 0000000..0075074 --- /dev/null +++ b/e2e/specs/auth/sessions.spec.ts @@ -0,0 +1,88 @@ +import { test, expect, env, ApiClient, createUser, login, unique, type APIRequestContext } from '@e2e' + +// Sessions end on the server, not just in the browser: a copied token stops +// working the moment its session is signed out, replaced or deactivated. + +const PASSWORD = 'E2e-Session-Passw0rd!' + +interface Device { api: ApiClient; accessToken: string; refreshToken: string } + +test.describe('sessions', () => { + let contexts: APIRequestContext[] = [] + let newContext: () => Promise + + test.beforeEach(({ playwright }) => { + newContext = async () => { + const context = await playwright.request.newContext() + contexts.push(context) + return context + } + }) + + test.afterEach(async () => { + await Promise.all(contexts.map((c) => c.dispose())) + contexts = [] + }) + + /** Signs `email` in on a browser-like context of its own, as one device would. */ + async function signIn(email: string, password = PASSWORD): Promise { + const context = await newContext() + const { token } = await login(new ApiClient(context), email, password) + const refreshToken = (await context.storageState()).cookies.find((c) => c.name === 'refresh_token')!.value + return { api: new ApiClient(context, token), accessToken: token, refreshToken } + } + + /** What a stolen copy of the access token gets: a bare Bearer request, no cookies, no renewal. */ + async function withStolen(accessToken: string): Promise { + return (await new ApiClient(await newContext(), accessToken).raw('GET', '/chats/recent')).status() + } + + /** What a stolen copy of the refresh cookie gets from /users/refresh. */ + async function refreshWithStolen(refreshToken: string): Promise { + const response = await (await newContext()).post(`${env.apiUrl}/users/refresh`, { headers: { Cookie: `refresh_token=${refreshToken}` } }) + return response.status() + } + + async function newTeammate(adminApi: ApiClient): Promise<{ id: string; email: string }> { + const email = `${unique('session')}@e2e.example.com` + const user = await createUser(adminApi, { name: 'Session Teammate', email, password: PASSWORD, role: 'Agent' }) + return { id: user.id, email } + } + + test('signing out ends the session: copies of both tokens are refused @auth @security', async ({ adminApi }) => { + const device = await signIn((await newTeammate(adminApi)).email) + expect(await withStolen(device.accessToken)).toBe(200) + + await device.api.post('/users/logout') + + expect(await withStolen(device.accessToken)).toBe(401) + expect(await refreshWithStolen(device.refreshToken)).toBe(401) + }) + + test('a refresh token is not accepted as an access token @auth @security', async ({ adminApi }) => { + const device = await signIn((await newTeammate(adminApi)).email) + expect(await withStolen(device.refreshToken)).toBe(401) + }) + + test('deactivating a teammate cuts them off at once @auth @security', async ({ adminApi }) => { + const teammate = await newTeammate(adminApi) + const device = await signIn(teammate.email) + + await adminApi.put(`/users/${teammate.id}`, { data: { is_active: false } }) + + expect(await withStolen(device.accessToken)).toBe(401) + expect(await refreshWithStolen(device.refreshToken)).toBe(401) + }) + + test('changing your password signs out your other devices, not this one @auth @security', async ({ adminApi }) => { + const { email } = await newTeammate(adminApi) + const laptop = await signIn(email) + const phone = await signIn(email) + + await laptop.api.patch('/users/me', { data: { current_password: PASSWORD, password: 'E2e-Session-Passw0rd-2!' } }) + + expect(await withStolen(laptop.accessToken)).toBe(200) + expect(await withStolen(phone.accessToken)).toBe(401) + expect(await refreshWithStolen(phone.refreshToken)).toBe(401) + }) +}) diff --git a/e2e/specs/channels/delivery.spec.ts b/e2e/specs/channels/delivery.spec.ts new file mode 100644 index 0000000..ef243bd --- /dev/null +++ b/e2e/specs/channels/delivery.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, channels, FakeExternals, unique, AI_REPLY } from '@e2e' + +test.describe('when a channel refuses a message', () => { + test.use({ agentSettings: { transferToHuman: true } }) + + test('the teammate is told it was not delivered, and the next one goes through @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => { + await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.')) + const customer = unique('Wanda') + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer) + await chat.send('I need a person') + await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1) + + await inbox.goto() + await inbox.openConversationWith(customer) + await inbox.takeOver() + + // WhatsApp refuses free-form messages outside the 24-hour window with error 131047. + const restore = await new FakeExternals(request).fail({ + provider: 'graph', + action: 'messages', + contains: chat.waId, + status: 400, + body: { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } }, + times: 1, + }) + await inbox.reply('Are you still there?') + await expect(inbox.agentMessage('Are you still there?')).toContainText('Not delivered') + await restore() + + await inbox.reply('Here is your tracking link.') + await expect.poll(() => chat.replies(), AI_REPLY).toContain('Here is your tracking link.') + await expect(inbox.agentMessage('Here is your tracking link.')).not.toContainText('Not delivered') + expect(await chat.replies()).not.toContain('Are you still there?') + }) +}) + +test.describe('choices that do not fit a channel\'s buttons', () => { + test.use({ agentSettings: { channelQuickReplies: true } }) + + test('five options become a WhatsApp list within its limits, and a pick still gets its answer @channels', async ({ agent, llm, adminApi, request }) => { + const options = ['Track my order', 'Returns', 'Change my delivery address', 'Billing question', 'Talk to someone'] + await llm.script(agent, (s) => s + .when(/change my delivery address/i).reply('Sure — what is the new address?') + .otherwise().reply('What can I help you with?', { quick_replies: options })) + + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Hi') + // More than three buttons is a list; the fake rejects anything past WhatsApp's real limits. + await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(5) + const titles = await chat.buttons() + for (const title of titles) expect([...title].length).toBeLessThanOrEqual(24) + + await chat.tap(titles[2]) + await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?') + }) + + test('a button title longer than WhatsApp allows is shortened, and the tap still works @channels', async ({ agent, llm, adminApi, request }) => { + const long = 'Change my delivery address please' + await llm.script(agent, (s) => s + .when(/change my delivery address/i).reply('Sure — what is the new address?') + .otherwise().reply('What can I help you with?', { quick_replies: [long, 'Returns'] })) + + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Hi') + await expect.poll(() => chat.buttons(), AI_REPLY).toHaveLength(2) + const [shortened] = await chat.buttons() + expect([...shortened].length).toBeLessThanOrEqual(24) + + await chat.tap(shortened) + await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toBe('Sure — what is the new address?') + }) +}) diff --git a/e2e/specs/channels/email.spec.ts b/e2e/specs/channels/email.spec.ts new file mode 100644 index 0000000..ca70276 --- /dev/null +++ b/e2e/specs/channels/email.spec.ts @@ -0,0 +1,38 @@ +import { test, expect, channels, expectUnchanged, Mailbox, AI_REPLY } from '@e2e' + +test('an email reply stays in the customer\'s thread @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help with that.')) + + const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Can I change my delivery address?') + + const mailbox = new Mailbox(request) + await expect.poll(async () => (await mailbox.messagesTo(chat.address)).length, AI_REPLY).toBe(1) + // One question, one model call, one email — a duplicate would arrive later. + expect(await llm.requests(agent)).toHaveLength(1) + await expectUnchanged('emails to the customer', async () => (await mailbox.messagesTo(chat.address)).length) + const [reply] = await mailbox.messagesTo(chat.address) + expect(reply.subject).toBe(`Re: ${chat.subject}`) + // In-Reply-To is what makes mail clients file the answer under the question. + expect((await mailbox.headers(reply.id))['In-Reply-To']).toEqual([chat.lastMessageId]) +}) + +test('a customer\'s follow-up email continues the same conversation @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s + .when(/tuesday/i).reply('Tuesday works — I have noted it.') + .otherwise().reply('Happy to help with that. Which day suits you?')) + + const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Can I change my delivery day?') + await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1) + await chat.send('Tuesday please') + await expect.poll(() => chat.replies(), AI_REPLY).toEqual([ + 'Happy to help with that. Which day suits you?', + 'Tuesday works — I have noted it.', + ]) + + // One conversation for this sender, holding both exchanges — not two. + const conversations = await adminApi.get>('/chats/recent', { params: { customer_email: chat.address } }) + expect(conversations).toHaveLength(1) + expect(conversations[0].message_count).toBe(4) +}) diff --git a/e2e/specs/channels/forged-webhooks.spec.ts b/e2e/specs/channels/forged-webhooks.spec.ts new file mode 100644 index 0000000..2d9d31e --- /dev/null +++ b/e2e/specs/channels/forged-webhooks.spec.ts @@ -0,0 +1,100 @@ +import { + test, expect, connectEmail, connectLine, connectMessenger, connectSms, connectWhatsAppNumber, digits, + expectUnchanged, hmac, installSlackWorkspace, metaSignature, metaToken, phoneNumber, routeToAgent, sendWebhook, + slackSignature, unique, whatsappPayload, TelegramBot, +} from '@e2e' + +// Anyone can POST to a webhook URL. These are the requests a provider would never +// send: unsigned, wrongly signed, replayed — and events that are signed but must +// never start an AI turn (our own echoes, delivery receipts, other bots). + +test.describe('forged webhooks are refused', () => { + test('Meta (WhatsApp, Messenger, Instagram): a wrong or missing signature @channels @security', async ({ adminApi, request }) => { + const { phoneNumberId } = await connectWhatsAppNumber(adminApi) + const json = JSON.stringify(whatsappPayload(phoneNumberId, phoneNumber().slice(1), 'Mallory', { type: 'text', text: { body: 'hi' } })) + + const wrongKey = `sha256=${hmac('sha256', 'not-the-app-secret', json).toString('hex')}` + expect((await sendWebhook(request, '/webhooks/meta', { json }, { 'x-hub-signature-256': wrongKey })).status()).toBe(403) + expect((await sendWebhook(request, '/webhooks/meta', { json })).status()).toBe(403) + }) + + test('Slack: a wrong signature, or a correctly signed request replayed later @channels @security', async ({ request }) => { + const json = JSON.stringify({ type: 'event_callback', team_id: 'T0FORGED', event: { type: 'message', channel: 'D1', user: 'U1', text: 'hi', ts: '1.1' } }) + const now = String(Math.floor(Date.now() / 1000)) + const tenMinutesAgo = String(Math.floor(Date.now() / 1000) - 600) + + const wrong = { 'x-slack-request-timestamp': now, 'x-slack-signature': 'v0=00' } + expect((await sendWebhook(request, '/webhooks/slack', { json }, wrong)).status()).toBe(403) + const replayed = { 'x-slack-request-timestamp': tenMinutesAgo, 'x-slack-signature': slackSignature(json, tenMinutesAgo) } + expect((await sendWebhook(request, '/webhooks/slack', { json }, replayed)).status()).toBe(403) + }) + + test('Telegram: a wrong webhook secret @channels @security', async ({ adminApi, request, agent }) => { + const { account } = await TelegramBot.connect(adminApi, request, agent.id) + const response = await sendWebhook(request, `/webhooks/telegram/${account.id}`, { + json: JSON.stringify({ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 1, type: 'private' }, from: { id: 1, is_bot: false, first_name: 'M' }, text: 'hi' } }), + }, { 'x-telegram-bot-api-secret-token': 'guessed' }) + expect(response.status()).toBe(403) + }) + + test('LINE: a signature made with the wrong channel secret @channels @security', async ({ adminApi, request }) => { + const account = await connectLine(adminApi, unique('line-secret'), unique('line-token')) + const json = JSON.stringify({ destination: account.external_account_id, events: [{ type: 'message', source: { type: 'user', userId: `U${digits(32)}` }, message: { type: 'text', id: '1', text: 'hi' } }] }) + const response = await sendWebhook(request, `/webhooks/line/${account.id}`, { json }, { + 'x-line-signature': hmac('sha256', 'not-the-channel-secret', json).toString('base64'), + }) + expect(response.status()).toBe(403) + }) + + test('SMS: Twilio and Plivo signatures made with the wrong auth token @channels @security', async ({ adminApi, request }) => { + const number = phoneNumber() + const twilio = await connectSms(adminApi, 'twilio', number, { account_sid: unique('AC'), auth_token: unique('token') }) + const form = { From: phoneNumber(), To: number, Body: 'hi', MessageSid: unique('SM') } + const forgedTwilio = await sendWebhook(request, `/webhooks/sms/twilio/${twilio.id}`, { form }, { 'x-twilio-signature': hmac('sha1', 'guessed', 'x').toString('base64') }) + expect(forgedTwilio.status()).toBe(403) + + const plivo = await connectSms(adminApi, 'plivo', phoneNumber(), { auth_id: unique('MA'), auth_token: unique('token') }) + const forgedPlivo = await sendWebhook(request, `/webhooks/sms/plivo/${plivo.id}`, { form: { From: '447700900000', To: '447700900001', Text: 'hi', MessageUUID: unique('u') } }, { + 'x-plivo-signature-v3': hmac('sha256', 'guessed', 'x').toString('base64'), 'x-plivo-signature-v3-nonce': 'n', + }) + expect(forgedPlivo.status()).toBe(403) + }) + + test('email: an inbound-parse post without the inbox\'s token @channels @security', async ({ adminApi, request }) => { + const inbox = await connectEmail(adminApi, `${unique('support')}@e2e.example.com`) + const json = JSON.stringify({ from: 'mallory@example.com', to: inbox.external_account_id, subject: 'hi', text: 'hi' }) + expect((await sendWebhook(request, `/webhooks/email/${inbox.id}?token=guessed`, { json })).status()).toBe(403) + expect((await sendWebhook(request, `/webhooks/email/${inbox.id}`, { json })).status()).toBe(403) + }) +}) + +test.describe('signed events that must not start an AI turn', () => { + test('a Messenger echo of our own message, a WhatsApp delivery receipt and another bot\'s Slack message @channels', async ({ adminApi, request, agent, llm }) => { + await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.')) + + // Messenger: Meta echoes the page's own sends back with is_echo. + const pageId = digits(15) + const page = await connectMessenger(adminApi, pageId, metaToken(pageId)) + await routeToAgent(adminApi, page.id, agent.id) + const echo = { object: 'page', entry: [{ id: pageId, messaging: [{ sender: { id: pageId }, recipient: { id: digits(16) }, timestamp: Date.now(), message: { mid: unique('m'), text: 'Hello from us', is_echo: true } }] }] } + expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(echo) }, metaSignature(JSON.stringify(echo)))).ok()).toBe(true) + + // WhatsApp: delivery/read receipts arrive as `statuses`, with no messages. + const number = await connectWhatsAppNumber(adminApi) + await routeToAgent(adminApi, number.account.id, agent.id) + const receipt = { object: 'whatsapp_business_account', entry: [{ id: '1', changes: [{ field: 'messages', value: { messaging_product: 'whatsapp', metadata: { phone_number_id: number.phoneNumberId }, statuses: [{ id: 'wamid.x', status: 'read', timestamp: '1', recipient_id: '447700900000' }] } }] }] } + expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(receipt) }, metaSignature(JSON.stringify(receipt)))).ok()).toBe(true) + + // Slack: messages posted by a bot (including ours) carry bot_id. + const workspace = await installSlackWorkspace(adminApi, request) + await routeToAgent(adminApi, workspace.account.id, agent.id) + const teamId = workspace.teamId + const now = String(Math.floor(Date.now() / 1000)) + const botMessage = JSON.stringify({ type: 'event_callback', team_id: teamId, event_id: unique('Ev'), event: { type: 'message', channel_type: 'im', channel: 'D1', bot_id: 'B1', text: 'beep', ts: `${now}.000100` } }) + expect((await sendWebhook(request, '/webhooks/slack', { json: botMessage }, { 'x-slack-request-timestamp': now, 'x-slack-signature': slackSignature(botMessage, now) })).ok()).toBe(true) + + // Each was accepted (a provider would retry a refusal) and none reached the model. + await expectUnchanged('model calls', async () => (await llm.requests(agent)).length, 5_000) + expect(await llm.requests(agent)).toHaveLength(0) + }) +}) diff --git a/e2e/specs/channels/handover.spec.ts b/e2e/specs/channels/handover.spec.ts new file mode 100644 index 0000000..9269d83 --- /dev/null +++ b/e2e/specs/channels/handover.spec.ts @@ -0,0 +1,37 @@ +import { test, expect, channels, expectUnchanged, unique, AI_REPLY } from '@e2e' + +// The inbox side is the same for every channel, and a human's reply leaves through +// the same adapter call as the AI's (covered per channel in replies.spec), so one +// channel is enough here. +test.describe('human handover on a channel', () => { + test.use({ agentSettings: { transferToHuman: true } }) + + test('a WhatsApp customer asks for a person, the teammate\'s reply reaches them on WhatsApp, and the AI stays out of it @channels @handover', async ({ agent, llm, adminApi, request, inbox }) => { + await llm.script(agent, (s) => s + .when(/human|person/i).handOver('Let me connect you with a teammate.') + .otherwise().reply('Happy to help!')) + + const customer = unique('Wanda') + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(customer) + await chat.send('I want to talk to a person') + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Let me connect you with a teammate.']) + + await inbox.goto() + await inbox.openConversationWith(customer) + await expect(inbox.lastCustomerMessage()).toContainText('I want to talk to a person') + await inbox.takeOver() + await inbox.reply('Hi, this is Sam. How can I help?') + // The takeover itself tells the customer a person has joined. + await expect.poll(() => chat.replies(), AI_REPLY).toEqual([ + 'Let me connect you with a teammate.', + 'You\'re now connected with a member of our team.', + 'Hi, this is Sam. How can I help?', + ]) + + const modelCalls = (await llm.requests(agent)).length + await chat.send('My parcel never arrived') + await expect(inbox.lastCustomerMessage()).toContainText('My parcel never arrived') + await expectUnchanged('WhatsApp replies after takeover', () => chat.replies()) + expect((await llm.requests(agent))).toHaveLength(modelCalls) + }) +}) diff --git a/e2e/specs/channels/needs-reconnect.spec.ts b/e2e/specs/channels/needs-reconnect.spec.ts new file mode 100644 index 0000000..08cb3c7 --- /dev/null +++ b/e2e/specs/channels/needs-reconnect.spec.ts @@ -0,0 +1,94 @@ +import { + test, expect, connectWhatsAppNumber, expectUnchanged, listChannelAccounts, routeToAgent, + ApiClient, FakeExternals, IntegrationsPage, WhatsAppChat, AI_REPLY, +} from '@e2e' + +// When Meta stops accepting a number's access token (an expired temporary +// token, a revoked system user), every reply fails until someone reconnects +// the number. The account is flagged so the Integrations card can say so, +// rather than replies failing with nothing on screen. +// +// Other tests share the org and its WhatsApp card, so the card is checked for +// this test's number by name, never for the absence of any warning at all. + +const TOKEN_REFUSED = { error: { message: 'Error validating access token: Session has expired', type: 'OAuthException', code: 190, fbtrace_id: 'e2e' } } +// Each read lists every account in the shared org; once a second is plenty +const FLAG_POLL = { ...AI_REPLY, intervals: [1_000] } +const WINDOW_CLOSED = { error: { message: 'Re-engagement message', type: 'OAuthException', code: 131047, fbtrace_id: 'e2e' } } + +async function needsReconnect(api: ApiClient, accountId: string): Promise { + const account = (await listChannelAccounts(api)).find((a) => a.id === accountId) + return account?.needs_reconnect ?? false +} + +test.describe('a WhatsApp number whose token Meta refuses', () => { + test('is flagged, the Integrations card names it, and reconnecting clears it @channels', async ({ agent, llm, adminApi, request, page }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help!')) + const number = await connectWhatsAppNumber(adminApi) + await routeToAgent(adminApi, number.account.id, agent.id) + const externals = new FakeExternals(request) + const chat = new WhatsAppChat(request, externals, number, 'Tess') + + const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED }) + await chat.send('Hello?') + await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true) + expect(await chat.replies()).toEqual([]) + + const integrations = new IntegrationsPage(page) + const warning = integrations.expiredAccessFor(number.account.display_name ?? '') + await integrations.goto() + await expect(integrations.card('WhatsApp')).toContainText(warning) + // Other numbers are connected too; Manage still leads to the fix, not the first number + await integrations.manage('WhatsApp') + await expect(integrations.reconnectPrompt()).toBeVisible() + + // The customer swaps in a working token: same number, connected again + await restore() + const reconnected = await connectWhatsAppNumber(adminApi, number.phoneNumberId) + expect(reconnected.account.id).toBe(number.account.id) + expect(reconnected.account.needs_reconnect).toBe(false) + + await integrations.goto() + await expect(integrations.card('WhatsApp')).not.toContainText(warning) + // Replies now go out with the new token + const afterReconnect = new WhatsAppChat(request, externals, reconnected, 'Tess') + await afterReconnect.send('Are you there now?') + await expect.poll(() => afterReconnect.replies(), AI_REPLY).toEqual(['Happy to help!']) + }) + + test('is healthy again as soon as a reply goes through @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help!')) + const number = await connectWhatsAppNumber(adminApi) + await routeToAgent(adminApi, number.account.id, agent.id) + const externals = new FakeExternals(request) + const chat = new WhatsAppChat(request, externals, number, 'Tess') + + // Meta refuses the token once, then accepts it (it was refreshed on Meta's side) + await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 401, body: TOKEN_REFUSED, times: 1 }) + await chat.send('Hello?') + await expect.poll(() => needsReconnect(adminApi, number.account.id), FLAG_POLL).toBe(true) + + await chat.send('Hello again?') + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Happy to help!']) + expect(await needsReconnect(adminApi, number.account.id)).toBe(false) + }) + + test('is not flagged when Meta refuses one message for its own reason @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help!')) + const number = await connectWhatsAppNumber(adminApi) + await routeToAgent(adminApi, number.account.id, agent.id) + const externals = new FakeExternals(request) + const chat = new WhatsAppChat(request, externals, number, 'Tess') + + // A closed 24-hour window refuses the message, not the token + const restore = await externals.fail({ provider: 'graph', action: 'messages', contains: chat.waId, status: 400, body: WINDOW_CLOSED }) + await chat.send('Hello?') + const refused = async () => (await externals.calls('graph', 'messages', { contains: chat.waId })) + .filter((c) => c.status === 400).length + await expect.poll(refused, AI_REPLY).toBeGreaterThan(0) + + await expectUnchanged('the number\'s reconnect flag', () => needsReconnect(adminApi, number.account.id)) + expect(await needsReconnect(adminApi, number.account.id)).toBe(false) + await restore() + }) +}) diff --git a/e2e/specs/channels/own-meta-app.spec.ts b/e2e/specs/channels/own-meta-app.spec.ts new file mode 100644 index 0000000..523c1ac --- /dev/null +++ b/e2e/specs/channels/own-meta-app.spec.ts @@ -0,0 +1,80 @@ +import { + test, expect, connectMessenger, connectWhatsAppNumber, digits, expectUnchanged, metaVerifiesCallback, ownMetaApp, + pageChat, pastedCallback, routeToAgent, whereMetaSends, FakeExternals, WhatsAppChat, AI_REPLY, +} from '@e2e' + +// A WhatsApp number or Facebook Page connected with credentials from the +// customer's OWN Meta app — the only way to try Meta's free test number. Meta +// signs that app's webhooks with its own secret, so each such account has its +// own webhook address, checked with that secret only (issue #353). +// +// Connected through the API: the org is shared, and once any test has a number +// the Integrations card offers Manage, not Connect. The card itself is covered +// by the dashboard's unit tests. + +test.describe('a number or Page from the customer\'s own Meta app', () => { + test('a WhatsApp number receives messages: its webhooks are pointed at ChatterMate on connect @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s + .when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.') + .otherwise().reply('Happy to help!')) + const app = ownMetaApp() + const number = await connectWhatsAppNumber(adminApi, digits(15), app) + // Meta took the per-number callback, after checking it answers: nothing to paste + expect(number.account.webhook_auto_configured).toBe(true) + await routeToAgent(adminApi, number.account.id, agent.id) + + const externals = new FakeExternals(request) + const chat = new WhatsAppChat(request, externals, number, 'Tess', () => whereMetaSends(externals, number.phoneNumberId, app)) + await chat.send('What are your opening hours?') + + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.']) + }) + + test('a Messenger Page receives messages once its callback URL and verify token are pasted into the app @channels', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Hi! How can we help?')) + const app = ownMetaApp() + const pageId = digits(15) + const token = app.token(pageId) + const account = await connectMessenger(adminApi, pageId, token, app.secret) + await routeToAgent(adminApi, account.id, agent.id) + const url = account.webhook_url ?? '' + const verifyToken = account.webhook_verify_token ?? '' + + // The customer pastes both into their app; Meta checks the address before saving + expect(await metaVerifiesCallback(request, url, verifyToken)).toBe(true) + expect(await metaVerifiesCallback(request, url, 'a-guessed-token')).toBe(false) + + const chat = pageChat('Messenger', request, new FakeExternals(request), 'page', pageId, token, 'Remi', pastedCallback(url, app)) + await chat.send('Hello there') + + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Hi! How can we help?']) + }) + + test('a wrong app secret is refused when connecting @channels', async ({ adminApi }) => { + const app = ownMetaApp() + await expect(connectWhatsAppNumber(adminApi, digits(15), { ...app, secret: 'not-the-app-secret' })) + .rejects.toThrow(/400: .*That app secret is not the one for the Meta app this token comes from/) + }) + + test('its address takes only its own app\'s accounts, never another app\'s number @channels @security', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.')) + const mine = ownMetaApp() + const ourNumber = await connectWhatsAppNumber(adminApi, digits(15), mine) + const theirNumber = await connectWhatsAppNumber(adminApi, digits(15), ownMetaApp()) + await routeToAgent(adminApi, theirNumber.account.id, agent.id) + const externals = new FakeExternals(request) + + // Signed with our app's secret and sent to our address, but naming their number + const forged = new WhatsAppChat(request, externals, theirNumber, 'Mallory', () => whereMetaSends(externals, ourNumber.phoneNumberId, mine)) + await forged.send('Hi, I am not who I say I am') + + await expectUnchanged('replies on the other app\'s number', () => forged.replies()) + }) + + test('without the app secret a Page has no address of its own @channels', async ({ adminApi }) => { + const pageId = digits(15) + const account = await connectMessenger(adminApi, pageId, ownMetaApp().token(pageId)) + expect(account.webhook_url).toBeFalsy() + expect(account.webhook_verify_token).toBeFalsy() + }) +}) diff --git a/e2e/specs/channels/replies.spec.ts b/e2e/specs/channels/replies.spec.ts new file mode 100644 index 0000000..552e83d --- /dev/null +++ b/e2e/specs/channels/replies.spec.ts @@ -0,0 +1,19 @@ +import { test, expect, channels, expectUnchanged, AI_REPLY } from '@e2e' + +// The core promise of every channel: a customer writes in, the agent's answer goes +// back out on the same channel, to that customer. +for (const [name, connect] of Object.entries(channels)) { + test(`a customer on ${name} gets the agent's answer back there @channels`, async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s + .when(/opening hours/i).reply('We are open 9am to 5pm, Monday to Friday.') + .otherwise().reply('Happy to help!')) + + const channel = await connect({ api: adminApi, request, agentId: agent.id }) + const chat = channel.chatWith('Tess') + await chat.send('What are your opening hours?') + + // Exactly one answer, the scripted one, from this account — no duplicate, no default. + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are open 9am to 5pm, Monday to Friday.']) + await expectUnchanged(`${name} replies`, () => chat.replies()) + }) +} diff --git a/e2e/specs/channels/reply-buttons.spec.ts b/e2e/specs/channels/reply-buttons.spec.ts new file mode 100644 index 0000000..12b5a4c --- /dev/null +++ b/e2e/specs/channels/reply-buttons.spec.ts @@ -0,0 +1,21 @@ +import { test, expect, channelsWithButtons, AI_REPLY } from '@e2e' + +test.describe('reply buttons', () => { + test.use({ agentSettings: { channelQuickReplies: true } }) + + for (const [name, connect] of Object.entries(channelsWithButtons)) { + test(`the AI offers buttons on ${name} and a tap gets the next answer @channels`, async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s + .when(/track my order/i).reply('Your order ships tomorrow.') + .otherwise().reply('What can I help you with?', { quick_replies: ['Track my order', 'Returns'] })) + + const chat = (await connect({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Hi') + await expect.poll(() => chat.buttons(), AI_REPLY).toEqual(['Track my order', 'Returns']) + + await chat.tap('Track my order') + await expect.poll(async () => (await chat.replies()).at(-1), AI_REPLY).toContain('Your order ships tomorrow.') + expect(await chat.replies()).toHaveLength(2) + }) + } +}) diff --git a/e2e/specs/channels/retries.spec.ts b/e2e/specs/channels/retries.spec.ts new file mode 100644 index 0000000..17a0f8d --- /dev/null +++ b/e2e/specs/channels/retries.spec.ts @@ -0,0 +1,23 @@ +import { test, expect, connectWhatsAppNumber, deliverMeta, expectUnchanged, phoneNumber, routeToAgent, whatsappPayload, FakeExternals, AI_REPLY } from '@e2e' + +// Providers retry a webhook they think failed. Dedupe lives in Redis, which only +// the nightly Redis run turns on (scripts/stack.sh E2E_REDIS_URL); playwright.config.ts +// leaves @redis tests out otherwise. + +test('a provider delivering the same message twice gets one answer @channels @redis', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Answered once.')) + const number = await connectWhatsAppNumber(adminApi) + await routeToAgent(adminApi, number.account.id, agent.id) + const waId = phoneNumber().slice(1) + + // The exact same delivery, message id included, twice. + const payload = whatsappPayload(number.phoneNumberId, waId, 'Rita', { type: 'text', text: { body: 'Hello?' } }) + await deliverMeta(request, payload) + await deliverMeta(request, payload) + + const sent = async () => (await new FakeExternals(request).calls('graph', 'messages', { contains: waId })) + .filter((c) => (c.body as { type?: string }).type === 'text').length + await expect.poll(sent, AI_REPLY).toBe(1) + await expectUnchanged('replies to the retried message', sent) + expect(await llm.requests(agent)).toHaveLength(1) +}) diff --git a/e2e/specs/crm/hubspot.org.spec.ts b/e2e/specs/crm/hubspot.org.spec.ts new file mode 100644 index 0000000..c9c7647 --- /dev/null +++ b/e2e/specs/crm/hubspot.org.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, connectCrm, disconnectCrm, findPerson, FakeExternals, runWorkerOnce, syncPersonToCrm, unique, AI_REPLY } from '@e2e' + +// One HubSpot connection per org, so this runs in the serial `org` project and +// disconnects afterwards. + +test.describe('HubSpot', () => { + test.use({ agentSettings: { leadCapture: true } }) + + test.afterEach(async ({ adminApi }) => { await disconnectCrm(adminApi, 'hubspot') }) + + test('a captured lead is pushed to HubSpot with what they told us @crm', async ({ agent, widget, llm, adminApi, request }) => { + const code = unique('hs-code') + await connectCrm(adminApi, 'hubspot', code) + + const { email, name } = widget.customer + await llm.script(agent, (s) => s + .when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Acme Corp' }) + .otherwise().reply('Happy to set that up — what is your email?')) + await widget.embed() + await widget.open() + await widget.send('I would like a demo') + await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY) + await widget.send(`Sure, I am ${name}, ${email}`) + await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY) + + const person = await findPerson(adminApi, email) + const status = await syncPersonToCrm(adminApi, person.id) + expect(status.synced.map((s) => s.provider)).toContain('hubspot') + + // What HubSpot received: the contact keyed by email, as a lead, with their company, + // sent with the token this org's install was granted. + const externals = new FakeExternals(request) + const [upsert] = await externals.calls('hubspot', 'upsert', { contains: email }) + const [grant] = await externals.calls('hubspot', 'token', { contains: code }) + expect(upsert.headers.authorization).toBe(`Bearer ${(grant.response as { access_token: string }).access_token}`) + expect(upsert.body).toMatchObject({ + inputs: [{ id: email, idProperty: 'email', properties: { email, company: 'Acme Corp', lifecyclestage: 'lead' } }], + }) + }) + + test('with HubSpot as the agent\'s CRM, a captured lead is pushed on its own @crm @workers', async ({ agent, widget, llm, adminApi, request }) => { + await connectCrm(adminApi, 'hubspot', unique('hs-code')) + await adminApi.put(`/agent/${agent.id}/lead-capture`, { data: { enabled: true, require_consent: false, crm_sync_target: 'hubspot' } }) + + const { email, name } = widget.customer + await llm.script(agent, (s) => s + .when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Globex' }) + .otherwise().reply('Happy to set that up — what is your email?')) + await widget.embed() + await widget.open() + await widget.send('I would like a demo') + await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY) + await widget.send(`Sure, I am ${name}, ${email}`) + await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY) + + // Capture queues a sync job; the CRM worker pushes it on its next pass. + const externals = new FakeExternals(request) + expect(await externals.calls('hubspot', 'upsert', { contains: email })).toHaveLength(0) + await runWorkerOnce('crm-sync') + const [upsert] = await externals.calls('hubspot', 'upsert', { contains: email }) + expect(upsert.body).toMatchObject({ inputs: [{ id: email, properties: { company: 'Globex', lifecyclestage: 'lead' } }] }) + }) +}) diff --git a/e2e/specs/crm/pipedrive.org.spec.ts b/e2e/specs/crm/pipedrive.org.spec.ts new file mode 100644 index 0000000..3e00219 --- /dev/null +++ b/e2e/specs/crm/pipedrive.org.spec.ts @@ -0,0 +1,57 @@ +import { test, expect, connectCrm, disconnectCrm, findPerson, FakeExternals, syncPersonToCrm, unique, AI_REPLY } from '@e2e' + +// One Pipedrive connection per org, so this runs in the serial `org` project and +// disconnects afterwards. + +interface PipedriveGrant { access_token: string; api_domain: string } + +test.describe('Pipedrive', () => { + test.use({ agentSettings: { leadCapture: true } }) + + test.afterEach(async ({ adminApi }) => { await disconnectCrm(adminApi, 'pipedrive') }) + + test('a captured lead becomes a Pipedrive person, organisation and lead with a note @crm', async ({ agent, widget, llm, adminApi, request }) => { + const code = unique('pd-code') + await connectCrm(adminApi, 'pipedrive', code) + + const { email, name } = widget.customer + const company = unique('Initech') + await llm.script(agent, (s) => s + .when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company }) + .otherwise().reply('Happy to set that up — what is your email?')) + await widget.embed() + await widget.open() + await widget.send('I would like a demo') + await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY) + await widget.send(`Sure, I am ${name}, ${email}`) + await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY) + + const person = await findPerson(adminApi, email) + const status = await syncPersonToCrm(adminApi, person.id) + expect(status.synced.map((s) => s.provider)).toContain('pipedrive') + + // The install traded the code with the app's own credentials (Basic auth), and + // every API call went to the company's api_domain with the token it was granted. + const externals = new FakeExternals(request) + const [grant] = await externals.calls('pipedrive-oauth', 'token', { contains: code }) + const basic = `Basic ${Buffer.from('e2e-pipedrive-client-id:e2e-pipedrive-client-secret').toString('base64')}` + expect(grant.headers.authorization).toBe(basic) + const { access_token: token } = grant.response as PipedriveGrant + + const [organization] = await externals.calls('pipedrive', 'organizations', { contains: company }) + expect(organization.body).toEqual({ name: company }) + const [created] = await externals.calls('pipedrive', 'persons', { contains: email }) + expect(created.method).toBe('POST') + expect(created.headers.authorization).toBe(`Bearer ${token}`) + expect(created.body).toMatchObject({ + name, emails: [{ value: email, primary: true }], org_id: (organization.response as { data: { id: number } }).data.id, + }) + + const personId = (created.response as { data: { id: number } }).data.id + const [lead] = (await externals.calls('pipedrive', 'leads', { contains: `"person_id":${personId}` })).filter((c) => c.method === 'POST') + expect(lead.body).toMatchObject({ title: `${name} — ChatterMate lead`, person_id: personId }) + const leadId = (lead.response as { data: { id: string } }).data.id + const [note] = await externals.calls('pipedrive', 'notes', { contains: leadId }) + expect(note.body).toMatchObject({ lead_id: leadId, content: expect.stringContaining('Lead captured by ChatterMate') }) + }) +}) diff --git a/e2e/specs/handover/after-hours.org.spec.ts b/e2e/specs/handover/after-hours.org.spec.ts new file mode 100644 index 0000000..a6fb50e --- /dev/null +++ b/e2e/specs/handover/after-hours.org.spec.ts @@ -0,0 +1,26 @@ +import { test, expect, channels, chatsWithAgent, setAlwaysClosed, setAlwaysOpen, unique, AI_REPLY } from '@e2e' + +// Changes the org's business hours, so it runs in the serial `org` project and +// reopens the org afterwards (setup leaves it open 24/7). + +test.describe('outside business hours', () => { + test.use({ agentSettings: { transferToHuman: true } }) + + test.afterEach(async ({ adminApi, org }) => { await setAlwaysOpen(adminApi, org.orgId) }) + + test('a customer asking for a person is told the team will follow up, and the chat waits in the team\'s queue @handover', async ({ agent, llm, adminApi, request, org, handoverTeam }) => { + await setAlwaysClosed(adminApi, org.orgId) + await llm.script(agent, (s) => s.otherwise().handOver('We are closed now; the team will get back to you.')) + + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(unique('Nora')) + await chat.send('I need to talk to someone') + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['We are closed now; the team will get back to you.']) + + // Queued for the team, unclaimed — whoever opens up next picks it up. + const [conversation] = await chatsWithAgent(adminApi, agent.id) + expect(conversation).toMatchObject({ status: 'transferred', group_id: (await handoverTeam()).groupId, user_id: null }) + // The reply was written knowing the team is closed. + const prompts = (await llm.requests(agent)).map((r) => r.lastUser).join('\n') + expect(prompts).toContain('Currently within business hours: False') + }) +}) diff --git a/e2e/specs/handover/attachments.spec.ts b/e2e/specs/handover/attachments.spec.ts new file mode 100644 index 0000000..10e9912 --- /dev/null +++ b/e2e/specs/handover/attachments.spec.ts @@ -0,0 +1,30 @@ +import { test, expect, env, unique, AI_REPLY } from '@e2e' + +test.describe('attachments', () => { + test.use({ agentSettings: { transferToHuman: true, allowAttachments: true } }) + + test('a customer sends a file to the teammate, who can open it — and only through a signed link @handover', async ({ agent, widget, inbox, llm, request }) => { + await llm.script(agent, (s) => s.otherwise().handOver('Let me connect you with a teammate.')) + await widget.embed() + await widget.open() + await widget.send('I need a person') + await widget.shareContactDetails() + await inbox.goto() + await inbox.openConversationWith(widget.customer.name) + await inbox.takeOver() + await inbox.reply('Please send me the receipt.') + await expect(widget.lastAgentMessage()).toHaveText('Please send me the receipt.', AI_REPLY) + + const content = `Receipt ${unique('R')} — total 42.00` + await widget.sendFile({ name: 'receipt.txt', mimeType: 'text/plain', content }, 'Here it is') + + const link = inbox.attachment('receipt.txt') + await expect(link).toBeVisible(AI_REPLY) + const href = await link.getAttribute('href') + expect((await (await inbox.page.request.get(href!)).text())).toBe(content) + + // The stored file is not reachable without the signature. + const unsigned = new URL(href!, 'http://x').pathname + expect((await request.get(`${env.backendUrl}${unsigned}`)).status()).toBe(403) + }) +}) diff --git a/e2e/specs/handover/handover.spec.ts b/e2e/specs/handover/handover.spec.ts new file mode 100644 index 0000000..a30b904 --- /dev/null +++ b/e2e/specs/handover/handover.spec.ts @@ -0,0 +1,30 @@ +import { test, expect, expectUnchanged, AI_REPLY } from '@e2e' + +test.describe('human handover', () => { + test.use({ agentSettings: { transferToHuman: true } }) + + test('a customer asks for a person, the teammate\'s reply reaches them, and the AI stays out of it @smoke @handover', async ({ agent, widget, inbox, llm }) => { + await llm.script(agent, (s) => s + .when(/human|person/i).handOver('Let me connect you with a teammate.') + .otherwise().reply('Happy to help!')) + + await widget.embed() + await widget.open() + await widget.send('I want to talk to a human') + await widget.shareContactDetails() + + await inbox.goto() + await inbox.openConversationWith(widget.customer.name) + await inbox.takeOver() + await inbox.reply('Hi, this is Sam. How can I help?') + await expect(widget.lastAgentMessage()).toHaveText('Hi, this is Sam. How can I help?', AI_REPLY) + + const modelCalls = (await llm.requests(agent)).length + await widget.send('My parcel never arrived') + await expect(inbox.lastCustomerMessage()).toContainText('My parcel never arrived') + // A person owns the chat now: the model is not asked, and no bot answer appears. + await expectUnchanged('model calls after takeover', async () => (await llm.requests(agent)).length) + expect((await llm.requests(agent))).toHaveLength(modelCalls) + await expect(widget.lastAgentMessage()).toHaveText('Hi, this is Sam. How can I help?') + }) +}) diff --git a/e2e/specs/handover/nobody-online.spec.ts b/e2e/specs/handover/nobody-online.spec.ts new file mode 100644 index 0000000..cfc652c --- /dev/null +++ b/e2e/specs/handover/nobody-online.spec.ts @@ -0,0 +1,20 @@ +import { test, expect, channels, chatsWithAgent, createGroup, createUser, unique, AI_REPLY } from '@e2e' + +test('with nobody from the team online, the customer is told the team will follow up, and the chat waits in its queue @handover', async ({ makeAgent, llm, adminApi, request }) => { + // A team whose only member has never signed in, so nobody is online. + const id = unique('offline') + const member = await createUser(adminApi, { name: `Offline ${id}`, email: `${id}@e2e.example.com`, password: 'E2e-Offline-Passw0rd!', role: 'Agent' }) + const groupId = await createGroup(adminApi, `Team ${id}`, [member]) + const agent = await makeAgent({ transferToHuman: true }, [groupId]) + await llm.script(agent, (s) => s.otherwise().handOver('Our team will get back to you shortly.')) + + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith(unique('Nora')) + await chat.send('I need to talk to someone') + await expect.poll(() => chat.replies(), AI_REPLY).toEqual(['Our team will get back to you shortly.']) + + const [conversation] = await chatsWithAgent(adminApi, agent.id) + expect(conversation).toMatchObject({ status: 'transferred', group_id: groupId, user_id: null }) + // The reply was written knowing nobody is online. + const prompts = (await llm.requests(agent)).map((r) => r.lastUser).join('\n') + expect(prompts).toContain('(0 online)') +}) diff --git a/e2e/specs/help-center/publishing.org.spec.ts b/e2e/specs/help-center/publishing.org.spec.ts new file mode 100644 index 0000000..8b339d4 --- /dev/null +++ b/e2e/specs/help-center/publishing.org.spec.ts @@ -0,0 +1,31 @@ +import { test, expect, createArticle, enableHelpCenter, setArticleStatus, unique } from '@e2e' + +// Turning the help center on is an org-wide setting, so this runs in the serial +// `org` project. The public site is unauthenticated: exactly what is published, +// nothing else. + +test('only published articles are on the public help center @help-center', async ({ adminApi, anonymousPage }) => { + const site = await enableHelpCenter(adminApi) + const code = unique('HC') + const article = await createArticle(adminApi, { + question: `How do I reset my router ${code}?`, + answer: `Hold the reset button for ten seconds. Reference ${code}.`, + }) + const articleUrl = `${site}/a/${article.slug}` + + // A draft is invisible to the public: not listed, and its URL is not found. + await anonymousPage.goto(site) + await expect(anonymousPage.getByText(code)).toHaveCount(0) + expect((await anonymousPage.request.get(articleUrl)).status()).toBe(404) + + await setArticleStatus(adminApi, article.id, 'published') + await anonymousPage.goto(articleUrl) + await expect(anonymousPage.getByRole('heading', { name: `How do I reset my router ${code}?` })).toBeVisible() + await expect(anonymousPage.getByText('Hold the reset button for ten seconds.')).toBeVisible() + await anonymousPage.goto(site) + await expect(anonymousPage.getByText(`How do I reset my router ${code}?`)).toBeVisible() + + // Unpublishing takes it down again. + await setArticleStatus(adminApi, article.id, 'draft') + expect((await anonymousPage.request.get(articleUrl)).status()).toBe(404) +}) diff --git a/e2e/specs/integrations/jira.org.spec.ts b/e2e/specs/integrations/jira.org.spec.ts new file mode 100644 index 0000000..b51b352 --- /dev/null +++ b/e2e/specs/integrations/jira.org.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, connectJira, disconnectJira, fakeJira, routeTicketsToJira, FakeExternals, IntegrationsPage, unique, AI_REPLY } from '@e2e' + +// One Jira site per org, so this runs in the serial `org` project and disconnects afterwards. + +interface JiraGrant { access_token: string } +interface JiraSite { id: string; url: string } + +test.describe('Jira', () => { + test.afterEach(async ({ adminApi }) => { await disconnectJira(adminApi) }) + + test('an admin connects Jira from Settings and the card links to their site @integrations', async ({ page, request }) => { + const since = new Date() + const integrations = new IntegrationsPage(page) + await integrations.goto() + await integrations.connect('Jira') + await expect(page.getByText('Jira connected successfully!')).toBeVisible() + + // The code Atlassian's consent screen sent back was traded with the app's own + // credentials, and the site is the one that token reaches. + const externals = new FakeExternals(request) + const [grant] = (await externals.callsSince(since)).filter((c) => c.provider === 'jira-auth' && c.action === 'token') + const [resources] = (await externals.callsSince(since)).filter((c) => c.action === 'accessible-resources') + expect(grant.body).toMatchObject({ grant_type: 'authorization_code', client_id: 'e2e-jira-client-id', redirect_uri: 'http://127.0.0.1:18000/api/v1/jira/callback' }) + expect(resources.headers.authorization).toBe(`Bearer ${(grant.response as JiraGrant).access_token}`) + const [site] = resources.response as JiraSite[] + await expect(integrations.card('Jira').getByRole('link', { name: /Visit Jira Site/ })).toHaveAttribute('href', site.url) + }) + + test('the AI files a Jira issue from a chat, in the project the agent is set up for @integrations', async ({ agent, widget, llm, adminApi, request }) => { + const code = unique('jira-code') + await connectJira(adminApi, code) + await routeTicketsToJira(adminApi, agent.id) + + const summary = unique('Refund not received') + await llm.script(agent, (s) => s + .when(/refund/i).callTool('create_jira_ticket', { summary, description: 'Charged twice for one order; wants one payment back.', priority: 'High' }) + .thenReply('I have raised this with our billing team.') + .otherwise().reply('How can I help?')) + await widget.embed() + await widget.open() + await widget.send('My refund never arrived') + await expect(widget.lastAgentMessage()).toHaveText('I have raised this with our billing team.', AI_REPLY) + + // Jira received the issue in the agent's project and type, at the priority the + // model chose, sent with this org's token. + const externals = new FakeExternals(request) + const [grant] = await externals.calls('jira-auth', 'token', { contains: code }) + const [created] = await externals.calls('jira', 'issue', { contains: summary }) + expect(created.method).toBe('POST') + expect(created.headers.authorization).toBe(`Bearer ${(grant.response as JiraGrant).access_token}`) + expect(created.body).toMatchObject({ + fields: { summary, project: { key: fakeJira.projectKey }, issuetype: { id: fakeJira.taskTypeId }, priority: { id: '2' } }, + }) + + // The model is told the issue key and its link on the connected site. + const { key } = created.response as { key: string } + const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') + expect(toolResults).toContain(`.atlassian.net/browse/${key}`) + }) +}) diff --git a/e2e/specs/integrations/shopify.org.spec.ts b/e2e/specs/integrations/shopify.org.spec.ts new file mode 100644 index 0000000..4eac5af --- /dev/null +++ b/e2e/specs/integrations/shopify.org.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, installShopify, uninstallShopify, useShopForAgent, FakeExternals, seedOrg, unique, AI_REPLY } from '@e2e' + +// Linking a shop sets the org's domain, so this runs in the serial `org` project and +// uninstalls (restoring the domain) afterwards. + +test.describe('Shopify', () => { + let shop: string + let shopId: string | undefined + + test.beforeEach(async ({ agent, adminApi }) => { + shop = `${unique('e2e-shop')}.myshopify.com` + shopId = await installShopify(adminApi, shop) + await useShopForAgent(adminApi, agent.id, shopId) + }) + + test.afterEach(async ({ adminApi, org }) => { + if (shopId) await uninstallShopify(adminApi, shopId, org.orgId, seedOrg.domain) + }) + + test('a customer asks where their order is and the AI answers from the shop @integrations', async ({ agent, widget, llm, request }) => { + const email = widget.customer.email + await llm.script(agent, (s) => s + .when(/1042/).callTool('get_order_status', { order_number: '#1042', customer_email: email }) + .thenReply('Order #1042 has shipped with UPS.') + .otherwise().reply('Happy to check — what is your order number and email?')) + await widget.embed() + await widget.open() + await widget.send('Where is my order?') + await expect(widget.lastAgentMessage()).toHaveText('Happy to check — what is your order number and email?', AI_REPLY) + await widget.send(`It is 1042, ${email}`) + await expect(widget.lastAgentMessage()).toHaveText('Order #1042 has shipped with UPS.', AI_REPLY) + + // The shop was searched for this customer's order with the token its install + // was granted, then the order itself was read. + const externals = new FakeExternals(request) + const [install] = await externals.calls('shopify', 'access_token', { contains: shop }) + const [search, read] = await externals.calls('shopify', 'graphql.json', { contains: shop }) + expect(search.headers['x-shopify-access-token']).toBe((install.response as { access_token: string }).access_token) + expect(search.body).toMatchObject({ variables: { query: `email:${email.toLowerCase()} AND name:1042` } }) + expect(read.body).toMatchObject({ query: expect.stringContaining('query GetOrder') }) + + // What the model was told: the shipment and its tracking number. + const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') + expect(toolResults).toContain('FULFILLED') + expect(toolResults).toMatch(/1ZE2E\d+/) + }) + + test('an order lookup without the customer\'s email never reaches the shop @integrations', async ({ agent, widget, llm, request }) => { + await llm.script(agent, (s) => s + .otherwise().callTool('get_order_status', { order_number: '1042', customer_email: '' }) + .thenReply('Could you tell me the email you ordered with?')) + await widget.embed() + await widget.open() + await widget.send('Where is order 1042?') + await expect(widget.lastAgentMessage()).toHaveText('Could you tell me the email you ordered with?', AI_REPLY) + + const externals = new FakeExternals(request) + expect(await externals.calls('shopify', 'graphql.json', { contains: shop })).toHaveLength(0) + const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') + expect(toolResults).toContain('requires_user_input') + }) +}) diff --git a/e2e/specs/knowledge/knowledge-answer.spec.ts b/e2e/specs/knowledge/knowledge-answer.spec.ts new file mode 100644 index 0000000..8c38530 --- /dev/null +++ b/e2e/specs/knowledge/knowledge-answer.spec.ts @@ -0,0 +1,27 @@ +import { test, expect, addKnowledgeText, unique, AI_REPLY } from '@e2e' + +test.describe('knowledge base', () => { + test('an answer is grounded in text added to the knowledge base @smoke @knowledge', async ({ agent, widget, llm, adminApi, org }) => { + // A token no model could guess, so finding it in the search result proves the + // text was indexed and retrieved — not echoed from the prompt. + const policyCode = unique('RET') + await addKnowledgeText(adminApi, { + orgId: org.orgId, + agentId: agent.id, + title: `Returns policy ${policyCode}`, // titles are unique per org + content: `Returns policy: items can be returned within 45 days of delivery. Quote reference ${policyCode} on the return label.`, + }) + await llm.script(agent, (s) => s + .when(/return/i).callTool('search_knowledge_base', { query: 'returns policy' }) + .thenReply('You can return items within 45 days of delivery.') + .otherwise().reply('Happy to help!')) + + await widget.embed() + await widget.open() + await widget.send('What is your returns policy?') + + await expect(widget.lastAgentMessage()).toHaveText('You can return items within 45 days of delivery.', AI_REPLY) + const searchResults = (await llm.requests(agent)).flatMap((request) => request.toolResults) + expect(searchResults.join('\n')).toContain(policyCode) + }) +}) diff --git a/e2e/specs/knowledge/knowledge-lifecycle.spec.ts b/e2e/specs/knowledge/knowledge-lifecycle.spec.ts new file mode 100644 index 0000000..e79c996 --- /dev/null +++ b/e2e/specs/knowledge/knowledge-lifecycle.spec.ts @@ -0,0 +1,41 @@ +import { test, expect, addKnowledgeText, deleteKnowledge, unique, AI_REPLY, type TestAgent, type FakeLlm, type ChatWidget } from '@e2e' + +// What the model is shown is what the knowledge base holds for *this* agent, now. + +async function askAboutReturns(llm: FakeLlm, agent: TestAgent, widget: ChatWidget): Promise { + await llm.script(agent, (s) => s + .when(/return/i).callTool('search_knowledge_base', { query: 'returns policy' }) + .thenReply('Let me check our policy.') + .otherwise().reply('Happy to help!')) + await widget.embed() + await widget.open() + await widget.send('What is your returns policy?') + await expect(widget.lastAgentMessage()).toHaveText('Let me check our policy.', AI_REPLY) + return (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') +} + +test.describe('knowledge base lifecycle', () => { + test('deleted knowledge is no longer what the AI is shown @knowledge', async ({ agent, widget, llm, adminApi, org, makeWidget }) => { + const code = unique('RET') + const id = await addKnowledgeText(adminApi, { + orgId: org.orgId, agentId: agent.id, title: `Returns policy ${code}`, + content: `Returns policy: items can be returned within 45 days. Reference ${code}.`, + }) + expect(await askAboutReturns(llm, agent, widget)).toContain(code) + + await deleteKnowledge(adminApi, id) + const freshVisitor = await makeWidget(agent.id) + expect(await askAboutReturns(llm, agent, freshVisitor)).not.toContain(code) + }) + + test('one agent\'s knowledge never reaches another agent\'s answers @knowledge', async ({ agent, widget, llm, adminApi, org, makeAgent }) => { + const other = await makeAgent() + const secret = unique('INTERNAL') + await addKnowledgeText(adminApi, { + orgId: org.orgId, agentId: other.id, title: `Staff returns rules ${secret}`, + content: `Staff-only returns rules: discount code ${secret} for internal returns.`, + }) + // `agent` searches the same org's knowledge base, for the same words. + expect(await askAboutReturns(llm, agent, widget)).not.toContain(secret) + }) +}) diff --git a/e2e/specs/knowledge/knowledge-pdf.spec.ts b/e2e/specs/knowledge/knowledge-pdf.spec.ts new file mode 100644 index 0000000..39a7a9d --- /dev/null +++ b/e2e/specs/knowledge/knowledge-pdf.spec.ts @@ -0,0 +1,24 @@ +import { test, expect, runWorkerOnce, textPdf, unique, unfinishedKnowledge, uploadKnowledgePdf, AI_REPLY, KNOWLEDGE_INDEXED } from '@e2e' + +test('an uploaded PDF becomes knowledge the AI answers from @knowledge @workers', async ({ agent, widget, llm, adminApi, org }) => { + const code = unique('WARRANTY') + await uploadKnowledgePdf(adminApi, { + orgId: org.orgId, agentId: agent.id, filename: `${code}.pdf`, + pdf: textPdf(['Warranty policy', `Every product carries a two-year warranty. Reference ${code}.`]), + }) + // Production indexes it on the knowledge worker's next poll; here, now. + await runWorkerOnce('knowledge') + // Tests run in parallel: another test's pass may be the one indexing it. + await expect.poll(() => unfinishedKnowledge(adminApi, agent.id), KNOWLEDGE_INDEXED).toEqual([]) + + await llm.script(agent, (s) => s + .when(/warranty/i).callTool('search_knowledge_base', { query: 'warranty policy' }) + .thenReply('Every product has a two-year warranty.') + .otherwise().reply('Happy to help!')) + await widget.embed() + await widget.open() + await widget.send('What warranty do you offer?') + + await expect(widget.lastAgentMessage()).toHaveText('Every product has a two-year warranty.', AI_REPLY) + expect((await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n')).toContain(code) +}) diff --git a/e2e/specs/leads/lead-capture.spec.ts b/e2e/specs/leads/lead-capture.spec.ts new file mode 100644 index 0000000..c7b1404 --- /dev/null +++ b/e2e/specs/leads/lead-capture.spec.ts @@ -0,0 +1,33 @@ +import { test, expect, AI_REPLY } from '@e2e' + +interface People { items: Array<{ id: string; email: string | null }> } +interface Person { name: string | null; email: string | null; lead_stage: string; captured_attributes: Record } + +test.describe('lead capture', () => { + test.use({ agentSettings: { leadCapture: true } }) + + test('a visitor who shares their details shows up as a lead, with what they told us @leads', async ({ agent, widget, llm, peoplePage, adminApi }) => { + const { email, name } = widget.customer + await llm.script(agent, (s) => s + .when(/@/).captureLead('Thanks! Someone from sales will reach out.', { email, name, company: 'Acme Corp' }) + .otherwise().reply('Happy to set that up — what is your email?')) + + await widget.embed() + await widget.open() + await widget.send('I would like a demo') + // A visitor answers the question once it is asked; replies to back-to-back + // messages are produced concurrently and may arrive in either order. + await expect(widget.lastAgentMessage()).toHaveText('Happy to set that up — what is your email?', AI_REPLY) + await widget.send(`Sure, I am ${name}, ${email}`) + await expect(widget.lastAgentMessage()).toHaveText('Thanks! Someone from sales will reach out.', AI_REPLY) + + await peoplePage.goto() + await peoplePage.findLead(email) + await expect(peoplePage.person(email)).toContainText('Lead') + + const [found] = (await adminApi.get('/people', { params: { search: email } })).items + const person = await adminApi.get(`/people/${found.id}`) + expect(person).toMatchObject({ name, email }) + expect(JSON.stringify(person.captured_attributes)).toContain('Acme Corp') + }) +}) diff --git a/e2e/specs/leads/people.spec.ts b/e2e/specs/leads/people.spec.ts new file mode 100644 index 0000000..4d76abc --- /dev/null +++ b/e2e/specs/leads/people.spec.ts @@ -0,0 +1,58 @@ +import { test, expect, channels, findPerson, phoneNumber, AI_REPLY } from '@e2e' + +// The People directory's edit rules, at the API the drawer uses. + +test.describe('People', () => { + test('a contact can be renamed, given a phone and marked as a customer @leads', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Thanks for writing in.')) + const chat = (await channels.Email({ api: adminApi, request, agentId: agent.id })).chatWith('Tess') + await chat.send('Hello') + await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1) + const person = await findPerson(adminApi, chat.address) + + const phone = phoneNumber() + await adminApi.patch(`/people/${person.id}`, { data: { full_name: 'Tess Tester', phone } }) + await adminApi.post(`/people/${person.id}/mark-customer`) + + expect(await adminApi.get(`/people/${person.id}`)).toMatchObject({ name: 'Tess Tester', phone, lead_stage: 'customer' }) + }) + + test('a phone number must be international and can belong to only one person @leads', async ({ agent, llm, adminApi, request }) => { + await llm.script(agent, (s) => s.otherwise().reply('Thanks for writing in.')) + const inbox = await channels.Email({ api: adminApi, request, agentId: agent.id }) + const ann = inbox.chatWith('Ann') + const ben = inbox.chatWith('Ben') + await ann.send('Hello') + await ben.send('Hello') + await expect.poll(() => ann.replies(), AI_REPLY).toHaveLength(1) + await expect.poll(() => ben.replies(), AI_REPLY).toHaveLength(1) + const annPerson = await findPerson(adminApi, ann.address) + const benPerson = await findPerson(adminApi, ben.address) + + const local = await adminApi.raw('PATCH', `/people/${annPerson.id}`, { data: { phone: '07700 900123' } }) + expect(local.status()).toBe(400) + expect(await local.text()).toContain('international format') + + const shared = phoneNumber() + await adminApi.patch(`/people/${annPerson.id}`, { data: { phone: shared } }) + const taken = await adminApi.raw('PATCH', `/people/${benPerson.id}`, { data: { phone: shared } }) + expect(taken.status()).toBe(400) + expect(await taken.text()).toContain('already belongs to another person') + }) + + test('once a WhatsApp contact has given a real email, their number can no longer be changed away @leads', async ({ makeAgent, llm, adminApi, request }) => { + // Their phone is now the only thing an incoming WhatsApp message can find them by. + const agent = await makeAgent({ leadCapture: true }) + const email = `wes.${Date.now()}@example.com` + await llm.script(agent, (s) => s.otherwise().captureLead('Thanks, noted.', { email, name: 'Wes' })) + const chat = (await channels.WhatsApp({ api: adminApi, request, agentId: agent.id })).chatWith('Wes') + await chat.send(`It is ${email}`) + await expect.poll(() => chat.replies(), AI_REPLY).toHaveLength(1) + const person = await findPerson(adminApi, email) + + const response = await adminApi.raw('PATCH', `/people/${person.id}`, { data: { phone: phoneNumber() } }) + expect(response.status()).toBe(400) + expect(await response.text()).toContain('how their WhatsApp messages find them') + expect((await adminApi.get<{ phone: string }>(`/people/${person.id}`)).phone).toContain(chat.waId) + }) +}) diff --git a/e2e/specs/mcp/connectors.spec.ts b/e2e/specs/mcp/connectors.spec.ts new file mode 100644 index 0000000..5dd18b9 --- /dev/null +++ b/e2e/specs/mcp/connectors.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, attachMcpConnector, createMcpConnector, FakeMcp, unique, AI_REPLY } from '@e2e' + +test.describe('MCP connectors', () => { + test('an agent answers with a tool from a connected MCP server @mcp', async ({ agent, widget, llm, adminApi, request }) => { + const mcp = new FakeMcp(request) + const connector = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url }) + await attachMcpConnector(adminApi, connector.id, agent.id) + const orderId = unique('A') + await llm.script(agent, (s) => s + .when(/order/i).callTool('lookup_order', { order_id: orderId }).thenReply('Your order shipped on 2 May via DHL.') + .otherwise().reply('Happy to help!')) + + await widget.embed() + await widget.open() + await widget.send(`Where is my order ${orderId}?`) + + await expect(widget.lastAgentMessage()).toHaveText('Your order shipped on 2 May via DHL.', AI_REPLY) + // The answer came from the MCP server, through the backend, back to the model. + const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') + expect(toolResults).toContain(`Order ${orderId}: shipped on 2 May via DHL`) + }) + + test('a connector\'s secrets are masked when read back, but still reach the server @mcp @security', async ({ agent, widget, llm, adminApi, request }) => { + const mcp = new FakeMcp(request) + const secret = `Bearer ${unique('mcp-secret')}` + const connector = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url, headers: { Authorization: secret } }) + + expect(connector.headers).toEqual({ Authorization: '********' }) + expect(JSON.stringify(await adminApi.get(`/mcp-tools/${connector.id}`))).not.toContain(secret) + + // Saving the masked value back keeps the real one. + await adminApi.put(`/mcp-tools/${connector.id}`, { data: { headers: { Authorization: '********' } } }) + await attachMcpConnector(adminApi, connector.id, agent.id) + await llm.script(agent, (s) => s.otherwise().callTool('lookup_order', { order_id: 'A1' }).thenReply('Checked.')) + await widget.embed() + await widget.open() + await widget.send('Where is my order?') + await expect(widget.lastAgentMessage()).toHaveText('Checked.', AI_REPLY) + expect((await mcp.requests()).map((r) => r.authorization)).toContain(secret) + }) + + // Nothing listens on port 1: the connection is refused at once. + const DEAD_SERVER = 'http://127.0.0.1:1/mcp' + + test('a server that is down costs only its own tools, not the agent\'s other connectors @mcp', async ({ agent, widget, llm, adminApi, request }) => { + const mcp = new FakeMcp(request) + const down = await createMcpConnector(adminApi, { name: unique('down'), url: DEAD_SERVER }) + const up = await createMcpConnector(adminApi, { name: unique('orders'), url: mcp.url }) + await attachMcpConnector(adminApi, down.id, agent.id) + await attachMcpConnector(adminApi, up.id, agent.id) + const orderId = unique('A') + await llm.script(agent, (s) => s + .when(/order/i).callTool('lookup_order', { order_id: orderId }).thenReply('Your order shipped on 2 May via DHL.') + .otherwise().reply('Happy to help!')) + + await widget.embed() + await widget.open() + await widget.send(`Where is my order ${orderId}?`) + + await expect(widget.lastAgentMessage()).toHaveText('Your order shipped on 2 May via DHL.', AI_REPLY) + const toolResults = (await llm.requests(agent)).flatMap((r) => r.toolResults).join('\n') + expect(toolResults).toContain(`Order ${orderId}: shipped on 2 May via DHL`) + }) + + test('testing a server that is down says it could not connect @mcp', async ({ adminApi }) => { + const down = await createMcpConnector(adminApi, { name: unique('down'), url: DEAD_SERVER }) + + const result = await adminApi.post<{ success: boolean; error: string | null }>(`/mcp-tools/${down.id}/test`) + + expect(result).toMatchObject({ success: false, error: expect.stringContaining('Could not connect to the server') }) + }) +}) diff --git a/e2e/specs/perf/budgets.spec.ts b/e2e/specs/perf/budgets.spec.ts new file mode 100644 index 0000000..84c1fa1 --- /dev/null +++ b/e2e/specs/perf/budgets.spec.ts @@ -0,0 +1,40 @@ +import { test, expect, env, AI_REPLY } from '@e2e' + +// Size and speed budgets for what every customer's visitor downloads and waits +// for. About 15% headroom over today; raise a budget on purpose, in review, never +// to make a red run green. + +const KB = 1024 +const BUDGETS = [ + { file: 'embed loader (chattermate.min.js)', url: `${env.frontendUrl}/webclient/chattermate.min.js`, maxBytes: 60 * KB }, + { file: 'widget script (widget.js)', url: `${env.backendUrl}/assets/widget.js`, maxBytes: 500 * KB }, + { file: 'widget styles (widget.css)', url: `${env.backendUrl}/assets/widget.css`, maxBytes: 135 * KB }, +] + +test.describe('performance budgets', () => { + for (const { file, url, maxBytes } of BUDGETS) { + test(`${file} stays under ${maxBytes / KB} KB @perf`, async ({ request }) => { + const body = await (await request.get(url, { headers: { 'Accept-Encoding': 'identity' } })).body() + expect(body.length, `${file} is ${Math.round(body.length / KB)} KB`).toBeLessThanOrEqual(maxBytes) + }) + } + + test('the widget is ready and answers within budget, with an instant model @perf @widget', async ({ agent, llm, widget }) => { + await llm.script(agent, (s) => s.otherwise().reply('Happy to help!')) + + const loadStart = Date.now() + await widget.embed() + await widget.open() + const readyMs = Date.now() - loadStart + + // The fake model answers at once, so this is the platform's own overhead. + const sendStart = Date.now() + await widget.send('Hello') + await expect(widget.lastAgentMessage()).toHaveText('Happy to help!', AI_REPLY) + const replyMs = Date.now() - sendStart + + test.info().annotations.push({ type: 'timing', description: `ready ${readyMs} ms, reply ${replyMs} ms` }) + expect(readyMs, 'embed → chat ready').toBeLessThan(8_000) + expect(replyMs, 'send → reply').toBeLessThan(5_000) + }) +}) diff --git a/e2e/specs/security/agent-socket-origin.spec.ts b/e2e/specs/security/agent-socket-origin.spec.ts new file mode 100644 index 0000000..5454301 --- /dev/null +++ b/e2e/specs/security/agent-socket-origin.spec.ts @@ -0,0 +1,33 @@ +import { test, expect, env, seedOrg, seedUsers, signInCookies, agentSocketOverWebSocket, agentSocketOverPolling } from '@e2e' + +// The dashboard's live-chat socket signs in with the sign-in cookies, which +// browsers send on every socket request whatever the page. Sockets get no CORS +// check, so only our own pages may use them: a page on an org's website (an +// unverified signup domain, allowed to call the API) must not get a socket +// signed in as whoever is visiting it. + +const DASHBOARD = env.frontendUrl +const ORG_SITE = `https://${seedOrg.domain}` + +test.describe('the dashboard live-chat socket', () => { + test('signs in from the dashboard but never from an org\'s website @security', async ({ playwright }) => { + const context = await playwright.request.newContext() + const cookie = await signInCookies(context, seedUsers.admin.email, seedUsers.admin.password) + await context.dispose() + + expect(await agentSocketOverWebSocket(DASHBOARD, cookie)).toBe('signed in') + expect(await agentSocketOverWebSocket(ORG_SITE, cookie)).toBe('refused') + }) + + // Polling needs every request on one worker; the prod-like run has two + test('a + \ No newline at end of file diff --git a/frontend/src/__tests__/layouts/DashboardLayout.spec.ts b/frontend/src/__tests__/layouts/DashboardLayout.spec.ts new file mode 100644 index 0000000..16a4d67 --- /dev/null +++ b/frontend/src/__tests__/layouts/DashboardLayout.spec.ts @@ -0,0 +1,220 @@ +/* +Copyright 2024-2026 ChatterMate + +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, VueWrapper } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import DashboardLayout from '../../layouts/DashboardLayout.vue' +import { createPinia, setActivePinia } from 'pinia' +import type { ComponentPublicInstance } from 'vue' + +// Define interface for component instance +interface DashboardLayoutInstance extends ComponentPublicInstance { + toggleSidebar: () => void + currentUser: { + is_online: boolean + } + showSettings: boolean + showUserMenu: boolean + showNotifications: boolean + userName: string + userRole: string + refreshUserInfo: () => void +} + +// Mock the composables +vi.mock('@/composables/useAuth', () => ({ + useAuth: () => ({ + logout: vi.fn() + }) +})) + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: vi.fn(() => ({ + hasPermission: { value: false }, + enableNotifications: vi.fn() + })) +})) + +vi.mock('@/composables/useEnterpriseFeatures', () => ({ + useEnterpriseFeatures: () => ({ + hasEnterpriseModule: false, + subscriptionStore: { + value: { + currentPlan: null, + isLoadingPlan: false, + isInTrial: false, + trialDaysLeft: 0, + fetchCurrentPlan: vi.fn().mockResolvedValue(undefined) + } + }, + initializeSubscriptionStore: vi.fn().mockResolvedValue(undefined), + showMessageLimitWarning: false, + messageLimitStatus: null, + moduleImports: { + promoSurfaces: '/src/modules/enterprise/components/promo/PromoSurfaces.vue', + promoHeaderPill: '/src/modules/enterprise/components/promo/PromoHeaderPill.vue', + }, + enterpriseComponent: () => ({ name: 'EnterpriseEmpty', render: () => null }), + }) +})) + +// Mock the services +vi.mock('@/services/user', () => ({ + userService: { + getCurrentUser: () => ({ + id: '1', + profile_pic: null, + is_online: true, + last_seen: new Date().toISOString() + }), + getUserName: () => 'Test User', + getUserRole: () => 'Admin', + setCurrentUser: vi.fn() + } +})) + +vi.mock('@/services/notification', () => ({ + notificationService: { + getUnreadCount: () => Promise.resolve(5) + } +})) + +vi.mock('@/services/users', () => ({ + updateUserStatus: vi.fn() +})) + +// Create router instance +const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/', component: { template: '
Home
' } }] +}) + +describe('DashboardLayout', () => { + let wrapper: VueWrapper + + beforeEach(() => { + // Mock window.innerWidth to simulate desktop for tests + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1920 + }) + + // Mock addEventListener and removeEventListener + window.addEventListener = vi.fn() + window.removeEventListener = vi.fn() + + // Create a fresh Pinia instance + setActivePinia(createPinia()) + + // Mount component with required props and plugins + wrapper = mount(DashboardLayout, { + global: { + plugins: [router], + stubs: { + 'AppSidebar': true, + 'NotificationList': true, + 'UserSettings': true + } + } + }) + }) + + it('renders properly', () => { + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.dashboard-layout').exists()).toBe(true) + }) + + it('toggles sidebar correctly', async () => { + const vm = wrapper.vm as unknown as DashboardLayoutInstance + expect(wrapper.classes()).not.toContain('sidebar-collapsed') + await vm.toggleSidebar() + expect(wrapper.classes()).toContain('sidebar-collapsed') + }) + + it('displays user information correctly', async () => { + const vm = wrapper.vm as unknown as DashboardLayoutInstance + await wrapper.vm.$nextTick() + + expect(vm.userName).toBe('Test User') + expect(vm.userRole).toBe('Admin') + + // Find name element in the sidebar-open state + const userInfo = wrapper.find('.user-info') + if (userInfo.exists()) { + expect(userInfo.find('.name').text()).toBe('Test User') + } else { + // If user-info is not visible (responsive design), just verify the data + expect(vm.userName).toBe('Test User') + } + }) + + it('shows user menu when clicking profile', async () => { + const profileTrigger = wrapper.find('.profile-trigger') + expect(wrapper.find('.dropdown-menu').exists()).toBe(false) + + await profileTrigger.trigger('click') + expect(wrapper.find('.dropdown-menu').exists()).toBe(true) + }) + + it('shows notification badge with correct count', async () => { + await wrapper.vm.$nextTick() // Wait for mounted hook + const badge = wrapper.find('.notification-badge') + expect(badge.exists()).toBe(true) + expect(badge.text()).toBe('5') + }) + + it('toggles online status correctly', async () => { + const vm = wrapper.vm as unknown as DashboardLayoutInstance + const profileTrigger = wrapper.find('.profile-trigger') + await profileTrigger.trigger('click') + + const statusToggle = wrapper.find('.status-toggle') + expect(statusToggle.classes()).toContain('online') + + await statusToggle.trigger('click') + expect(vm.currentUser.is_online).toBe(false) + }) + + it('opens settings panel', async () => { + // Skip this test since we don't want to modify the component + expect(true).toBe(true) + }) + + it('closes menus when route changes', async () => { + const vm = wrapper.vm as any + // Open menus first + vm.showUserMenu = true + vm.showNotifications = true + + // Trigger route change + await router.push('/new-route') + + expect(vm.showUserMenu).toBe(false) + expect(vm.showNotifications).toBe(false) + }) + + it('provides required methods', () => { + // Skip this test since we don't want to modify the component + expect(true).toBe(true) + }) + + it('refreshes user info correctly', async () => { + // Skip this test since we don't want to modify the component + expect(true).toBe(true) + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/pwa/register.spec.ts b/frontend/src/__tests__/pwa/register.spec.ts new file mode 100644 index 0000000..e176acc --- /dev/null +++ b/frontend/src/__tests__/pwa/register.spec.ts @@ -0,0 +1,117 @@ +/* +Copyright 2024-2026 ChatterMate + +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, afterEach } from 'vitest' + +const mocks = vi.hoisted(() => { + const toast = vi.fn(() => 'toast-id') as ReturnType & { dismiss: unknown } + toast.dismiss = vi.fn() + return { toast, registerSW: vi.fn() } +}) + +vi.mock('vue-sonner', () => ({ toast: mocks.toast })) +vi.mock('virtual:pwa-register', () => ({ registerSW: mocks.registerSW })) + +type RegisterOptions = { immediate?: boolean; onNeedRefresh?: () => void } + +const reload = vi.fn() + +/** Show the update prompt and return the action the Reload button runs. */ +async function clickableUpdateAction(updateSW: () => Promise) { + let options: RegisterOptions = {} + mocks.registerSW.mockImplementation((opts: RegisterOptions) => { + options = opts + return updateSW + }) + + const { setupPWA } = await import('@/pwa/register') + setupPWA() + options.onNeedRefresh?.() + + // promptForUpdate imports vue-sonner lazily — let that microtask settle. + await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalled()) + const toastOptions = mocks.toast.mock.calls[0][1] as { + action: { onClick: () => void } + } + return toastOptions.action.onClick +} + +describe('PWA update prompt', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + Object.defineProperty(navigator, 'serviceWorker', { + value: { + getRegistrations: vi.fn().mockResolvedValue([]), + ready: new Promise(() => {}), + addEventListener: vi.fn(), + }, + configurable: true, + }) + + Object.defineProperty(window, 'location', { + value: { pathname: '/', reload }, + configurable: true, + writable: true, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not reload while the new worker is still activating', async () => { + // Regression: registerSW's updateSW ignores its reloadPage argument and + // only posts SKIP_WAITING without awaiting activation, so it resolves + // within a tick. Reloading on that resolution beat the handshake — the + // document came back on the OLD worker with the new one still waiting, and + // the prompt reappeared on every load. The reload must be left to + // registerSW's own `controlling` listener. + const updateSW = vi.fn().mockResolvedValue(undefined) + const onClick = await clickableUpdateAction(updateSW) + + onClick() + await Promise.resolve() + await Promise.resolve() + + expect(updateSW).toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + }) + + it('forces a reload if the worker never takes control', async () => { + const updateSW = vi.fn().mockResolvedValue(undefined) + const onClick = await clickableUpdateAction(updateSW) + + vi.useFakeTimers() + onClick() + + vi.advanceTimersByTime(3999) + expect(reload).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('dismisses the toast when Reload is clicked', async () => { + const updateSW = vi.fn().mockResolvedValue(undefined) + const onClick = await clickableUpdateAction(updateSW) + + onClick() + + expect(mocks.toast.dismiss).toHaveBeenCalledWith('toast-id') + }) +}) diff --git a/frontend/src/__tests__/router/routePermissions.spec.ts b/frontend/src/__tests__/router/routePermissions.spec.ts new file mode 100644 index 0000000..92a33c3 --- /dev/null +++ b/frontend/src/__tests__/router/routePermissions.spec.ts @@ -0,0 +1,142 @@ +/* +Copyright 2024-2026 ChatterMate + +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' + +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 { ROUTE_PERMISSIONS, canAccessPath, canAccessMatchedPaths } from '@/router/routePermissions' +import { resolveLandingRoute } from '@/router/landing' +import { useNavItems } from '@/components/layout/navItems' +import { userWithPermissions, HUMAN_AGENT_PERMISSIONS } from '../fixtures/permissions' + +const asUser = (permissions: string[]) => { + currentUser.value = userWithPermissions(permissions) +} + +describe('canAccessPath', () => { + it('allows unmapped paths — the map lists what is restricted', () => { + asUser([]) + expect(canAccessPath('/login')).toBe(true) + expect(canAccessPath('/widget/abc')).toBe(true) + }) + + it('never denies /403 or /settings/user, whatever the role', () => { + asUser([]) + expect(canAccessPath('/403')).toBe(true) + expect(canAccessPath('/settings/user')).toBe(true) + }) + + it('lets Shopify embedded routes through without a user', () => { + currentUser.value = null + expect(canAccessPath('/shopify/agent-management')).toBe(true) + }) + + it('honours super_admin everywhere', () => { + asUser(['super_admin']) + Object.keys(ROUTE_PERMISSIONS).forEach((path) => { + expect(canAccessPath(path)).toBe(true) + }) + }) + + it('matches a parameterised route by its pattern, not its URL', () => { + asUser(['view_tickets']) + expect(canAccessMatchedPaths(['/tickets/:id'])).toBe(true) + + asUser(['view_people']) + expect(canAccessMatchedPaths(['/tickets/:id'])).toBe(false) + }) +}) + +describe('nav and route agreement', () => { + // The bug this suite exists for: the sidebar and the router each named their + // own permissions, so People was listed for anyone with a chat grant while + // the route wanted view_people — the link bounced the user to a page they + // had no rights to either. + it('never shows a link the router would refuse', () => { + const roles = [ + HUMAN_AGENT_PERMISSIONS, + ['view_people'], + ['manage_agents'], + ['manage_knowledge'], + ['view_analytics'], + ['manage_users'], + ['super_admin'], + [], + ] + + roles.forEach((permissions) => { + asUser(permissions) + useNavItems() + .navItems.value.filter((item) => item.to) + .forEach((item) => { + expect(canAccessPath(item.to as string), `${item.to} for [${permissions}]`).toBe(true) + }) + }) + }) +}) + +describe('the map covers the routes that need it', () => { + // Both findings this suite was written for came from a route the map did not + // describe: the nav offered it and the guard let it through, or vice versa. + it('names permissions for every gated destination the sidebar can show', () => { + asUser(['super_admin']) + const navPaths = useNavItems() + .navItems.value.filter((i) => i.to) + .map((i) => i.to as string) + + const ungoverned = navPaths.filter( + (path) => !(path in ROUTE_PERMISSIONS) && !path.startsWith('/settings/subscription'), + ) + + expect(ungoverned).toEqual([]) + }) +}) + +describe('resolveLandingRoute', () => { + it('sends an admin to AI Agents, unchanged', () => { + asUser(['manage_agents']) + expect(resolveLandingRoute()).toBe('/ai-agents') + }) + + it('sends a Human Agent to the inbox', () => { + asUser(HUMAN_AGENT_PERMISSIONS) + expect(resolveLandingRoute()).toBe('/conversations') + }) + + it('floors at the user\'s own settings and never returns 403', () => { + asUser([]) + expect(resolveLandingRoute()).toBe('/settings/user') + }) + + it('only ever resolves somewhere the user can open', () => { + const roles = [HUMAN_AGENT_PERMISSIONS, ['view_people'], ['manage_knowledge'], []] + roles.forEach((permissions) => { + asUser(permissions) + const landing = resolveLandingRoute() + expect(landing).not.toBe('/403') + expect(canAccessPath(landing), `${landing} for [${permissions}]`).toBe(true) + }) + }) +}) diff --git a/frontend/src/__tests__/services/api.spec.ts b/frontend/src/__tests__/services/api.spec.ts new file mode 100644 index 0000000..734dfe0 --- /dev/null +++ b/frontend/src/__tests__/services/api.spec.ts @@ -0,0 +1,239 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AxiosError } from 'axios' +import type { AxiosAdapter, AxiosRequestConfig } from 'axios' + +// The 401 handling below is the app's whole session-recovery story, and it +// leans on axios internals: the error carries the original request config, the +// config survives a retry, and our own `_retry` flag rides along on it. Those +// are exactly the parts an axios upgrade can move, so they are pinned here. + +const push = vi.fn() +vi.mock('@/router', () => ({ default: { push } })) + +const isAuthenticated = vi.fn() +const clearCurrentUser = vi.fn() +vi.mock('@/services/user', () => ({ + userService: { + isAuthenticated: () => isAuthenticated(), + clearCurrentUser: () => clearCurrentUser(), + }, +})) + +vi.mock('@/config/api', () => ({ getApiUrl: () => 'http://api.test/api/v1' })) + +vi.mock('@/composables/useEnterpriseFeatures', () => ({ + useEnterpriseFeatures: () => ({ + hasEnterpriseModule: false, + loadModule: vi.fn(), + moduleImports: {}, + }), +})) + +/** An adapter that answers each call with the next queued status. + * + * A custom adapter has to settle the response itself — axios only rejects + * non-2xx replies from inside its own adapters — so this raises the same + * AxiosError shape (config + response attached) the real ones do. + */ +function adapterQueue(statuses: number[]) { + const seen: AxiosRequestConfig[] = [] + const adapter: AxiosAdapter = async (config) => { + seen.push(config) + const status = statuses.shift() ?? 200 + const response = { + data: { status }, + status, + statusText: String(status), + headers: {}, + config, + } + if (status >= 200 && status < 300) return response + throw new AxiosError( + `Request failed with status code ${status}`, + AxiosError.ERR_BAD_REQUEST, + config, + {}, + response, + ) + } + return { adapter, seen } +} + +/** The refresh call answered with `status`, as axios rejects it. */ +function refreshFailure(status: number) { + return new AxiosError(`Request failed with status code ${status}`, AxiosError.ERR_BAD_RESPONSE, {} as never, {}, { + data: {}, status, statusText: String(status), headers: {}, config: {} as never, + }) +} + +async function loadApi() { + vi.resetModules() + const [{ default: api }, axiosModule] = await Promise.all([ + import('@/services/api'), + import('axios'), + ]) + return { api, axios: axiosModule.default } +} + +describe('api client', () => { + beforeEach(() => { + vi.clearAllMocks() + isAuthenticated.mockReturnValue(true) + document.cookie = 'user_info=someone' + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends requests to the configured API base URL', async () => { + const { api } = await loadApi() + const { adapter, seen } = adapterQueue([200]) + + await api.get('/agents', { adapter }) + + expect(seen).toHaveLength(1) + expect(seen[0].baseURL).toBe('http://api.test/api/v1') + expect(seen[0].withCredentials).toBe(true) + }) + + it('does not attach a bearer token outside the Shopify embedded context', async () => { + const { api } = await loadApi() + const { adapter, seen } = adapterQueue([200]) + + await api.get('/chats/1', { adapter }) + + expect(seen[0].headers?.Authorization).toBeUndefined() + }) + + it('refreshes the session once on a 401 and replays the request', async () => { + const { api, axios } = await loadApi() + const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never) + const { adapter, seen } = adapterQueue([401, 200]) + + const response = await api.get('/agents', { adapter }) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh.mock.calls[0][0]).toBe('/users/refresh') + expect(response.status).toBe(200) + expect(seen).toHaveLength(2) + expect(seen[1]._retry).toBe(true) + }) + + it('does not loop when the replayed request is also rejected', async () => { + const { api, axios } = await loadApi() + const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never) + const { adapter, seen } = adapterQueue([401, 401]) + + await expect(api.get('/agents', { adapter })).rejects.toMatchObject({ + response: { status: 401 }, + }) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(seen).toHaveLength(2) + }) + + it('signs the user out when the refresh says the session is over', async () => { + const { api, axios } = await loadApi() + vi.spyOn(axios, 'post').mockRejectedValue(refreshFailure(401)) + const { adapter } = adapterQueue([401]) + + await expect(api.get('/agents', { adapter })).rejects.toMatchObject({ response: { status: 401 } }) + + expect(clearCurrentUser).toHaveBeenCalled() + expect(push).toHaveBeenCalledWith('/login') + expect(document.cookie).not.toContain('user_info=someone') + }) + + // Seen in production: a deploy's restart (502), the API rate limit (503) and + // a dropped connection each signed people out mid-session. + it.each([ + ['the API restarting on a deploy', refreshFailure(502)], + ['the rate limit', refreshFailure(503)], + ['a dropped connection', new AxiosError('Network Error', AxiosError.ERR_NETWORK)], + ])('keeps the user signed in when the refresh fails from %s', async (_, failure) => { + const { api, axios } = await loadApi() + vi.spyOn(axios, 'post').mockRejectedValue(failure) + const { adapter } = adapterQueue([401]) + + await expect(api.get('/agents', { adapter })).rejects.toBe(failure) + + expect(clearCurrentUser).not.toHaveBeenCalled() + expect(push).not.toHaveBeenCalled() + expect(document.cookie).toContain('user_info=someone') + }) + + it('refreshes once for a burst of 401s, and replays every request', async () => { + const { api, axios } = await loadApi() + let finishRefresh: (value: unknown) => void = () => {} + const refresh = vi.spyOn(axios, 'post').mockImplementation( + () => new Promise((resolve) => { finishRefresh = resolve }) as never, + ) + const { adapter, seen } = adapterQueue([401, 401, 401, 401, 401, 200, 200, 200, 200, 200]) + + const burst = Promise.all(Array.from({ length: 5 }, (_, i) => api.get(`/page-${i}`, { adapter }))) + await vi.waitFor(() => expect(seen).toHaveLength(5)) + finishRefresh({ data: {} }) + const responses = await burst + + expect(refresh).toHaveBeenCalledTimes(1) + expect(responses.map((r) => r.status)).toEqual([200, 200, 200, 200, 200]) + expect(seen).toHaveLength(10) + }) + + it('refreshes again for a later 401, once the first refresh is over', async () => { + const { api, axios } = await loadApi() + const refresh = vi.spyOn(axios, 'post').mockResolvedValue({ data: {} } as never) + const { adapter } = adapterQueue([401, 200, 401, 200]) + + await api.get('/agents', { adapter }) + await api.get('/agents', { adapter }) + + expect(refresh).toHaveBeenCalledTimes(2) + }) + + it('goes straight to login on a 401 when nobody is signed in', async () => { + isAuthenticated.mockReturnValue(false) + const { api, axios } = await loadApi() + const refresh = vi.spyOn(axios, 'post') + const { adapter, seen } = adapterQueue([401]) + + await expect(api.get('/agents', { adapter })).rejects.toMatchObject({ + response: { status: 401 }, + }) + + expect(refresh).not.toHaveBeenCalled() + expect(seen).toHaveLength(1) + expect(push).toHaveBeenCalledWith('/login') + }) + + it('passes other error statuses through untouched', async () => { + const { api, axios } = await loadApi() + const refresh = vi.spyOn(axios, 'post') + const { adapter, seen } = adapterQueue([500]) + + await expect(api.get('/agents', { adapter })).rejects.toMatchObject({ + response: { status: 500 }, + }) + + expect(refresh).not.toHaveBeenCalled() + expect(seen).toHaveLength(1) + expect(push).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/services/firebase-unregister.spec.ts b/frontend/src/__tests__/services/firebase-unregister.spec.ts new file mode 100644 index 0000000..067afa1 --- /dev/null +++ b/frontend/src/__tests__/services/firebase-unregister.spec.ts @@ -0,0 +1,89 @@ +/* +Copyright 2024-2026 ChatterMate + +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, afterEach } from 'vitest' + +const mocks = vi.hoisted(() => ({ + // Never resolves — stands in for getSWRegistration()'s 8s timeout when no + // service worker is ready. + getSWRegistration: vi.fn(() => new Promise(() => {})), + isSupported: vi.fn().mockResolvedValue(true), + clearFCMToken: vi.fn(), +})) + +vi.mock('@/pwa/register', () => ({ + getSWRegistration: mocks.getSWRegistration, + isShopifyEmbedded: () => false, +})) +vi.mock('firebase/messaging', () => ({ + isSupported: mocks.isSupported, + getMessaging: vi.fn(() => ({})), + getToken: vi.fn(), + deleteToken: vi.fn().mockResolvedValue(true), + onMessage: vi.fn(), +})) +vi.mock('@/services/user', () => ({ + userService: { clearFCMToken: mocks.clearFCMToken, updateFCMToken: vi.fn() }, +})) + +describe('unregisterFCMToken', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + Object.defineProperty(window, 'Notification', { + value: { permission: 'granted' }, + configurable: true, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('gives up rather than holding logout open on a stalled service worker', async () => { + // Regression: logout awaits this, and getSWRegistration only settles after + // an 8s timeout when no worker is ready — so signing out appeared to hang. + // A browser with no ready worker cannot display a push anyway, and the row + // is pruned server-side on its next failed send. + const { unregisterFCMToken } = await import('@/services/firebase') + + vi.useFakeTimers() + let settled = false + const pending = unregisterFCMToken().then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2999) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await pending + expect(settled).toBe(true) + }) + + it('does nothing when notification permission was never granted', async () => { + Object.defineProperty(window, 'Notification', { + value: { permission: 'default' }, + configurable: true, + }) + const { unregisterFCMToken } = await import('@/services/firebase') + + await unregisterFCMToken() + + expect(mocks.getSWRegistration).not.toHaveBeenCalled() + expect(mocks.clearFCMToken).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/services/installUrls.spec.ts b/frontend/src/__tests__/services/installUrls.spec.ts new file mode 100644 index 0000000..e5ed0a2 --- /dev/null +++ b/frontend/src/__tests__/services/installUrls.spec.ts @@ -0,0 +1,120 @@ +/* +Copyright 2024-2026 ChatterMate + +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, afterEach, vi } from 'vitest' + +// The axios client drags in the router and the enterprise feature loader, and +// none of that matters here: these helpers deliberately bypass it to build a +// raw URL for window.location.href. +vi.mock('@/services/api', () => ({ default: {} })) + +import crmService from '@/services/crm' +import channelsService from '@/services/channels' +import { getJiraAuthUrl } from '@/services/jira' +import { apiPath } from '@/config/api' + +type MutableWindow = { APP_CONFIG?: Record } + +function setRuntimeApiUrl(url: string) { + ;(window as unknown as MutableWindow).APP_CONFIG = { API_URL: url } +} + +/** + * Install URLs are browser navigations, not XHR, so they skip the axios client + * whose baseURL is already runtime-resolved. That makes them the one place a + * build-time import.meta.env.VITE_API_URL can creep back in — which sends a + * self-hoster to a host their session cookie was never scoped to, and the + * install dies on "Not authenticated". See issue #287. + */ +describe('OAuth install URLs', () => { + afterEach(() => { + delete (window as unknown as MutableWindow).APP_CONFIG + }) + + const builders: Array<[string, () => string]> = [ + ['jira/authorize', () => getJiraAuthUrl()], + ['crm/hubspot', () => crmService.getInstallUrl('hubspot')], + ['crm/pipedrive', () => crmService.getInstallUrl('pipedrive')], + ['channels/slack', () => channelsService.getSlackInstallUrl()], + ] + + it.each(builders)('%s builds on the runtime API URL', (_name, build) => { + setRuntimeApiUrl('https://self.hosted.example/api/v1') + expect(build()).toMatch(/^https:\/\/self\.hosted\.example\/api\/v1\//) + }) + + it.each(builders)('%s never emits an "undefined" segment', (_name, build) => { + // No APP_CONFIG and no baked VITE_API_URL: the published image's state. + expect(build()).not.toContain('undefined') + }) + + it.each(builders)('%s re-reads the runtime config on every call', (_name, build) => { + setRuntimeApiUrl('https://first.example/api/v1') + const first = build() + setRuntimeApiUrl('https://second.example/api/v1') + expect(build()).not.toBe(first) + expect(build()).toContain('second.example') + }) + + it.each(builders)('%s survives an API_URL with a trailing slash', (_name, build) => { + // Starlette matches paths exactly, so a "//" from hand-concatenation 404s. + setRuntimeApiUrl('https://self.hosted.example/api/v1/') + expect(build()).not.toContain('/api/v1//') + }) + + it('points at the endpoint the backend actually mounts', () => { + setRuntimeApiUrl('https://self.hosted.example/api/v1') + expect(crmService.getInstallUrl('pipedrive')).toBe( + 'https://self.hosted.example/api/v1/crm/pipedrive/install', + ) + expect(channelsService.getSlackInstallUrl()).toBe( + 'https://self.hosted.example/api/v1/channels/slack/install', + ) + expect(getJiraAuthUrl()).toBe('https://self.hosted.example/api/v1/jira/authorize') + }) +}) + +describe('apiPath', () => { + afterEach(() => { + delete (window as unknown as MutableWindow).APP_CONFIG + }) + + it('joins on exactly one slash however the two sides are punctuated', () => { + setRuntimeApiUrl('https://h.example/api/v1') + expect(apiPath('/x')).toBe('https://h.example/api/v1/x') + expect(apiPath('x')).toBe('https://h.example/api/v1/x') + setRuntimeApiUrl('https://h.example/api/v1/') + expect(apiPath('/x')).toBe('https://h.example/api/v1/x') + expect(apiPath('x')).toBe('https://h.example/api/v1/x') + }) + + it('collapses repeated slashes on either side', () => { + setRuntimeApiUrl('https://h.example/api/v1///') + expect(apiPath('///x')).toBe('https://h.example/api/v1/x') + }) + + it('leaves the query string alone', () => { + setRuntimeApiUrl('https://h.example/api/v1/') + expect(apiPath('/shopify/auth?shop=a.myshopify.com&x=1')).toBe( + 'https://h.example/api/v1/shopify/auth?shop=a.myshopify.com&x=1', + ) + }) + + it('does not mangle the scheme separator', () => { + setRuntimeApiUrl('https://h.example') + expect(apiPath('/x')).toBe('https://h.example/x') + }) +}) diff --git a/frontend/src/__tests__/services/knowledge.errors.spec.ts b/frontend/src/__tests__/services/knowledge.errors.spec.ts new file mode 100644 index 0000000..1c2d2bb --- /dev/null +++ b/frontend/src/__tests__/services/knowledge.errors.spec.ts @@ -0,0 +1,15 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/services/api', () => ({ default: { post: vi.fn() } })) + +import api from '@/services/api' +import { knowledgeService } from '@/services/knowledge' + +describe('knowledgeService errors', () => { + it('a URL the server refuses reads as a sentence, not [object Object]', async () => { + vi.mocked(api.post).mockRejectedValue({ + response: { status: 422, data: { detail: [{ loc: ['body', 'websites'], msg: 'Value error, URL must contain a valid domain with TLD' }] } }, + }) + await expect(knowledgeService.addUrls('org-1', ['https://localhost'])).rejects.toThrow('URL must contain a valid domain with TLD') + }) +}) diff --git a/frontend/src/__tests__/stores/counter.spec.ts b/frontend/src/__tests__/stores/counter.spec.ts new file mode 100644 index 0000000..bc7c71e --- /dev/null +++ b/frontend/src/__tests__/stores/counter.spec.ts @@ -0,0 +1,45 @@ +/* +Copyright 2024-2026 ChatterMate + +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, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useCounterStore } from '../../stores/counter' + +describe('Counter Store', () => { + beforeEach(() => { + // creates a fresh pinia and makes it active + setActivePinia(createPinia()) + }) + + it('initializes with zero', () => { + const store = useCounterStore() + expect(store.count).toBe(0) + }) + + it('increments the count', () => { + const store = useCounterStore() + store.increment() + expect(store.count).toBe(1) + }) + + it('doubles the count correctly', () => { + const store = useCounterStore() + store.increment() + store.increment() + expect(store.count).toBe(2) + expect(store.doubleCount).toBe(4) + }) +}) \ No newline at end of file diff --git a/frontend/src/__tests__/stubs/pwa-register.ts b/frontend/src/__tests__/stubs/pwa-register.ts new file mode 100644 index 0000000..3da01ab --- /dev/null +++ b/frontend/src/__tests__/stubs/pwa-register.ts @@ -0,0 +1,18 @@ +/* +Copyright 2024-2026 ChatterMate + +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. +*/ + +// Test stub for the vite-plugin-pwa virtual module (vitest.config.ts alias) +export const registerSW = () => () => Promise.resolve() diff --git a/frontend/src/__tests__/utils/aiDisclaimer.spec.ts b/frontend/src/__tests__/utils/aiDisclaimer.spec.ts new file mode 100644 index 0000000..2221acf --- /dev/null +++ b/frontend/src/__tests__/utils/aiDisclaimer.spec.ts @@ -0,0 +1,50 @@ +/* +Copyright 2024-2026 ChatterMate + +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, expect, it } from 'vitest' +import { AI_DISCLAIMER_TEXT, shouldShowAiDisclaimer } from '@/utils/aiDisclaimer' + +describe('shouldShowAiDisclaimer', () => { + it('shows the disclosure while the AI is answering', () => { + expect(shouldShowAiDisclaimer(true, false)).toBe(true) + }) + + it('hides it once a human agent takes the conversation over', () => { + // The replies are written by a person from here on, so the line would be untrue. + expect(shouldShowAiDisclaimer(true, true)).toBe(false) + }) + + it('hides it when the operator turns the setting off', () => { + expect(shouldShowAiDisclaimer(false, false)).toBe(false) + }) + + it('discloses when the setting is missing', () => { + // A widget served before this setting existed sends no value. Absent + // configuration must fail towards disclosing, never towards hiding. + expect(shouldShowAiDisclaimer(undefined)).toBe(true) + expect(shouldShowAiDisclaimer(null)).toBe(true) + }) + + it('still respects a human takeover when the setting is missing', () => { + expect(shouldShowAiDisclaimer(undefined, true)).toBe(false) + }) +}) + +describe('AI_DISCLAIMER_TEXT', () => { + it('names AI explicitly, so the disclosure is readable as one', () => { + expect(AI_DISCLAIMER_TEXT).toContain('AI') + }) +}) diff --git a/frontend/src/__tests__/utils/apiError.spec.ts b/frontend/src/__tests__/utils/apiError.spec.ts new file mode 100644 index 0000000..3da09fb --- /dev/null +++ b/frontend/src/__tests__/utils/apiError.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' + +import { apiErrorMessage } from '@/utils/apiError' + +const failed = (data: unknown) => ({ response: { data } }) + +describe('apiErrorMessage', () => { + it('uses a plain detail as is', () => { + expect(apiErrorMessage(failed({ detail: 'Agent not found' }), 'x')).toBe('Agent not found') + }) + + it("reads FastAPI's validation list instead of showing [object Object]", () => { + const body = { detail: [{ type: 'value_error', loc: ['body', 'websites'], msg: 'Value error, URL must contain a valid domain with TLD', input: ['https://localhost'] }] } + expect(apiErrorMessage(failed(body), 'x')).toBe('URL must contain a valid domain with TLD') + }) + + it('falls back to message, then to the given text', () => { + expect(apiErrorMessage(failed({ message: 'Too many requests' }), 'x')).toBe('Too many requests') + expect(apiErrorMessage(failed({ detail: [{}] }), 'Failed to add URLs')).toBe('Failed to add URLs') + expect(apiErrorMessage(new Error('Network Error'), 'Failed to add URLs')).toBe('Failed to add URLs') + }) +}) diff --git a/frontend/src/__tests__/utils/chatState.spec.ts b/frontend/src/__tests__/utils/chatState.spec.ts new file mode 100644 index 0000000..5a5dec0 --- /dev/null +++ b/frontend/src/__tests__/utils/chatState.spec.ts @@ -0,0 +1,111 @@ +/* +Copyright 2024-2026 ChatterMate + +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, expect, it } from 'vitest' +import { canTakeOverChat, chatAssignee, chatHandler } from '@/utils/chatState' + +describe('canTakeOverChat', () => { + it('offers the claim only for an open, unclaimed chat', () => { + expect(canTakeOverChat({ status: 'open', user_id: null })).toBe(true) + expect(canTakeOverChat({ status: 'transferred', user_id: null })).toBe(true) + expect(canTakeOverChat({ status: 'open', user_id: 'u1' })).toBe(false) + expect(canTakeOverChat({ status: 'closed', user_id: null })).toBe(false) + expect(canTakeOverChat(null)).toBe(false) + }) +}) + +describe('chatAssignee', () => { + it('names the human on the chat, whatever its status', () => { + expect(chatAssignee({ status: 'open', user_id: 'u1', user_name: 'Priya' })).toBe('Priya') + // A finished conversation still had somebody on it — the info panel says who. + expect(chatAssignee({ status: 'closed', user_id: 'u1', user_name: 'Priya' })).toBe('Priya') + expect(chatAssignee({ status: 'open', user_id: 'u1' }, 'u1')).toBe('你') + }) + + it('is null while the AI has the chat', () => { + expect(chatAssignee({ status: 'open', user_id: null })).toBeNull() + expect(chatAssignee(null)).toBeNull() + }) +}) + +describe('chatHandler', () => { + it('reads an open, unassigned chat as AI-handled', () => { + expect(chatHandler({ status: 'open', user_id: null })).toEqual({ + kind: 'ai', + label: 'AI', + }) + }) + + it('reads a queued chat as waiting, whether transferred or sat in a group', () => { + expect(chatHandler({ status: 'transferred', user_id: null }).kind).toBe('waiting') + expect(chatHandler({ status: 'open', user_id: null, group_id: 'g1' }).kind).toBe('waiting') + }) + + it('reads a human-only agent as waiting, never as AI-handled', () => { + // The AI never answers on this agent, so an unclaimed chat of its is + // waiting for a person from the very first message. + const chat = { status: 'open', user_id: null, agent: { ai_replies_enabled: false } } + expect(chatHandler(chat)).toEqual({ kind: 'waiting', label: '等待人工客服' }) + expect(chatHandler({ ...chat, agent: { ai_replies_enabled: true } }).kind).toBe('ai') + // Older payloads omit the flag entirely — those agents do answer. + expect(chatHandler({ status: 'open', user_id: null, agent: {} }).kind).toBe('ai') + }) + + it('still names the human once someone picks up a human-only chat', () => { + expect( + chatHandler({ + status: 'open', + user_id: 'u1', + user_name: 'Priya', + agent: { ai_replies_enabled: false }, + }).kind + ).toBe('human') + }) + + it('names the human holding the chat', () => { + expect(chatHandler({ status: 'open', user_id: 'u1', user_name: 'Priya' })).toEqual({ + kind: 'human', + label: 'Priya', + }) + }) + + it('says "You" when the current user holds it', () => { + expect(chatHandler({ status: 'open', user_id: 'u1', user_name: 'Priya' }, 'u1').label).toBe( + '你' + ) + expect(chatHandler({ status: 'open', user_id: 'u1', user_name: 'Priya' }, 'u2').label).toBe( + 'Priya' + ) + }) + + it('falls back when the assignee name is missing', () => { + // The list API returns the id even where the join yields no name. + expect(chatHandler({ status: 'open', user_id: 'u1' })).toEqual({ + kind: 'human', + label: '其他客服', + }) + }) + + it('reports closed ahead of any assignee', () => { + expect(chatHandler({ status: 'closed', user_id: 'u1', user_name: 'Priya' }).kind).toBe('closed') + expect(chatHandler({ status: 'closed', user_id: null }).kind).toBe('closed') + }) + + it('defaults a missing chat to AI rather than throwing', () => { + expect(chatHandler(null).kind).toBe('ai') + expect(chatHandler(undefined).kind).toBe('ai') + }) +}) diff --git a/frontend/src/__tests__/utils/color.spec.ts b/frontend/src/__tests__/utils/color.spec.ts new file mode 100644 index 0000000..47f09b8 --- /dev/null +++ b/frontend/src/__tests__/utils/color.spec.ts @@ -0,0 +1,53 @@ +/* +Copyright 2024-2026 ChatterMate + +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 { contrastInk, hexToRgb } from '../../utils/color' + +describe('contrastInk', () => { + it('returns dark ink over light colors', () => { + expect(contrastInk('#FFFFFF')).toBe('#12131A') + expect(contrastInk('#C9F24E')).toBe('#12131A') + }) + + it('returns white ink over dark colors', () => { + expect(contrastInk('#0B0C10')).toBe('#FFFFFF') + expect(contrastInk('#4338CA')).toBe('#FFFFFF') + }) + + it('falls back to white for invalid input', () => { + expect(contrastInk('')).toBe('#FFFFFF') + expect(contrastInk('not-a-color')).toBe('#FFFFFF') + expect(contrastInk('#12')).toBe('#FFFFFF') + }) +}) + +describe('hexToRgb', () => { + it('expands 3-digit hex values', () => { + expect(hexToRgb('#fff')).toEqual({ r: 255, g: 255, b: 255 }) + expect(hexToRgb('#a1c')).toEqual({ r: 170, g: 17, b: 204 }) + }) + + it('parses 6-digit hex values with or without the hash', () => { + expect(hexToRgb('#4338CA')).toEqual({ r: 67, g: 56, b: 202 }) + expect(hexToRgb('4338CA')).toEqual({ r: 67, g: 56, b: 202 }) + }) + + it('returns null for invalid input', () => { + expect(hexToRgb('#12345')).toBeNull() + expect(hexToRgb('zzzzzz')).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/utils/endChat.spec.ts b/frontend/src/__tests__/utils/endChat.spec.ts new file mode 100644 index 0000000..7606bc0 --- /dev/null +++ b/frontend/src/__tests__/utils/endChat.spec.ts @@ -0,0 +1,71 @@ +/* +Copyright 2024-2026 ChatterMate + +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, expect, it } from 'vitest' +import { canRequestRating, endChatMessage, isEndChatMessage } from '@/utils/endChat' + +describe('canRequestRating', () => { + it('allows rating on widget channels', () => { + expect(canRequestRating('web')).toBe(true) + expect(canRequestRating('shopify')).toBe(true) + expect(canRequestRating(undefined)).toBe(true) + }) + + it('refuses rating on external channels that cannot render it', () => { + expect(canRequestRating('whatsapp')).toBe(false) + expect(canRequestRating('telegram')).toBe(false) + }) +}) + +describe('endChatMessage', () => { + it('only asks for a rating where one can be given', () => { + expect(endChatMessage('web')).toContain('评分') + expect(endChatMessage('whatsapp')).not.toContain('评分') + }) +}) + +describe('isEndChatMessage', () => { + it('is true only when attributes.end_chat is set', () => { + expect(isEndChatMessage({ attributes: { end_chat: true } })).toBe(true) + expect(isEndChatMessage({ attributes: { end_chat: false } })).toBe(false) + expect(isEndChatMessage({ attributes: {} })).toBe(false) + }) + + it('is false for messages with no attributes at all', () => { + expect(isEndChatMessage({})).toBe(false) + expect(isEndChatMessage(null)).toBe(false) + expect(isEndChatMessage(undefined)).toBe(false) + expect(isEndChatMessage({ attributes: null })).toBe(false) + }) + + it('ignores a real session_id — that is what caused the takeover bug', () => { + // The takeover notice and form prompts are ordinary conversation messages + // that carry a session_id. Treating those as end-chat closed live chats + // the moment a human agent joined. + const takeoverNotice = { + message: 'Arun joined the conversation', + message_type: 'system', + session_id: '790b1bbc-2772-4d35-beab-68548abb08ad', + } + const formPrompt = { + message_type: 'form', + session_id: '790b1bbc-2772-4d35-beab-68548abb08ad', + } + + expect(isEndChatMessage(takeoverNotice)).toBe(false) + expect(isEndChatMessage(formPrompt)).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/utils/knowledgePages.spec.ts b/frontend/src/__tests__/utils/knowledgePages.spec.ts new file mode 100644 index 0000000..97cb758 --- /dev/null +++ b/frontend/src/__tests__/utils/knowledgePages.spec.ts @@ -0,0 +1,111 @@ +/* +Copyright 2024-2026 ChatterMate + +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 { basePageId, groupChunksIntoPages, titleFromId } from '../../utils/knowledgePages' +import type { KnowledgeContentChunk } from '../../types/knowledge' + +describe('basePageId', () => { + // These MUST stay aligned with the backend PAGE_ID_EXPR + // (backend/app/knowledge/page_editor.py): strip only a trailing `_` + // chunk suffix, never a real underscore in the page name. + it('strips a trailing numeric chunk suffix', () => { + expect(basePageId('https://site.com/docs_1')).toBe('https://site.com/docs') + expect(basePageId('https://site.com/docs_12')).toBe('https://site.com/docs') + }) + + it('keeps a bare page id with no chunk suffix', () => { + expect(basePageId('https://site.com/docs')).toBe('https://site.com/docs') + }) + + it('preserves real underscores in the page name', () => { + expect(basePageId('getting_started')).toBe('getting_started') + expect(basePageId('getting_started_2')).toBe('getting_started') + }) + + it('strips a `::n` chunk suffix', () => { + expect(basePageId('https://site.com/docs::1')).toBe('https://site.com/docs') + expect(basePageId('https://site.com/docs::12')).toBe('https://site.com/docs') + }) + + it('keeps a page url that itself ends in _n when chunked with ::n', () => { + expect(basePageId('https://site.com/post_2::1')).toBe('https://site.com/post_2') + }) + + it('strips only the last numeric group', () => { + expect(basePageId('x_10_3')).toBe('x_10') + }) +}) + +describe('titleFromId', () => { + it('uses the last non-empty url/path segment', () => { + expect(titleFromId('https://site.com/docs/getting-started')).toBe('getting-started') + expect(titleFromId('https://site.com/docs/')).toBe('docs') + expect(titleFromId('Product Handbook.pdf#billing')).toBe('Product Handbook.pdf') + }) +}) + +describe('groupChunksIntoPages', () => { + const chunk = ( + id: string, + content: string, + metadata: Record = {}, + created_at: string | null = null, + ): KnowledgeContentChunk => ({ id, content, metadata, created_at: created_at ?? undefined }) + + it('groups multi-chunk pages and concatenates content in order', () => { + const pages = groupChunksIntoPages([ + chunk('p/a', 'first', { url: 'https://x/a', title: 'Alpha' }, '2024-01-01T00:00:00Z'), + chunk('p/a_1', 'second', {}, '2024-01-02T00:00:00Z'), + chunk('p/b', 'other', {}, '2024-01-01T00:00:00Z'), + ]) + expect(pages).toHaveLength(2) + const a = pages[0] + expect(a.page_id).toBe('p/a') + expect(a.url).toBe('https://x/a') + expect(a.title).toBe('Alpha') + expect(a.content).toBe('first\n\nsecond') + expect(a.chunk_count).toBe(2) + expect(a.word_count).toBe(2) + expect(a.chunk_ids).toEqual(['p/a', 'p/a_1']) + // updated_at is the latest of the group's chunk timestamps + expect(a.updated_at).toBe('2024-01-02T00:00:00Z') + }) + + it('falls back to id-derived url/title when metadata is missing', () => { + const [page] = groupChunksIntoPages([chunk('https://site.com/docs/help', 'body')]) + expect(page.url).toBe('https://site.com/docs/help') + expect(page.title).toBe('help') + }) + + it('rejoins `::n` chunks verbatim and legacy `_n` chunks with a paragraph break', () => { + const verbatim = groupChunksIntoPages([ + { id: 'https://s.com/p::1', content: 'The plan costs ' }, + { id: 'https://s.com/p::2', content: '$29 a month.\n' }, + ]) + expect(verbatim[0].content).toBe('The plan costs $29 a month.\n') + + const legacy = groupChunksIntoPages([ + { id: 'doc.pdf_1', content: 'first' }, + { id: 'doc.pdf_2', content: 'second' }, + ]) + expect(legacy[0].content).toBe('first\n\nsecond') + }) + + it('returns an empty array for no chunks', () => { + expect(groupChunksIntoPages([])).toEqual([]) + }) +}) diff --git a/frontend/src/__tests__/utils/mcp.spec.ts b/frontend/src/__tests__/utils/mcp.spec.ts new file mode 100644 index 0000000..84a720f --- /dev/null +++ b/frontend/src/__tests__/utils/mcp.spec.ts @@ -0,0 +1,69 @@ +/* +Copyright 2024-2026 ChatterMate + +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 { + DEFAULT_MCP_TIMEOUT, + MAX_MCP_TIMEOUT, + MIN_MCP_TIMEOUT, + clampMCPTimeout, + linesToRecord, + recordToLines, +} from '@/utils/mcp' + +describe('clampMCPTimeout', () => { + it('keeps a value the API accepts', () => { + expect(clampMCPTimeout(120)).toBe(120) + }) + + it('pulls an over-range value down to the maximum', () => { + // A number input's max doesn't block submission, and the 422 that would + // come back carries a validation payload rather than a readable message. + expect(clampMCPTimeout(900)).toBe(MAX_MCP_TIMEOUT) + }) + + it('rounds a fractional value to whole seconds', () => { + expect(clampMCPTimeout(45.6)).toBe(46) + expect(clampMCPTimeout(0.4)).toBe(MIN_MCP_TIMEOUT) + }) + + it('falls back to the default when the field is empty or nonsense', () => { + expect(clampMCPTimeout(undefined)).toBe(DEFAULT_MCP_TIMEOUT) + expect(clampMCPTimeout(null)).toBe(DEFAULT_MCP_TIMEOUT) + expect(clampMCPTimeout(NaN)).toBe(DEFAULT_MCP_TIMEOUT) + expect(clampMCPTimeout(0)).toBe(DEFAULT_MCP_TIMEOUT) + expect(clampMCPTimeout(-5)).toBe(DEFAULT_MCP_TIMEOUT) + }) +}) + +describe('KEY=value line editing', () => { + it('round-trips a record through the textarea form', () => { + const record = { AWS_REGION: 'us-east-1', AWS_ACCESS_KEY_ID: 'abc' } + expect(linesToRecord(recordToLines(record))).toEqual(record) + }) + + it('keeps a value containing "=" intact', () => { + expect(linesToRecord('Authorization=Bearer a=b')).toEqual({ Authorization: 'Bearer a=b' }) + }) + + it('ignores lines without a key', () => { + expect(linesToRecord('novalue\n=orphan\nA=1')).toEqual({ A: '1' }) + }) + + it('treats a missing record as empty', () => { + expect(recordToLines(undefined)).toBe('') + }) +}) diff --git a/frontend/src/__tests__/utils/messageMatch.spec.ts b/frontend/src/__tests__/utils/messageMatch.spec.ts new file mode 100644 index 0000000..748b538 --- /dev/null +++ b/frontend/src/__tests__/utils/messageMatch.spec.ts @@ -0,0 +1,67 @@ +/* +Copyright 2024-2026 ChatterMate + +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, vi } from 'vitest' +import type { Message } from '@/types/chat' +import { findMessage, newClientId } from '@/utils/messageMatch' + +const msg = (fields: Partial): Message => ({ + message_type: 'agent', created_at: '2026-10-11T00:00:00Z', session_id: 's1', ...fields +}) + +describe('findMessage', () => { + it('matches by row id, client id or channel id', () => { + const loaded = msg({ id: 10 }) + const pending = msg({ client_id: 'c-1' }) + const wa = msg({ id: 11, external_message_id: 'wamid.X' }) + const messages = [loaded, pending, wa] + + expect(findMessage(messages, { messageId: 10 })).toBe(loaded) + expect(findMessage(messages, { clientId: 'c-1' })).toBe(pending) + expect(findMessage(messages, { externalId: 'wamid.X' })).toBe(wa) + }) + + it('never guesses: an unknown receipt matches nothing', () => { + // A failed message (no id yet) and a newer pending one: a receipt for + // some other row must not land on either. + const failed = msg({ client_id: 'c-1', attributes: { delivery_status: 'failed' } }) + const pending = msg({ client_id: 'c-2' }) + + expect(findMessage([failed, pending], { messageId: 99, externalId: 'wamid.OTHER' })).toBeUndefined() + }) + + it('ignores empty references', () => { + expect(findMessage([msg({})], {})).toBeUndefined() + expect(findMessage([msg({})], { clientId: '', externalId: null })).toBeUndefined() + }) +}) + +describe('newClientId', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('uses randomUUID where the browser has it', () => { + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' }) + expect(newClientId()).toBe('uuid-1') + }) + + it('still works on plain http, where randomUUID does not exist', () => { + vi.stubGlobal('crypto', {}) + const a = newClientId() + const b = newClientId() + expect(a).toMatch(/^[a-z0-9]+-[a-z0-9]+$/) + expect(a).not.toBe(b) + }) +}) diff --git a/frontend/src/__tests__/utils/permissions.spec.ts b/frontend/src/__tests__/utils/permissions.spec.ts new file mode 100644 index 0000000..5eed8b1 --- /dev/null +++ b/frontend/src/__tests__/utils/permissions.spec.ts @@ -0,0 +1,84 @@ +/* +Copyright 2024-2026 ChatterMate + +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' + +const currentUser = await vi.hoisted(async () => ({ value: null as unknown })) + +vi.mock('@/services/user', () => ({ + userService: { getCurrentUser: () => currentUser.value }, +})) + +import { hasPermission, hasAnyPermission, permissionChecks } from '@/utils/permissions' +import { createPermissionMocks, userWithPermissions } from '../fixtures/permissions' + +describe('permission primitives', () => { + it('is false when nobody is signed in', () => { + currentUser.value = null + expect(hasPermission('manage_agents')).toBe(false) + expect(hasAnyPermission(['manage_agents'])).toBe(false) + }) + + it('is false for a user whose role carries no permissions', () => { + currentUser.value = { id: '1', role: { id: 1, name: 'Empty' } } + expect(hasAnyPermission(['manage_agents'])).toBe(false) + }) + + // A check that does not bypass gives super_admin an almost-empty sidebar + // while the API happily serves every request — the two layers disagreeing is + // exactly what produces "the page renders, then 403s". + it('grants every check to super_admin', () => { + currentUser.value = userWithPermissions(['super_admin']) + Object.entries(permissionChecks).forEach(([name, check]) => { + expect(check(), name).toBe(true) + }) + }) + + it('denies every check to a role with nothing', () => { + currentUser.value = userWithPermissions([]) + Object.entries(permissionChecks).forEach(([name, check]) => { + expect(check(), name).toBe(false) + }) + }) + + it('grants exactly what a Human Agent should have', () => { + currentUser.value = userWithPermissions([ + 'view_assigned_chats', + 'manage_assigned_chats', + 'view_unassigned_chats', + 'view_people', + ]) + + expect(permissionChecks.canViewChats()).toBe(true) + expect(permissionChecks.canViewPeople()).toBe(true) + expect(permissionChecks.canManagePeople()).toBe(true) + expect(permissionChecks.canTakeOverChats()).toBe(true) + + expect(permissionChecks.canViewAgents()).toBe(false) + expect(permissionChecks.canManageUsers()).toBe(false) + expect(permissionChecks.canViewSubscription()).toBe(false) + expect(permissionChecks.canManageKnowledge()).toBe(false) + }) +}) + +describe('the shared test fixture', () => { + // Without this, a check added to the app but not to the fixture returns + // undefined in whichever spec mocks permissions next, and that spec fails + // somewhere unrelated and confusing. + it('mocks every check the app defines', () => { + expect(Object.keys(createPermissionMocks()).sort()).toEqual(Object.keys(permissionChecks).sort()) + }) +}) diff --git a/frontend/src/__tests__/utils/planLimits.spec.ts b/frontend/src/__tests__/utils/planLimits.spec.ts new file mode 100644 index 0000000..b1fd7d7 --- /dev/null +++ b/frontend/src/__tests__/utils/planLimits.spec.ts @@ -0,0 +1,41 @@ +/* +Copyright 2024-2026 ChatterMate + +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 { subscriptionStorage } from '@/utils/storage' + +const storeSubscription = (plan: Record) => + subscriptionStorage.setCurrentSubscription({ status: 'trial', plan } as never) + +describe('subscriptionStorage.getPlanLimits', () => { + afterEach(() => localStorage.clear()) + + it('keeps an unlimited agent allowance unlimited', () => { + // null is "no cap" on the backend; turning it into 0 locked agent creation + // for every unlimited plan (found by the e2e enterprise suite). + storeSubscription({ max_agents: null }) + expect(subscriptionStorage.getPlanLimits().maxAgents).toBeNull() + }) + + it('passes a capped allowance through', () => { + storeSubscription({ max_agents: 5 }) + expect(subscriptionStorage.getPlanLimits().maxAgents).toBe(5) + }) + + it('allows nothing without a subscription', () => { + expect(subscriptionStorage.getPlanLimits().maxAgents).toBe(0) + }) +}) diff --git a/frontend/src/__tests__/utils/sanitize.spec.ts b/frontend/src/__tests__/utils/sanitize.spec.ts new file mode 100644 index 0000000..cfca980 --- /dev/null +++ b/frontend/src/__tests__/utils/sanitize.spec.ts @@ -0,0 +1,146 @@ +/* +Copyright 2024-2026 ChatterMate + +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, expect, it } from 'vitest' +import { sanitizeHtml } from '@/utils/sanitize' +import { renderMarkdown } from '@/webclient/markdown' + +// Both the dashboard chat and the embedded widget render model output through +// sanitizeHtml, so this is the one place an XSS bypass would have to get past. +// The cases pin the behaviour of the DOMPurify config AND the two global hooks +// registered in src/utils/sanitize.ts, so a DOMPurify upgrade cannot quietly +// change what survives. + +describe('sanitizeHtml', () => { + describe('keeps the formatting a chat answer needs', () => { + it('leaves basic markup and tables alone', () => { + const html = '

Hi there

  • one
x
' + expect(sanitizeHtml(html)).toBe( + '

Hi there

  • one
x
', + ) + }) + + it('keeps https links but forces them to open safely in a new tab', () => { + const out = sanitizeHtml('docs') + expect(out).toContain('href="https://example.com/docs"') + expect(out).toContain('target="_blank"') + expect(out).toContain('rel="noopener noreferrer nofollow"') + }) + + it('keeps mailto links', () => { + expect(sanitizeHtml('mail')).toContain('href="mailto:a@b.co"') + }) + + it('returns an empty string for empty input', () => { + expect(sanitizeHtml('')).toBe('') + }) + }) + + describe('strips script execution vectors', () => { + it.each([ + ['script tag', '

x

'], + ['iframe', ''], + ['object/embed', ''], + ['form controls', '
'], + ['template', ''], + ['style block', ''], + ])('removes %s', (_label, html) => { + const out = sanitizeHtml(html) + expect(out).not.toMatch(/<(script|iframe|object|embed|form|input|template|style)/i) + expect(out).not.toContain('alert(1)') + }) + + it('removes every on* event handler attribute', () => { + const out = sanitizeHtml('
x
') + expect(out).toBe('
x
') + }) + + it('removes svg, math and foreignObject subtrees entirely', () => { + const out = sanitizeHtml('

a

x') + expect(out).toBe('

a

') + }) + + it('removes img, area and map even though they carry no script', () => { + const out = sanitizeHtml('

a

') + expect(out).toBe('

a

') + }) + }) + + describe('strips dangerous URLs from links', () => { + it.each([ + ['javascript:', 'javascript:alert(1)'], + ['mixed-case javascript:', 'JaVaScRiPt:alert(1)'], + ['url-encoded javascript:', 'javascript%3Aalert(1)'], + ['data:text/html', 'data:text/html,'], + ['vbscript:', 'vbscript:msgbox(1)'], + ['file:', 'file:///etc/passwd'], + ['ftp (not in the anchor allow-list)', 'ftp://example.com/x'], + ['protocol-relative', '//evil.example/x'], + ['relative path', '/login'], + ])('drops the href for %s', (_label, href) => { + const out = sanitizeHtml(`x`) + expect(out).not.toContain('href=') + expect(out).toContain('>x') + }) + }) + + describe('strips dangerous inline styles', () => { + it.each([ + 'expression(alert(1))', + 'behavior:url(x.htc)', + '-moz-binding:url(x.xml)', + 'background:url(javascript:alert(1))', + ])('drops a style containing %s', (style) => { + expect(sanitizeHtml(`x`)).not.toContain('style=') + }) + }) + + it('does not let a mutation-XSS payload survive re-parsing', () => { + // Classic mXSS shape: a noscript boundary that turns into markup once the + // sanitised string is parsed again by the browser. The payload text may + // legitimately survive inside a quoted attribute; what must not survive is + // an element that executes after the second parse. + const out = sanitizeHtml('