Add initial project structure with core functionality for book and stationery uploads

- Created main application entry point in `main.go`.
- Added configuration management in `config/config.go` and tests in `config/config_test.go`.
- Implemented handlers for book and stationery uploads in `handlers/book.go` and `handlers/stationery.go`, including validation logic.
- Established database connection and services in `services/database.go` and `services/book_service.go`.
- Defined models for books and stationery in `models/book.go` and `models/stationery.go`.
- Set up Firebase integration for image uploads in `services/firebase.go`.
- Created migration scripts for database schema in `migrations/001_create_tables.sql` and subsequent updates.
- Added CORS and error handling middleware.
- Included comprehensive tests for handlers, services, and utilities.
- Documented API endpoints and usage in `README.md` and migration instructions in `migrations/README.md`.
- Introduced `.gitignore` to exclude unnecessary files and directories.
- Added Go module support with `go.mod` and `go.sum` files.
- Implemented utility functions for slug generation and validation in `utils/slug.go` and `utils/validation.go`.
This commit is contained in:
ianshaloom
2025-11-21 08:50:27 +03:00
commit ebeae34e01
39 changed files with 4044 additions and 0 deletions

87
utils/slug.go Normal file
View File

@@ -0,0 +1,87 @@
package utils
import (
"regexp"
"strings"
"unicode"
)
// GenerateSlug converts a string to a URL-friendly slug
// Converts to lowercase, replaces spaces with hyphens, removes special characters
func GenerateSlug(text string) string {
if text == "" {
return ""
}
// Convert to lowercase
slug := strings.ToLower(text)
// Replace spaces and underscores with hyphens
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
// Remove all non-alphanumeric characters except hyphens
reg := regexp.MustCompile(`[^a-z0-9-]`)
slug = reg.ReplaceAllString(slug, "")
// Remove multiple consecutive hyphens
reg = regexp.MustCompile(`-+`)
slug = reg.ReplaceAllString(slug, "-")
// Remove leading and trailing hyphens
slug = strings.Trim(slug, "-")
// If empty after processing, return a default
if slug == "" {
slug = "item"
}
return slug
}
// IsValidSlug checks if a string is a valid slug format
func IsValidSlug(slug string) bool {
if slug == "" {
return false
}
// Check if it contains only lowercase letters, numbers, and hyphens
reg := regexp.MustCompile(`^[a-z0-9-]+$`)
if !reg.MatchString(slug) {
return false
}
// Check it doesn't start or end with hyphen
if strings.HasPrefix(slug, "-") || strings.HasSuffix(slug, "-") {
return false
}
// Check it doesn't have consecutive hyphens
if strings.Contains(slug, "--") {
return false
}
return true
}
// CleanString removes leading/trailing whitespace and normalizes spaces
func CleanString(s string) string {
// Trim whitespace
s = strings.TrimSpace(s)
// Replace multiple spaces with single space
reg := regexp.MustCompile(`\s+`)
s = reg.ReplaceAllString(s, " ")
return s
}
// ContainsOnlyLetters checks if a string contains only letters (and spaces)
func ContainsOnlyLetters(s string) bool {
for _, r := range s {
if !unicode.IsLetter(r) && !unicode.IsSpace(r) {
return false
}
}
return true
}

177
utils/slug_test.go Normal file
View File

