mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 14:29:36 +00:00
Merge pull request #712 from abhinavxd/qa/2.9.0-release
fix issues found during release testing
This commit is contained in:
+2
-3
@@ -86,7 +86,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
|
||||
if err := app.conversation.UpdateCSATResponse(uuid, rating, feedback, metaJSON); err != nil {
|
||||
// Surface only known, user-safe messages; never leak raw driver errors
|
||||
// (e.g. a malformed UUID triggers a Postgres syntax error string).
|
||||
msg := app.i18n.T("globals.messages.somethingWentWrong")
|
||||
@@ -161,8 +161,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error {
|
||||
|
||||
req.Feedback = truncateFeedback(req.Feedback)
|
||||
|
||||
// Update CSAT response
|
||||
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
|
||||
if err := app.conversation.UpdateCSATResponse(uuid, req.Rating, req.Feedback, nil /** meta **/); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@ func handleOverviewCharts(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
charts, err := app.report.GetOverviewChart(days)
|
||||
charts, err := app.report.GetOverviewChart(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func handleOverviewSLA(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
sla, err := app.report.GetOverviewSLA(days)
|
||||
sla, err := app.report.GetOverviewSLA(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func handleOverviewCSAT(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
csat, err := app.report.GetOverviewCSAT(days)
|
||||
csat, err := app.report.GetOverviewCSAT(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func handleOverviewMessageVolume(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
volume, err := app.report.GetOverviewMessageVolume(days)
|
||||
volume, err := app.report.GetOverviewMessageVolume(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func handleOverviewTagDistribution(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
tags, err := app.report.GetOverviewTagDistribution(days)
|
||||
tags, err := app.report.GetOverviewTagDistribution(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ export const createFormSchema = (t) => z.object({
|
||||
smtp: z.object({
|
||||
host: z.string().min(1, t('globals.messages.required')),
|
||||
port: z.number().min(1).max(65535),
|
||||
username: z.string().min(1, t('globals.messages.required')),
|
||||
password: z.string().min(1, t('globals.messages.required')),
|
||||
username: z.string().default(''),
|
||||
password: z.string().default(''),
|
||||
max_conns: z.number().min(1),
|
||||
max_msg_retries: z.number().min(0).max(100),
|
||||
idle_timeout: z.string().min(1, t('globals.messages.required')).refine(isGoDuration, {
|
||||
@@ -70,6 +70,13 @@ export const createFormSchema = (t) => z.object({
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
hello_hostname: z.string().optional(),
|
||||
auth_protocol: z.enum(['login', 'cram', 'plain', 'none'])
|
||||
}).superRefine((smtp, ctx) => {
|
||||
if (smtp.auth_protocol === 'none') return
|
||||
for (const field of ['username', 'password']) {
|
||||
if (!smtp[field]) {
|
||||
ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') })
|
||||
}
|
||||
}
|
||||
})
|
||||
}).superRefine((values, ctx) => {
|
||||
const primary = extractEmailAddress(values.from).toLowerCase()
|
||||
|
||||
@@ -248,6 +248,28 @@ describe('Email Inbox Form Schema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('unauthenticated SMTP accepts empty and omitted credentials', () => {
|
||||
for (const credential of ['', undefined]) {
|
||||
expect(() => schema.parse(withSmtp({
|
||||
auth_protocol: 'none', username: credential, password: credential
|
||||
}))).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated SMTP requires both credentials', () => {
|
||||
for (const auth_protocol of ['login', 'cram', 'plain']) {
|
||||
for (const field of ['username', 'password']) {
|
||||
for (const credential of ['', undefined]) {
|
||||
const result = schema.safeParse(withSmtp({ auth_protocol, [field]: credential }))
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['smtp', field] })
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('smtp hello_hostname optional', () => {
|
||||
expect(() => schema.parse(withSmtp({ hello_hostname: 'desk.example.com' }))).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -3,9 +3,7 @@ import { isGoDuration } from '@shared-ui/utils/string';
|
||||
|
||||
export const createFormSchema = (t) => z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
username: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
username: z.string().default(''),
|
||||
host: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
@@ -17,9 +15,7 @@ export const createFormSchema = (t) => z.object({
|
||||
.min(1, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) })
|
||||
.max(65535, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) })
|
||||
.default(587),
|
||||
password: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
password: z.string().default(''),
|
||||
max_conns: z
|
||||
.number({
|
||||
invalid_type_error: t('globals.messages.mustBeNumber'),
|
||||
@@ -54,4 +50,11 @@ export const createFormSchema = (t) => z.object({
|
||||
hello_hostname: z.string().optional(),
|
||||
tls_type: z.enum(['none', 'starttls', 'tls']),
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
}).superRefine((smtp, ctx) => {
|
||||
if (smtp.auth_protocol === 'none') return
|
||||
for (const field of ['username', 'password']) {
|
||||
if (!smtp[field]) {
|
||||
ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,6 +161,28 @@ describe('Notification Form Schema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('unauthenticated SMTP accepts empty and omitted credentials', () => {
|
||||
for (const credential of ['', undefined]) {
|
||||
expect(() => schema.parse({
|
||||
...validForm, auth_protocol: 'none', username: credential, password: credential
|
||||
})).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated SMTP requires both credentials', () => {
|
||||
for (const auth_protocol of ['login', 'cram', 'plain']) {
|
||||
for (const field of ['username', 'password']) {
|
||||
for (const credential of ['', undefined]) {
|
||||
const result = schema.safeParse({ ...validForm, auth_protocol, [field]: credential })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: [field] })
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('tls_type missing', () => {
|
||||
const { tls_type, ...form } = validForm
|
||||
expect(() => schema.parse(form)).toThrow()
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button
|
||||
v-if="initialValues?.is_builtin && defaultBody"
|
||||
v-if="(isOutgoingTemplate || initialValues?.is_builtin) && defaultBody"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isLoading"
|
||||
@@ -88,7 +88,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue'
|
||||
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
|
||||
import { Label } from '@shared-ui/components/ui/label/index.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js'
|
||||
import { BUILT_IN_TEMPLATE_BODIES, DEFAULT_OUTGOING_TEMPLATE_BODY } from './defaults.js'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
@@ -133,6 +133,7 @@ const hideSubject = computed(() => {
|
||||
})
|
||||
|
||||
const defaultBody = computed(() => {
|
||||
if (isOutgoingTemplate.value) return DEFAULT_OUTGOING_TEMPLATE_BODY
|
||||
return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
export const DEFAULT_OUTGOING_TEMPLATE_BODY = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #0055d4; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; background-color: #f4f4f4; padding: 12px; }
|
||||
@media screen and (max-width: 600px) {
|
||||
.email-container { padding: 16px 8px !important; }
|
||||
.email-card { padding: 24px 20px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; background-color: #f0f1f3; font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 15px; line-height: 26px; color: #444;">
|
||||
<div class="email-container" style="padding: 32px 16px;">
|
||||
<div class="email-card" style="max-width: 525px; margin: 0 auto; padding: 32px; background-color: #fff; border-radius: 5px; overflow-wrap: anywhere;">
|
||||
{{/* Optional logo. Replace the image URL and alt text, or remove this div. */}}
|
||||
<div style="margin-bottom: 28px;">
|
||||
<img src="https://libredesk.io/logo.png" alt="libredesk" width="150" style="display: block; width: 150px; max-width: 100%; height: auto; border: 0;">
|
||||
</div>
|
||||
|
||||
{{/* Uses the recipient's first name when available, otherwise displays Hi. */}}
|
||||
<p style="margin: 0 0 20px;">Hi{{ if .Recipient.FirstName }} {{ .Recipient.FirstName }}{{ end }},</p>
|
||||
|
||||
{{/* Required reply content. Keep the following template call exactly once. */}}
|
||||
{{ template "content" . }}
|
||||
|
||||
{{/* Displays the signature when the author's first name is available. */}}
|
||||
{{ if .Author.FirstName }}
|
||||
<p style="margin: 24px 0 0;">
|
||||
Best regards,<br>
|
||||
{{ .Author.FirstName }}
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export const BUILT_IN_TEMPLATE_BODIES = {
|
||||
'Conversation assigned': `<p>Hi {{ .Recipient.FirstName }},</p>
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, toRaw, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { getTextFromHTML } from '@shared-ui/utils/string'
|
||||
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
|
||||
@@ -337,7 +338,7 @@ const {
|
||||
|
||||
// Rest of existing state
|
||||
const isEditorFullscreen = ref(false)
|
||||
const isMinimized = ref(false)
|
||||
const isMinimized = useStorage('replyBoxMinimized', false)
|
||||
const isSending = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const to = ref('')
|
||||
|
||||
@@ -696,6 +696,23 @@ const router = createRouter({
|
||||
routes: routes
|
||||
})
|
||||
|
||||
const preloadErrors = new WeakSet()
|
||||
const reloadKey = 'libredesk:preload-reload'
|
||||
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
preloadErrors.add(event.payload)
|
||||
})
|
||||
|
||||
router.onError((error, to) => {
|
||||
if (!preloadErrors.has(error) || sessionStorage.getItem(reloadKey) === to.fullPath) return
|
||||
sessionStorage.setItem(reloadKey, to.fullPath)
|
||||
window.location.assign(to.fullPath)
|
||||
})
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
if (!failure) sessionStorage.removeItem(reloadKey)
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Cancel in-flight requests.
|
||||
if (to.fullPath !== from.fullPath) {
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
:key="attachment.uuid"
|
||||
class="flex items-center cursor-pointer"
|
||||
>
|
||||
<!-- Image preview -->
|
||||
<div v-if="isImage(attachment)" class="relative">
|
||||
<img
|
||||
:src="getThumbnailUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
class="max-w-48 max-h-32 rounded-md object-cover"
|
||||
@error="fallbackToOriginal($event, attachment.url)"
|
||||
@error="failedThumbnails.add(attachment.uuid)"
|
||||
@click="openImage(attachment.url)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File attachment -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-2 p-2 bg-muted rounded-md border border-border hover:bg-muted/80 transition-colors"
|
||||
@@ -35,6 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { File } from 'lucide-vue-next';
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file';
|
||||
defineProps({
|
||||
@@ -44,21 +43,17 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const failedThumbnails = ref(new Set())
|
||||
|
||||
const isImage = (attachment) => {
|
||||
return attachment.content_type && attachment.content_type.startsWith('image/')
|
||||
}
|
||||
|
||||
const getThumbnailUrl = (attachment) => {
|
||||
if (!isImage(attachment)) return attachment.url
|
||||
if (!isImage(attachment) || failedThumbnails.value.has(attachment.uuid)) return attachment.url
|
||||
return attachment.thumbnail_url || getThumbFilepath(attachment.url)
|
||||
}
|
||||
|
||||
const fallbackToOriginal = (event, originalUrl) => {
|
||||
if (event.target.dataset.originalFallback) return
|
||||
event.target.dataset.originalFallback = 'true'
|
||||
event.target.src = originalUrl
|
||||
}
|
||||
|
||||
const openImage = (url) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export function useReplyPreviews() {
|
||||
identity: String(user.userID || 'visitor'),
|
||||
previews,
|
||||
labels: {
|
||||
openChat: t('globals.messages.openChat'),
|
||||
closeChat: t('globals.messages.closeChat'),
|
||||
dismiss: t('globals.terms.dismiss'),
|
||||
dismissAll: t('widget.dismissPreviews'),
|
||||
open: t('globals.messages.openConversation')
|
||||
|
||||
@@ -158,3 +158,30 @@ describe('Admin setup and outgoing conversation', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recovering a stale route after deployment', () => {
|
||||
it('reloads a missing route chunk once and opens the requested page', () => {
|
||||
let loads = 0
|
||||
cy.login()
|
||||
cy.viewport(1280, 800)
|
||||
cy.on('window:before:load', () => { loads++ })
|
||||
cy.on('uncaught:exception', (error) => {
|
||||
if (error.message.includes('Failed to fetch dynamically imported module:') &&
|
||||
error.message.includes('/assets/WebhookList-')) return false
|
||||
})
|
||||
cy.intercept({ method: 'GET', url: '**/assets/WebhookList-*.js', times: 1 }, {
|
||||
statusCode: 404, body: '', headers: { 'content-type': 'text/javascript' }
|
||||
}).as('missingRouteChunk')
|
||||
cy.visit('/admin/conversations/tags')
|
||||
cy.contains('button', /^Integrations$/).click()
|
||||
cy.get('a[href="/admin/webhooks"]').first().scrollIntoView()
|
||||
cy.get('a[href="/admin/webhooks"]').first().click()
|
||||
cy.wait('@missingRouteChunk').its('response.statusCode').should('eq', 404)
|
||||
cy.location('pathname').should('eq', '/admin/webhooks')
|
||||
cy.contains('button', 'New webhook').should('be.visible')
|
||||
cy.window().should((win) => {
|
||||
expect(loads).to.eq(2)
|
||||
expect(win.sessionStorage.getItem('libredesk:preload-reload')).to.eq(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
describe('API: templates', () => {
|
||||
const stamp = Date.now()
|
||||
const name = `api-template-${stamp}`
|
||||
const reply = '{{ template "content" . }}'
|
||||
let templateId
|
||||
|
||||
before(() => cy.login())
|
||||
@@ -64,12 +65,22 @@ describe('API: templates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an outgoing template without the reply block', () => {
|
||||
cy.api('POST', '/api/v1/templates', {
|
||||
name: `${name}-noreply`, type: 'email_outgoing', body: '<p>no reply</p>'
|
||||
}, { failOnStatusCode: false }).then(({ status, body }) => {
|
||||
expect(status).to.eq(400)
|
||||
expect(body.error_type).to.eq('InputException')
|
||||
expect(body.message).to.contain('{{ template "content" . }}')
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a template and persists every field', () => {
|
||||
cy.api('POST', '/api/v1/templates', {
|
||||
name,
|
||||
type: 'email_outgoing',
|
||||
subject: 'Api subject',
|
||||
body: '<p>api body</p>',
|
||||
body: `<p>api body</p>${reply}`,
|
||||
is_default: false
|
||||
}).then(({ status, body }) => {
|
||||
expect(status).to.eq(200)
|
||||
@@ -79,7 +90,7 @@ describe('API: templates', () => {
|
||||
expect(body.data.name).to.eq(name)
|
||||
expect(body.data.type).to.eq('email_outgoing')
|
||||
expect(body.data.subject).to.eq('Api subject')
|
||||
expect(body.data.body).to.eq('<p>api body</p>')
|
||||
expect(body.data.body).to.eq(`<p>api body</p>${reply}`)
|
||||
expect(body.data.is_default).to.eq(false)
|
||||
expect(body.data.is_builtin).to.eq(false)
|
||||
})
|
||||
@@ -113,14 +124,14 @@ describe('API: templates', () => {
|
||||
name: `${name}-renamed`,
|
||||
type: 'email_outgoing',
|
||||
subject: 'Api subject updated',
|
||||
body: '<p>updated</p>',
|
||||
body: `<p>updated</p>${reply}`,
|
||||
is_default: false
|
||||
}).its('status').should('eq', 200)
|
||||
|
||||
cy.api('GET', `/api/v1/templates/${templateId}`).then(({ body }) => {
|
||||
expect(body.data.name).to.eq(`${name}-renamed`)
|
||||
expect(body.data.subject).to.eq('Api subject updated')
|
||||
expect(body.data.body).to.eq('<p>updated</p>')
|
||||
expect(body.data.body).to.eq(`<p>updated</p>${reply}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,114 @@ describe('Live chat widget embedded on a host page', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the launcher usable by keyboard under host button styles', () => {
|
||||
cy.createLivechatInbox(embedConfig()).then((inbox) => {
|
||||
cy.visitWidgetHost(inbox.uuid)
|
||||
cy.document().then((doc) => {
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = 'button { margin: 30px; padding: 40px; min-width: 280px; background-image: linear-gradient(black, white); border: 10px solid black; }'
|
||||
doc.head.appendChild(style)
|
||||
})
|
||||
cy.widgetLauncher().should('be.visible').and('have.prop', 'tagName', 'BUTTON')
|
||||
.and('have.attr', 'type', 'button')
|
||||
.and('have.attr', 'aria-expanded', 'false')
|
||||
.and('have.attr', 'aria-label', 'Open chat')
|
||||
cy.widgetLauncher().then(($button) => {
|
||||
const button = $button[0]
|
||||
const style = button.ownerDocument.defaultView.getComputedStyle(button)
|
||||
expect(button.getBoundingClientRect().width).to.eq(60)
|
||||
expect(button.getBoundingClientRect().height).to.eq(60)
|
||||
expect(style.margin).to.eq('0px')
|
||||
expect(style.backgroundImage).to.eq('none')
|
||||
})
|
||||
cy.viewport(390, 844)
|
||||
cy.widgetLauncher().focus()
|
||||
cy.widgetLauncher().type('{enter}')
|
||||
cy.widgetLauncher().should('have.attr', 'aria-expanded', 'true')
|
||||
.and('have.attr', 'aria-label', 'Close chat')
|
||||
cy.widgetBody().find('button[aria-label="Close chat"]').focus()
|
||||
cy.widgetBody().find('button[aria-label="Close chat"]').click()
|
||||
cy.widgetLauncher().should('be.focused').and('have.attr', 'aria-expanded', 'false')
|
||||
cy.viewport(1000, 660)
|
||||
cy.widgetLauncher().type(' ')
|
||||
cy.get('iframe[src*="/widget?inbox_id="]').should('be.visible')
|
||||
cy.widgetLauncher().focus()
|
||||
cy.widgetLauncher().type(' ')
|
||||
cy.get('iframe[src*="/widget?inbox_id="]').should('not.be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the original image visible after a missing thumbnail and reconnect', () => {
|
||||
cy.createLivechatInbox(embedConfig({ features: { file_upload: true } })).then((inbox) => {
|
||||
cy.intercept('GET', '**/uploads/thumb_*', { statusCode: 404, body: '' }).as('missingThumbnail')
|
||||
cy.intercept('GET', '**/api/v1/widget/chat/conversations/*').as('openedConversation')
|
||||
cy.visitWidgetHost(inbox.uuid)
|
||||
cy.widgetLauncher().click()
|
||||
cy.widgetBody().contains(startButtonText).click()
|
||||
cy.widgetSend('Image fallback conversation')
|
||||
cy.wait('@openedConversation')
|
||||
cy.widgetBody().find('input[type="file"]').selectFile('../static/public/launcher-logo.png', { force: true })
|
||||
cy.wait('@missingThumbnail')
|
||||
const image = () => cy.widgetBody().find('[data-message-uuid] img[alt="launcher-logo.png"]')
|
||||
image().should(($image) => {
|
||||
expect($image[0].naturalWidth).to.be.greaterThan(0)
|
||||
expect($image.attr('src')).not.to.include('/thumb_')
|
||||
})
|
||||
let originalImage
|
||||
image().then(($image) => { originalImage = $image[0] })
|
||||
cy.intercept('GET', '**/api/v1/widget/chat/conversations/*', (req) => {
|
||||
req.continue((res) => {
|
||||
for (const message of res.body.data.messages) {
|
||||
for (const attachment of message.attachments || []) {
|
||||
if (attachment.name !== 'launcher-logo.png') continue
|
||||
const thumbnail = new URL(attachment.thumbnail_url)
|
||||
thumbnail.searchParams.set('refresh', '1')
|
||||
attachment.thumbnail_url = thumbnail.href
|
||||
}
|
||||
}
|
||||
})
|
||||
}).as('recoveredConversation')
|
||||
cy.window().then((win) => {
|
||||
const frame = win.Libredesk.iframe.contentWindow
|
||||
frame.dispatchEvent(new frame.Event('online'))
|
||||
})
|
||||
cy.wait('@recoveredConversation').its('response.statusCode').should('eq', 200)
|
||||
image().should(($image) => {
|
||||
expect($image[0]).to.eq(originalImage)
|
||||
expect($image[0].naturalWidth).to.be.greaterThan(0)
|
||||
expect($image.attr('src')).not.to.include('/thumb_')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
for (const rating of [0, 5]) {
|
||||
it(`shows submitted CSAT feedback live in the agent view with rating ${rating}`, () => {
|
||||
const feedback = `Live customer feedback ${rating} ${Date.now()}`
|
||||
let inbox
|
||||
let session
|
||||
let originalDocument
|
||||
cy.createLivechatInbox({}, { csat_enabled: true }).then((created) => { inbox = created })
|
||||
cy.then(() => cy.widgetInit(inbox.uuid)).then((created) => { session = created })
|
||||
cy.then(() => cy.visit(`/inboxes/all/conversation/${session.conversationUuid}`))
|
||||
cy.document().then((doc) => { originalDocument = doc })
|
||||
cy.then(() => cy.api('PUT', `/api/v1/conversations/${session.conversationUuid}/status`, {
|
||||
status: 'Resolved'
|
||||
})).its('status').should('eq', 200)
|
||||
cy.contains('[data-message-uuid]', 'Awaiting Response', { timeout: 20000 }).should('be.visible')
|
||||
cy.then(() => cy.widgetApi('GET', `/api/v1/widget/chat/conversations/${session.conversationUuid}`,
|
||||
session.sessionToken, inbox.uuid)).then(({ body }) => {
|
||||
const survey = body.data.messages.find((message) => message.meta?.is_csat)
|
||||
expect(survey?.meta.csat_uuid).to.be.a('string')
|
||||
cy.request('POST', `/api/v1/csat/${survey.meta.csat_uuid}/response`, { rating, feedback })
|
||||
.its('status').should('eq', 200)
|
||||
cy.get(`[data-message-uuid="${survey.uuid}"]`).should('contain.text', feedback)
|
||||
.and('not.contain.text', 'Awaiting Response')
|
||||
if (rating) cy.get(`[data-message-uuid="${survey.uuid}"]`).should('contain.text', '5/5')
|
||||
})
|
||||
cy.document().should((doc) => { expect(doc).to.eq(originalDocument) })
|
||||
})
|
||||
}
|
||||
|
||||
it('moves the launcher when the inbox launcher position changes', () => {
|
||||
cy.createLivechatInbox(
|
||||
embedConfig({ launcher: { position: 'right', spacing: { side: 20, bottom: 20 } } })
|
||||
|
||||
@@ -169,6 +169,9 @@ describe('Conversation lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1440, 900) // desktop layout: list, conversation and sidebar all on screen
|
||||
cy.login()
|
||||
for (const uuid of [conversationUuid, ccConversationUuid]) {
|
||||
cy.api('DELETE', `/api/v1/conversations/${uuid}/draft?type=reply`)
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the conversation from the inbox list', () => {
|
||||
@@ -346,7 +349,199 @@ describe('Conversation lifecycle', () => {
|
||||
expectSentRecipients(ccConversationUuid, { cc: [], bcc: [] })
|
||||
})
|
||||
|
||||
it('sends only what each conversation shows after switching between them', () => {
|
||||
for (const field of ['CC', 'BCC']) {
|
||||
it(`keeps ${field} open and focused while replacing an address`, () => {
|
||||
openConversation(ccConversationUuid)
|
||||
cy.get('.tiptap.ProseMirror[contenteditable="true"]').should('be.focused')
|
||||
if (field === 'BCC') {
|
||||
cy.contains('button', /^BCC$/).click()
|
||||
recipientRow(/^BCC$/).find('input').type(bccAddress)
|
||||
}
|
||||
recipientRow(new RegExp(`^${field}$`)).find('input').clear()
|
||||
recipientRow(new RegExp(`^${field}$`)).find('input')
|
||||
.should('be.focused').and('have.value', '')
|
||||
.type('replacement@example.com')
|
||||
recipientRow(new RegExp(`^${field}$`)).find('input')
|
||||
.should('have.value', 'replacement@example.com')
|
||||
})
|
||||
}
|
||||
|
||||
for (const copies of [
|
||||
{ name: 'without copies', cc: '', bcc: '' },
|
||||
{ name: 'with CC', cc: 'draft.copy@example.com', bcc: '' },
|
||||
{ name: 'with BCC', cc: '', bcc: bccAddress },
|
||||
{ name: 'with CC and BCC', cc: 'draft.copy@example.com', bcc: bccAddress }
|
||||
]) {
|
||||
it(`preserves a minimized reply through reload and send ${copies.name}`, () => {
|
||||
const content = `Minimized reply ${copies.name} ${stamp}`
|
||||
const sender = `lifecycle+${stamp}@cypress.test`
|
||||
const contactEmail = `minimized.${copies.name.toLowerCase().replaceAll(' ', '.')}.${stamp}@example.com`
|
||||
const to = [contactEmail, 'second.recipient@example.com']
|
||||
const uploads = []
|
||||
let uuid
|
||||
cy.api('POST', '/api/v1/conversations', {
|
||||
inbox_id: inboxId, contact_email: contactEmail, first_name: 'Minimize', subject: content,
|
||||
content: '<p>Minimize test conversation.</p>', initiator: 'contact'
|
||||
}).then(({ body }) => {
|
||||
uuid = body.data.uuid
|
||||
cy.api('POST', `/api/v1/conversations/${uuid}/messages`, {
|
||||
sender_type: 'agent', private: false, message: '<p>Previous reply.</p>',
|
||||
to, cc: copies.cc ? [copies.cc] : [], bcc: copies.bcc ? [copies.bcc] : [],
|
||||
send_from: sender
|
||||
}).its('status').should('eq', 200)
|
||||
cy.api('POST', `/api/v1/conversations/${uuid}/draft`, {
|
||||
type: 'reply', content: `<p>${content}</p>`, meta: {}
|
||||
}).its('status').should('eq', 200)
|
||||
openConversation(uuid)
|
||||
})
|
||||
cy.get('.tiptap.ProseMirror').should('contain.text', content)
|
||||
cy.intercept('POST', '**/api/v1/media').as('replyUpload')
|
||||
cy.intercept('POST', '**/conversations/*/draft', (req) => {
|
||||
if (req.body.meta?.attachments?.length === 2) req.alias = 'savedMinimizedDraft'
|
||||
})
|
||||
cy.get('input[type="file"]').first().selectFile([
|
||||
{ contents: Cypress.Buffer.from('Reply attachment'), fileName: 'reply-draft.txt', mimeType: 'text/plain' },
|
||||
{
|
||||
contents: Cypress.Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz0AAAAASUVORK5CYII=', 'base64'),
|
||||
fileName: 'reply-image.png', mimeType: 'image/png'
|
||||
}
|
||||
], { force: true })
|
||||
for (let index = 0; index < 2; index++) {
|
||||
cy.wait('@replyUpload').then(({ response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
uploads.push(response.body.data)
|
||||
})
|
||||
}
|
||||
cy.wait('@savedMinimizedDraft').its('response.statusCode').should('eq', 200)
|
||||
const checkDraft = () => {
|
||||
cy.get('.tiptap.ProseMirror').should('contain.text', content)
|
||||
recipientRow(/^To$/).find('input').should('have.value', to.join(', '))
|
||||
for (const field of ['cc', 'bcc']) {
|
||||
if (field === 'cc' && copies[field]) recipientRow(new RegExp(`^${field.toUpperCase()}$`)).find('input').should('have.value', copies[field])
|
||||
else cy.contains('label', new RegExp(`^${field.toUpperCase()}$`)).should('not.exist')
|
||||
}
|
||||
cy.contains('reply-draft.txt').should('be.visible')
|
||||
cy.contains('reply-image.png').should('be.visible')
|
||||
}
|
||||
checkDraft()
|
||||
cy.get('.box button[aria-label="Collapse"]').click()
|
||||
cy.window().its('localStorage').invoke('getItem', 'replyBoxMinimized').should('eq', 'true')
|
||||
cy.reload()
|
||||
cy.wait('@loadMessages')
|
||||
cy.contains('button', content).should('be.visible')
|
||||
cy.get('.tiptap.ProseMirror').should('not.exist')
|
||||
cy.contains('button', content).click()
|
||||
checkDraft()
|
||||
cy.window().its('localStorage').invoke('getItem', 'replyBoxMinimized').should('eq', 'false')
|
||||
cy.get('.box button[aria-label="Collapse"]').next('button').click()
|
||||
cy.get('[role="dialog"]').should('be.visible')
|
||||
checkDraft()
|
||||
cy.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
|
||||
cy.get('[role="dialog"]').should('not.exist')
|
||||
cy.reload()
|
||||
cy.wait('@loadMessages')
|
||||
checkDraft()
|
||||
if (copies.bcc) {
|
||||
cy.contains('button', /^BCC$/).click()
|
||||
recipientRow(/^BCC$/).find('input').type(copies.bcc)
|
||||
cy.get('.box button[aria-label="Collapse"]').click()
|
||||
cy.contains('button', content).click()
|
||||
recipientRow(/^BCC$/).find('input').should('have.value', copies.bcc)
|
||||
}
|
||||
cy.intercept('POST', '**/conversations/*/messages').as('sendMinimizedReply')
|
||||
cy.contains('button', /^Send$/).click()
|
||||
cy.wait('@sendMinimizedReply').then(({ request, response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
expect(request.body.message).to.contain(content)
|
||||
expect(request.body.send_from).to.eq(sender)
|
||||
expect(request.body.to).to.deep.eq(to)
|
||||
expect(request.body.cc).to.deep.eq(copies.cc ? [copies.cc] : [])
|
||||
expect(request.body.bcc).to.deep.eq(copies.bcc ? [copies.bcc] : [])
|
||||
expect(request.body.attachments).to.have.members(uploads.map((upload) => upload.id))
|
||||
expect(request.body.private).to.eq(false)
|
||||
cy.api('GET', `/api/v1/conversations/${uuid}/messages?page=1`).then(({ body }) => {
|
||||
const message = body.data.results.find((message) => message.uuid === response.body.data.uuid)
|
||||
expect(message.content).to.contain(content)
|
||||
expect(message.meta.to).to.deep.eq(to)
|
||||
expect(message.meta.cc || []).to.deep.eq(copies.cc ? [copies.cc] : [])
|
||||
expect(message.meta.bcc || []).to.deep.eq(copies.bcc ? [copies.bcc] : [])
|
||||
expect(message.meta.send_from).to.eq(sender)
|
||||
expect(message.attachments.map((attachment) => attachment.uuid)).to.have.members(uploads.map((upload) => upload.uuid))
|
||||
})
|
||||
})
|
||||
cy.get('.tiptap.ProseMirror').should('have.text', '')
|
||||
})
|
||||
}
|
||||
|
||||
it('restores reply content with conversation recipients after reload and a failed send', () => {
|
||||
const sender = 'billing@example.com'
|
||||
const extraRecipient = 'draft.recipient@example.com'
|
||||
const copy = 'draft.copy@example.com'
|
||||
const content = `Reply draft ${stamp}`
|
||||
const update = `Another agent replied ${stamp}`
|
||||
cy.intercept('GET', `**/api/v1/conversations/${ccConversationUuid}`, (req) => {
|
||||
req.continue((res) => {
|
||||
res.body.data.inbox_aliases = [{ email: sender, verification_status: 'verified' }]
|
||||
})
|
||||
})
|
||||
cy.intercept('POST', `**/conversations/${ccConversationUuid}/draft`, (req) => {
|
||||
if (req.body.content?.includes(content)) req.alias = 'savedReplyDraft'
|
||||
})
|
||||
openConversation(ccConversationUuid)
|
||||
recipientRow(/^From$/).find('[role="combobox"]').click()
|
||||
cy.contains('[role="option"]', sender).trigger('keydown', { key: 'Enter' })
|
||||
recipientRow(/^To$/).find('input').clear()
|
||||
recipientRow(/^To$/).find('input').type(`${ccCustomerEmail}, ${extraRecipient}`)
|
||||
recipientRow(/^CC$/).find('input').clear()
|
||||
recipientRow(/^CC$/).find('input').type(copy)
|
||||
cy.contains('button', /^BCC$/).click()
|
||||
recipientRow(/^BCC$/).find('input').type(bccAddress)
|
||||
cy.get('.tiptap.ProseMirror').first().type(content)
|
||||
cy.wait('@savedReplyDraft').then(({ request, response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
expect(request.body.meta).not.to.have.property('recipients')
|
||||
})
|
||||
|
||||
const checkDraft = () => {
|
||||
recipientRow(/^From$/).find('[role="combobox"]').should('contain.text', `lifecycle+${stamp}@cypress.test`)
|
||||
recipientRow(/^To$/).find('input').should('have.value', ccCustomerEmail)
|
||||
recipientRow(/^CC$/).find('input').should('have.value', ccAddress)
|
||||
cy.contains('label', /^BCC$/).should('not.exist')
|
||||
cy.get('.tiptap.ProseMirror').first().should('contain.text', content)
|
||||
}
|
||||
cy.reload()
|
||||
cy.wait('@loadMessages')
|
||||
checkDraft()
|
||||
cy.api('POST', `/api/v1/conversations/${ccConversationUuid}/messages`, {
|
||||
sender_type: 'agent', private: false, message: `<p>${update}</p>`,
|
||||
to: [ccCustomerEmail], cc: [ccAddress], bcc: []
|
||||
}).its('status').should('eq', 200)
|
||||
cy.contains('[data-message-uuid]', update).should('be.visible')
|
||||
checkDraft()
|
||||
|
||||
cy.intercept('POST', `**/conversations/${ccConversationUuid}/messages`, {
|
||||
statusCode: 500, body: { status: 'error', message: 'Reply failed', error_type: 'GeneralException' }
|
||||
}).as('failedReply')
|
||||
cy.contains('button', /^Send$/).click()
|
||||
cy.wait('@failedReply').its('response.statusCode').should('eq', 500)
|
||||
cy.reload()
|
||||
cy.wait('@loadMessages')
|
||||
checkDraft()
|
||||
|
||||
captureSend()
|
||||
cy.intercept('DELETE', `**/conversations/${ccConversationUuid}/draft*`).as('clearedReplyDraft')
|
||||
cy.contains('button', /^Send$/).click()
|
||||
cy.wait('@captureSend').then(({ request }) => {
|
||||
expect(request.body.send_from).to.eq(`lifecycle+${stamp}@cypress.test`)
|
||||
expect(request.body.to).to.deep.eq([ccCustomerEmail])
|
||||
expect(request.body.cc).to.deep.eq([ccAddress])
|
||||
expect(request.body.bcc).to.deep.eq([])
|
||||
})
|
||||
cy.wait('@clearedReplyDraft').its('response.statusCode').should('eq', 200)
|
||||
cy.get('.tiptap.ProseMirror').first().should('have.text', '')
|
||||
})
|
||||
|
||||
it('restores conversation recipients when switching between conversations', () => {
|
||||
captureSend()
|
||||
openConversation(ccConversationUuid)
|
||||
recipientRow(/^CC$/).find('button[aria-label="Remove CC"]').click()
|
||||
|
||||
@@ -140,6 +140,51 @@ describe('Email inbox form', () => {
|
||||
cy.contains('New support inbox').should('be.visible')
|
||||
})
|
||||
|
||||
for (const [protocol, value] of [
|
||||
['None', 'none'],
|
||||
['Login', 'login'],
|
||||
['CRAM', 'cram'],
|
||||
['Plain', 'plain']
|
||||
]) {
|
||||
it(`validates and saves SMTP credentials with ${protocol} authentication`, () => {
|
||||
openManualForm()
|
||||
fillRequiredFields()
|
||||
field('smtp.username').clear()
|
||||
field('smtp.password').clear()
|
||||
cy.toggleEmailInboxAdvanced('smtp')
|
||||
cy.contains('label', 'Authentication Protocol').parent().find('[role="combobox"]').click()
|
||||
cy.contains('[role="option"]', new RegExp(`^${protocol}$`)).click()
|
||||
submit()
|
||||
if (protocol === 'None') {
|
||||
cy.wait('@createInbox').then(({ request }) => {
|
||||
expect(request.body.config.smtp[0].auth_protocol).to.eq('none')
|
||||
expect(request.body.config.smtp[0].username).to.eq('')
|
||||
expect(request.body.config.smtp[0].password).to.eq('')
|
||||
})
|
||||
} else {
|
||||
cy.get('@createInbox.all').should('have.length', 0)
|
||||
field('smtp.username').parent().parent().should('contain.text', 'Required')
|
||||
field('smtp.password').parent().parent().should('contain.text', 'Required')
|
||||
field('smtp.username').type('support@example.com')
|
||||
submit()
|
||||
field('smtp.password').should('have.attr', 'aria-invalid', 'true')
|
||||
cy.get('@createInbox.all').should('have.length', 0)
|
||||
field('smtp.username').clear()
|
||||
field('smtp.password').type('test-password')
|
||||
submit()
|
||||
field('smtp.username').should('have.attr', 'aria-invalid', 'true')
|
||||
cy.get('@createInbox.all').should('have.length', 0)
|
||||
field('smtp.username').type('support@example.com')
|
||||
submit()
|
||||
cy.wait('@createInbox').then(({ request }) => {
|
||||
expect(request.body.config.smtp[0].auth_protocol).to.eq(value)
|
||||
expect(request.body.config.smtp[0].username).to.eq('support@example.com')
|
||||
expect(request.body.config.smtp[0].password).to.eq('test-password')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('loads saved fields once and saves values from closed sections', () => {
|
||||
cy.visit(editPath)
|
||||
field('name').should('have.value', 'Support')
|
||||
|
||||
@@ -147,4 +147,58 @@ describe('Notification settings form', () => {
|
||||
cy.contains('p.text-destructive', 'Invalid duration format').should('exist')
|
||||
cy.get('@saveNotification.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
for (const [protocol, value] of [
|
||||
['None', 'none'],
|
||||
['Login', 'login'],
|
||||
['CRAM-MD5', 'cram'],
|
||||
['Plain', 'plain']
|
||||
]) {
|
||||
it(`validates and saves notification SMTP credentials with ${protocol} authentication`, () => {
|
||||
cy.intercept('PUT', '**/api/v1/settings/notifications/email').as('saveNotification')
|
||||
cy.visit(path)
|
||||
cy.get('input[name="host"]').should('not.have.value', '')
|
||||
cy.get('input[name="username"]').clear()
|
||||
cy.get('input[name="password"]').clear()
|
||||
cy.contains('label', 'Authentication Protocol').parent().find('[role="combobox"]').click()
|
||||
cy.contains('[role="option"]', new RegExp(`^${protocol}$`)).click()
|
||||
cy.get('button[type="submit"]').click()
|
||||
if (protocol === 'None') {
|
||||
cy.wait('@saveNotification').then(({ request, response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
expect(request.body[`${prefix}auth_protocol`]).to.eq('none')
|
||||
expect(request.body[`${prefix}username`]).to.eq('')
|
||||
expect(request.body[`${prefix}password`]).to.eq('')
|
||||
})
|
||||
} else {
|
||||
cy.get('@saveNotification.all').should('have.length', 0)
|
||||
cy.get('input[name="username"]').parent().parent().should('contain.text', 'Required')
|
||||
cy.get('input[name="password"]').parent().parent().should('contain.text', 'Required')
|
||||
cy.get('input[name="username"]').type(seedUsername)
|
||||
cy.get('button[type="submit"]').click()
|
||||
cy.get('input[name="password"]').should('have.attr', 'aria-invalid', 'true')
|
||||
cy.get('@saveNotification.all').should('have.length', 0)
|
||||
cy.get('input[name="username"]').clear()
|
||||
cy.get('input[name="password"]').type('test-password')
|
||||
cy.get('button[type="submit"]').click()
|
||||
cy.get('input[name="username"]').should('have.attr', 'aria-invalid', 'true')
|
||||
cy.get('@saveNotification.all').should('have.length', 0)
|
||||
cy.get('input[name="username"]').type(seedUsername)
|
||||
cy.get('button[type="submit"]').click()
|
||||
cy.wait('@saveNotification').then(({ request, response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
expect(request.body[`${prefix}auth_protocol`]).to.eq(value)
|
||||
expect(request.body[`${prefix}username`]).to.eq(seedUsername)
|
||||
expect(request.body[`${prefix}password`]).to.eq('test-password')
|
||||
})
|
||||
cy.reload()
|
||||
cy.contains('label', 'Authentication Protocol')
|
||||
.parent()
|
||||
.find('[role="combobox"]')
|
||||
.should('contain.text', protocol)
|
||||
cy.get('input[name="username"]').should('have.value', seedUsername)
|
||||
cy.get('input[name="password"]').should('have.value', maskedPassword)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
const stamp = Date.now()
|
||||
const templateName = `Cypress Template ${stamp}`
|
||||
const renamedTemplate = `Cypress Template ${stamp} edited`
|
||||
const templateBody = `Cypress template body ${stamp}`
|
||||
const updatedBody = `Cypress template body ${stamp} edited`
|
||||
const templateBody = `Cypress template body ${stamp} {{ template "content" . }}`
|
||||
const updatedBody = `Cypress template body ${stamp} edited {{ template "content" . }}`
|
||||
const listPath = '/admin/templates'
|
||||
|
||||
const filterList = (text) => cy.get('input[placeholder="Search"]').clear().type(text)
|
||||
|
||||
@@ -45,6 +45,7 @@ Cypress.Commands.add('login', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
cy.clearLocalStorage('replyBoxMinimized')
|
||||
})
|
||||
|
||||
Cypress.Commands.add('setEmailInboxSection', (section, open) => {
|
||||
|
||||
+2
-1
@@ -1711,9 +1711,10 @@
|
||||
"team.edit": "Edit team",
|
||||
"team.new": "New team",
|
||||
"template.cannotDeleteBuiltInTemplate": "Cannot delete built-in template",
|
||||
"template.defaultTemplateAlreadyExists": "Default template already exists",
|
||||
"template.defaultTemplateAlreadyExists": "Another outgoing email template is already the default. Clear its default setting before choosing this template.",
|
||||
"template.deletionConfirmation": "This action cannot be undone. This will permanently delete this template.",
|
||||
"template.edit": "Edit template",
|
||||
"template.invalidOutgoingTemplate": "This email template cannot deliver replies. Check the syntax and include {content}.",
|
||||
"toast.apiKeyGenerated": "API key generated",
|
||||
"toast.authorizationDenied": "Authorization denied",
|
||||
"toast.avatarUpdated": "Avatar updated successfully",
|
||||
|
||||
@@ -226,6 +226,7 @@ type settingsStore interface {
|
||||
type csatStore interface {
|
||||
Create(conversationID int) (csatModels.CSATResponse, error)
|
||||
Get(uuid string) (csatModels.CSATResponse, error)
|
||||
UpdateResponse(uuid string, score int, feedback string, meta json.RawMessage) error
|
||||
MakePublicURL(appBaseURL, uuid string) string
|
||||
}
|
||||
|
||||
@@ -386,6 +387,7 @@ type queries struct {
|
||||
|
||||
// Message queries.
|
||||
GetMessage *sqlx.Stmt `query:"get-message"`
|
||||
GetCSATMessages *sqlx.Stmt `query:"get-csat-messages"`
|
||||
GetMessages string `query:"get-messages"`
|
||||
GetContactUnreadPreviewMessages *sqlx.Stmt `query:"get-contact-unread-preview-messages"`
|
||||
GetOutgoingPendingMessages *sqlx.Stmt `query:"get-outgoing-pending-messages"`
|
||||
@@ -1960,6 +1962,23 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateCSATResponse(uuid string, rating int, feedback string, meta json.RawMessage) error {
|
||||
if err := m.csatStore.UpdateResponse(uuid, rating, feedback, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
var messages []models.Message
|
||||
if err := m.q.GetCSATMessages.Select(&messages, uuid); err != nil {
|
||||
m.lo.Error("error fetching CSAT messages for update", "csat_uuid", uuid, "error", err)
|
||||
return nil
|
||||
}
|
||||
for _, message := range messages {
|
||||
message.CensorCSATContentWithStatus(true /** csatSubmitted **/, uuid, rating, feedback)
|
||||
message.StripCSATUUID()
|
||||
m.BroadcastMessageUpdate(message.ConversationUUID, message.UUID, map[string]any{"meta": message.Meta})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteConversation deletes a conversation.
|
||||
func (m *Manager) DeleteConversation(uuid string) error {
|
||||
res, err := m.q.DeleteConversation.Exec(uuid)
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
am "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/csat"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestCSATSubmissionBroadcastsSavedFeedback(t *testing.T) {
|
||||
db := testutil.NewDB(t, "csat_live_update")
|
||||
lo := logf.New(logf.Opts{})
|
||||
lang := testutil.NewI18n(t)
|
||||
csats, err := csat.New(csat.Opts{DB: db, Lo: &lo, I18n: lang})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := ws.NewHub(&lo, nil /** userStore **/)
|
||||
m := &Manager{lo: &lo, i18n: lang, csatStore: csats, wsHub: hub}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &m.q, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rating := range []int{0, 5} {
|
||||
t.Run(fmt.Sprint(rating), func(t *testing.T) {
|
||||
var conv models.Conversation
|
||||
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, contactID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := csats.Create(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var messageUUID string
|
||||
if err := db.Get(&messageUUID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, meta)
|
||||
VALUES ($1, 'outgoing', 'sent', $2, 'agent', jsonb_build_object('is_csat', true, 'csat_uuid', $3::text)) RETURNING uuid`, conv.ID, contactID, response.UUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &ws.Client{Send: make(chan wsmodels.WSMessage, 2)}
|
||||
hub.SubscribeOpenConv(client, conv.UUID)
|
||||
feedback := "Helpful support. Café 日本語."
|
||||
if err := m.UpdateCSATResponse(response.UUID, rating, feedback, nil /** meta **/); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case update := <-client.Send:
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
UUID string `json:"uuid"`
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(update.Data, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Type != wsmodels.MessageTypeMessageUpdate || payload.Data.UUID != messageUUID || payload.Data.ConversationUUID != conv.UUID {
|
||||
t.Fatalf("wrong message update: %+v", payload)
|
||||
}
|
||||
meta := payload.Data.Meta
|
||||
if meta["csat_submitted"] != true || meta["submitted_feedback"] != feedback || meta["csat_uuid"] != nil {
|
||||
t.Fatalf("incorrect or exposed CSAT metadata: %+v", meta)
|
||||
}
|
||||
if rating > 0 && meta["submitted_rating"] != float64(rating) {
|
||||
t.Fatalf("wrong rating: %+v", meta)
|
||||
}
|
||||
default:
|
||||
t.Fatal("no live CSAT update")
|
||||
}
|
||||
if err := m.UpdateCSATResponse(response.UUID, 1 /** rating **/, "duplicate", nil /** meta **/); err == nil {
|
||||
t.Fatal("duplicate response accepted")
|
||||
}
|
||||
if len(client.Send) != 0 {
|
||||
t.Fatal("rejected response broadcast an update")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousConversationsRespectAccessBeforeLimit(t *testing.T) {
|
||||
db := testutil.NewDB(t, "previous_access")
|
||||
lo := logf.New(logf.Opts{})
|
||||
|
||||
@@ -947,6 +947,13 @@ AND NOT EXISTS (
|
||||
)
|
||||
ORDER BY m.id
|
||||
|
||||
-- name: get-csat-messages
|
||||
SELECT m.uuid, m.meta, c.uuid AS conversation_uuid
|
||||
FROM csat_responses r
|
||||
JOIN conversations c ON c.id = r.conversation_id
|
||||
JOIN conversation_messages m ON m.conversation_id = c.id
|
||||
WHERE r.uuid = $1 AND m.meta->>'csat_uuid' = r.uuid::text;
|
||||
|
||||
-- name: get-message
|
||||
SELECT
|
||||
m.id,
|
||||
|
||||
@@ -1,15 +1,77 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestDeletedMessageMediaUnavailable(t *testing.T) {
|
||||
db := testutil.NewDB(t, "deleted_message_media")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m := &Manager{lo: &lo, i18n: testutil.NewI18n(t), rootURL: func() string { return "https://support.example.com" }}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &m.queries, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID, conversationID, messageID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, contactID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type)
|
||||
VALUES ($1, 'incoming', 'sent', $2, 'contact') RETURNING id`, conversationID, contactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attachments []models.Media
|
||||
for _, modelID := range []null.Int{{}, null.IntFrom(0), null.IntFrom(messageID)} {
|
||||
var med models.Media
|
||||
if err := db.Get(&med, `INSERT INTO media (store, filename, content_type, content_id, size, model_type, model_id, private)
|
||||
VALUES ('fs', 'receipt.png', 'image/png', '', 1, 'messages', $1, false) RETURNING id, uuid`, modelID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachments = append(attachments, med)
|
||||
if _, err := m.Get(med.ID, "" /** uuid **/); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Get(0 /** id **/, med.UUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM users WHERE id = $1`, contactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, med := range attachments {
|
||||
for _, lookup := range []func() (models.Media, error){
|
||||
func() (models.Media, error) { return m.Get(med.ID, "" /** uuid **/) },
|
||||
func() (models.Media, error) { return m.Get(0 /** id **/, med.UUID) },
|
||||
} {
|
||||
_, err := lookup()
|
||||
if i < 2 {
|
||||
if err != nil {
|
||||
t.Fatalf("unattached draft became unavailable: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var missing envelope.Error
|
||||
if !errors.As(err, &missing) || missing.ErrorType != envelope.NotFoundError {
|
||||
t.Fatalf("deleted contact attachment remained available: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingMediaOwnership(t *testing.T) {
|
||||
db := testutil.NewDB(t, "media_ownership")
|
||||
lo := logf.New(logf.Opts{})
|
||||
|
||||
@@ -20,9 +20,12 @@ RETURNING id;
|
||||
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta, private, uploaded_by
|
||||
FROM media
|
||||
WHERE
|
||||
($1 > 0 AND id = $1)
|
||||
OR
|
||||
($2 != '' AND uuid = NULLIF($2, '')::uuid)
|
||||
(($1 > 0 AND id = $1) OR ($2 != '' AND uuid = NULLIF($2, '')::uuid))
|
||||
AND (
|
||||
model_type IS DISTINCT FROM 'messages'
|
||||
OR COALESCE(model_id, 0) = 0
|
||||
OR EXISTS (SELECT 1 FROM conversation_messages cm WHERE cm.id = media.model_id)
|
||||
);
|
||||
|
||||
-- name: get-media-by-uuid
|
||||
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta, private, uploaded_by
|
||||
|
||||
+10
-10
@@ -67,7 +67,7 @@ WITH latest_applied AS (
|
||||
resolution_met_at, resolution_breached_at
|
||||
FROM applied_slas
|
||||
WHERE created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
ORDER BY conversation_id, created_at DESC, id DESC
|
||||
@@ -148,7 +148,7 @@ next_response AS (
|
||||
sla_events
|
||||
WHERE
|
||||
created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
AND type = 'next_response'
|
||||
@@ -189,13 +189,13 @@ WITH new_conversations AS (
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
TO_CHAR(created_at :: date, 'YYYY-MM-DD') AS date,
|
||||
TO_CHAR(created_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE
|
||||
c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -210,13 +210,13 @@ resolved_conversations AS (
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
TO_CHAR(resolved_at :: date, 'YYYY-MM-DD') AS date,
|
||||
TO_CHAR(resolved_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE
|
||||
c.resolved_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -263,7 +263,7 @@ FROM
|
||||
csat_responses
|
||||
WHERE
|
||||
created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END;
|
||||
|
||||
@@ -281,7 +281,7 @@ WITH per_conversation AS (
|
||||
AND private = false
|
||||
AND (type = 'incoming' OR status = 'sent')
|
||||
AND created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -321,7 +321,7 @@ WITH tag_counts AS (
|
||||
LEFT JOIN conversation_tags ct ON t.id = ct.tag_id
|
||||
LEFT JOIN conversations c ON ct.conversation_id = c.id
|
||||
AND c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -343,7 +343,7 @@ tagging AS (
|
||||
conversations c
|
||||
WHERE
|
||||
c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
)
|
||||
|
||||
+11
-10
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/report/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/zerodha/logf"
|
||||
@@ -90,7 +91,7 @@ func (m *Manager) GetOverViewCounts() (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewSLA returns overview SLA data
|
||||
func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewSLA(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
ReadOnly: true,
|
||||
@@ -104,7 +105,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
var result models.OverviewSLA
|
||||
// Format query with days parameter for both CTEs
|
||||
query := fmt.Sprintf(m.q.GetOverviewSLA, days, days, days, days)
|
||||
if err := tx.Get(&result, query); err != nil {
|
||||
if err := tx.Get(&result, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview SLA data", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewChart returns overview chart data
|
||||
func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewChart(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -137,7 +138,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewCharts, days, days, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview charts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewCSAT returns CSAT metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewCSAT(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -158,7 +159,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewCSAT, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview CSAT", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -166,7 +167,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewMessageVolume returns message volume metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewMessageVolume(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -179,7 +180,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewMessageVolume, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview message volume", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -187,7 +188,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewTagDistribution returns tag distribution metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewTagDistribution(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -200,7 +201,7 @@ func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error)
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewTagDistribution, days, days, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview tag distribution", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestOverviewDatesUseWorkspaceTimezone(t *testing.T) {
|
||||
db := testutil.NewDB(t, "report_timezone")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(Opts{DB: db, Lo: &lo, I18n: testutil.NewI18n(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Report', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Report', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now().UTC().Truncate(24 * time.Hour).Add(-24 * time.Hour)
|
||||
instants := []time.Time{base.Add(30 * time.Minute), base.Add(23*time.Hour + 30*time.Minute)}
|
||||
for _, instant := range instants {
|
||||
if _, err := db.Exec(`INSERT INTO conversations (contact_id, inbox_id, status_id, created_at, resolved_at)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3, $3)`, contactID, inboxID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, timezone := range []string{"UTC", "Asia/Kathmandu", "America/Los_Angeles", "", "invalid/zone", "Local"} {
|
||||
t.Run(timezone, func(t *testing.T) {
|
||||
loc := time.UTC
|
||||
if timezone == "Asia/Kathmandu" || timezone == "America/Los_Angeles" {
|
||||
loc, err = time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := map[string]int{}
|
||||
for _, instant := range instants {
|
||||
want[instant.In(loc).Format(time.DateOnly)]++
|
||||
}
|
||||
raw, err := m.GetOverviewChart(7 /** days **/, timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var charts map[string][]struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &charts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"new_conversations", "resolved_conversations"} {
|
||||
if len(charts[name]) != len(want) {
|
||||
t.Fatalf("%s dates = %v, want %v", name, charts[name], want)
|
||||
}
|
||||
for _, row := range charts[name] {
|
||||
if row.Count != want[row.Date] {
|
||||
t.Fatalf("%s row = %+v, want %v", name, row, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`DELETE FROM conversations`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timezone := "Asia/Kathmandu"
|
||||
var midnight time.Time
|
||||
if err := db.Get(&midnight, `SELECT DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1`, timezone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, instant := range []time.Time{midnight.Add(-time.Minute), midnight.Add(time.Minute)} {
|
||||
var conversationID int
|
||||
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id, created_at)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3) RETURNING id`, contactID, inboxID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO csat_responses (conversation_id, rating, created_at) VALUES ($1, 5, $2)`, conversationID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, created_at)
|
||||
VALUES ($1, 'incoming', 'sent', $2, 'contact', $3)`, conversationID, contactID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
get func(int, string) (json.RawMessage, error)
|
||||
key string
|
||||
want float64
|
||||
}{
|
||||
{"csat", m.GetOverviewCSAT, "total_responses", 1},
|
||||
{"messages", m.GetOverviewMessageVolume, "total_messages", 1},
|
||||
{"tags", m.GetOverviewTagDistribution, "untagged_conversations", 1},
|
||||
{"sla", m.GetOverviewSLA, "first_response_met_count", 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := tc.get(0 /** days **/, timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result[tc.key] != tc.want {
|
||||
t.Fatalf("%s = %v, want %v", tc.key, result[tc.key], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"text/template/parse"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
@@ -74,7 +75,15 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
|
||||
return "", fmt.Errorf("parsing base template: %w", err)
|
||||
}
|
||||
|
||||
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content)
|
||||
contentCount := 0
|
||||
trackContent := template.FuncMap{
|
||||
"_libredeskTrackContent": func() string {
|
||||
contentCount++
|
||||
return ""
|
||||
},
|
||||
}
|
||||
baseTemplate.Funcs(trackContent)
|
||||
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Funcs(trackContent).Parse(`{{_libredeskTrackContent}}` + content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing content template: %w", err)
|
||||
}
|
||||
@@ -89,6 +98,9 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
|
||||
return "", fmt.Errorf("executing base template: %w", err)
|
||||
}
|
||||
|
||||
if contentCount == 0 {
|
||||
return "", fmt.Errorf("outgoing template did not render the reply")
|
||||
}
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
@@ -215,3 +227,44 @@ func personData(person umodels.User) map[string]any {
|
||||
"Email": person.Email.String,
|
||||
}
|
||||
}
|
||||
|
||||
// parseOutgoingTemplate checks syntax and requires a reply placeholder. Rendering checks that the reply actually appears.
|
||||
func parseOutgoingTemplate(body string, funcs template.FuncMap) (*template.Template, error) {
|
||||
tmpl, err := template.New(TmplBase).Funcs(funcs).Parse(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := 0
|
||||
for _, part := range tmpl.Templates() {
|
||||
count += countContentTemplates(part.Tree.Root)
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("outgoing template contains no reply template")
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func countContentTemplates(node parse.Node) int {
|
||||
switch node := node.(type) {
|
||||
case *parse.TemplateNode:
|
||||
if node.Name == TmplContent {
|
||||
return 1
|
||||
}
|
||||
case *parse.ListNode:
|
||||
if node == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, child := range node.Nodes {
|
||||
count += countContentTemplates(child)
|
||||
}
|
||||
return count
|
||||
case *parse.IfNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
case *parse.RangeNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
case *parse.WithNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"sync"
|
||||
texttemplate "text/template"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
@@ -65,8 +66,17 @@ func New(lo *logf.Logger, db *sqlx.DB, webTpls *template.Template, tpls *templat
|
||||
|
||||
// Update updates a new template with the given name, and body.
|
||||
func (m *Manager) Update(id int, t models.Template) (models.Template, error) {
|
||||
if t.IsDefault {
|
||||
t.Type = TypeEmailOutgoing
|
||||
}
|
||||
if err := m.validateOutgoingTemplate(t); err != nil {
|
||||
return models.Template{}, err
|
||||
}
|
||||
var result models.Template
|
||||
if err := m.q.UpdateTemplate.Get(&result, id, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
|
||||
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
|
||||
}
|
||||
m.lo.Error("error updating template", "error", err)
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -78,10 +88,13 @@ func (m *Manager) Create(t models.Template) (models.Template, error) {
|
||||
if t.IsDefault {
|
||||
t.Type = TypeEmailOutgoing
|
||||
}
|
||||
if err := m.validateOutgoingTemplate(t); err != nil {
|
||||
return models.Template{}, err
|
||||
}
|
||||
var result models.Template
|
||||
if err := m.q.InsertTemplate.Get(&result, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil)
|
||||
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
|
||||
}
|
||||
m.lo.Error("error inserting template", "error", err)
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
@@ -192,3 +205,15 @@ func (m *Manager) Reload(webTpls, tpls *template.Template, funcMap template.Func
|
||||
m.funcMap = funcMap
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateOutgoingTemplate(t models.Template) error {
|
||||
if t.Type != TypeEmailOutgoing && !t.IsDefault {
|
||||
return nil
|
||||
}
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
if _, err := parseOutgoingTemplate(t.Body, texttemplate.FuncMap(m.funcMap)); err != nil {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.Ts("template.invalidOutgoingTemplate", "content", `{{ template "content" . }}`), nil /** data **/)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
@@ -9,12 +10,236 @@ import (
|
||||
"testing"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/template/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var notificationSeedPattern = regexp.MustCompile(`(?s)VALUES\s*\(\s*'email_notification'::template_type,\s*'((?:''|[^'])*)'\s*,\s*false,\s*'([^']+)'`)
|
||||
|
||||
func TestDefaultTemplateConflict(t *testing.T) {
|
||||
db := testutil.NewDB(t, "default_template_conflict")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := m.Create(models.Template{Name: "First", Type: TypeEmailOutgoing, IsDefault: true, Body: `{{ template "content" . }}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := m.Create(models.Template{Name: "Second", Type: TypeEmailOutgoing, Body: `{{ template "content" . }}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second.IsDefault = true
|
||||
for _, save := range []func() (models.Template, error){
|
||||
func() (models.Template, error) { return m.Create(second) },
|
||||
func() (models.Template, error) { return m.Update(second.ID, second) },
|
||||
} {
|
||||
_, err := save()
|
||||
var conflict envelope.Error
|
||||
if !errors.As(err, &conflict) || conflict.ErrorType != envelope.ConflictError {
|
||||
t.Fatalf("expected an actionable conflict, got %v", err)
|
||||
}
|
||||
current, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil || current.ID != first.ID {
|
||||
t.Fatalf("default changed after rejected save: %+v, %v", current, err)
|
||||
}
|
||||
}
|
||||
first.IsDefault = false
|
||||
if _, err := m.Update(first.ID, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Update(second.ID, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil || current.ID != second.ID {
|
||||
t.Fatalf("default did not switch: %+v, %v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDefaultTemplateType(t *testing.T) {
|
||||
db := testutil.NewDB(t, "default_template_type")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := m.Create(models.Template{Name: "Layout", Type: TypeEmailOutgoing, Body: `{{ template "content" . }}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, typ := range []string{TypeEmailNotification, "", "invalid", TypeEmailOutgoing} {
|
||||
candidate := saved
|
||||
candidate.Type = typ
|
||||
candidate.IsDefault = true
|
||||
updated, err := m.Update(saved.ID, candidate)
|
||||
if err != nil || updated.Type != TypeEmailOutgoing || !updated.IsDefault {
|
||||
t.Fatalf("default type %q was not normalized: %+v, %v", typ, updated, err)
|
||||
}
|
||||
stored, err := m.Get(saved.ID)
|
||||
if err != nil || stored.Type != TypeEmailOutgoing || !stored.IsDefault {
|
||||
t.Fatalf("incorrect persisted default: %+v, %v", stored, err)
|
||||
}
|
||||
candidate.Body = "<p>No reply placeholder</p>"
|
||||
if _, err := m.Update(saved.ID, candidate); err == nil {
|
||||
t.Fatalf("accepted default type %q without a reply", typ)
|
||||
}
|
||||
}
|
||||
saved.Type = TypeEmailNotification
|
||||
saved.Body = "<p>Notification</p>"
|
||||
updated, err := m.Update(saved.ID, saved)
|
||||
if err != nil || updated.Type != TypeEmailNotification || updated.IsDefault {
|
||||
t.Fatalf("changed nondefault notification type: %+v, %v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutgoingTemplate(t *testing.T) {
|
||||
m := &Manager{i18n: testutil.NewI18n(t)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
ok bool
|
||||
}{
|
||||
{"empty", "", false},
|
||||
{"missing", "<p>Header</p>", false},
|
||||
{"single", `{{ template "content" . }}`, true},
|
||||
{"duplicate", `{{ template "content" . }}{{ template "content" . }}`, true},
|
||||
{"triple", `{{ template "content" . }}{{ template "content" . }}{{ template "content" . }}`, true},
|
||||
{"trimmed duplicate", `{{- template "content" . -}}<p>Footer</p>{{- template "content" . -}}`, true},
|
||||
{"comment only", `{{/* template "content" . */}}`, false},
|
||||
{"comment and single", `{{/* template "content" . */}}{{ template "content" . }}`, true},
|
||||
{"malformed", `{{ template "content" .`, false},
|
||||
{"wrong name", `{{ template "contents" . }}`, false},
|
||||
{"literal template text", `{{ printf "%s" "{{ template \"content\" . }}" }}`, false},
|
||||
{"nested wrappers", `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, true},
|
||||
{"nested duplicate", `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, true},
|
||||
{"unused duplicate", `{{ define "unused" }}{{ template "content" . }}{{ template "content" . }}{{ end }}{{ template "content" . }}`, true},
|
||||
{"duplicate alternatives", `{{ if .Show }}{{ template "content" . }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"nested alternatives", `{{ if .Show }}{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}{{ else }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"dynamic conditional", `{{ template "content" . }}{{ if .Show }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"alternatives", `{{ if .Show }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"with alternatives", `{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"duplicate with alternatives", `{{ template "content" . }}{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"unused definition", `{{ define "unused" }}{{ template "content" . }}{{ end }}{{ template "content" . }}`, true},
|
||||
{"single wrapper", `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, true},
|
||||
{"repeated wrapper", `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, true},
|
||||
{"previous messages", `{{ template "content" . }}{{ range .PreviousMessages }}{{ .Content }}{{ end }}`, true},
|
||||
{"dynamic loop", `{{ range . }}{{ template "content" . }}{{ end }}`, true},
|
||||
{"recursive definition", `{{ define "wrapper" }}{{ template "wrapper" . }}{{ end }}{{ template "wrapper" . }}{{ template "content" . }}`, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := m.validateOutgoingTemplate(models.Template{Type: TypeEmailOutgoing, Body: tc.body})
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("validation error = %v, want valid = %v", err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutgoingTemplatePreservesReply(t *testing.T) {
|
||||
db := testutil.NewDB(t, "outgoing_template_content")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := models.Template{Name: "Customer layout", Type: TypeEmailOutgoing, IsDefault: true, Body: `<header>Support</header>{{ template "content" . }}<footer>Thank you</footer>`}
|
||||
saved, err := m.Create(valid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, body := range []string{
|
||||
"",
|
||||
"<p>Support header</p>",
|
||||
`{{/* template "content" . */}}`,
|
||||
`{{ template "content" .`,
|
||||
} {
|
||||
t.Run("reject "+body, func(t *testing.T) {
|
||||
candidate := valid
|
||||
candidate.Body = body
|
||||
candidate.IsDefault = false
|
||||
if _, err := m.Create(candidate); err == nil {
|
||||
t.Fatal("created an invalid outgoing template")
|
||||
}
|
||||
if _, err := m.Update(saved.ID, candidate); err == nil {
|
||||
t.Fatal("updated to an invalid outgoing template")
|
||||
}
|
||||
stored, err := m.Get(saved.ID)
|
||||
if err != nil || stored.Body != valid.Body || !stored.IsDefault {
|
||||
t.Fatalf("rejected edit changed the saved template: %+v, %v", stored, err)
|
||||
}
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM templates WHERE type = $1`, TypeEmailOutgoing); err != nil || count != 1 {
|
||||
t.Fatalf("rejected create changed template count: %d, %v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
stored, err := m.Get(saved.ID)
|
||||
if err != nil || stored.Body != valid.Body {
|
||||
t.Fatalf("rejected edit changed template: %+v, %v", stored, err)
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{{ template "content" . }}{{ template "content" . }}`,
|
||||
`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`,
|
||||
`{{ range . }}{{ template "content" . }}{{ end }}`,
|
||||
} {
|
||||
candidate := valid
|
||||
candidate.Body = body
|
||||
candidate.IsDefault = false
|
||||
if _, err := m.Create(candidate); err != nil {
|
||||
t.Fatalf("could not create repeated reply template: %v", err)
|
||||
}
|
||||
candidate.IsDefault = true
|
||||
if _, err := m.Update(saved.ID, candidate); err != nil {
|
||||
t.Fatalf("could not update repeated reply template: %v", err)
|
||||
}
|
||||
}
|
||||
const conditional = `{{ if .Show }}<p>Hello</p>{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`
|
||||
if _, err := m.Update(saved.ID, models.Template{Name: valid.Name, Type: valid.Type, IsDefault: true, Body: conditional}); err != nil {
|
||||
t.Fatalf("rejected a conditional layout that renders the reply once: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
data any
|
||||
count int
|
||||
}{
|
||||
{valid.Body, nil, 1},
|
||||
{`{{template "content" .}}`, nil, 1},
|
||||
{`{{- template "content" . -}}`, nil, 1},
|
||||
{`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, nil, 1},
|
||||
{`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": true}, 1},
|
||||
{`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": false}, 0},
|
||||
{conditional, map[string]any{"Show": true}, 1},
|
||||
{conditional, map[string]any{"Show": false}, 1},
|
||||
{`{{ template "content" . }}{{ template "content" . }}`, nil, 2},
|
||||
{`{{ range . }}{{ template "content" . }}{{ end }}`, []string{}, 0},
|
||||
{`{{ range . }}{{ template "content" . }}{{ end }}`, []string{"first", "second"}, 2},
|
||||
{`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, nil, 2},
|
||||
{`<p>Previously saved layout without a reply</p>`, nil, 0},
|
||||
} {
|
||||
t.Run("render "+tc.body+fmt.Sprint(tc.data), func(t *testing.T) {
|
||||
if _, err := db.Exec(`UPDATE templates SET body = $1 WHERE id = $2`, tc.body, saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reply = "<p>Your receipt is ready. Café 日本語.</p>"
|
||||
got, err := m.RenderEmailWithTemplate(tc.data, reply)
|
||||
if tc.count > 0 {
|
||||
if err != nil || strings.Count(got, reply) != tc.count {
|
||||
t.Fatalf("unexpected rendered reply: %q, %v", got, err)
|
||||
}
|
||||
} else if err == nil || got != "" {
|
||||
t.Fatalf("unsafe outgoing content accepted: %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmailUsesSystemLayout(t *testing.T) {
|
||||
m := emailTemplateManager(t)
|
||||
var header strings.Builder
|
||||
|
||||
+13
-1
@@ -226,8 +226,12 @@
|
||||
createElements () {
|
||||
const branding = this.branding();
|
||||
|
||||
this.toggleButton = document.createElement('div');
|
||||
this.toggleButton = document.createElement('button');
|
||||
this.toggleButton.type = 'button';
|
||||
this.toggleButton.style.cssText = `
|
||||
all: revert;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
@@ -243,6 +247,7 @@
|
||||
`;
|
||||
|
||||
this.iconContainer = document.createElement('div');
|
||||
this.iconContainer.setAttribute('aria-hidden', 'true');
|
||||
this.iconContainer.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -255,6 +260,7 @@
|
||||
const iconTransition = `opacity ${this.OPEN_MS}ms ${this.EASE_OUT}, transform ${this.OPEN_MS}ms ${this.EASE_OUT}`;
|
||||
|
||||
this.defaultIcon = document.createElement('img');
|
||||
this.defaultIcon.alt = '';
|
||||
this.defaultIcon.src = branding.launcher?.logo_url || (this.config.baseURL + DEFAULT_LAUNCHER_LOGO_PATH);
|
||||
this.defaultIcon.style.transition = iconTransition;
|
||||
this.styleLauncherIcon();
|
||||
@@ -320,6 +326,8 @@
|
||||
this.widgetButtonWrapper = widgetButtonWrapper;
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.id = `libredesk-widget-${this.config.inboxID}`;
|
||||
this.toggleButton.setAttribute('aria-controls', this.iframe.id);
|
||||
this.iframe.src = `${this.config.baseURL}/widget?inbox_id=${encodeURIComponent(this.config.inboxID)}&parent_origin=${encodeURIComponent(window.location.origin)}` +
|
||||
(this.language ? `&lang=${encodeURIComponent(this.language)}` : '');
|
||||
this.iframeLanguage = this.language;
|
||||
@@ -498,6 +506,7 @@
|
||||
case 'REPLY_PREVIEWS':
|
||||
if (event.data.previews?.length && this.campaignData) this.dropCampaign(this.campaignData.id);
|
||||
this.previewData = event.data;
|
||||
this.setLauncherIcon(this.isChatVisible);
|
||||
this.renderPreviews();
|
||||
break;
|
||||
case 'VUE_APP_READY':
|
||||
@@ -668,6 +677,7 @@
|
||||
if (this.unreadCount > 0) this.showUnreadBadge(this.unreadCount);
|
||||
|
||||
this.postToIframe({ type: 'WIDGET_CLOSED' });
|
||||
if (document.activeElement === iframe) this.toggleButton.focus();
|
||||
|
||||
if (this._onHideCallback) this._onHideCallback();
|
||||
}
|
||||
@@ -680,6 +690,8 @@
|
||||
}
|
||||
|
||||
setLauncherIcon (open) {
|
||||
this.toggleButton.setAttribute('aria-expanded', String(open));
|
||||
this.toggleButton.setAttribute('aria-label', this.previewData?.labels?.[open ? 'closeChat' : 'openChat'] || 'libredesk');
|
||||
const hidden = (deg) => this.reducedMotion ? 'none' : `rotate(${deg}deg) scale(0.9)`;
|
||||
if (this.defaultIcon) {
|
||||
this.defaultIcon.style.opacity = open ? '0' : '1';
|
||||
|
||||
Reference in New Issue
Block a user