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:
87
utils/slug.go
Normal file
87
utils/slug.go
Normal 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
177
utils/slug_test.go
Normal 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
182
utils/validation.go
Normal 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
195
utils/validation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user