@@ -0,0 +1,177 @@
package utils
import "testing"
func TestGenerateSlug(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple text",
input: "Hello World",
expected: "hello-world",
},
{
name: "text with special characters",
input: "Book Name!@#$%",
expected: "book-name",
},
{
name: "text with numbers",
input: "Book 123",
expected: "book-123",
},
{
name: "text with multiple spaces",
input: "Book Name Here",
expected: "book-name-here",
},
{
name: "text with underscores",
input: "Book_Name_Here",
expected: "book-name-here",
},
{
name: "text with mixed case",
input: "My Awesome Book",
expected: "my-awesome-book",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only special characters",
input: "!@#$%",
expected: "item",
},
{
name: "leading and trailing spaces",
input: " Book Name ",
expected: "book-name",
},
{
name: "text with hyphens",
input: "Book-Name-Here",
expected: "book-name-here",
},
{
name: "text with consecutive special chars",
input: "Book!!!Name",
expected: "bookname",
},
}
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)
}
})
}
}
func TestIsValidSlug(t *testing.T) {
tests := []struct {
name string
slug string
expected bool
}{
{
name: "valid slug",
slug: "hello-world",
expected: true,
},
{
name: "valid slug with numbers",
slug: "book-123",
expected: true,
},
{
name: "invalid - uppercase",
slug: "Hello-World",
expected: false,
},
{
name: "invalid - special characters",
slug: "hello-world!",
expected: false,
},
{
name: "invalid - leading hyphen",
slug: "-hello-world",
expected: false,
},
{
name: "invalid - trailing hyphen",
slug: "hello-world-",
expected: false,
},
{
name: "invalid - consecutive hyphens",
slug: "hello--world",
expected: false,
},
{
name: "invalid - empty",
slug: "",
expected: false,
},
{
name: "valid - single word",
slug: "book",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsValidSlug(tt.slug)
if result != tt.expected {
t.Errorf("IsValidSlug(%q) = %v, want %v", tt.slug, result, tt.expected)
}
})
}
}
func TestCleanString(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "normal string",
input: "Hello World",
expected: "Hello World",
},
{
name: "with leading/trailing spaces",
input: " Hello World ",
expected: "Hello World",
},
{
name: "with multiple spaces",
input: "Hello World",
expected: "Hello World",
},
{
name: "empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CleanString(tt.input)
if result != tt.expected {
t.Errorf("CleanString(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

182
utils/validation.go Normal file
View File

@@ -0,0 +1,182 @@
package utils
import (
"fmt"
"strconv"
"strings"
)
// ValidationError represents a validation error with field name
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidateBookFields validates all book form fields
func ValidateBookFields(bookName, cost, price, discount, quantity, publisherAuthor, category, description string) []*ValidationError {
var errors []*ValidationError
// Book Name
bookName = strings.TrimSpace(bookName)
if bookName == "" {
errors = append(errors, &ValidationError{Field: "book_name", Message: "is required"})
} else if len(bookName) > 200 {
errors = append(errors, &ValidationError{Field: "book_name", Message: "exceeds 200 characters"})
}
// Cost
if cost == "" {
errors = append(errors, &ValidationError{Field: "cost", Message: "is required"})
} else {
costVal, err := strconv.ParseFloat(cost, 64)
if err != nil || costVal <= 0 {
errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"})
}
}
// Price
if price == "" {
errors = append(errors, &ValidationError{Field: "price", Message: "is required"})
} else {
priceVal, err := strconv.ParseFloat(price, 64)
if err != nil || priceVal <= 0 {
errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"})
}
}
// Discount (optional)
if discount != "" {
discountVal, err := strconv.ParseFloat(discount, 64)
if err != nil || discountVal < 0 {
errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"})
}
}
// Quantity
if quantity == "" {
errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"})
} else {
quantityVal, err := strconv.Atoi(quantity)
if err != nil || quantityVal <= 0 {
errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"})
}
}
// Publisher/Author
publisherAuthor = strings.TrimSpace(publisherAuthor)
if publisherAuthor == "" {
errors = append(errors, &ValidationError{Field: "publisher_author", Message: "is required"})
} else if len(publisherAuthor) > 200 {
errors = append(errors, &ValidationError{Field: "publisher_author", Message: "exceeds 200 characters"})
}
// Category
category = strings.TrimSpace(category)
if category == "" {
errors = append(errors, &ValidationError{Field: "category", Message: "is required"})
} else if len(category) > 100 {
errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"})
}
// Description (optional)
if description != "" && len(description) > 1000 {
errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"})
}
return errors
}
// ValidateStationeryFields validates all stationery form fields
func ValidateStationeryFields(stationeryName, cost, price, discount, quantity, category, description string) []*ValidationError {
var errors []*ValidationError
// Stationery Name
stationeryName = strings.TrimSpace(stationeryName)
if stationeryName == "" {
errors = append(errors, &ValidationError{Field: "stationery_name", Message: "is required"})
} else if len(stationeryName) > 200 {
errors = append(errors, &ValidationError{Field: "stationery_name", Message: "exceeds 200 characters"})
}
// Cost
if cost == "" {
errors = append(errors, &ValidationError{Field: "cost", Message: "is required"})
} else {
costVal, err := strconv.ParseFloat(cost, 64)
if err != nil || costVal <= 0 {
errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"})
}
}
// Price
if price == "" {
errors = append(errors, &ValidationError{Field: "price", Message: "is required"})
} else {
priceVal, err := strconv.ParseFloat(price, 64)
if err != nil || priceVal <= 0 {
errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"})
}
}
// Discount (optional)
if discount != "" {
discountVal, err := strconv.ParseFloat(discount, 64)
if err != nil || discountVal < 0 {
errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"})
}
}
// Quantity
if quantity == "" {
errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"})
} else {
quantityVal, err := strconv.Atoi(quantity)
if err != nil || quantityVal <= 0 {
errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"})
}
}
// Category
category = strings.TrimSpace(category)
if category == "" {
errors = append(errors, &ValidationError{Field: "category", Message: "is required"})
} else if len(category) > 100 {
errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"})
}
// Description (optional)
if description != "" && len(description) > 1000 {
errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"})
}
return errors
}
// ValidateFileType checks if file type is allowed
func ValidateFileType(contentType string) error {
allowedTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
}
if !allowedTypes[contentType] {
return fmt.Errorf("invalid file type. Only PNG, JPEG, and WEBP are allowed")
}
return nil
}
// ValidateFileSize checks if file size is within limit
func ValidateFileSize(size, maxSize int64) error {
if size > maxSize {
return fmt.Errorf("file size exceeds %dMB limit", maxSize/(1024*1024))
}
return nil
}

195
utils/validation_test.go Normal file
View File

@@ -0,0 +1,195 @@
package utils
import "testing"
func TestValidateBookFields(t *testing.T) {
tests := []struct {
name string
bookName string
cost string
price string
discount string
quantity string
publisherAuthor string
category string
description string
wantErrors int
}{
{
name: "valid fields",
bookName: "Test Book",
cost: "10.50",
price: "15.99",
discount: "0",
quantity: "100",
publisherAuthor: "Test Publisher",
category: "Fiction",
description: "A test book",
wantErrors: 0,
},
{
name: "missing book_name",
bookName: "",
cost: "10.50",
price: "15.99",
quantity: "100",
publisherAuthor: "Test Publisher",
category: "Fiction",
wantErrors: 1,
},
{
name: "invalid cost",
bookName: "Test Book",
cost: "-10",
price: "15.99",
quantity: "100",
publisherAuthor: "Test Publisher",
category: "Fiction",
wantErrors: 1,
},
{
name: "missing category",
bookName: "Test Book",
cost: "10.50",
price: "15.99",
quantity: "100",
publisherAuthor: "Test Publisher",
category: "",
wantErrors: 1,
},
{
name: "description too long",
bookName: "Test Book",
cost: "10.50",
price: "15.99",
quantity: "100",
publisherAuthor: "Test Publisher",
category: "Fiction",
description: string(make([]byte, 1001)), // 1001 characters
wantErrors: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errors := ValidateBookFields(
tt.bookName,
tt.cost,
tt.price,
tt.discount,
tt.quantity,
tt.publisherAuthor,
tt.category,
tt.description,
)
if len(errors) != tt.wantErrors {
t.Errorf("ValidateBookFields() returned %d errors, want %d", len(errors), tt.wantErrors)
}
})
}
}
func TestValidateStationeryFields(t *testing.T) {
tests := []struct {
name string
stationeryName string
cost string
price string
quantity string
category string
wantErrors int
}{
{
name: "valid fields",
stationeryName: "Test Pen",
cost: "2.50",
price: "5.99",
quantity: "200",
category: "Writing",
wantErrors: 0,
},
{
name: "missing stationery_name",
stationeryName: "",
cost: "2.50",
price: "5.99",
quantity: "200",
category: "Writing",
wantErrors: 1,
},
{
name: "invalid quantity",
stationeryName: "Test Pen",
cost: "2.50",
price: "5.99",
quantity: "-10",
category: "Writing",
wantErrors: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errors := ValidateStationeryFields(
tt.stationeryName,
tt.cost,
tt.price,
"",
tt.quantity,
tt.category,
"",
)
if len(errors) != tt.wantErrors {
t.Errorf("ValidateStationeryFields() returned %d errors, want %d", len(errors), tt.wantErrors)
}
})
}
}
func TestValidateFileType(t *testing.T) {
tests := []struct {
name string
contentType string
wantErr bool
}{
{"valid PNG", "image/png", false},
{"valid JPEG", "image/jpeg", false},
{"valid JPG", "image/jpg", false},
{"valid WEBP", "image/webp", false},
{"invalid type", "image/gif", true},
{"invalid type", "text/plain", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileType(tt.contentType)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFileType() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateFileSize(t *testing.T) {
tests := []struct {
name string
size int64
maxSize int64
wantErr bool
}{
{"valid size", 5 * 1024 * 1024, 10 * 1024 * 1024, false},
{"exact max size", 10 * 1024 * 1024, 10 * 1024 * 1024, false},
{"too large", 11 * 1024 * 1024, 10 * 1024 * 1024, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileSize(tt.size, tt.maxSize)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFileSize() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}