@consilioweb/payload-support 0.15.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
- package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
- package/dist/components/TicketConversation/components/QuickActions.js +22 -1
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
- package/dist/components/TicketConversation/index.cjs +42 -149
- package/dist/components/TicketConversation/index.js +37 -144
- package/dist/index.cjs +1835 -741
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1833 -741
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +23 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +137 -31
- package/src/collections/Tickets.ts +31 -72
- package/src/collections/index.ts +3 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
- package/src/components/TicketConversation/index.tsx +29 -157
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +70 -32
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +38 -18
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +24 -2
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +186 -0
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +48 -16
- package/src/plugin.ts +6 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +6 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createInvoiceEndpoint } from '../../endpoints/invoice'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
+
const PW = 'test-pw-12345'
|
|
8
|
+
|
|
9
|
+
/** @mentions (P2 #6): `@email` in a message resolves to agent IDs on `mentions`. */
|
|
10
|
+
describe('@mentions (P2 #6)', () => {
|
|
11
|
+
it('resolves @email mentions in a message body to agent IDs', async () => {
|
|
12
|
+
const payload = await buildTestPayload()
|
|
13
|
+
const agent = await payload.create({ collection: 'users', data: { email: 'agent@example.com', password: PW } as never, overrideAccess: true })
|
|
14
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'mc@example.com', password: PW, firstName: 'M', lastName: 'C', company: 'C' }, overrideAccess: true })
|
|
15
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'mention', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
+
|
|
17
|
+
const m = await payload.create({
|
|
18
|
+
collection: 'ticket-messages',
|
|
19
|
+
data: { ticket: t.id, body: 'Hey @agent@example.com can you check this?', authorType: 'admin', isInternal: true },
|
|
20
|
+
overrideAccess: true,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const reread = await payload.findByID({ collection: 'ticket-messages', id: m.id, depth: 0, overrideAccess: true })
|
|
24
|
+
const mentions = (reread as { mentions?: Array<number | string> }).mentions || []
|
|
25
|
+
expect(mentions.map(String)).toContain(String(agent.id))
|
|
26
|
+
}, 60_000)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** Invoice (P2 #6): a print-ready HTML invoice with the correct total. */
|
|
30
|
+
describe('printable invoice (P2 #6)', () => {
|
|
31
|
+
it('renders an HTML invoice summing billable tickets', async () => {
|
|
32
|
+
const payload = await buildTestPayload()
|
|
33
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'inv@example.com', password: PW, firstName: 'I', lastName: 'V', company: 'ACME Corp' }, overrideAccess: true })
|
|
34
|
+
const t1 = await payload.create({ collection: 'tickets', data: { subject: 'inv-a', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 100 }, overrideAccess: true })
|
|
35
|
+
await payload.create({ collection: 'tickets', data: { subject: 'inv-b', client: c.id, status: 'resolved', priority: 'normal', billable: true, billedAmount: 50 }, overrideAccess: true })
|
|
36
|
+
|
|
37
|
+
const admin = await payload.create({ collection: 'users', data: { email: 'inv-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
38
|
+
const from = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
|
|
39
|
+
const to = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
|
40
|
+
const ep = createInvoiceEndpoint(slugs)
|
|
41
|
+
const req = { payload, user: { ...admin, collection: 'users' }, url: `http://localhost/api/support/billing/invoice?from=${from}&to=${to}`, headers: new Headers({}) }
|
|
42
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
43
|
+
|
|
44
|
+
expect(res.headers.get('content-type')).toMatch(/text\/html/)
|
|
45
|
+
const body = await res.text()
|
|
46
|
+
expect(body).toContain('150.00 €') // 100 + 50
|
|
47
|
+
expect(body).toContain(String(t1.ticketNumber))
|
|
48
|
+
expect(body).toContain('ACME Corp')
|
|
49
|
+
}, 60_000)
|
|
50
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createAdminStatsEndpoint } from '../../endpoints/admin-stats'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
+
const PW = 'test-pw-12345'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* NPS (P2): satisfaction surveys can carry an NPS score (0-10), and admin-stats
|
|
11
|
+
* computes the Net Promoter Score = %promoters(9-10) − %detractors(0-6).
|
|
12
|
+
*/
|
|
13
|
+
describe('NPS (P2)', () => {
|
|
14
|
+
it('stores NPS scores and computes the NPS in admin-stats', async () => {
|
|
15
|
+
const payload = await buildTestPayload()
|
|
16
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'nps@example.com', password: PW, firstName: 'N', lastName: 'P', company: 'C' }, overrideAccess: true })
|
|
17
|
+
|
|
18
|
+
// 3 promoters (10,9,9), 1 passive (7), 2 detractors (3,5) → (3-2)/6 = 16.67% ≈ 17
|
|
19
|
+
for (const nps of [10, 9, 9, 7, 3, 5]) {
|
|
20
|
+
await payload.create({ collection: 'satisfaction-surveys', data: { source: 'ticket', client: c.id, nps }, overrideAccess: true })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const admin = await payload.create({ collection: 'users', data: { email: 'nps-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
24
|
+
const ep = createAdminStatsEndpoint(slugs)
|
|
25
|
+
const req = {
|
|
26
|
+
payload,
|
|
27
|
+
user: { ...admin, collection: 'users' },
|
|
28
|
+
url: 'http://localhost/api/support/admin-stats',
|
|
29
|
+
headers: new Headers({}),
|
|
30
|
+
}
|
|
31
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
32
|
+
const json = await res.json()
|
|
33
|
+
|
|
34
|
+
expect(json.npsCount).toBe(6)
|
|
35
|
+
expect(json.npsScore).toBe(17)
|
|
36
|
+
}, 60_000)
|
|
37
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
const PW = 'test-pw-12345'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates that the bodyHtml sanitization (P0-1) is actually wired into the
|
|
8
|
+
* TicketMessages beforeChange hook — i.e. stored HTML is stripped of script /
|
|
9
|
+
* event handlers before it can be persisted and later rendered.
|
|
10
|
+
*/
|
|
11
|
+
describe('stored-XSS sanitization on write (P0-1)', () => {
|
|
12
|
+
it('strips <script> and inline handlers from bodyHtml at persistence', async () => {
|
|
13
|
+
const payload = await buildTestPayload()
|
|
14
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'xss@example.com', password: PW, firstName: 'X', lastName: 'S', company: 'C' }, overrideAccess: true })
|
|
15
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'xss', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
+
|
|
17
|
+
const m = await payload.create({
|
|
18
|
+
collection: 'ticket-messages',
|
|
19
|
+
data: {
|
|
20
|
+
ticket: t.id,
|
|
21
|
+
body: 'hello',
|
|
22
|
+
bodyHtml: '<img src=x onerror=alert(1)><script>alert(2)</script><a href="javascript:alert(3)">x</a><b>kept</b>',
|
|
23
|
+
authorType: 'client',
|
|
24
|
+
authorClient: c.id,
|
|
25
|
+
},
|
|
26
|
+
overrideAccess: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const stored = (m as { bodyHtml?: string }).bodyHtml || ''
|
|
30
|
+
expect(stored).not.toMatch(/onerror/i)
|
|
31
|
+
expect(stored).not.toMatch(/<script/i)
|
|
32
|
+
expect(stored).not.toMatch(/javascript:/i)
|
|
33
|
+
expect(stored).toContain('<b>kept</b>') // legitimate formatting preserved
|
|
34
|
+
}, 60_000)
|
|
35
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
const PW = 'test-pw-12345'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SLA pause-on-hold (P2): time spent waiting on the client must not count against
|
|
8
|
+
* the resolution SLA — the resolution deadline is pushed forward by the paused span.
|
|
9
|
+
*/
|
|
10
|
+
describe('SLA pause-on-hold (P2)', () => {
|
|
11
|
+
it('extends the resolution deadline by the time spent in waiting_client', async () => {
|
|
12
|
+
const payload = await buildTestPayload()
|
|
13
|
+
await payload.create({ collection: 'sla-policies', data: { name: 'P', priority: 'normal', firstResponseTime: 60, resolutionTime: 240, businessHoursOnly: false, isDefault: true }, overrideAccess: true })
|
|
14
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'pause@example.com', password: PW, firstName: 'P', lastName: 'H', company: 'C' }, overrideAccess: true })
|
|
15
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'pause', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
16
|
+
|
|
17
|
+
const before = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
18
|
+
const dueBefore = new Date(before.slaResolutionDue as string).getTime()
|
|
19
|
+
expect(dueBefore).toBeGreaterThan(0)
|
|
20
|
+
|
|
21
|
+
// Enter hold → the clock should pause.
|
|
22
|
+
await payload.update({ collection: 'tickets', id: t.id, data: { status: 'waiting_client' }, overrideAccess: true })
|
|
23
|
+
const mid = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
24
|
+
expect(mid.slaPausedAt).toBeTruthy()
|
|
25
|
+
|
|
26
|
+
// Simulate ~2h of waiting by backdating the pause marker (status unchanged → hook no-op).
|
|
27
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()
|
|
28
|
+
await payload.update({ collection: 'tickets', id: t.id, data: { slaPausedAt: twoHoursAgo }, overrideAccess: true })
|
|
29
|
+
|
|
30
|
+
// Leave hold → deadline extended, pause cleared.
|
|
31
|
+
await payload.update({ collection: 'tickets', id: t.id, data: { status: 'open' }, overrideAccess: true })
|
|
32
|
+
const after = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
33
|
+
|
|
34
|
+
expect(after.slaPausedAt).toBeFalsy()
|
|
35
|
+
const extensionMs = new Date(after.slaResolutionDue as string).getTime() - dueBefore
|
|
36
|
+
expect(extensionMs).toBeGreaterThan(1.9 * 3_600_000)
|
|
37
|
+
expect(extensionMs).toBeLessThan(2.2 * 3_600_000)
|
|
38
|
+
}, 60_000)
|
|
39
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
describe('integration harness', () => {
|
|
5
|
+
it('boots Payload with the support plugin and round-trips a ticket', async () => {
|
|
6
|
+
const payload = await buildTestPayload()
|
|
7
|
+
|
|
8
|
+
const client = await payload.create({
|
|
9
|
+
collection: 'support-clients',
|
|
10
|
+
data: { email: 'smoke@example.com', password: 'pw-smoke-123', firstName: 'Smoke', lastName: 'Test', company: 'ACME' },
|
|
11
|
+
overrideAccess: true,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const ticket = await payload.create({
|
|
15
|
+
collection: 'tickets',
|
|
16
|
+
data: { subject: 'Smoke ticket', client: client.id, status: 'open', priority: 'normal' },
|
|
17
|
+
overrideAccess: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
expect(ticket.id).toBeTruthy()
|
|
21
|
+
expect(ticket.ticketNumber).toMatch(/^TK-/)
|
|
22
|
+
|
|
23
|
+
const reread = await payload.findByID({ collection: 'tickets', id: ticket.id, overrideAccess: true })
|
|
24
|
+
expect(reread.subject).toBe('Smoke ticket')
|
|
25
|
+
}, 60_000)
|
|
26
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createProcessSnoozeEndpoint } from '../../endpoints/process-snooze'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const PW = 'test-pw-12345'
|
|
7
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
8
|
+
|
|
9
|
+
async function makeClient(payload: Awaited<ReturnType<typeof buildTestPayload>>, email: string) {
|
|
10
|
+
return payload.create({ collection: 'support-clients', data: { email, password: PW, firstName: 'S', lastName: 'N', company: 'C' }, overrideAccess: true })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Snooze wake-up cron (P2): a snoozed ticket must be hidden from the inbox while
|
|
15
|
+
* `snoozeUntil` is in the future, resurface once it passes, and the cron must
|
|
16
|
+
* clear expired snoozes (audit trail) while leaving future ones untouched.
|
|
17
|
+
*/
|
|
18
|
+
describe('snooze wake-up cron + inbox hide (P2)', () => {
|
|
19
|
+
it('cron clears expired snoozeUntil and leaves future ones', async () => {
|
|
20
|
+
const payload = await buildTestPayload()
|
|
21
|
+
process.env.CRON_SECRET = 'test-cron-secret'
|
|
22
|
+
const c = await makeClient(payload, 'snz@example.com')
|
|
23
|
+
const past = new Date(Date.now() - 3_600_000).toISOString()
|
|
24
|
+
const future = new Date(Date.now() + 3_600_000).toISOString()
|
|
25
|
+
const tPast = await payload.create({ collection: 'tickets', data: { subject: 'past', client: c.id, status: 'open', priority: 'normal', snoozeUntil: past }, overrideAccess: true })
|
|
26
|
+
const tFuture = await payload.create({ collection: 'tickets', data: { subject: 'future', client: c.id, status: 'open', priority: 'normal', snoozeUntil: future }, overrideAccess: true })
|
|
27
|
+
|
|
28
|
+
const ep = createProcessSnoozeEndpoint(slugs)
|
|
29
|
+
const req = { payload, headers: new Headers({ 'x-cron-secret': 'test-cron-secret' }) }
|
|
30
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
31
|
+
const json = await res.json()
|
|
32
|
+
expect(json.ok).toBe(true)
|
|
33
|
+
expect(json.processed).toBeGreaterThanOrEqual(1)
|
|
34
|
+
|
|
35
|
+
const rePast = await payload.findByID({ collection: 'tickets', id: tPast.id, overrideAccess: true })
|
|
36
|
+
const reFuture = await payload.findByID({ collection: 'tickets', id: tFuture.id, overrideAccess: true })
|
|
37
|
+
expect(rePast.snoozeUntil).toBeFalsy() // woken up
|
|
38
|
+
expect(reFuture.snoozeUntil).toBeTruthy() // still snoozed
|
|
39
|
+
}, 60_000)
|
|
40
|
+
|
|
41
|
+
it('rejects the cron without the secret', async () => {
|
|
42
|
+
const payload = await buildTestPayload()
|
|
43
|
+
process.env.CRON_SECRET = 'test-cron-secret'
|
|
44
|
+
const ep = createProcessSnoozeEndpoint(slugs)
|
|
45
|
+
const req = { payload, headers: new Headers({}) }
|
|
46
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
47
|
+
expect(res.status).toBe(401)
|
|
48
|
+
}, 60_000)
|
|
49
|
+
|
|
50
|
+
it('the inbox snooze-hide query excludes future-snoozed tickets', async () => {
|
|
51
|
+
const payload = await buildTestPayload()
|
|
52
|
+
const c = await makeClient(payload, 'snz2@example.com')
|
|
53
|
+
const visible = await payload.create({ collection: 'tickets', data: { subject: 'visible', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
54
|
+
const hidden = await payload.create({ collection: 'tickets', data: { subject: 'hidden', client: c.id, status: 'open', priority: 'normal', snoozeUntil: new Date(Date.now() + 3_600_000).toISOString() }, overrideAccess: true })
|
|
55
|
+
|
|
56
|
+
const nowIso = new Date().toISOString()
|
|
57
|
+
// Same where the inbox builds via query params.
|
|
58
|
+
const found = await payload.find({
|
|
59
|
+
collection: 'tickets',
|
|
60
|
+
where: { and: [{ or: [{ snoozeUntil: { exists: false } }, { snoozeUntil: { less_than_equal: nowIso } }] }] },
|
|
61
|
+
limit: 200,
|
|
62
|
+
overrideAccess: true,
|
|
63
|
+
})
|
|
64
|
+
const ids = found.docs.map((t) => t.id)
|
|
65
|
+
expect(ids).toContain(visible.id)
|
|
66
|
+
expect(ids).not.toContain(hidden.id)
|
|
67
|
+
}, 60_000)
|
|
68
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
|
|
4
|
+
const PW = 'test-pw-12345'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Multi-team / workspaces (roadmap): with `SUPPORT_TEAM_SCOPING` enabled, an agent
|
|
8
|
+
* only sees tickets of the teams they belong to (plus team-less tickets).
|
|
9
|
+
*/
|
|
10
|
+
describe('multi-team scoping (roadmap)', () => {
|
|
11
|
+
it('scopes ticket visibility to the agent\'s teams when enabled', async () => {
|
|
12
|
+
process.env.SUPPORT_TEAM_SCOPING = '1'
|
|
13
|
+
const payload = await buildTestPayload()
|
|
14
|
+
|
|
15
|
+
const agentA = await payload.create({ collection: 'users', data: { email: 'team-a@example.com', password: PW } as never, overrideAccess: true })
|
|
16
|
+
const agentB = await payload.create({ collection: 'users', data: { email: 'team-b@example.com', password: PW } as never, overrideAccess: true })
|
|
17
|
+
const teamA = await payload.create({ collection: 'support-teams', data: { name: 'Team A', members: [agentA.id] }, overrideAccess: true })
|
|
18
|
+
const teamB = await payload.create({ collection: 'support-teams', data: { name: 'Team B', members: [agentB.id] }, overrideAccess: true })
|
|
19
|
+
|
|
20
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'team-c@example.com', password: PW, firstName: 'T', lastName: 'C', company: 'C' }, overrideAccess: true })
|
|
21
|
+
const tA = await payload.create({ collection: 'tickets', data: { subject: 'A', client: c.id, status: 'open', priority: 'normal', team: teamA.id }, overrideAccess: true })
|
|
22
|
+
const tB = await payload.create({ collection: 'tickets', data: { subject: 'B', client: c.id, status: 'open', priority: 'normal', team: teamB.id }, overrideAccess: true })
|
|
23
|
+
const tNone = await payload.create({ collection: 'tickets', data: { subject: 'none', client: c.id, status: 'open', priority: 'normal' }, overrideAccess: true })
|
|
24
|
+
|
|
25
|
+
const asA = await payload.find({
|
|
26
|
+
collection: 'tickets',
|
|
27
|
+
overrideAccess: false,
|
|
28
|
+
user: { ...agentA, collection: 'users' } as never,
|
|
29
|
+
limit: 200,
|
|
30
|
+
})
|
|
31
|
+
const ids = asA.docs.map((t) => String(t.id))
|
|
32
|
+
expect(ids).toContain(String(tA.id))
|
|
33
|
+
expect(ids).toContain(String(tNone.id)) // team-less tickets stay visible
|
|
34
|
+
expect(ids).not.toContain(String(tB.id)) // other team's ticket hidden
|
|
35
|
+
|
|
36
|
+
delete process.env.SUPPORT_TEAM_SCOPING
|
|
37
|
+
}, 60_000)
|
|
38
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.useRealTimers()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
describe('RateLimiter — under the limit', () => {
|
|
9
|
+
it('allows the first request', () => {
|
|
10
|
+
const limiter = new RateLimiter(60_000, 5)
|
|
11
|
+
expect(limiter.check('user:1')).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('allows requests up to the maximum (maxRequests = 3)', () => {
|
|
15
|
+
const limiter = new RateLimiter(60_000, 3)
|
|
16
|
+
expect(limiter.check('key')).toBe(false) // count = 1
|
|
17
|
+
expect(limiter.check('key')).toBe(false) // count = 2
|
|
18
|
+
expect(limiter.check('key')).toBe(false) // count = 3
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('RateLimiter — exceeding the limit', () => {
|
|
23
|
+
it('blocks the request when count exceeds maxRequests', () => {
|
|
24
|
+
const limiter = new RateLimiter(60_000, 2)
|
|
25
|
+
limiter.check('key') // count = 1
|
|
26
|
+
limiter.check('key') // count = 2
|
|
27
|
+
// count = 3 > maxRequests(2) → blocked
|
|
28
|
+
expect(limiter.check('key')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keeps blocking subsequent requests after limit exceeded', () => {
|
|
32
|
+
const limiter = new RateLimiter(60_000, 1)
|
|
33
|
+
limiter.check('key') // count = 1, passes
|
|
34
|
+
expect(limiter.check('key')).toBe(true) // count = 2 → blocked
|
|
35
|
+
expect(limiter.check('key')).toBe(true) // count = 3 → still blocked
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('RateLimiter — reset', () => {
|
|
40
|
+
it('allows requests again after manual reset', () => {
|
|
41
|
+
const limiter = new RateLimiter(60_000, 1)
|
|
42
|
+
limiter.check('key') // count = 1
|
|
43
|
+
expect(limiter.check('key')).toBe(true) // blocked
|
|
44
|
+
limiter.reset('key')
|
|
45
|
+
expect(limiter.check('key')).toBe(false) // reset → allowed again
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reset only affects the targeted key', () => {
|
|
49
|
+
const limiter = new RateLimiter(60_000, 1)
|
|
50
|
+
limiter.check('keyA')
|
|
51
|
+
limiter.check('keyA') // keyA blocked
|
|
52
|
+
limiter.check('keyB')
|
|
53
|
+
limiter.check('keyB') // keyB blocked
|
|
54
|
+
|
|
55
|
+
limiter.reset('keyA')
|
|
56
|
+
expect(limiter.check('keyA')).toBe(false) // keyA allowed
|
|
57
|
+
expect(limiter.check('keyB')).toBe(true) // keyB still blocked
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('RateLimiter — window expiry', () => {
|
|
62
|
+
it('resets the counter automatically after the window expires', () => {
|
|
63
|
+
vi.useFakeTimers()
|
|
64
|
+
const windowMs = 1_000
|
|
65
|
+
const limiter = new RateLimiter(windowMs, 2)
|
|
66
|
+
|
|
67
|
+
limiter.check('key') // count = 1
|
|
68
|
+
limiter.check('key') // count = 2
|
|
69
|
+
expect(limiter.check('key')).toBe(true) // blocked
|
|
70
|
+
|
|
71
|
+
// Advance past the window
|
|
72
|
+
vi.advanceTimersByTime(windowMs + 1)
|
|
73
|
+
|
|
74
|
+
// The next check should open a new window
|
|
75
|
+
expect(limiter.check('key')).toBe(false) // new window, count = 1
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('RateLimiter — key independence', () => {
|
|
80
|
+
it('tracks different keys independently', () => {
|
|
81
|
+
const limiter = new RateLimiter(60_000, 1)
|
|
82
|
+
// key-A hits limit
|
|
83
|
+
limiter.check('key-A') // count = 1
|
|
84
|
+
expect(limiter.check('key-A')).toBe(true) // blocked
|
|
85
|
+
|
|
86
|
+
// key-B untouched
|
|
87
|
+
expect(limiter.check('key-B')).toBe(false) // fresh key, allowed
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sanitizeMessageHtml } from '../utils/sanitizeHtml'
|
|
3
|
+
|
|
4
|
+
describe('sanitizeMessageHtml — XSS vectors stripped', () => {
|
|
5
|
+
it('strips <script> tags entirely', () => {
|
|
6
|
+
const result = sanitizeMessageHtml('<script>alert(1)</script>hi')
|
|
7
|
+
expect(result).not.toContain('<script')
|
|
8
|
+
expect(result).not.toContain('alert')
|
|
9
|
+
expect(result).toContain('hi')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('strips <script> with attributes', () => {
|
|
13
|
+
const result = sanitizeMessageHtml('<script src="evil.js" type="text/javascript"></script>safe')
|
|
14
|
+
expect(result).not.toContain('<script')
|
|
15
|
+
expect(result).toContain('safe')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('strips onerror inline event handlers on <img>', () => {
|
|
19
|
+
const result = sanitizeMessageHtml('<img src="x" onerror="alert(1)">')
|
|
20
|
+
expect(result).not.toContain('onerror')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('strips onclick inline event handlers', () => {
|
|
24
|
+
const result = sanitizeMessageHtml('<span onclick="stealCookies()">click me</span>')
|
|
25
|
+
expect(result).not.toContain('onclick')
|
|
26
|
+
expect(result).toContain('click me')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('strips onmouseover event handler', () => {
|
|
30
|
+
const result = sanitizeMessageHtml('<div onmouseover="evil()">hover</div>')
|
|
31
|
+
expect(result).not.toContain('onmouseover')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('strips javascript: URI in anchor href', () => {
|
|
35
|
+
const result = sanitizeMessageHtml('<a href="javascript:alert(1)">x</a>')
|
|
36
|
+
expect(result).not.toContain('javascript:')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('strips javascript: URI with mixed case', () => {
|
|
40
|
+
const result = sanitizeMessageHtml('<a href="JaVaScRiPt:alert(1)">x</a>')
|
|
41
|
+
expect(result).not.toContain('avascript:')
|
|
42
|
+
expect(result).not.toContain('href="JaVaScRiPt')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('strips <iframe>', () => {
|
|
46
|
+
const result = sanitizeMessageHtml('<iframe src="https://evil.com"></iframe>text')
|
|
47
|
+
expect(result).not.toContain('<iframe')
|
|
48
|
+
expect(result).toContain('text')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('strips <embed>', () => {
|
|
52
|
+
const result = sanitizeMessageHtml('<embed src="payload.swf">text')
|
|
53
|
+
expect(result).not.toContain('<embed')
|
|
54
|
+
expect(result).toContain('text')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('strips <object>', () => {
|
|
58
|
+
const result = sanitizeMessageHtml('<object data="evil.swf"></object>after')
|
|
59
|
+
expect(result).not.toContain('<object')
|
|
60
|
+
expect(result).toContain('after')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('sanitizeMessageHtml — legitimate formatting preserved', () => {
|
|
65
|
+
it('preserves <b> tag', () => {
|
|
66
|
+
const result = sanitizeMessageHtml('<b>bold text</b>')
|
|
67
|
+
expect(result).toContain('<b>bold text</b>')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('preserves <strong> tag', () => {
|
|
71
|
+
const result = sanitizeMessageHtml('<strong>important</strong>')
|
|
72
|
+
expect(result).toContain('<strong>important</strong>')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('preserves <a href="https://..."> anchor', () => {
|
|
76
|
+
const result = sanitizeMessageHtml('<a href="https://example.com">link</a>')
|
|
77
|
+
expect(result).toContain('href="https://example.com"')
|
|
78
|
+
expect(result).toContain('link')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('adds rel="noopener noreferrer nofollow" to links (tabnabbing defense)', () => {
|
|
82
|
+
const result = sanitizeMessageHtml('<a href="https://example.com">link</a>')
|
|
83
|
+
expect(result).toContain('rel="noopener noreferrer nofollow"')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('preserves <ul><li> list structure', () => {
|
|
87
|
+
const result = sanitizeMessageHtml('<ul><li>item 1</li><li>item 2</li></ul>')
|
|
88
|
+
expect(result).toContain('<ul>')
|
|
89
|
+
expect(result).toContain('<li>item 1</li>')
|
|
90
|
+
expect(result).toContain('<li>item 2</li>')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('preserves <ol> numbered list', () => {
|
|
94
|
+
const result = sanitizeMessageHtml('<ol><li>first</li></ol>')
|
|
95
|
+
expect(result).toContain('<ol>')
|
|
96
|
+
expect(result).toContain('<li>first</li>')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('preserves safe inline style: color', () => {
|
|
100
|
+
const result = sanitizeMessageHtml('<span style="color: red">colored</span>')
|
|
101
|
+
// sanitize-html normalises CSS by stripping the space after ':'
|
|
102
|
+
expect(result).toContain('color:red')
|
|
103
|
+
expect(result).toContain('colored')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('preserves safe inline style: color hex', () => {
|
|
107
|
+
const result = sanitizeMessageHtml('<span style="color: #ff0000">red</span>')
|
|
108
|
+
expect(result).toContain('color:#ff0000')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('preserves text-align style', () => {
|
|
112
|
+
const result = sanitizeMessageHtml('<p style="text-align: center">centered</p>')
|
|
113
|
+
// sanitize-html normalises CSS by stripping the space after ':'
|
|
114
|
+
expect(result).toContain('text-align:center')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('preserves <blockquote>', () => {
|
|
118
|
+
const result = sanitizeMessageHtml('<blockquote>quoted text</blockquote>')
|
|
119
|
+
expect(result).toContain('<blockquote>')
|
|
120
|
+
expect(result).toContain('quoted text')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('preserves <code> and <pre>', () => {
|
|
124
|
+
const result = sanitizeMessageHtml('<pre><code>const x = 1;</code></pre>')
|
|
125
|
+
expect(result).toContain('<pre>')
|
|
126
|
+
expect(result).toContain('<code>')
|
|
127
|
+
expect(result).toContain('const x = 1;')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('preserves heading tags h1-h3', () => {
|
|
131
|
+
const result = sanitizeMessageHtml('<h1>Title</h1><h2>Sub</h2><h3>Sub2</h3>')
|
|
132
|
+
expect(result).toContain('<h1>Title</h1>')
|
|
133
|
+
expect(result).toContain('<h2>Sub</h2>')
|
|
134
|
+
expect(result).toContain('<h3>Sub2</h3>')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('sanitizeMessageHtml — dangerous style values stripped', () => {
|
|
139
|
+
it('strips expression() from style (IE CSS injection)', () => {
|
|
140
|
+
const result = sanitizeMessageHtml('<span style="width: expression(alert(1))">x</span>')
|
|
141
|
+
expect(result).not.toContain('expression')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('strips url() from style', () => {
|
|
145
|
+
const result = sanitizeMessageHtml('<span style="background: url(javascript:alert(1))">x</span>')
|
|
146
|
+
expect(result).not.toContain('url(')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('sanitizeMessageHtml — edge cases', () => {
|
|
151
|
+
it('returns empty string unchanged when falsy', () => {
|
|
152
|
+
expect(sanitizeMessageHtml('')).toBe('')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('handles plain text without HTML', () => {
|
|
156
|
+
const result = sanitizeMessageHtml('Hello world')
|
|
157
|
+
expect(result).toBe('Hello world')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('does not escape regular ampersands in text', () => {
|
|
161
|
+
const result = sanitizeMessageHtml('Tom & Jerry')
|
|
162
|
+
expect(result).toContain('Tom')
|
|
163
|
+
expect(result).toContain('Jerry')
|
|
164
|
+
})
|
|
165
|
+
})
|