diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 68c6a4db..d7ba04ea 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -33,6 +33,29 @@ jobs: name: docker-image path: /tmp/docker-image.tar + test-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'backend/go.mod' + cache-dependency-path: 'backend/go.sum' + - name: Install dependencies + working-directory: backend + run: | + go get ./... + - name: Run backend unit tests + working-directory: backend + run: | + go test -v ./... | tee /tmp/TestResults.log + - uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-unit-tests + path: /tmp/TestResults.log + retention-days: 15 + test-sqlite: runs-on: ubuntu-latest needs: build diff --git a/backend/internal/utils/file_util.go b/backend/internal/utils/file_util.go index b644cee1..ce0a088f 100644 --- a/backend/internal/utils/file_util.go +++ b/backend/internal/utils/file_util.go @@ -5,14 +5,16 @@ import ( "mime/multipart" "os" "path/filepath" - "strings" "github.com/pocket-id/pocket-id/backend/resources" ) func GetFileExtension(filename string) string { - splitted := strings.Split(filename, ".") - return splitted[len(splitted)-1] + ext := filepath.Ext(filename) + if len(ext) > 0 && ext[0] == '.' { + return ext[1:] + } + return filename } func GetImageMimeType(ext string) string { diff --git a/backend/internal/utils/file_util_test.go b/backend/internal/utils/file_util_test.go new file mode 100644 index 00000000..6c60682a --- /dev/null +++ b/backend/internal/utils/file_util_test.go @@ -0,0 +1,73 @@ +package utils + +import ( + "testing" +) + +func TestGetFileExtension(t *testing.T) { + tests := []struct { + name string + filename string + want string + }{ + { + name: "Simple file with extension", + filename: "document.pdf", + want: "pdf", + }, + { + name: "File with path", + filename: "/path/to/document.txt", + want: "txt", + }, + { + name: "File with path (Windows style)", + filename: "C:\\path\\to\\document.jpg", + want: "jpg", + }, + { + name: "Multiple extensions", + filename: "archive.tar.gz", + want: "gz", + }, + { + name: "Hidden file with extension", + filename: ".config.json", + want: "json", + }, + { + name: "Filename with dots", + filename: "version.1.2.3.txt", + want: "txt", + }, + { + name: "File with uppercase extension", + filename: "image.JPG", + want: "JPG", + }, + { + name: "File without extension", + filename: "README", + want: "README", + }, + { + name: "Hidden file without extension", + filename: ".gitignore", + want: "gitignore", + }, + { + name: "Empty filename", + filename: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetFileExtension(tt.filename) + if got != tt.want { + t.Errorf("GetFileExtension(%q) = %q, want %q", tt.filename, got, tt.want) + } + }) + } +} diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go index 4ec6d3bd..9e4842c3 100644 --- a/backend/internal/utils/image/profile_picture.go +++ b/backend/internal/utils/image/profile_picture.go @@ -90,7 +90,7 @@ func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, err var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.PNG) if err != nil { - return nil, fmt.Errorf("failed to encode image: %v", err) + return nil, fmt.Errorf("failed to encode image: %w", err) } return &buf, nil diff --git a/backend/internal/utils/string_util.go b/backend/internal/utils/string_util.go index 24c78c91..8643d3d3 100644 --- a/backend/internal/utils/string_util.go +++ b/backend/internal/utils/string_util.go @@ -2,8 +2,9 @@ package utils import ( "crypto/rand" + "errors" "fmt" - "math/big" + "io" "net/url" "regexp" "strings" @@ -13,23 +14,41 @@ import ( // GenerateRandomAlphanumericString generates a random alphanumeric string of the given length func GenerateRandomAlphanumericString(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - const charsetLength = int64(len(charset)) if length <= 0 { - return "", fmt.Errorf("length must be a positive integer") + return "", errors.New("length must be a positive integer") } - result := make([]byte, length) + // The algorithm below is adapted from https://stackoverflow.com/a/35615565 + const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1< 0 { - result = append(result, '_') + result.WriteByte('_') } - result = append(result, unicode.ToLower(r)) + result.WriteRune(unicode.ToLower(r)) } - return string(result) + return result.String() } +var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`) + func CamelCaseToScreamingSnakeCase(s string) string { // Insert underscores before uppercase letters (except the first one) - re := regexp.MustCompile(`([a-z0-9])([A-Z])`) - snake := re.ReplaceAllString(s, `${1}_${2}`) + snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`) // Convert to uppercase return strings.ToUpper(snake) diff --git a/backend/internal/utils/string_util_test.go b/backend/internal/utils/string_util_test.go new file mode 100644 index 00000000..fdf96b2e --- /dev/null +++ b/backend/internal/utils/string_util_test.go @@ -0,0 +1,105 @@ +package utils + +import ( + "regexp" + "testing" +) + +func TestGenerateRandomAlphanumericString(t *testing.T) { + t.Run("valid length returns correct string", func(t *testing.T) { + const length = 10 + str, err := GenerateRandomAlphanumericString(length) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(str) != length { + t.Errorf("Expected length %d, got %d", length, len(str)) + } + + matched, err := regexp.MatchString(`^[a-zA-Z0-9]+$`, str) + if err != nil { + t.Errorf("Regex match failed: %v", err) + } + if !matched { + t.Errorf("String contains non-alphanumeric characters: %s", str) + } + }) + + t.Run("zero length returns error", func(t *testing.T) { + _, err := GenerateRandomAlphanumericString(0) + if err == nil { + t.Error("Expected error for zero length, got nil") + } + }) + + t.Run("negative length returns error", func(t *testing.T) { + _, err := GenerateRandomAlphanumericString(-1) + if err == nil { + t.Error("Expected error for negative length, got nil") + } + }) + + t.Run("generates different strings", func(t *testing.T) { + str1, _ := GenerateRandomAlphanumericString(10) + str2, _ := GenerateRandomAlphanumericString(10) + if str1 == str2 { + t.Error("Generated strings should be different") + } + }) +} + +func TestCapitalizeFirstLetter(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"lowercase first letter", "hello", "Hello"}, + {"already capitalized", "Hello", "Hello"}, + {"single lowercase letter", "h", "H"}, + {"single uppercase letter", "H", "H"}, + {"starts with number", "123abc", "123abc"}, + {"unicode character", "étoile", "Étoile"}, + {"special character", "_test", "_test"}, + {"multi-word", "hello world", "Hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CapitalizeFirstLetter(tt.input) + if result != tt.expected { + t.Errorf("CapitalizeFirstLetter(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestCamelCaseToSnakeCase(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"simple camelCase", "camelCase", "camel_case"}, + {"PascalCase", "PascalCase", "pascal_case"}, + {"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"}, + {"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"}, + {"single lowercase word", "word", "word"}, + {"single uppercase word", "WORD", "w_o_r_d"}, + {"with numbers", "camel123Case", "camel123_case"}, + {"with numbers in middle", "model2Name", "model2_name"}, + {"mixed case", "iPhone6sPlus", "i_phone6s_plus"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CamelCaseToSnakeCase(tt.input) + if result != tt.expected { + t.Errorf("CamelCaseToSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +}