@consilioweb/payload-support 2.0.0 → 3.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 +656 -355
- package/dist/components/RichTextEditor/index.d.ts +19 -0
- package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
- package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
- package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
- package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
- package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
- package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
- package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
- package/dist/components/TicketConversation/config.d.ts +10 -0
- package/dist/components/TicketConversation/config.js +1 -40
- package/dist/components/TicketConversation/constants.d.ts +56 -0
- package/dist/components/TicketConversation/context.d.ts +53 -0
- package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
- package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
- package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
- package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
- package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
- package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
- package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
- package/dist/components/TicketConversation/hooks/useTranslation.js +22 -3
- package/dist/components/TicketConversation/index.d.ts +4 -0
- package/dist/components/TicketConversation/index.js +2 -2
- package/dist/components/TicketConversation/types.d.ts +56 -0
- package/dist/components/TicketConversation/utils.d.ts +2 -0
- package/dist/index.cjs +392 -320
- package/dist/index.d.cts +100 -3
- package/dist/index.d.ts +100 -3
- package/dist/index.js +388 -321
- package/dist/utils/features.d.ts +123 -0
- package/dist/utils/features.js +147 -0
- package/dist/views/BillingView/client.d.ts +2 -0
- package/dist/views/BillingView/index.d.ts +4 -0
- package/dist/views/ChatView/client.d.ts +2 -0
- package/dist/views/ChatView/client.js +9 -9
- package/dist/views/ChatView/index.d.ts +4 -0
- package/dist/views/CrmView/client.d.ts +2 -0
- package/dist/views/CrmView/client.js +6 -9
- package/dist/views/CrmView/index.d.ts +4 -0
- package/dist/views/EmailTrackingView/client.d.ts +2 -0
- package/dist/views/EmailTrackingView/index.d.ts +4 -0
- package/dist/views/ImportConversationView/client.d.ts +2 -0
- package/dist/views/ImportConversationView/index.d.ts +4 -0
- package/dist/views/LogsView/client.d.ts +2 -0
- package/dist/views/LogsView/index.d.ts +4 -0
- package/dist/views/NewTicketView/client.d.ts +2 -0
- package/dist/views/NewTicketView/index.d.ts +4 -0
- package/dist/views/PendingEmailsView/client.d.ts +2 -0
- package/dist/views/PendingEmailsView/index.d.ts +4 -0
- package/dist/views/SupportDashboardView/client.d.ts +2 -0
- package/dist/views/SupportDashboardView/client.js +1 -1
- package/dist/views/SupportDashboardView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
- package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
- package/dist/views/TicketDetailView/client.d.ts +2 -0
- package/dist/views/TicketDetailView/client.js +2 -2
- package/dist/views/TicketDetailView/constants.d.ts +13 -0
- package/dist/views/TicketDetailView/helpers.d.ts +2 -0
- package/dist/views/TicketDetailView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/types.d.ts +40 -0
- package/dist/views/TicketInboxView/client.d.ts +2 -0
- package/dist/views/TicketInboxView/client.js +5 -5
- package/dist/views/TicketInboxView/index.d.ts +4 -0
- package/dist/views/TicketingSettingsView/client.d.ts +2 -0
- package/dist/views/TicketingSettingsView/client.js +6 -5
- package/dist/views/TicketingSettingsView/index.d.ts +4 -0
- package/dist/views/TimeDashboardView/client.d.ts +2 -0
- package/dist/views/TimeDashboardView/index.d.ts +4 -0
- package/dist/views/shared/AdminViewHeader.d.ts +13 -0
- package/dist/views/shared/ErrorBoundary.d.ts +17 -0
- package/dist/views/shared/Skeleton.d.ts +21 -0
- package/dist/views/shared/StatusPill.d.ts +8 -0
- package/dist/views/shared/adminTokens.d.ts +19 -0
- package/dist/views/shared/config.d.ts +8 -0
- package/dist/views/shared/config.js +1 -40
- package/dist/views/shared/dateLocale.d.ts +5 -0
- package/dist/views/shared/index.d.ts +12 -0
- package/dist/views/shared/index.js +2 -1
- package/dist/views/shared/locales/en.json +708 -0
- package/dist/views/shared/locales/fr.json +708 -0
- package/dist/views/shared/sla.d.ts +14 -0
- package/dist/views.d.ts +13 -13
- package/package.json +26 -41
- package/src/collections/SupportClients.ts +29 -0
- package/src/collections/TicketCollaborators.ts +6 -1
- package/src/collections/TicketMessages.ts +46 -24
- package/src/collections/Tickets.ts +117 -79
- package/src/collections/TimeEntries.ts +57 -32
- package/src/components/TicketConversation/config.ts +16 -80
- package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +38 -4
- package/src/components/TicketConversation/index.tsx +2 -2
- package/src/endpoints/ai.ts +2 -1
- package/src/endpoints/client-intelligence.ts +2 -1
- package/src/endpoints/process-scheduled.ts +13 -8
- package/src/endpoints/round-robin-config.ts +30 -20
- package/src/endpoints/settings.ts +28 -77
- package/src/index.ts +6 -2
- package/src/portal/LiveChat.tsx +33 -22
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
- package/src/types/lucide-react.d.ts +10 -2
- package/src/utils/aiAgent.ts +2 -1
- package/src/utils/aiProvider.ts +21 -0
- package/src/utils/features.ts +297 -0
- package/src/utils/fireWebhooks.ts +6 -0
- package/src/utils/generateTicketSynthesis.ts +2 -1
- package/src/utils/index.ts +4 -2
- package/src/utils/readSettings.ts +82 -9
- package/src/utils/webhookDispatcher.ts +19 -7
- package/src/views/ChatView/client.tsx +13 -9
- package/src/views/CrmView/client.tsx +10 -11
- package/src/views/SupportDashboardView/client.tsx +1 -1
- package/src/views/TicketDetailView/client.tsx +2 -2
- package/src/views/TicketInboxView/client.tsx +10 -5
- package/src/views/TicketingSettingsView/client.tsx +9 -7
- package/src/views/shared/config.ts +14 -80
- package/src/views/shared/index.ts +3 -1
- package/src/views/shared/locales/en.json +2 -2
- package/src/views/shared/locales/fr.json +2 -2
- package/dist/components/RichTextEditor/index.cjs +0 -279
- package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
- package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
- package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
- package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
- package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
- package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
- package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
- package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
- package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
- package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
- package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
- package/dist/components/TicketConversation/config.cjs +0 -44
- package/dist/components/TicketConversation/constants.cjs +0 -127
- package/dist/components/TicketConversation/context.cjs +0 -13
- package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
- package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
- package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
- package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
- package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -78
- package/dist/components/TicketConversation/index.cjs +0 -1114
- package/dist/components/TicketConversation/types.cjs +0 -2
- package/dist/components/TicketConversation/utils.cjs +0 -27
- package/dist/views/BillingView/client.cjs +0 -469
- package/dist/views/BillingView/index.cjs +0 -34
- package/dist/views/ChatView/client.cjs +0 -301
- package/dist/views/ChatView/index.cjs +0 -34
- package/dist/views/CrmView/client.cjs +0 -458
- package/dist/views/CrmView/index.cjs +0 -34
- package/dist/views/EmailTrackingView/client.cjs +0 -171
- package/dist/views/EmailTrackingView/index.cjs +0 -34
- package/dist/views/ImportConversationView/client.cjs +0 -238
- package/dist/views/ImportConversationView/index.cjs +0 -34
- package/dist/views/LogsView/client.cjs +0 -148
- package/dist/views/LogsView/index.cjs +0 -32
- package/dist/views/NewTicketView/client.cjs +0 -213
- package/dist/views/NewTicketView/index.cjs +0 -32
- package/dist/views/PendingEmailsView/client.cjs +0 -471
- package/dist/views/PendingEmailsView/index.cjs +0 -34
- package/dist/views/SupportDashboardView/client.cjs +0 -437
- package/dist/views/SupportDashboardView/index.cjs +0 -34
- package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
- package/dist/views/TicketDetailView/client.cjs +0 -1412
- package/dist/views/TicketDetailView/constants.cjs +0 -20
- package/dist/views/TicketDetailView/helpers.cjs +0 -23
- package/dist/views/TicketDetailView/index.cjs +0 -34
- package/dist/views/TicketDetailView/types.cjs +0 -2
- package/dist/views/TicketInboxView/client.cjs +0 -359
- package/dist/views/TicketInboxView/index.cjs +0 -32
- package/dist/views/TicketingSettingsView/client.cjs +0 -740
- package/dist/views/TicketingSettingsView/index.cjs +0 -34
- package/dist/views/TimeDashboardView/client.cjs +0 -180
- package/dist/views/TimeDashboardView/index.cjs +0 -34
- package/dist/views/shared/AdminViewHeader.cjs +0 -68
- package/dist/views/shared/ErrorBoundary.cjs +0 -55
- package/dist/views/shared/Skeleton.cjs +0 -77
- package/dist/views/shared/StatusPill.cjs +0 -47
- package/dist/views/shared/adminTokens.cjs +0 -34
- package/dist/views/shared/config.cjs +0 -44
- package/dist/views/shared/dateLocale.cjs +0 -5
- package/dist/views/shared/index.cjs +0 -72
- package/dist/views/shared/sla.cjs +0 -37
- package/dist/views.cjs +0 -86
- package/dist/views.d.cts +0 -13
- package/src/__tests__/aiSummaryRendering.test.ts +0 -21
- package/src/__tests__/authResponses.test.ts +0 -69
- package/src/__tests__/capabilities.test.ts +0 -71
- package/src/__tests__/generateTrackingToken.test.ts +0 -67
- package/src/__tests__/integration/ai-agent.test.ts +0 -46
- package/src/__tests__/integration/auth.test.ts +0 -43
- package/src/__tests__/integration/automation-rules.test.ts +0 -68
- package/src/__tests__/integration/buildTestPayload.ts +0 -52
- package/src/__tests__/integration/channels.test.ts +0 -52
- package/src/__tests__/integration/core-behaviors.test.ts +0 -81
- package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
- package/src/__tests__/integration/digest.test.ts +0 -48
- package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
- package/src/__tests__/integration/isolation.test.ts +0 -81
- package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
- package/src/__tests__/integration/nps.test.ts +0 -37
- package/src/__tests__/integration/per-team-sla.test.ts +0 -44
- package/src/__tests__/integration/push.test.ts +0 -75
- package/src/__tests__/integration/sanitization.test.ts +0 -35
- package/src/__tests__/integration/sla-pause.test.ts +0 -39
- package/src/__tests__/integration/smoke.test.ts +0 -26
- package/src/__tests__/integration/snooze.test.ts +0 -68
- package/src/__tests__/integration/teams.test.ts +0 -38
- package/src/__tests__/rateLimiter.test.ts +0 -83
- package/src/__tests__/sanitizeHtml.test.ts +0 -165
- package/src/__tests__/sla.test.ts +0 -258
- package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
- package/src/__tests__/webhookSecurity.test.ts +0 -58
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,38 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
3
|
-
|
|
4
|
-
afterEach(() => {
|
|
5
|
-
vi.useRealTimers()
|
|
6
|
-
})
|
|
7
|
-
|
|
8
|
-
describe('RateLimiter', () => {
|
|
9
|
-
it('allows requests up to the maximum and then blocks', async () => {
|
|
10
|
-
const limiter = new RateLimiter(60_000, 2)
|
|
11
|
-
await expect(limiter.check('key')).resolves.toBe(false)
|
|
12
|
-
await expect(limiter.check('key')).resolves.toBe(false)
|
|
13
|
-
await expect(limiter.check('key')).resolves.toBe(true)
|
|
14
|
-
await expect(limiter.check('key')).resolves.toBe(true)
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('resets only the targeted key', async () => {
|
|
18
|
-
const limiter = new RateLimiter(60_000, 1)
|
|
19
|
-
await limiter.check('key-a')
|
|
20
|
-
await limiter.check('key-b')
|
|
21
|
-
await expect(limiter.check('key-a')).resolves.toBe(true)
|
|
22
|
-
await expect(limiter.check('key-b')).resolves.toBe(true)
|
|
23
|
-
|
|
24
|
-
await limiter.reset('key-a')
|
|
25
|
-
|
|
26
|
-
await expect(limiter.check('key-a')).resolves.toBe(false)
|
|
27
|
-
await expect(limiter.check('key-b')).resolves.toBe(true)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('opens a new window after expiry', async () => {
|
|
31
|
-
vi.useFakeTimers()
|
|
32
|
-
const limiter = new RateLimiter(1_000, 1)
|
|
33
|
-
await limiter.check('key')
|
|
34
|
-
await expect(limiter.check('key')).resolves.toBe(true)
|
|
35
|
-
|
|
36
|
-
vi.advanceTimersByTime(1_001)
|
|
37
|
-
|
|
38
|
-
await expect(limiter.check('key')).resolves.toBe(false)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('shares counters between limiter instances using the same store', async () => {
|
|
42
|
-
const store = new MemoryRateLimitStore()
|
|
43
|
-
const firstProcess = new RateLimiter(60_000, 1, store)
|
|
44
|
-
const secondProcess = new RateLimiter(60_000, 1, store)
|
|
45
|
-
|
|
46
|
-
await expect(firstProcess.check('shared')).resolves.toBe(false)
|
|
47
|
-
await expect(secondProcess.check('shared')).resolves.toBe(true)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('supports an asynchronous persistent store adapter', async () => {
|
|
51
|
-
const increment = vi.fn(async () => ({ count: 2, resetAt: Date.now() + 1_000 }))
|
|
52
|
-
const store: RateLimitStore = {
|
|
53
|
-
increment,
|
|
54
|
-
reset: vi.fn(async () => undefined),
|
|
55
|
-
}
|
|
56
|
-
const limiter = new RateLimiter(1_000, 1, store)
|
|
57
|
-
|
|
58
|
-
await expect(limiter.check('client:42')).resolves.toBe(true)
|
|
59
|
-
expect(increment).toHaveBeenCalledWith('client:42', 1_000)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('persists counters through the Payload adapter', async () => {
|
|
63
|
-
let record: { id: number; key: string; count: number; resetAt: string } | undefined
|
|
64
|
-
const payload = {
|
|
65
|
-
find: vi.fn(async () => ({ docs: record ? [record] : [] })),
|
|
66
|
-
create: vi.fn(async ({ data }: { data: typeof record }) => {
|
|
67
|
-
record = { ...data!, id: 1 }
|
|
68
|
-
return record
|
|
69
|
-
}),
|
|
70
|
-
update: vi.fn(async ({ data }: { data: Partial<typeof record> }) => {
|
|
71
|
-
record = { ...record!, ...data }
|
|
72
|
-
return record
|
|
73
|
-
}),
|
|
74
|
-
delete: vi.fn(async () => undefined),
|
|
75
|
-
}
|
|
76
|
-
const store = new PayloadRateLimitStore()
|
|
77
|
-
const firstProcess = new RateLimiter(60_000, 1, store)
|
|
78
|
-
const secondProcess = new RateLimiter(60_000, 1, store)
|
|
79
|
-
|
|
80
|
-
await expect(firstProcess.check('shared', payload)).resolves.toBe(false)
|
|
81
|
-
await expect(secondProcess.check('shared', payload)).resolves.toBe(true)
|
|
82
|
-
})
|
|
83
|
-
})
|
|
@@ -1,165 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { computeSlaState, formatSlaRemaining } from '../views/shared/sla'
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// computeSlaState
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
describe('computeSlaState — no SLA data', () => {
|
|
9
|
-
it('returns state "none" when no due date is set', () => {
|
|
10
|
-
const result = computeSlaState({})
|
|
11
|
-
expect(result.state).toBe('none')
|
|
12
|
-
expect(result.remainingMs).toBeNull()
|
|
13
|
-
expect(result.due).toBeNull()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('returns state "none" when dueRaw is null', () => {
|
|
17
|
-
const result = computeSlaState({ slaFirstResponseDue: null })
|
|
18
|
-
expect(result.state).toBe('none')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('returns state "none" when dueRaw is an invalid date string', () => {
|
|
22
|
-
const result = computeSlaState({ slaFirstResponseDue: 'not-a-date' })
|
|
23
|
-
expect(result.state).toBe('none')
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('computeSlaState — first response SLA (no firstResponseAt)', () => {
|
|
28
|
-
const future2h = new Date(Date.now() + 2 * 3_600_000).toISOString()
|
|
29
|
-
const future30m = new Date(Date.now() + 30 * 60_000).toISOString()
|
|
30
|
-
const past = new Date(Date.now() - 60_000).toISOString()
|
|
31
|
-
|
|
32
|
-
it('returns "ok" when deadline is more than 1 hour away', () => {
|
|
33
|
-
const result = computeSlaState({ slaFirstResponseDue: future2h })
|
|
34
|
-
expect(result.state).toBe('ok')
|
|
35
|
-
expect(result.remainingMs).toBeGreaterThan(3_600_000)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('returns "warn" when deadline is less than 1 hour away', () => {
|
|
39
|
-
const result = computeSlaState({ slaFirstResponseDue: future30m })
|
|
40
|
-
expect(result.state).toBe('warn')
|
|
41
|
-
expect(result.remainingMs).toBeGreaterThan(0)
|
|
42
|
-
expect(result.remainingMs).toBeLessThan(3_600_000)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('returns "breach" when deadline is in the past', () => {
|
|
46
|
-
const result = computeSlaState({ slaFirstResponseDue: past })
|
|
47
|
-
expect(result.state).toBe('breach')
|
|
48
|
-
expect(result.remainingMs).toBeLessThan(0)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('returns "breach" when slaFirstResponseBreached flag is set (even with future due)', () => {
|
|
52
|
-
const result = computeSlaState({
|
|
53
|
-
slaFirstResponseDue: future2h,
|
|
54
|
-
slaFirstResponseBreached: true,
|
|
55
|
-
})
|
|
56
|
-
expect(result.state).toBe('breach')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('exposes the due ISO string in result', () => {
|
|
60
|
-
const result = computeSlaState({ slaFirstResponseDue: future2h })
|
|
61
|
-
expect(result.due).toBe(future2h)
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
describe('computeSlaState — resolution SLA (firstResponseAt present)', () => {
|
|
66
|
-
const firstResponseAt = new Date(Date.now() - 60_000).toISOString()
|
|
67
|
-
const future2h = new Date(Date.now() + 2 * 3_600_000).toISOString()
|
|
68
|
-
const past = new Date(Date.now() - 60_000).toISOString()
|
|
69
|
-
|
|
70
|
-
it('uses resolution SLA when firstResponseAt is set', () => {
|
|
71
|
-
const result = computeSlaState({
|
|
72
|
-
firstResponseAt,
|
|
73
|
-
slaFirstResponseDue: past, // would be breach if used
|
|
74
|
-
slaResolutionDue: future2h, // ok
|
|
75
|
-
})
|
|
76
|
-
// Should use resolution SLA → ok
|
|
77
|
-
expect(result.state).toBe('ok')
|
|
78
|
-
expect(result.due).toBe(future2h)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('returns "breach" when resolution deadline is passed', () => {
|
|
82
|
-
const result = computeSlaState({
|
|
83
|
-
firstResponseAt,
|
|
84
|
-
slaResolutionDue: past,
|
|
85
|
-
})
|
|
86
|
-
expect(result.state).toBe('breach')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('returns "breach" when slaResolutionBreached flag is set', () => {
|
|
90
|
-
const result = computeSlaState({
|
|
91
|
-
firstResponseAt,
|
|
92
|
-
slaResolutionDue: future2h,
|
|
93
|
-
slaResolutionBreached: true,
|
|
94
|
-
})
|
|
95
|
-
expect(result.state).toBe('breach')
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
describe('computeSlaState — pinned "now" parameter', () => {
|
|
100
|
-
it('uses the provided "now" date for comparison', () => {
|
|
101
|
-
const due = '2024-01-15T10:00:00.000Z'
|
|
102
|
-
// now before due → ok
|
|
103
|
-
const before = new Date('2024-01-15T08:00:00.000Z')
|
|
104
|
-
expect(computeSlaState({ slaFirstResponseDue: due }, before).state).toBe('ok')
|
|
105
|
-
// now after due → breach
|
|
106
|
-
const after = new Date('2024-01-15T12:00:00.000Z')
|
|
107
|
-
expect(computeSlaState({ slaFirstResponseDue: due }, after).state).toBe('breach')
|
|
108
|
-
// now 30 min before due → warn
|
|
109
|
-
const soon = new Date('2024-01-15T09:35:00.000Z')
|
|
110
|
-
expect(computeSlaState({ slaFirstResponseDue: due }, soon).state).toBe('warn')
|
|
111
|
-
})
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// formatSlaRemaining
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
describe('formatSlaRemaining', () => {
|
|
119
|
-
it('returns "—" for null', () => {
|
|
120
|
-
expect(formatSlaRemaining(null)).toBe('—')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('formats less-than-1-hour remaining as minutes (positive)', () => {
|
|
124
|
-
const thirtyMin = 30 * 60_000
|
|
125
|
-
expect(formatSlaRemaining(thirtyMin)).toBe('30m')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('formats 0 remaining as "0m"', () => {
|
|
129
|
-
expect(formatSlaRemaining(0)).toBe('0m')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('formats negative < 1 hour as −Xm', () => {
|
|
133
|
-
const minus15min = -(15 * 60_000)
|
|
134
|
-
expect(formatSlaRemaining(minus15min)).toBe('−15m')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('formats exactly 1 hour as "1h"', () => {
|
|
138
|
-
const oneHour = 3_600_000
|
|
139
|
-
expect(formatSlaRemaining(oneHour)).toBe('1h')
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('formats 90 minutes as "1h 30"', () => {
|
|
143
|
-
const ninetyMin = 90 * 60_000
|
|
144
|
-
expect(formatSlaRemaining(ninetyMin)).toBe('1h 30')
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('formats negative hours as −Xh', () => {
|
|
148
|
-
const minus2h = -(2 * 3_600_000)
|
|
149
|
-
expect(formatSlaRemaining(minus2h)).toBe('−2h')
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('formats 24h+ as days (Xj)', () => {
|
|
153
|
-
const twoDays = 48 * 3_600_000
|
|
154
|
-
expect(formatSlaRemaining(twoDays)).toBe('2j')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('formats negative 24h+ as −Xj', () => {
|
|
158
|
-
const minusThreeDays = -(3 * 24 * 3_600_000)
|
|
159
|
-
expect(formatSlaRemaining(minusThreeDays)).toBe('−3j')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('formats whole hours without trailing "00" minutes', () => {
|
|
163
|
-
const twoHoursExact = 2 * 3_600_000
|
|
164
|
-
const result = formatSlaRemaining(twoHoursExact)
|
|
165
|
-
expect(result).toBe('2h')
|
|
166
|
-
})
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// calculateBusinessHoursDeadline (imported from hooks/checkSLA)
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
|
|
173
|
-
import { calculateBusinessHoursDeadline } from '../hooks/checkSLA'
|
|
174
|
-
|
|
175
|
-
describe('calculateBusinessHoursDeadline', () => {
|
|
176
|
-
// Use UTC dates — the function uses local Date methods without TZ conversion.
|
|
177
|
-
// We pin times to Tuesday 2024-01-09 so all boundary math is predictable.
|
|
178
|
-
|
|
179
|
-
it('adds 60 minutes within the same business day', () => {
|
|
180
|
-
// Tuesday 2024-01-09 at 10:00 local
|
|
181
|
-
const start = new Date(2024, 0, 9, 10, 0, 0) // month is 0-indexed
|
|
182
|
-
const result = calculateBusinessHoursDeadline(start, 60)
|
|
183
|
-
expect(result.getHours()).toBe(11)
|
|
184
|
-
expect(result.getMinutes()).toBe(0)
|
|
185
|
-
expect(result.getDate()).toBe(9)
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('carries over minutes past end-of-day to the next business day', () => {
|
|
189
|
-
// Tuesday at 17:00, add 120 minutes (only 60 remain today: 17:00→18:00)
|
|
190
|
-
// Remaining 60 minutes spills into Wednesday morning: 9:00→10:00
|
|
191
|
-
const start = new Date(2024, 0, 9, 17, 0, 0)
|
|
192
|
-
const result = calculateBusinessHoursDeadline(start, 120)
|
|
193
|
-
expect(result.getDate()).toBe(10) // Wednesday
|
|
194
|
-
expect(result.getHours()).toBe(10)
|
|
195
|
-
expect(result.getMinutes()).toBe(0)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('skips Saturday and Sunday', () => {
|
|
199
|
-
// Friday 2024-01-12 at 17:00, add 120 minutes → skips Sat/Sun → Monday 10:00
|
|
200
|
-
const start = new Date(2024, 0, 12, 17, 0, 0) // Friday
|
|
201
|
-
const result = calculateBusinessHoursDeadline(start, 120)
|
|
202
|
-
// 60 min remaining Friday (17→18), then 60 min on Monday (9→10)
|
|
203
|
-
expect(result.getDay()).toBe(1) // Monday
|
|
204
|
-
expect(result.getHours()).toBe(10)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('moves a Saturday start to Monday 9:00 before counting', () => {
|
|
208
|
-
// Saturday at 10:00, add 60 minutes → Monday 10:00
|
|
209
|
-
const start = new Date(2024, 0, 13, 10, 0, 0) // Saturday
|
|
210
|
-
const result = calculateBusinessHoursDeadline(start, 60)
|
|
211
|
-
expect(result.getDay()).toBe(1) // Monday
|
|
212
|
-
expect(result.getHours()).toBe(10)
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('moves a Sunday start to Monday 9:00 before counting', () => {
|
|
216
|
-
// Sunday at 15:00, add 30 minutes → Monday 9:30
|
|
217
|
-
const start = new Date(2024, 0, 14, 15, 0, 0) // Sunday
|
|
218
|
-
const result = calculateBusinessHoursDeadline(start, 30)
|
|
219
|
-
expect(result.getDay()).toBe(1) // Monday
|
|
220
|
-
expect(result.getHours()).toBe(9)
|
|
221
|
-
expect(result.getMinutes()).toBe(30)
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
it('moves a before-hours start to 9:00 before counting', () => {
|
|
225
|
-
// Tuesday at 7:00 (before 9:00), add 30 minutes → 9:30 same day
|
|
226
|
-
const start = new Date(2024, 0, 9, 7, 0, 0)
|
|
227
|
-
const result = calculateBusinessHoursDeadline(start, 30)
|
|
228
|
-
expect(result.getDate()).toBe(9)
|
|
229
|
-
expect(result.getHours()).toBe(9)
|
|
230
|
-
expect(result.getMinutes()).toBe(30)
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
it('moves an after-hours start to next business day 9:00', () => {
|
|
234
|
-
// Tuesday at 19:00 (after 18:00), add 60 minutes → Wednesday 10:00
|
|
235
|
-
const start = new Date(2024, 0, 9, 19, 0, 0)
|
|
236
|
-
const result = calculateBusinessHoursDeadline(start, 60)
|
|
237
|
-
expect(result.getDate()).toBe(10) // Wednesday
|
|
238
|
-
expect(result.getHours()).toBe(10)
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('handles 0 minutes (deadline = start clamped to business hours)', () => {
|
|
242
|
-
const start = new Date(2024, 0, 9, 10, 30, 0) // Tuesday at 10:30
|
|
243
|
-
const result = calculateBusinessHoursDeadline(start, 0)
|
|
244
|
-
// Start is within business hours → no movement
|
|
245
|
-
expect(result.getDate()).toBe(9)
|
|
246
|
-
expect(result.getHours()).toBe(10)
|
|
247
|
-
expect(result.getMinutes()).toBe(30)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
it('handles a full business day (540 min = 9 hours) spanning exactly one day', () => {
|
|
251
|
-
// Tuesday at 9:00, add 540 min (exactly one business day) → Tuesday 18:00
|
|
252
|
-
const start = new Date(2024, 0, 9, 9, 0, 0)
|
|
253
|
-
const result = calculateBusinessHoursDeadline(start, 540)
|
|
254
|
-
expect(result.getDate()).toBe(9)
|
|
255
|
-
expect(result.getHours()).toBe(18)
|
|
256
|
-
expect(result.getMinutes()).toBe(0)
|
|
257
|
-
})
|
|
258
|
-
})
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { createTrackOpenEndpoint, generateTrackingToken } from '../endpoints/track-open'
|
|
3
|
-
import { resolveSlugs } from '../utils/slugs'
|
|
4
|
-
|
|
5
|
-
const secret = 'test-payload-secret'
|
|
6
|
-
|
|
7
|
-
function createRequest(ticketId: string, messageId: string, signature: string) {
|
|
8
|
-
const findByID = vi.fn(async ({ collection }: { collection: string }) => {
|
|
9
|
-
if (collection === 'ticket-messages') {
|
|
10
|
-
return { id: Number(messageId), ticket: Number(ticketId) + 1, emailOpenedAt: null }
|
|
11
|
-
}
|
|
12
|
-
return { id: Number(ticketId), lastClientReadAt: null }
|
|
13
|
-
})
|
|
14
|
-
const update = vi.fn()
|
|
15
|
-
return {
|
|
16
|
-
req: {
|
|
17
|
-
url: `https://example.test/api/support/track-open?t=${ticketId}&m=${messageId}&sig=${signature}`,
|
|
18
|
-
payload: { findByID, update },
|
|
19
|
-
},
|
|
20
|
-
findByID,
|
|
21
|
-
update,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('track-open endpoint', () => {
|
|
26
|
-
it('does not query the database for signed non-numeric identifiers', async () => {
|
|
27
|
-
process.env.PAYLOAD_SECRET = secret
|
|
28
|
-
const ticketId = 'not-a-ticket'
|
|
29
|
-
const messageId = 'not-a-message'
|
|
30
|
-
const signature = generateTrackingToken(ticketId, messageId, secret)
|
|
31
|
-
const { req, findByID, update } = createRequest(ticketId, messageId, signature)
|
|
32
|
-
const endpoint = createTrackOpenEndpoint(resolveSlugs())
|
|
33
|
-
|
|
34
|
-
const response = await endpoint.handler(req as never)
|
|
35
|
-
|
|
36
|
-
expect(response.status).toBe(200)
|
|
37
|
-
expect(findByID).not.toHaveBeenCalled()
|
|
38
|
-
expect(update).not.toHaveBeenCalled()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('does not mutate when the signed message belongs to another ticket', async () => {
|
|
42
|
-
process.env.PAYLOAD_SECRET = secret
|
|
43
|
-
const ticketId = '10'
|
|
44
|
-
const messageId = '20'
|
|
45
|
-
const signature = generateTrackingToken(ticketId, messageId, secret)
|
|
46
|
-
const { req, update } = createRequest(ticketId, messageId, signature)
|
|
47
|
-
const endpoint = createTrackOpenEndpoint(resolveSlugs())
|
|
48
|
-
|
|
49
|
-
const response = await endpoint.handler(req as never)
|
|
50
|
-
|
|
51
|
-
expect(response.status).toBe(200)
|
|
52
|
-
expect(update).not.toHaveBeenCalled()
|
|
53
|
-
})
|
|
54
|
-
})
|