mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 06:19:36 +00:00
76db5aa851
Review pass over the help center commit. Bug fixes: - Image-only or video-only articles were rejected on save because the editor blanked the content when it had no text. - An article could be moved into a collection in another help center. Slugs are only unique per help center, so two articles could end up on the same public URL. - The article editor fired the "agent is typing" indicator into whatever conversation was last open, which the contact could see. - Unsupported locales like /hc/x/zz-ZZ returned an empty 200 page with a canonical URL, so crawlers saw unlimited URLs. They 404 now. - Article feedback was accepted for paused help centers. - French pages announced lang="en". - Editing a help center popped validation errors mid-keystroke. Shared code that was duplicated: - Help articles had their own copy of the snippet embedding lifecycle. The copy was missing the wait group, the concurrency cap and the generation gating, so article embeds were killed at shutdown, all hit the provider at once, and a slow embed could overwrite a newer edit or bring back a deleted article. Both content types now go through one embedSource lifecycle. Fingerprints are unchanged, so nothing re-embeds on deploy. - Callout and collapsible CSS was written twice, once for the editor and once for the public page. They now share article-content.css, so the editor stays WYSIWYG. - The excerpt was computed three ways. The page uses the stored one. Less work at runtime: - The admin tree no longer sends every article's HTML. The edit sheet loads the one article it needs. Publishing an article used to re-download the whole help center. - Embedding reconcile no longer reads every article body every minute. - The tree view fetched the help center twice on load. Cleanups: one update-article route instead of two, media privacy comes from the model type instead of a bool threaded through the handler, a LinkListField component replaces three copies of the same repeater, TreeNode renders the article row once, and 12 duplicate or dead i18n keys are gone.
357 lines
8.9 KiB
Go
357 lines
8.9 KiB
Go
package stringutil
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRemoveItemByValue(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
remove string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "empty slice",
|
|
input: []string{},
|
|
remove: "a",
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: "no matches",
|
|
input: []string{"b", "c"},
|
|
remove: "a",
|
|
expected: []string{"b", "c"},
|
|
},
|
|
{
|
|
name: "single match",
|
|
input: []string{"a", "b", "c"},
|
|
remove: "b",
|
|
expected: []string{"a", "c"},
|
|
},
|
|
{
|
|
name: "multiple matches",
|
|
input: []string{"a", "b", "a", "c", "a"},
|
|
remove: "a",
|
|
expected: []string{"b", "c"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := RemoveItemByValue(tt.input, tt.remove)
|
|
if len(result) != len(tt.expected) {
|
|
t.Errorf("got len %d, want %d", len(result), len(tt.expected))
|
|
}
|
|
for i := range result {
|
|
if result[i] != tt.expected[i] {
|
|
t.Errorf("at index %d got %s, want %s", i, result[i], tt.expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatDuration(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
duration time.Duration
|
|
includeSeconds bool
|
|
expected string
|
|
}{
|
|
{
|
|
name: "zero duration with seconds",
|
|
duration: 0,
|
|
includeSeconds: true,
|
|
expected: "0 minutes",
|
|
},
|
|
{
|
|
name: "hours only",
|
|
duration: 2 * time.Hour,
|
|
includeSeconds: false,
|
|
expected: "2 hours 0 minutes",
|
|
},
|
|
{
|
|
name: "hours and minutes",
|
|
duration: 2*time.Hour + 30*time.Minute,
|
|
includeSeconds: false,
|
|
expected: "2 hours 30 minutes",
|
|
},
|
|
{
|
|
name: "full duration with seconds",
|
|
duration: 2*time.Hour + 30*time.Minute + 15*time.Second,
|
|
includeSeconds: true,
|
|
expected: "2 hours 30 minutes 15 seconds",
|
|
},
|
|
{
|
|
name: "full duration without seconds",
|
|
duration: 2*time.Hour + 30*time.Minute + 15*time.Second,
|
|
includeSeconds: false,
|
|
expected: "2 hours 30 minutes",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := FormatDuration(tt.duration, tt.includeSeconds)
|
|
if result != tt.expected {
|
|
t.Errorf("got %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractConvUUID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
email string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "valid UUID v4",
|
|
email: "support+conv-13216cf7-6626-4b0d-a938-46ce65a20701@domain.com",
|
|
expected: "13216cf7-6626-4b0d-a938-46ce65a20701",
|
|
},
|
|
{
|
|
name: "uppercase UUID v4",
|
|
email: "support+conv-13216CF7-6626-4B0D-A938-46CE65A20701@domain.com",
|
|
expected: "13216CF7-6626-4B0D-A938-46CE65A20701",
|
|
},
|
|
{
|
|
name: "no plus addressing",
|
|
email: "support@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "non-conv plus addressing",
|
|
email: "support+other@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "short non-UUID (user email)",
|
|
email: "support+conv-21321@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "invalid UUID format",
|
|
email: "support+conv-abc123-def456@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "missing 4 in UUID (invalid v4)",
|
|
email: "support+conv-13216cf7-6626-ab0d-a938-46ce65a20701@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
email: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "missing @ symbol",
|
|
email: "support+conv-13216cf7-6626-4b0d-a938-46ce65a20701",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "UUID with extra chars",
|
|
email: "support+conv-13216cf7-6626-4b0d-a938-46ce65a20701-extra@domain.com",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "valid UUID different local part",
|
|
email: "inbox+conv-a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d@example.org",
|
|
expected: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := ExtractConvUUID(tt.email)
|
|
if result != tt.expected {
|
|
t.Errorf("ExtractConvUUID(%q) = %q, want %q", tt.email, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractReferenceNumber(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
subject string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple reference number",
|
|
subject: "Test - #392",
|
|
expected: "392",
|
|
},
|
|
{
|
|
name: "with RE prefix",
|
|
subject: "RE: Test - #392",
|
|
expected: "392",
|
|
},
|
|
{
|
|
name: "multiple hashes picks last",
|
|
subject: "Order #123 - #392",
|
|
expected: "392",
|
|
},
|
|
{
|
|
name: "no reference number",
|
|
subject: "Just a regular subject",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "hash without number",
|
|
subject: "Test #abc",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
subject: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "number without hash",
|
|
subject: "Test 392",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "multiple RE prefixes",
|
|
subject: "RE: RE: Test - #100",
|
|
expected: "100",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := ExtractReferenceNumber(tt.subject)
|
|
if result != tt.expected {
|
|
t.Errorf("ExtractReferenceNumber(%q) = %q, want %q", tt.subject, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizeUTF8(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"empty", "", ""},
|
|
{"plain ascii unchanged", "Hello, world!", "Hello, world!"},
|
|
{"valid copyright unchanged", "© 2026", "© 2026"},
|
|
{"orphan 0xa9 replaced", "\xa9 2026 Upstox", "� 2026 Upstox"},
|
|
{"nul stripped", "a\x00b", "ab"},
|
|
{"chinese unchanged", "你好世界", "你好世界"},
|
|
{"devanagari unchanged", "नमस्ते", "नमस्ते"},
|
|
{"arabic unchanged", "مرحبا", "مرحبا"},
|
|
{"emoji unchanged", "ok 😀👍", "ok 😀👍"},
|
|
{"accented latin unchanged", "café résumé", "café résumé"},
|
|
{"run of invalid bytes collapses to one replacement", "x\xa9\xa9y", "x�y"},
|
|
{"truncated 3-byte char replaced", "\xe4\xbd", "�"},
|
|
{"lead byte at end replaced", "abc\xc3", "abc�"},
|
|
{"overlong encoding replaced", "x\xc0\x80y", "x�y"},
|
|
{"cp1252 smart quotes replaced", "\x93hi\x94", "�hi�"},
|
|
{"multiple embedded nuls stripped", "a\x00\x00b\x00c", "abc"},
|
|
{"nul and invalid byte combined", "a\x00\xa9b", "a�b"},
|
|
{"valid multibyte preserved around invalid byte", "a你\xa9好b", "a你�好b"},
|
|
{"bom preserved", "\ufeffhi", "\ufeffhi"},
|
|
{"existing replacement char preserved", "a�b", "a�b"},
|
|
{"crlf and tab preserved", "l1\r\nl2\t", "l1\r\nl2\t"},
|
|
{"paired continuation kept, orphan replaced", "é\xa9", "é�"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := SanitizeUTF8(tt.input); got != tt.expected {
|
|
t.Errorf("SanitizeUTF8(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantFirst string
|
|
wantLast string
|
|
}{
|
|
{name: "empty", input: "", wantFirst: "", wantLast: ""},
|
|
{name: "whitespace only", input: " ", wantFirst: "", wantLast: ""},
|
|
{name: "single name", input: "Cher", wantFirst: "Cher", wantLast: ""},
|
|
{name: "first and last", input: "John Doe", wantFirst: "John", wantLast: "Doe"},
|
|
{name: "middle name", input: "John Michael Doe", wantFirst: "John", wantLast: "Michael Doe"},
|
|
{name: "multi-word surname", input: "Ludwig van der Berg", wantFirst: "Ludwig", wantLast: "van der Berg"},
|
|
{name: "extra spaces", input: " John Doe ", wantFirst: "John", wantLast: "Doe"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
first, last := SplitName(tt.input)
|
|
if first != tt.wantFirst || last != tt.wantLast {
|
|
t.Errorf("SplitName(%q) = (%q, %q), want (%q, %q)", tt.input, first, last, tt.wantFirst, tt.wantLast)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestGenerateSlug(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple title",
|
|
input: "Hello World",
|
|
expected: "hello-world",
|
|
},
|
|
{
|
|
name: "title with special characters",
|
|
input: "Hello, World! How are you?",
|
|
expected: "hello-world-how-are-you",
|
|
},
|
|
{
|
|
name: "title with numbers",
|
|
input: "Article 123: How to Code",
|
|
expected: "article-123-how-to-code",
|
|
},
|
|
{
|
|
name: "title with underscores",
|
|
input: "test_article_name",
|
|
expected: "test_article_name",
|
|
},
|
|
{
|
|
name: "title with multiple spaces",
|
|
input: "Hello World",
|
|
expected: "hello-world",
|
|
},
|
|
{
|
|
name: "title with leading/trailing spaces",
|
|
input: " Hello World ",
|
|
expected: "hello-world",
|
|
},
|
|
{
|
|
name: "title with multiple hyphens",
|
|
input: "Hello---World",
|
|
expected: "hello-world",
|
|
},
|
|
{
|
|
name: "unicode characters",
|
|
input: "Hello World",
|
|
expected: "hello-world",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := GenerateSlug(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("GenerateSlug(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|