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

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
jd-book-uploader
# Test binary
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# Environment variables
.env
# Firebase credentials
firebase-service-account.json
*.json
!go.mod
!go.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Build artifacts
dist/
build/
.env.example
.env.production
.env.local
.env.production.example

490
README.md Normal file
View File

@@ -0,0 +1,490 @@
# JD Book Uploader - Backend API Documentation
## Overview
Backend API for uploading book and stationery products with images to Firebase Storage and storing metadata in PostgreSQL.
## Base URL
- **Development**: `http://localhost:8080`
- **Production**: Configure via `FRONTEND_URL` environment variable
---
## API Endpoints
### 1. Health Check
**Endpoint:** `GET /api/health`
**Description:** Check the health status of the API and its dependencies (database and Firebase).
**Request:**
- **Method:** `GET`
- **Path:** `/api/health`
- **Headers:** None required
- **Body:** None
**Response:**
**Success (200 OK):**
```json
{
"status": "ok",
"database": "connected",
"firebase": "connected"
}
```
**Service Unavailable (503):**
```json
{
"status": "degraded",
"database": "disconnected",
"firebase": "connected"
}
```
**Response Fields:**
- `status` (string): `"ok"` if all services connected, `"degraded"` if any service unavailable
- `database` (string): `"connected"`, `"disconnected"`, or `"not_initialized"`
- `firebase` (string): `"connected"`, `"disconnected"`, or `"not_initialized"`
---
### 2. Upload Book
**Endpoint:** `POST /api/books`
**Description:** Upload a book product with cover image. Image is uploaded to Firebase Storage and book metadata is stored in PostgreSQL.
**Request:**
- **Method:** `POST`
- **Path:** `/api/books`
- **Content-Type:** `multipart/form-data`
- **Body:** FormData with the following fields:
**Required Parameters:**
- `image` (File) - Book cover image file
- **Type:** File (multipart/form-data)
- **Allowed types:** `image/png`, `image/jpeg`, `image/jpg`, `image/webp`
- **Max size:** 10MB
- **Required:** Yes
- `book_name` (string) - Book name
- **Type:** String
- **Max length:** 200 characters
- **Required:** Yes
- **Validation:** Not empty
- `cost` (number) - Cost price
- **Type:** Number (decimal)
- **Required:** Yes
- **Validation:** Must be a positive number
- `price` (number) - Selling price
- **Type:** Number (decimal)
- **Required:** Yes
- **Validation:** Must be a positive number
- `quantity` (integer) - Stock quantity
- **Type:** Integer
- **Required:** Yes
- **Validation:** Must be a positive integer
- `publisher_author` (string) - Publisher or author name
- **Type:** String
- **Max length:** 200 characters
- **Required:** Yes
- **Validation:** Not empty
- `category` (string) - Book category
- **Type:** String
- **Max length:** 100 characters
- **Required:** Yes
- **Validation:** Not empty
**Optional Parameters:**
- `discount` (number) - Discount amount
- **Type:** Number (decimal)
- **Default:** 0
- **Required:** No
- **Validation:** Must be non-negative (>= 0)
- `description` (string) - Book description
- **Type:** String
- **Max length:** 1000 characters
- **Required:** No
**Request Body Format:**
```
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
------WebKitFormBoundary...
Content-Disposition: form-data; name="image"; filename="book-cover.png"
Content-Type: image/png
[binary image data]
------WebKitFormBoundary...
Content-Disposition: form-data; name="book_name"
The Great Gatsby
------WebKitFormBoundary...
Content-Disposition: form-data; name="cost"
15.50
------WebKitFormBoundary...
Content-Disposition: form-data; name="price"
24.99
------WebKitFormBoundary...
Content-Disposition: form-data; name="discount"
0
------WebKitFormBoundary...
Content-Disposition: form-data; name="quantity"
100
------WebKitFormBoundary...
Content-Disposition: form-data; name="publisher_author"
F. Scott Fitzgerald
------WebKitFormBoundary...
Content-Disposition: form-data; name="category"
Fiction
------WebKitFormBoundary...
Content-Disposition: form-data; name="description"
A classic American novel
------WebKitFormBoundary...--
```
**Success Response (201 Created):**
```json
{
"success": true,
"data": {
"book_code": "550e8400-e29b-41d4-a716-446655440000",
"book_name": "The Great Gatsby",
"image_url": "https://storage.googleapis.com/download/storage/v1/b/kaisa-341a6.appspot.com/o/images%2F2025%2F11%2F1732123456_abc123.png?generation=1732123456789&alt=media",
"slug": "the-great-gatsby",
"created_at": "2025-11-20T17:30:00Z"
}
}
```
**Response Fields:**
- `success` (boolean): Always `true` on success
- `data` (object): Contains the created book data
- `book_code` (string): UUID generated for the book
- `book_name` (string): Book name as provided
- `image_url` (string): Public URL of uploaded image in Firebase Storage
- `slug` (string): URL-friendly slug generated from book name
- `created_at` (string): ISO 8601 timestamp of creation
**Error Responses:**
**400 Bad Request** - Validation error:
```json
{
"success": false,
"error": "book_name is required"
}
```
**413 Payload Too Large** - File size exceeds limit:
```json
{
"success": false,
"error": "File size exceeds 10MB limit"
}
```
**500 Internal Server Error** - Server error:
```json
{
"success": false,
"error": "Failed to upload image"
}
```
**Common Error Messages:**
- `"Image file is required"` - No image file provided
- `"Invalid file type. Only PNG, JPEG, and WEBP are allowed"` - Unsupported file type
- `"File size exceeds 10MB limit"` - File too large
- `"book_name is required"` - Missing required field
- `"Invalid cost value"` - Invalid number format or negative value
- `"Failed to parse form data"` - Malformed request
- `"Failed to upload image"` - Firebase upload error
- `"Failed to create book"` - Database insert error
---
### 3. Upload Stationery
**Endpoint:** `POST /api/stationery`
**Description:** Upload a stationery product with image. Image is uploaded to Firebase Storage and stationery metadata is stored in PostgreSQL.
**Request:**
- **Method:** `POST`
- **Path:** `/api/stationery`
- **Content-Type:** `multipart/form-data`
- **Body:** FormData with the following fields:
**Required Parameters:**
- `image` (File) - Stationery image file
- **Type:** File (multipart/form-data)
- **Allowed types:** `image/png`, `image/jpeg`, `image/jpg`, `image/webp`
- **Max size:** 10MB
- **Required:** Yes
- `stationery_name` (string) - Stationery name
- **Type:** String
- **Max length:** 200 characters
- **Required:** Yes
- **Validation:** Not empty
- `cost` (number) - Cost price
- **Type:** Number (decimal)
- **Required:** Yes
- **Validation:** Must be a positive number
- `price` (number) - Selling price
- **Type:** Number (decimal)
- **Required:** Yes
- **Validation:** Must be a positive number
- `quantity` (integer) - Stock quantity
- **Type:** Integer
- **Required:** Yes
- **Validation:** Must be a positive integer
- `category` (string) - Stationery category
- **Type:** String
- **Max length:** 100 characters
- **Required:** Yes
- **Validation:** Not empty
**Optional Parameters:**
- `discount` (number) - Discount amount
- **Type:** Number (decimal)
- **Default:** 0
- **Required:** No
- **Validation:** Must be non-negative (>= 0)
- `color` (string) - Product color
- **Type:** String
- **Required:** No
- `material` (string) - Product material
- **Type:** String
- **Required:** No
- `dimensions` (string) - Product dimensions
- **Type:** String
- **Required:** No
- `description` (string) - Product description
- **Type:** String
- **Max length:** 1000 characters
- **Required:** No
**Request Body Format:**
```
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
------WebKitFormBoundary...
Content-Disposition: form-data; name="image"; filename="pen.png"
Content-Type: image/png
[binary image data]
------WebKitFormBoundary...
Content-Disposition: form-data; name="stationery_name"
Blue Ballpoint Pen
------WebKitFormBoundary...
Content-Disposition: form-data; name="cost"
2.50
------WebKitFormBoundary...
Content-Disposition: form-data; name="price"
5.99
------WebKitFormBoundary...
Content-Disposition: form-data; name="discount"
0
------WebKitFormBoundary...
Content-Disposition: form-data; name="quantity"
200
------WebKitFormBoundary...
Content-Disposition: form-data; name="category"
Writing
------WebKitFormBoundary...
Content-Disposition: form-data; name="color"
Blue
------WebKitFormBoundary...
Content-Disposition: form-data; name="material"
Plastic
------WebKitFormBoundary...
Content-Disposition: form-data; name="dimensions"
15cm
------WebKitFormBoundary...
Content-Disposition: form-data; name="description"
Smooth writing ballpoint pen
------WebKitFormBoundary...--
```
**Success Response (201 Created):**
```json
{
"success": true,
"data": {
"stationery_code": "550e8400-e29b-41d4-a716-446655440001",
"stationery_name": "Blue Ballpoint Pen",
"image_url": "https://storage.googleapis.com/download/storage/v1/b/kaisa-341a6.appspot.com/o/images%2F2025%2F11%2F1732123456_def456.png?generation=1732123456789&alt=media",
"slug": "blue-ballpoint-pen",
"created_at": "2025-11-20T17:30:00Z"
}
}
```
**Response Fields:**
- `success` (boolean): Always `true` on success
- `data` (object): Contains the created stationery data
- `stationery_code` (string): UUID generated for the stationery item
- `stationery_name` (string): Stationery name as provided
- `image_url` (string): Public URL of uploaded image in Firebase Storage
- `slug` (string): URL-friendly slug generated from stationery name
- `created_at` (string): ISO 8601 timestamp of creation
**Error Responses:**
**400 Bad Request** - Validation error:
```json
{
"success": false,
"error": "stationery_name is required"
}
```
**413 Payload Too Large** - File size exceeds limit:
```json
{
"success": false,
"error": "File size exceeds 10MB limit"
}
```
**500 Internal Server Error** - Server error:
```json
{
"success": false,
"error": "Failed to upload image"
}
```
**Common Error Messages:**
- `"Image file is required"` - No image file provided
- `"Invalid file type. Only PNG, JPEG, and WEBP are allowed"` - Unsupported file type
- `"File size exceeds 10MB limit"` - File too large
- `"stationery_name is required"` - Missing required field
- `"Invalid cost value"` - Invalid number format or negative value
- `"Failed to parse form data"` - Malformed request
- `"Failed to upload image"` - Firebase upload error
- `"Failed to create stationery"` - Database insert error
---
## Response Format
All API responses follow a consistent structure:
**Success Response:**
```json
{
"success": true,
"data": { ... }
}
```
**Error Response:**
```json
{
"success": false,
"error": "Error message"
}
```
---
## HTTP Status Codes
- **200 OK** - Health check successful
- **201 Created** - Resource created successfully (book/stationery uploaded)
- **400 Bad Request** - Validation error or invalid input
- **413 Payload Too Large** - File size exceeds 10MB limit
- **500 Internal Server Error** - Server error (database, Firebase, or internal error)
- **503 Service Unavailable** - Service dependencies (database or Firebase) unavailable
---
## Field Validation Summary
### Book Upload Fields
| Field | Type | Required | Max Length | Validation Rules |
|-------|------|----------|------------|------------------|
| `image` | File | Yes | 10MB | PNG, JPEG, or WEBP only |
| `book_name` | string | Yes | 200 | Not empty |
| `cost` | number | Yes | - | Positive number (> 0) |
| `price` | number | Yes | - | Positive number (> 0) |
| `discount` | number | No | - | Non-negative (>= 0), default: 0 |
| `quantity` | integer | Yes | - | Positive integer (> 0) |
| `publisher_author` | string | Yes | 200 | Not empty |
| `category` | string | Yes | 100 | Not empty |
| `description` | string | No | 1000 | - |
### Stationery Upload Fields
| Field | Type | Required | Max Length | Validation Rules |
|-------|------|----------|------------|------------------|
| `image` | File | Yes | 10MB | PNG, JPEG, or WEBP only |
| `stationery_name` | string | Yes | 200 | Not empty |
| `cost` | number | Yes | - | Positive number (> 0) |
| `price` | number | Yes | - | Positive number (> 0) |
| `discount` | number | No | - | Non-negative (>= 0), default: 0 |
| `quantity` | integer | Yes | - | Positive integer (> 0) |
| `category` | string | Yes | 100 | Not empty |
| `color` | string | No | - | - |
| `material` | string | No | - | - |
| `dimensions` | string | No | - | - |
| `description` | string | No | 1000 | - |
---
## Notes
1. **Content-Type Header**: For file uploads, use `multipart/form-data`. Do NOT manually set the `Content-Type` header - let the browser/client set it automatically with the proper boundary.
2. **Image Processing**: The backend expects images to be processed on the frontend (resized to 1000x1000, background removed) before upload. Upload the processed PNG image.
3. **Auto-Generated Fields**: The following fields are automatically generated by the backend:
- `book_code` / `stationery_code`: UUID v4
- `slug`: Generated from product name (lowercase, spaces to hyphens, special characters removed)
- `created_at`: Current timestamp
- `updated_at`: Current timestamp
4. **File Upload**: Only one image file per request. The file field name must be `image`.
5. **Error Messages**: Always check the `error` field in error responses for user-friendly error messages.
6. **CORS**: The backend is configured to accept requests from the frontend URL specified in `FRONTEND_URL` environment variable (default: `http://localhost:5173`).

107
config/config.go Normal file
View File

@@ -0,0 +1,107 @@
package config
import (
"fmt"
"os"
"github.com/joho/godotenv"
)
// Config holds all application configuration
type Config struct {
// Server
Port string
FrontendURL string
// Database
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
// Firebase
FirebaseProjectID string
FirebaseStorageBucket string
FirebaseCredentialsFile string
}
// LoadConfig loads configuration from environment variables
// Loading order (later files override earlier ones):
// 1. .env (base/default values)
// 2. .env.local (local development overrides - takes precedence)
// Note: OS environment variables take highest precedence (godotenv won't override them)
func LoadConfig() (*Config, error) {
// Load .env file if it exists (optional, won't error if missing)
_ = godotenv.Load(".env")
// Load .env.local if it exists (overrides .env values)
// This allows local development overrides without modifying .env
_ = godotenv.Load(".env.local")
cfg := &Config{
// Server
Port: getEnv("PORT", "8080"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:5173"),
// Database
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", ""),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", ""),
// Firebase
FirebaseProjectID: getEnv("FIREBASE_PROJECT_ID", ""),
FirebaseStorageBucket: getEnv("FIREBASE_STORAGE_BUCKET", ""),
FirebaseCredentialsFile: getEnv("FIREBASE_CREDENTIALS_FILE", ""),
}
// Validate required fields
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
return cfg, nil
}
// Validate checks that all required configuration values are set
func (c *Config) Validate() error {
var missing []string
if c.DBUser == "" {
missing = append(missing, "DB_USER")
}
if c.DBPassword == "" {
missing = append(missing, "DB_PASSWORD")
}
if c.DBName == "" {
missing = append(missing, "DB_NAME")
}
if c.FirebaseProjectID == "" {
missing = append(missing, "FIREBASE_PROJECT_ID")
}
if c.FirebaseStorageBucket == "" {
missing = append(missing, "FIREBASE_STORAGE_BUCKET")
}
if c.FirebaseCredentialsFile == "" {
// Check if GOOGLE_APPLICATION_CREDENTIALS is set as fallback
if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") == "" {
missing = append(missing, "FIREBASE_CREDENTIALS_FILE (or GOOGLE_APPLICATION_CREDENTIALS)")
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required environment variables: %v", missing)
}
return nil
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

205
config/config_test.go Normal file
View File

@@ -0,0 +1,205 @@
package config
import (
"os"
"testing"
)
func TestLoadConfig(t *testing.T) {
// Save original env values
originalEnv := make(map[string]string)
envVars := []string{
"PORT", "FRONTEND_URL",
"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME",
"FIREBASE_PROJECT_ID", "FIREBASE_STORAGE_BUCKET", "FIREBASE_CREDENTIALS_FILE",
}
for _, key := range envVars {
originalEnv[key] = os.Getenv(key)
os.Unsetenv(key)
}
defer func() {
// Restore original env values
for key, value := range originalEnv {
if value != "" {
os.Setenv(key, value)
} else {
os.Unsetenv(key)
}
}
}()
tests := []struct {
name string
setup func()
wantErr bool
}{
{
name: "valid config",
setup: func() {
os.Setenv("DB_USER", "test_user")
os.Setenv("DB_PASSWORD", "test_password")
os.Setenv("DB_NAME", "test_db")
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
},
wantErr: false,
},
{
name: "missing DB_USER",
setup: func() {
os.Setenv("DB_PASSWORD", "test_password")
os.Setenv("DB_NAME", "test_db")
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
},
wantErr: true,
},
{
name: "missing FIREBASE_PROJECT_ID",
setup: func() {
os.Setenv("DB_USER", "test_user")
os.Setenv("DB_PASSWORD", "test_password")
os.Setenv("DB_NAME", "test_db")
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
},
wantErr: true,
},
{
name: "uses defaults for optional fields",
setup: func() {
os.Setenv("DB_USER", "test_user")
os.Setenv("DB_PASSWORD", "test_password")
os.Setenv("DB_NAME", "test_db")
os.Setenv("FIREBASE_PROJECT_ID", "test-project")
os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com")
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json")
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up env before each test
for _, key := range envVars {
os.Unsetenv(key)
}
tt.setup()
cfg, err := LoadConfig()
if (err != nil) != tt.wantErr {
t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && cfg == nil {
t.Error("LoadConfig() returned nil config without error")
return
}
if !tt.wantErr {
// Verify defaults are used
if cfg.Port == "" {
t.Error("Port should have default value")
}
if cfg.DBHost == "" {
t.Error("DBHost should have default value")
}
}
})
}
}
func TestConfig_Validate(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "valid config",
config: &Config{
DBUser: "user",
DBPassword: "password",
DBName: "dbname",
FirebaseProjectID: "project-id",
FirebaseStorageBucket: "bucket.appspot.com",
FirebaseCredentialsFile: "./credentials.json",
},
wantErr: false,
},
{
name: "missing DB_USER",
config: &Config{
DBPassword: "password",
DBName: "dbname",
FirebaseProjectID: "project-id",
FirebaseStorageBucket: "bucket.appspot.com",
FirebaseCredentialsFile: "./credentials.json",
},
wantErr: true,
},
{
name: "missing FIREBASE_PROJECT_ID",
config: &Config{
DBUser: "user",
DBPassword: "password",
DBName: "dbname",
FirebaseStorageBucket: "bucket.appspot.com",
FirebaseCredentialsFile: "./credentials.json",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadConfig_EnvFilePrecedence(t *testing.T) {
// This test verifies that .env.local overrides .env values
// Note: This is a conceptual test - actual file loading would require test files
// Save original env
originalPort := os.Getenv("PORT")
defer func() {
if originalPort != "" {
os.Setenv("PORT", originalPort)
} else {
os.Unsetenv("PORT")
}
}()
// Set required vars
os.Setenv("DB_USER", "test")
os.Setenv("DB_PASSWORD", "test")
os.Setenv("DB_NAME", "test")
os.Setenv("FIREBASE_PROJECT_ID", "test")
os.Setenv("FIREBASE_STORAGE_BUCKET", "test.appspot.com")
os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test.json")
// Test that LoadConfig works
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if cfg == nil {
t.Fatal("LoadConfig() returned nil")
}
// Verify it uses default PORT if not set
if cfg.Port == "" {
t.Error("Port should have default value")
}
}

79
go.mod Normal file
View File

@@ -0,0 +1,79 @@
module jd-book-uploader
go 1.25.0
require (
firebase.google.com/go/v4 v4.18.0
github.com/gofiber/fiber/v2 v2.52.10
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
google.golang.org/api v0.247.0
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.6 // indirect
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.7.0 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.57.2 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.74.3 // indirect
google.golang.org/protobuf v1.36.7 // indirect
)

266
go.sum Normal file
View File

@@ -0,0 +1,266 @@
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q=
google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A=
google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc=
google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.74.3 h1:Upn9dMUIfuKB8AGEIdaAx21wDy1z/hV+Z3s5SScLkI4=
google.golang.org/grpc v1.74.3/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

237
handlers/book.go Normal file
View File

@@ -0,0 +1,237 @@
package handlers
import (
"fmt"
"io"
"log"
"strconv"
"jd-book-uploader/models"
"jd-book-uploader/services"
"jd-book-uploader/utils"
"github.com/gofiber/fiber/v2"
)
// UploadBook handles book upload requests
func UploadBook(c *fiber.Ctx) error {
ctx := c.Context()
// Parse multipart form
form, err := c.MultipartForm()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Failed to parse form data",
})
}
// Extract image file
imageFiles := form.File["image"]
if len(imageFiles) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Image file is required",
})
}
imageFile := imageFiles[0]
// Validate file type
allowedTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
}
if !allowedTypes[imageFile.Header.Get("Content-Type")] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid file type. Only PNG, JPEG, and WEBP are allowed",
})
}
// Validate file size (max 10MB)
maxSize := int64(10 * 1024 * 1024) // 10MB
if imageFile.Size > maxSize {
return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
"success": false,
"error": "File size exceeds 10MB limit",
})
}
// Read image file
file, err := imageFile.Open()
if err != nil {
log.Printf("Failed to open image file: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to read image file",
})
}
defer file.Close()
imageData, err := io.ReadAll(file)
if err != nil {
log.Printf("Failed to read image data: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to read image data",
})
}
// Extract form fields
bookName := c.FormValue("book_name")
costStr := c.FormValue("cost")
priceStr := c.FormValue("price")
discountStr := c.FormValue("discount")
quantityStr := c.FormValue("quantity")
publisherAuthor := c.FormValue("publisher_author")
category := c.FormValue("category")
description := c.FormValue("description")
// Validate required fields
if err := validateBookFields(bookName, costStr, priceStr, quantityStr, publisherAuthor, category); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": err.Error(),
})
}
// Parse numeric fields
cost, err := strconv.ParseFloat(costStr, 64)
if err != nil || cost <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid cost value",
})
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil || price <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid price value",
})
}
discount := 0.0
if discountStr != "" {
discount, err = strconv.ParseFloat(discountStr, 64)
if err != nil || discount < 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid discount value",
})
}
}
quantity, err := strconv.Atoi(quantityStr)
if err != nil || quantity <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid quantity value",
})
}
// Generate slug from book name
slug := utils.GenerateSlug(utils.CleanString(bookName))
// Check if slug already exists
slugExists, err := services.BookSlugExists(ctx, slug)
if err != nil {
log.Printf("Failed to check slug existence: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to validate product",
})
}
if slugExists {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"success": false,
"error": fmt.Sprintf("A book with slug '%s' already exists", slug),
})
}
// Prepare filename: slug.png
filename := fmt.Sprintf("%s.png", slug)
// Upload image to Firebase Storage with correct path
imageURL, err := services.UploadImage(ctx, imageData, "/jd-bookshop/books", filename)
if err != nil {
log.Printf("Firebase upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to upload image",
})
}
// Create book request
bookReq := &models.BookCreateRequest{
BookName: utils.CleanString(bookName),
Cost: cost,
Price: price,
Discount: discount,
Quantity: quantity,
PublisherAuthor: utils.CleanString(publisherAuthor),
Category: utils.CleanString(category),
Description: utils.CleanString(description),
ImageURL: imageURL,
}
// Create book in database
book, err := services.CreateBook(ctx, bookReq)
if err != nil {
log.Printf("Database insert failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to create book",
})
}
// Return success response
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"book_code": book.BookCode,
"book_name": book.BookName,
"image_url": book.ImageURL,
"slug": book.Slug,
"created_at": book.CreatedAt,
},
})
}
// validateBookFields validates required book fields
func validateBookFields(bookName, cost, price, quantity, publisherAuthor, category string) error {
if bookName == "" {
return fmt.Errorf("book_name is required")
}
if len(bookName) > 200 {
return fmt.Errorf("book_name exceeds 200 characters")
}
if cost == "" {
return fmt.Errorf("cost is required")
}
if price == "" {
return fmt.Errorf("price is required")
}
if quantity == "" {
return fmt.Errorf("quantity is required")
}
if publisherAuthor == "" {
return fmt.Errorf("publisher_author is required")
}
if len(publisherAuthor) > 200 {
return fmt.Errorf("publisher_author exceeds 200 characters")
}
if category == "" {
return fmt.Errorf("category is required")
}
if len(category) > 100 {
return fmt.Errorf("category exceeds 100 characters")
}
return nil
}

72
handlers/book_test.go Normal file
View File

@@ -0,0 +1,72 @@
package handlers
import (
"bytes"
"mime/multipart"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestUploadBook(t *testing.T) {
// This test requires database and Firebase to be initialized
// Skip if not available
t.Skip("Skipping UploadBook test - requires database and Firebase")
app := fiber.New()
app.Post("/api/books", UploadBook)
// Create multipart form with test data
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add form fields
writer.WriteField("book_name", "Test Book")
writer.WriteField("cost", "10.50")
writer.WriteField("price", "15.99")
writer.WriteField("quantity", "100")
writer.WriteField("publisher_author", "Test Publisher")
writer.WriteField("category", "Fiction")
// Add image file
part, _ := writer.CreateFormFile("image", "test.png")
part.Write([]byte("fake image data"))
writer.Close()
req := httptest.NewRequest("POST", "/api/books", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusCreated {
t.Errorf("Expected status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
}
func TestUploadBook_ValidationErrors(t *testing.T) {
app := fiber.New()
app.Post("/api/books", UploadBook)
// Test missing required field
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("book_name", "") // Empty book name
writer.Close()
req := httptest.NewRequest("POST", "/api/books", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusBadRequest {
t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode)
}
}

52
handlers/health.go Normal file
View File

@@ -0,0 +1,52 @@
package handlers
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
"jd-book-uploader/services"
)
// HealthCheck handles health check requests
func HealthCheck(c *fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
status := fiber.Map{
"status": "ok",
}
// Check database connection
if services.DB != nil {
err := services.DB.Ping(ctx)
if err != nil {
status["database"] = "disconnected"
status["status"] = "degraded"
return c.Status(fiber.StatusServiceUnavailable).JSON(status)
}
status["database"] = "connected"
} else {
status["database"] = "not_initialized"
status["status"] = "degraded"
return c.Status(fiber.StatusServiceUnavailable).JSON(status)
}
// Check Firebase connection
if services.FirebaseClient != nil {
// Try to get bucket to verify connection
bucket, err := services.FirebaseClient.DefaultBucket()
if err != nil || bucket == nil {
status["firebase"] = "disconnected"
status["status"] = "degraded"
return c.Status(fiber.StatusServiceUnavailable).JSON(status)
}
status["firebase"] = "connected"
} else {
status["firebase"] = "not_initialized"
status["status"] = "degraded"
return c.Status(fiber.StatusServiceUnavailable).JSON(status)
}
return c.JSON(status)
}

23
handlers/health_test.go Normal file
View File

@@ -0,0 +1,23 @@
package handlers
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestHealthCheck(t *testing.T) {
app := fiber.New()
app.Get("/health", HealthCheck)
req := httptest.NewRequest("GET", "/health", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusOK && resp.StatusCode != fiber.StatusServiceUnavailable {
t.Errorf("Expected status %d or %d, got %d", fiber.StatusOK, fiber.StatusServiceUnavailable, resp.StatusCode)
}
}

234
handlers/stationery.go Normal file
View File

@@ -0,0 +1,234 @@
package handlers
import (
"fmt"
"io"
"log"
"strconv"
"github.com/gofiber/fiber/v2"
"jd-book-uploader/models"
"jd-book-uploader/services"
"jd-book-uploader/utils"
)
// UploadStationery handles stationery upload requests
func UploadStationery(c *fiber.Ctx) error {
ctx := c.Context()
// Parse multipart form
form, err := c.MultipartForm()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Failed to parse form data",
})
}
// Extract image file
imageFiles := form.File["image"]
if len(imageFiles) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Image file is required",
})
}
imageFile := imageFiles[0]
// Validate file type
allowedTypes := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/webp": true,
}
if !allowedTypes[imageFile.Header.Get("Content-Type")] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid file type. Only PNG, JPEG, and WEBP are allowed",
})
}
// Validate file size (max 10MB)
maxSize := int64(10 * 1024 * 1024) // 10MB
if imageFile.Size > maxSize {
return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
"success": false,
"error": "File size exceeds 10MB limit",
})
}
// Read image file
file, err := imageFile.Open()
if err != nil {
log.Printf("Failed to open image file: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to read image file",
})
}
defer file.Close()
imageData, err := io.ReadAll(file)
if err != nil {
log.Printf("Failed to read image data: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to read image data",
})
}
// Extract form fields
stationeryName := c.FormValue("stationery_name")
costStr := c.FormValue("cost")
priceStr := c.FormValue("price")
discountStr := c.FormValue("discount")
quantityStr := c.FormValue("quantity")
color := c.FormValue("color")
material := c.FormValue("material")
dimensions := c.FormValue("dimensions")
category := c.FormValue("category")
description := c.FormValue("description")
// Validate required fields
if err := validateStationeryFields(stationeryName, costStr, priceStr, quantityStr, category); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": err.Error(),
})
}
// Parse numeric fields
cost, err := strconv.ParseFloat(costStr, 64)
if err != nil || cost <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid cost value",
})
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil || price <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid price value",
})
}
discount := 0.0
if discountStr != "" {
discount, err = strconv.ParseFloat(discountStr, 64)
if err != nil || discount < 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid discount value",
})
}
}
quantity, err := strconv.Atoi(quantityStr)
if err != nil || quantity <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"error": "Invalid quantity value",
})
}
// Generate slug from stationery name
slug := utils.GenerateSlug(utils.CleanString(stationeryName))
// Check if slug already exists
slugExists, err := services.StationerySlugExists(ctx, slug)
if err != nil {
log.Printf("Failed to check slug existence: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to validate product",
})
}
if slugExists {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"success": false,
"error": fmt.Sprintf("A stationery item with slug '%s' already exists", slug),
})
}
// Prepare filename: slug.png
filename := fmt.Sprintf("%s.png", slug)
// Upload image to Firebase Storage with correct path
imageURL, err := services.UploadImage(ctx, imageData, "/jd-bookshop/stationery", filename)
if err != nil {
log.Printf("Firebase upload failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to upload image",
})
}
// Create stationery request
stationeryReq := &models.StationeryCreateRequest{
StationeryName: utils.CleanString(stationeryName),
Cost: cost,
Price: price,
Discount: discount,
Quantity: quantity,
Color: utils.CleanString(color),
Material: utils.CleanString(material),
Dimensions: utils.CleanString(dimensions),
Category: utils.CleanString(category),
Description: utils.CleanString(description),
ImageURL: imageURL,
}
// Create stationery in database
stationery, err := services.CreateStationery(ctx, stationeryReq)
if err != nil {
log.Printf("Database insert failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Failed to create stationery",
})
}
// Return success response
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"data": fiber.Map{
"stationery_code": stationery.StationeryCode,
"stationery_name": stationery.StationeryName,
"image_url": stationery.ImageURL,
"slug": stationery.Slug,
"created_at": stationery.CreatedAt,
},
})
}
// validateStationeryFields validates required stationery fields
func validateStationeryFields(stationeryName, cost, price, quantity, category string) error {
if stationeryName == "" {
return fmt.Errorf("stationery_name is required")
}
if len(stationeryName) > 200 {
return fmt.Errorf("stationery_name exceeds 200 characters")
}
if cost == "" {
return fmt.Errorf("cost is required")
}
if price == "" {
return fmt.Errorf("price is required")
}
if quantity == "" {
return fmt.Errorf("quantity is required")
}
if category == "" {
return fmt.Errorf("category is required")
}
if len(category) > 100 {
return fmt.Errorf("category exceeds 100 characters")
}
return nil
}

View File

@@ -0,0 +1,72 @@
package handlers
import (
"bytes"
"mime/multipart"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestUploadStationery(t *testing.T) {
// This test requires database and Firebase to be initialized
// Skip if not available
t.Skip("Skipping UploadStationery test - requires database and Firebase")
app := fiber.New()
app.Post("/api/stationery", UploadStationery)
// Create multipart form with test data
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add form fields
writer.WriteField("stationery_name", "Test Pen")
writer.WriteField("cost", "2.50")
writer.WriteField("price", "5.99")
writer.WriteField("quantity", "200")
writer.WriteField("category", "Writing")
writer.WriteField("color", "Blue")
// Add image file
part, _ := writer.CreateFormFile("image", "test.png")
part.Write([]byte("fake image data"))
writer.Close()
req := httptest.NewRequest("POST", "/api/stationery", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusCreated {
t.Errorf("Expected status %d, got %d", fiber.StatusCreated, resp.StatusCode)
}
}
func TestUploadStationery_ValidationErrors(t *testing.T) {
app := fiber.New()
app.Post("/api/stationery", UploadStationery)
// Test missing required field
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("stationery_name", "") // Empty stationery name
writer.Close()
req := httptest.NewRequest("POST", "/api/stationery", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusBadRequest {
t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode)
}
}

87
main.go Normal file
View File

@@ -0,0 +1,87 @@
package main
import (
"log"
"os"
"os/signal"
"syscall"
"jd-book-uploader/config"
"jd-book-uploader/handlers"
"jd-book-uploader/middleware"
"jd-book-uploader/services"
"github.com/gofiber/fiber/v2"
)
func main() {
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database connection
_, err = services.NewDBPool(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer services.CloseDB()
log.Println("Database connected successfully")
// Initialize Firebase
_, err = services.InitFirebase(cfg)
if err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
log.Println("Firebase initialized successfully")
// Create Fiber app
app := fiber.New(fiber.Config{
AppName: "JD Book Uploader API",
})
// Register middleware
app.Use(middleware.RecoverHandler()) // Recover from panics
app.Use(middleware.ErrorHandler) // Global error handler
app.Use(middleware.SetupCORS(cfg)) // CORS
app.Use(middleware.SetupLogger()) // Request logging
// Register routes
api := app.Group("/api")
api.Get("/health", handlers.HealthCheck)
api.Post("/books", handlers.UploadBook)
api.Post("/stationery", handlers.UploadStationery)
// Start server
port := cfg.Port
if port == "" {
port = "8080"
}
// Graceful shutdown
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
log.Printf("Server starting on port %s", port)
if err := app.Listen(":" + port); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}()
<-c
log.Println("Gracefully shutting down...")
// Close database connection
if err := services.CloseDB(); err != nil {
log.Printf("Error closing database: %v", err)
}
// Shutdown Fiber app
if err := app.Shutdown(); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
log.Println("Server stopped")
}

20
middleware/cors.go Normal file
View File

@@ -0,0 +1,20 @@
package middleware
import (
"jd-book-uploader/config"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
)
// SetupCORS configures CORS middleware based on application config
func SetupCORS(cfg *config.Config) fiber.Handler {
return cors.New(cors.Config{
AllowOrigins: cfg.FrontendURL,
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
AllowCredentials: true,
ExposeHeaders: "Content-Length",
MaxAge: 3600, // 1 hour
})
}

42
middleware/cors_test.go Normal file
View File

@@ -0,0 +1,42 @@
package middleware
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"jd-book-uploader/config"
)
func TestSetupCORS(t *testing.T) {
cfg := &config.Config{
FrontendURL: "http://localhost:5173",
}
app := fiber.New()
app.Use(SetupCORS(cfg))
app.Get("/test", func(c *fiber.Ctx) error {
return c.SendString("OK")
})
// Test CORS preflight request
req := httptest.NewRequest("OPTIONS", "/test", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusNoContent {
t.Errorf("Expected status %d, got %d", fiber.StatusNoContent, resp.StatusCode)
}
// Check CORS headers
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
if allowOrigin != "http://localhost:5173" {
t.Errorf("Expected Access-Control-Allow-Origin %s, got %s", "http://localhost:5173", allowOrigin)
}
}

View File

@@ -0,0 +1,66 @@
package middleware
import (
"log"
"github.com/gofiber/fiber/v2"
)
// ErrorHandler is a global error handler middleware
func ErrorHandler(c *fiber.Ctx) error {
// Call next middleware/handler
err := c.Next()
// If no error, return
if err == nil {
return nil
}
// Log error with context
log.Printf("Error: %v | Method: %s | Path: %s | IP: %s",
err,
c.Method(),
c.Path(),
c.IP(),
)
// Check if error is a Fiber error
code := fiber.StatusInternalServerError
message := "Internal Server Error"
if e, ok := err.(*fiber.Error); ok {
code = e.Code
message = e.Message
}
// Return structured error response
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": message,
})
}
// RecoverHandler recovers from panics and returns a proper error response
func RecoverHandler() fiber.Handler {
return func(c *fiber.Ctx) error {
defer func() {
if r := recover(); r != nil {
// Log panic with context
log.Printf("Panic recovered: %v | Method: %s | Path: %s | IP: %s",
r,
c.Method(),
c.Path(),
c.IP(),
)
// Return error response
c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"success": false,
"error": "Internal Server Error",
})
}
}()
return c.Next()
}
}

View File

@@ -0,0 +1,63 @@
package middleware
import (
"errors"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestErrorHandler(t *testing.T) {
app := fiber.New()
app.Use(ErrorHandler)
app.Get("/error", func(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Bad Request")
})
app.Get("/panic", func(c *fiber.Ctx) error {
return errors.New("some error")
})
// Test Fiber error
req := httptest.NewRequest("GET", "/error", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusBadRequest {
t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode)
}
// Test generic error
req = httptest.NewRequest("GET", "/panic", nil)
resp, err = app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", fiber.StatusInternalServerError, resp.StatusCode)
}
}
func TestRecoverHandler(t *testing.T) {
app := fiber.New()
app.Use(RecoverHandler())
app.Get("/panic", func(c *fiber.Ctx) error {
panic("test panic")
})
req := httptest.NewRequest("GET", "/panic", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Test request failed: %v", err)
}
if resp.StatusCode != fiber.StatusInternalServerError {
t.Errorf("Expected status %d, got %d", fiber.StatusInternalServerError, resp.StatusCode)
}
}

44
middleware/logger.go Normal file
View File

@@ -0,0 +1,44 @@
package middleware
import (
"log"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
)
// SetupLogger configures request logging middleware
func SetupLogger() fiber.Handler {
return logger.New(logger.Config{
Format: "${time} ${status} - ${latency} ${method} ${path} ${ip}\n",
TimeFormat: "2006-01-02 15:04:05",
TimeZone: "Local",
Output: nil, // Use default (stdout)
})
}
// CustomLogger is a more detailed logger with structured output
func CustomLogger() fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
// Process request
err := c.Next()
// Calculate latency
latency := time.Since(start)
// Log request details
log.Printf("[%s] %s %s | Status: %d | Latency: %v | IP: %s",
time.Now().Format("2006-01-02 15:04:05"),
c.Method(),
c.Path(),
c.Response().StatusCode(),
latency,
c.IP(),
)
return err
}
}

View File

@@ -0,0 +1,46 @@
-- Create books table
CREATE TABLE IF NOT EXISTS books (
book_code UUID PRIMARY KEY DEFAULT gen_random_uuid(),
book_name VARCHAR(200) NOT NULL,
cost DECIMAL(10,2) NOT NULL,
price DECIMAL(10,2) NOT NULL,
discount DECIMAL(10,2) DEFAULT 0,
quantity INTEGER NOT NULL,
publisher_author VARCHAR(200) NOT NULL,
category VARCHAR(100) NOT NULL,
description TEXT,
image_url TEXT NOT NULL,
slug VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for books table
CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_books_category ON books(category);
CREATE INDEX IF NOT EXISTS idx_books_slug ON books(slug);
-- Create stationery table
CREATE TABLE IF NOT EXISTS stationery (
stationery_code UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stationery_name VARCHAR(200) NOT NULL,
cost DECIMAL(10,2) NOT NULL,
price DECIMAL(10,2) NOT NULL,
discount DECIMAL(10,2) DEFAULT 0,
quantity INTEGER NOT NULL,
color VARCHAR(100),
material VARCHAR(100),
dimensions VARCHAR(100),
category VARCHAR(100) NOT NULL,
description TEXT,
image_url TEXT NOT NULL,
slug VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for stationery table
CREATE INDEX IF NOT EXISTS idx_stationery_created_at ON stationery(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_stationery_category ON stationery(category);
CREATE INDEX IF NOT EXISTS idx_stationery_slug ON stationery(slug);

View File

@@ -0,0 +1,25 @@
-- Drop the old books table if it exists with wrong schema
DROP TABLE IF EXISTS books CASCADE;
-- Recreate books table with correct schema
CREATE TABLE books (
book_code UUID PRIMARY KEY DEFAULT gen_random_uuid(),
book_name VARCHAR(200) NOT NULL,
cost DECIMAL(10,2) NOT NULL,
price DECIMAL(10,2) NOT NULL,
discount DECIMAL(10,2) DEFAULT 0,
quantity INTEGER NOT NULL,
publisher_author VARCHAR(200) NOT NULL,
category VARCHAR(100) NOT NULL,
description TEXT,
image_url TEXT NOT NULL,
slug VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for books table
CREATE INDEX idx_books_created_at ON books(created_at DESC);
CREATE INDEX idx_books_category ON books(category);
CREATE INDEX idx_books_slug ON books(slug);

View File

@@ -0,0 +1,43 @@
-- Add UNIQUE constraint to slug columns in both books and stationery tables
-- This prevents duplicate slugs and enables slug-based image filename uniqueness
-- First, handle any existing duplicate slugs by appending a suffix
-- For books
UPDATE books b1
SET slug = slug || '-' || book_code::text
WHERE EXISTS (
SELECT 1 FROM books b2
WHERE b2.slug = b1.slug
AND b2.book_code < b1.book_code
);
-- For stationery
UPDATE stationery s1
SET slug = slug || '-' || stationery_code::text
WHERE EXISTS (
SELECT 1 FROM stationery s2
WHERE s2.slug = s1.slug
AND s2.stationery_code < s1.stationery_code
);
-- Add unique constraint to books.slug (if not exists)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'books_slug_unique'
) THEN
ALTER TABLE books ADD CONSTRAINT books_slug_unique UNIQUE (slug);
END IF;
END $$;
-- Add unique constraint to stationery.slug (if not exists)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'stationery_slug_unique'
) THEN
ALTER TABLE stationery ADD CONSTRAINT stationery_slug_unique UNIQUE (slug);
END IF;
END $$;

41
migrations/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Database Migrations
## Running Migrations
### Prerequisites
1. Ensure PostgreSQL is running
2. Create the database if it doesn't exist:
```bash
psql -U postgres -c "CREATE DATABASE jd_book_uploader;"
```
3. Ensure your `.env` or `.env.local` file in the backend directory has the correct database credentials
### Run Migration
```bash
cd backend
./scripts/run_migration.sh
```
Or manually:
```bash
cd backend
export $(grep -v '^#' .env.local | grep -v '^$' | xargs)
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f migrations/001_create_tables.sql
```
### Verify Schema
```bash
cd backend
./scripts/verify_schema.sh
```
This will show:
- Table structures for `books` and `stationery`
- All indexes created on both tables
## Migration Files
- `001_create_tables.sql` - Creates books and stationery tables with indexes

33
models/book.go Normal file
View File

@@ -0,0 +1,33 @@
package models
import "time"
// Book represents a book product in the database
type Book struct {
BookCode string `json:"book_code" db:"book_code"`
BookName string `json:"book_name" db:"book_name"`
Cost float64 `json:"cost" db:"cost"`
Price float64 `json:"price" db:"price"`
Discount float64 `json:"discount" db:"discount"`
Quantity int `json:"quantity" db:"quantity"`
PublisherAuthor string `json:"publisher_author" db:"publisher_author"`
Category string `json:"category" db:"category"`
Description string `json:"description" db:"description"`
ImageURL string `json:"image_url" db:"image_url"`
Slug string `json:"slug" db:"slug"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// BookCreateRequest represents the data needed to create a new book
type BookCreateRequest struct {
BookName string `json:"book_name"`
Cost float64 `json:"cost"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
Quantity int `json:"quantity"`
PublisherAuthor string `json:"publisher_author"`
Category string `json:"category"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
}

37
models/stationery.go Normal file
View File

@@ -0,0 +1,37 @@
package models
import "time"
// Stationery represents a stationery product in the database
type Stationery struct {
StationeryCode string `json:"stationery_code" db:"stationery_code"`
StationeryName string `json:"stationery_name" db:"stationery_name"`
Cost float64 `json:"cost" db:"cost"`
Price float64 `json:"price" db:"price"`
Discount float64 `json:"discount" db:"discount"`
Quantity int `json:"quantity" db:"quantity"`
Color string `json:"color" db:"color"`
Material string `json:"material" db:"material"`
Dimensions string `json:"dimensions" db:"dimensions"`
Category string `json:"category" db:"category"`
Description string `json:"description" db:"description"`
ImageURL string `json:"image_url" db:"image_url"`
Slug string `json:"slug" db:"slug"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// StationeryCreateRequest represents the data needed to create a new stationery item
type StationeryCreateRequest struct {
StationeryName string `json:"stationery_name"`
Cost float64 `json:"cost"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
Quantity int `json:"quantity"`
Color string `json:"color"`
Material string `json:"material"`
Dimensions string `json:"dimensions"`
Category string `json:"category"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
}

9
project.toml Normal file
View File

@@ -0,0 +1,9 @@
[build]
# Paketo buildpacks will auto-detect Go version from go.mod
# No need to specify GO_VERSION
[metadata]
include_files = [
"**/*"
]

32
scripts/run_migration.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Load environment variables from .env or .env.local
if [ -f .env ]; then
export $(grep -v '^#' .env | grep -v '^$' | xargs)
elif [ -f .env.local ]; then
export $(grep -v '^#' .env.local | grep -v '^$' | xargs)
else
echo "Error: No .env or .env.local file found"
exit 1
fi
# Check if required variables are set
if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then
echo "Error: Required database environment variables not set"
echo "Required: DB_HOST, DB_PORT, DB_USER, DB_NAME"
exit 1
fi
# Run migration
echo "Running database migration..."
echo "Connecting to: $DB_HOST:$DB_PORT/$DB_NAME as $DB_USER"
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f migrations/001_create_tables.sql
if [ $? -eq 0 ]; then
echo "Migration completed successfully!"
else
echo "Migration failed!"
exit 1
fi

62
scripts/verify_schema.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Load environment variables from .env or .env.local
if [ -f .env ]; then
export $(grep -v '^#' .env | grep -v '^$' | xargs)
elif [ -f .env.local ]; then
export $(grep -v '^#' .env.local | grep -v '^$' | xargs)
else
echo "Error: No .env or .env.local file found"
exit 1
fi
# Check if required variables are set
if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then
echo "Error: Required database environment variables not set"
exit 1
fi
echo "Verifying database schema..."
echo "Connecting to: $DB_HOST:$DB_PORT/$DB_NAME as $DB_USER"
echo ""
# Verify books table
echo "=== Checking books table ==="
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "\d books" 2>&1
if [ $? -eq 0 ]; then
echo "✓ books table exists"
else
echo "✗ books table not found"
fi
echo ""
# Verify stationery table
echo "=== Checking stationery table ==="
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "\d stationery" 2>&1
if [ $? -eq 0 ]; then
echo "✓ stationery table exists"
else
echo "✗ stationery table not found"
fi
echo ""
# Verify indexes
echo "=== Checking indexes ==="
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND (tablename = 'books' OR tablename = 'stationery')
ORDER BY tablename, indexname;
" 2>&1
echo ""
echo "Schema verification complete!"

151
services/book_service.go Normal file
View File

@@ -0,0 +1,151 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"jd-book-uploader/models"
"jd-book-uploader/utils"
)
// CreateBook creates a new book record in the database
func CreateBook(ctx context.Context, req *models.BookCreateRequest) (*models.Book, error) {
if DB == nil {
return nil, fmt.Errorf("database connection not initialized")
}
// Generate UUID for book_code
bookCode := uuid.New().String()
// Generate slug from book name
slug := utils.GenerateSlug(req.BookName)
// Set default discount if not provided
discount := req.Discount
if discount < 0 {
discount = 0
}
now := time.Now()
// Insert book into database
query := `
INSERT INTO books (
book_code, book_name, cost, price, discount, quantity,
publisher_author, category, description, image_url, slug,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING
book_code, book_name, cost, price, discount, quantity,
publisher_author, category, description, image_url, slug,
created_at, updated_at
`
var book models.Book
err := DB.QueryRow(ctx, query,
bookCode,
req.BookName,
req.Cost,
req.Price,
discount,
req.Quantity,
req.PublisherAuthor,
req.Category,
req.Description,
req.ImageURL,
slug,
now,
now,
).Scan(
&book.BookCode,
&book.BookName,
&book.Cost,
&book.Price,
&book.Discount,
&book.Quantity,
&book.PublisherAuthor,
&book.Category,
&book.Description,
&book.ImageURL,
&book.Slug,
&book.CreatedAt,
&book.UpdatedAt,
)
if err != nil {
// Check for duplicate key error
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" { // unique_violation
return nil, fmt.Errorf("book with this code already exists")
}
}
return nil, fmt.Errorf("failed to create book: %w", err)
}
return &book, nil
}
// GetBookByCode retrieves a book by its code
func GetBookByCode(ctx context.Context, bookCode string) (*models.Book, error) {
if DB == nil {
return nil, fmt.Errorf("database connection not initialized")
}
query := `
SELECT
book_code, book_name, cost, price, discount, quantity,
publisher_author, category, description, image_url, slug,
created_at, updated_at
FROM books
WHERE book_code = $1
`
var book models.Book
err := DB.QueryRow(ctx, query, bookCode).Scan(
&book.BookCode,
&book.BookName,
&book.Cost,
&book.Price,
&book.Discount,
&book.Quantity,
&book.PublisherAuthor,
&book.Category,
&book.Description,
&book.ImageURL,
&book.Slug,
&book.CreatedAt,
&book.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("book not found")
}
return nil, fmt.Errorf("failed to get book: %w", err)
}
return &book, nil
}
// BookSlugExists checks if a slug already exists in the books table
func BookSlugExists(ctx context.Context, slug string) (bool, error) {
if DB == nil {
return false, fmt.Errorf("database connection not initialized")
}
query := `SELECT EXISTS(SELECT 1 FROM books WHERE slug = $1)`
var exists bool
err := DB.QueryRow(ctx, query, slug).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check slug existence: %w", err)
}
return exists, nil
}

View File

@@ -0,0 +1,82 @@
package services
import (
"context"
"testing"
"jd-book-uploader/models"
)
func TestCreateBook(t *testing.T) {
// This test requires a running database
// Skip if not available
t.Skip("Skipping CreateBook test - requires running database")
ctx := context.Background()
// This test would require:
// 1. Database connection initialized
// 2. Valid test data
// 3. Cleanup after test
req := &models.BookCreateRequest{
BookName: "Test Book",
Cost: 10.50,
Price: 15.99,
Discount: 0,
Quantity: 100,
PublisherAuthor: "Test Publisher",
Category: "Fiction",
Description: "A test book",
ImageURL: "https://example.com/image.png",
}
book, err := CreateBook(ctx, req)
if err != nil {
t.Fatalf("CreateBook() error = %v", err)
}
if book == nil {
t.Fatal("CreateBook() returned nil book")
}
if book.BookCode == "" {
t.Error("CreateBook() did not generate book_code")
}
if book.Slug == "" {
t.Error("CreateBook() did not generate slug")
}
if book.Slug != "test-book" {
t.Errorf("CreateBook() slug = %q, want %q", book.Slug, "test-book")
}
}
func TestGetBookByCode(t *testing.T) {
// This test requires a running database
// Skip if not available
t.Skip("Skipping GetBookByCode test - requires running database")
ctx := context.Background()
// This test would require:
// 1. Database connection initialized
// 2. A book already in the database
// 3. Valid book_code
bookCode := "test-book-code"
book, err := GetBookByCode(ctx, bookCode)
if err != nil {
t.Logf("GetBookByCode() error = %v (expected if book not found)", err)
return
}
if book == nil {
t.Error("GetBookByCode() returned nil book")
}
if book.BookCode != bookCode {
t.Errorf("GetBookByCode() book_code = %q, want %q", book.BookCode, bookCode)
}
}

85
services/database.go Normal file
View File

@@ -0,0 +1,85 @@
package services
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"jd-book-uploader/config"
)
// DB holds the database connection pool
var DB *pgxpool.Pool
// NewDBPool creates a new database connection pool
func NewDBPool(cfg *config.Config) (*pgxpool.Pool, error) {
// Build connection string
connString := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost,
cfg.DBPort,
cfg.DBUser,
cfg.DBPassword,
cfg.DBName,
)
// Parse connection string
poolConfig, err := pgxpool.ParseConfig(connString)
if err != nil {
return nil, fmt.Errorf("failed to parse connection string: %w", err)
}
// Configure connection pool
poolConfig.MaxConns = 10
poolConfig.MinConns = 2
poolConfig.MaxConnLifetime = time.Hour
poolConfig.MaxConnIdleTime = time.Minute * 30
poolConfig.HealthCheckPeriod = time.Minute
// Set connection timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Create connection pool
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
DB = pool
return pool, nil
}
// CloseDB closes the database connection pool
func CloseDB() error {
if DB != nil {
DB.Close()
}
return nil
}
// RetryConnection attempts to reconnect to the database with retries
func RetryConnection(cfg *config.Config, maxRetries int, retryDelay time.Duration) (*pgxpool.Pool, error) {
var pool *pgxpool.Pool
var err error
for i := 0; i < maxRetries; i++ {
pool, err = NewDBPool(cfg)
if err == nil {
return pool, nil
}
if i < maxRetries-1 {
time.Sleep(retryDelay)
}
}
return nil, fmt.Errorf("failed to connect after %d attempts: %w", maxRetries, err)
}

57
services/database_test.go Normal file
View File

@@ -0,0 +1,57 @@
package services
import (
"testing"
"time"
"jd-book-uploader/config"
)
func TestNewDBPool(t *testing.T) {
// This test requires a running PostgreSQL instance
// Skip if not available
t.Skip("Skipping database connection test - requires running PostgreSQL")
cfg := &config.Config{
DBHost: "localhost",
DBPort: "5432",
DBUser: "test_user",
DBPassword: "test_password",
DBName: "test_db",
}
pool, err := NewDBPool(cfg)
if err != nil {
t.Fatalf("NewDBPool() error = %v", err)
}
defer pool.Close()
if pool == nil {
t.Error("NewDBPool() returned nil pool")
}
}
func TestRetryConnection(t *testing.T) {
// This test requires a running PostgreSQL instance
// Skip if not available
t.Skip("Skipping database retry test - requires running PostgreSQL")
cfg := &config.Config{
DBHost: "localhost",
DBPort: "5432",
DBUser: "test_user",
DBPassword: "test_password",
DBName: "test_db",
}
pool, err := RetryConnection(cfg, 3, time.Second)
if err != nil {
t.Logf("RetryConnection() error = %v (expected if DB not available)", err)
return
}
defer pool.Close()
if pool == nil {
t.Error("RetryConnection() returned nil pool")
}
}

141
services/firebase.go Normal file
View File

@@ -0,0 +1,141 @@
package services
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"jd-book-uploader/config"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/storage"
"google.golang.org/api/option"
)
var (
FirebaseApp *firebase.App
FirebaseClient *storage.Client
)
// InitFirebase initializes Firebase Admin SDK and Storage client
func InitFirebase(cfg *config.Config) (*storage.Client, error) {
ctx := context.Background()
// Determine credentials file path
credentialsFile := cfg.FirebaseCredentialsFile
if credentialsFile == "" {
// Fallback to GOOGLE_APPLICATION_CREDENTIALS env var
credentialsFile = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
}
// Check if credentials file exists
if credentialsFile != "" {
if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
return nil, fmt.Errorf("firebase credentials file not found: %s", credentialsFile)
}
// Convert relative path to absolute
if !filepath.IsAbs(credentialsFile) {
credentialsFile, _ = filepath.Abs(credentialsFile)
}
}
// Build Firebase config
firebaseConfig := &firebase.Config{
ProjectID: cfg.FirebaseProjectID,
StorageBucket: cfg.FirebaseStorageBucket,
}
// Initialize Firebase app
var app *firebase.App
var err error
if credentialsFile != "" {
// Use credentials file
opt := option.WithCredentialsFile(credentialsFile)
app, err = firebase.NewApp(ctx, firebaseConfig, opt)
} else {
// Use default credentials (from environment)
app, err = firebase.NewApp(ctx, firebaseConfig)
}
if err != nil {
return nil, fmt.Errorf("failed to initialize Firebase app: %w", err)
}
// Create Storage client
client, err := app.Storage(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create Firebase Storage client: %w", err)
}
FirebaseApp = app
FirebaseClient = client
return client, nil
}
// UploadImage uploads an image to Firebase Storage and returns the public URL
// folderPath: storage folder path (e.g., "/jd-bookshop/books" or "/jd-bookshop/stationery")
// filename: image filename (should be the slug with .png extension)
func UploadImage(ctx context.Context, imageData []byte, folderPath string, filename string) (string, error) {
if FirebaseClient == nil {
return "", fmt.Errorf("firebase client not initialized")
}
// Build object path: {folderPath}/{filename}
// Use provided path strictly without adding year/month
objectPath := fmt.Sprintf("%s/%s", folderPath, filename)
// Get bucket handle
bucket, err := FirebaseClient.DefaultBucket()
if err != nil {
return "", fmt.Errorf("failed to get default bucket: %w", err)
}
// Create object handle
obj := bucket.Object(objectPath)
// Create writer
writer := obj.NewWriter(ctx)
writer.ContentType = "image/png"
writer.CacheControl = "public, max-age=31536000" // 1 year cache
// Write image data with retry logic
maxRetries := 3
var writeErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
// Wait before retry (exponential backoff)
time.Sleep(time.Duration(i) * time.Second)
}
// Write data
_, writeErr = writer.Write(imageData)
if writeErr == nil {
break
}
}
if writeErr != nil {
writer.Close()
return "", fmt.Errorf("failed to write image data after %d attempts: %w", maxRetries, writeErr)
}
// Close writer to finalize upload
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to close writer: %w", err)
}
// Get public URL from Firebase
attrs, err := obj.Attrs(ctx)
if err != nil {
return "", fmt.Errorf("failed to get object attributes: %w", err)
}
// Use Firebase's original download link (MediaLink)
publicURL := attrs.MediaLink
return publicURL, nil
}

108
services/firebase_test.go Normal file
View File

@@ -0,0 +1,108 @@
package services
import (
"context"
"os"
"testing"
"jd-book-uploader/config"
)
func TestInitFirebase(t *testing.T) {
// Load config from environment
cfg, err := config.LoadConfig()
if err != nil {
t.Skipf("Skipping Firebase test - config not available: %v", err)
return
}
// Check if credentials file exists
if cfg.FirebaseCredentialsFile == "" && os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") == "" {
t.Skip("Skipping Firebase test - no credentials file configured")
return
}
// Test Firebase initialization
client, err := InitFirebase(cfg)
if err != nil {
t.Fatalf("InitFirebase() error = %v", err)
}
if client == nil {
t.Fatal("InitFirebase() returned nil client")
}
// Verify client can access bucket
ctx := context.Background()
bucket, err := client.DefaultBucket()
if err != nil {
t.Fatalf("Failed to get default bucket: %v", err)
}
if bucket == nil {
t.Fatal("DefaultBucket() returned nil")
}
// Test bucket name matches config
bucketAttrs, err := bucket.Attrs(ctx)
if err != nil {
t.Fatalf("Failed to get bucket attributes: %v", err)
}
expectedBucket := cfg.FirebaseStorageBucket
if bucketAttrs.Name != expectedBucket {
t.Logf("Warning: Bucket name mismatch. Expected: %s, Got: %s", expectedBucket, bucketAttrs.Name)
}
t.Logf("Firebase initialized successfully. Bucket: %s", bucketAttrs.Name)
}
func TestUploadImage(t *testing.T) {
// Load config from environment
cfg, err := config.LoadConfig()
if err != nil {
t.Skipf("Skipping Firebase upload test - config not available: %v", err)
return
}
// Initialize Firebase first
client, err := InitFirebase(cfg)
if err != nil {
t.Skipf("Skipping Firebase upload test - initialization failed: %v", err)
return
}
if client == nil {
t.Skip("Skipping Firebase upload test - client is nil")
return
}
ctx := context.Background()
// Create a small test image (1x1 PNG)
// PNG header + minimal valid PNG data
testImageData := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, // IHDR data
0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // IDAT data
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND
}
// Upload test image
url, err := UploadImage(ctx, testImageData, "test", "test-image.png")
if err != nil {
t.Fatalf("UploadImage() error = %v", err)
}
if url == "" {
t.Fatal("UploadImage() returned empty URL")
}
t.Logf("Image uploaded successfully. URL: %s", url)
// Verify URL is accessible (optional - can be skipped if network issues)
// This would require HTTP client to check if URL is accessible
}

View File

@@ -0,0 +1,157 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"jd-book-uploader/models"
"jd-book-uploader/utils"
)
// CreateStationery creates a new stationery record in the database
func CreateStationery(ctx context.Context, req *models.StationeryCreateRequest) (*models.Stationery, error) {
if DB == nil {
return nil, fmt.Errorf("database connection not initialized")
}
// Generate UUID for stationery_code
stationeryCode := uuid.New().String()
// Generate slug from stationery name
slug := utils.GenerateSlug(req.StationeryName)
// Set default discount if not provided
discount := req.Discount
if discount < 0 {
discount = 0
}
now := time.Now()
// Insert stationery into database
query := `
INSERT INTO stationery (
stationery_code, stationery_name, cost, price, discount, quantity,
color, material, dimensions, category, description, image_url, slug,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING
stationery_code, stationery_name, cost, price, discount, quantity,
color, material, dimensions, category, description, image_url, slug,
created_at, updated_at
`
var stationery models.Stationery
err := DB.QueryRow(ctx, query,
stationeryCode,
req.StationeryName,
req.Cost,
req.Price,
discount,
req.Quantity,
req.Color,
req.Material,
req.Dimensions,
req.Category,
req.Description,
req.ImageURL,
slug,
now,
now,
).Scan(
&stationery.StationeryCode,
&stationery.StationeryName,
&stationery.Cost,
&stationery.Price,
&stationery.Discount,
&stationery.Quantity,
&stationery.Color,
&stationery.Material,
&stationery.Dimensions,
&stationery.Category,
&stationery.Description,
&stationery.ImageURL,
&stationery.Slug,
&stationery.CreatedAt,
&stationery.UpdatedAt,
)
if err != nil {
// Check for duplicate key error
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" { // unique_violation
return nil, fmt.Errorf("stationery with this code already exists")
}
}
return nil, fmt.Errorf("failed to create stationery: %w", err)
}
return &stationery, nil
}
// GetStationeryByCode retrieves a stationery item by its code
func GetStationeryByCode(ctx context.Context, stationeryCode string) (*models.Stationery, error) {
if DB == nil {
return nil, fmt.Errorf("database connection not initialized")
}
query := `
SELECT
stationery_code, stationery_name, cost, price, discount, quantity,
color, material, dimensions, category, description, image_url, slug,
created_at, updated_at
FROM stationery
WHERE stationery_code = $1
`
var stationery models.Stationery
err := DB.QueryRow(ctx, query, stationeryCode).Scan(
&stationery.StationeryCode,
&stationery.StationeryName,
&stationery.Cost,
&stationery.Price,
&stationery.Discount,
&stationery.Quantity,
&stationery.Color,
&stationery.Material,
&stationery.Dimensions,
&stationery.Category,
&stationery.Description,
&stationery.ImageURL,
&stationery.Slug,
&stationery.CreatedAt,
&stationery.UpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("stationery not found")
}
return nil, fmt.Errorf("failed to get stationery: %w", err)
}
return &stationery, nil
}
// StationerySlugExists checks if a slug already exists in the stationery table
func StationerySlugExists(ctx context.Context, slug string) (bool, error) {
if DB == nil {
return false, fmt.Errorf("database connection not initialized")
}
query := `SELECT EXISTS(SELECT 1 FROM stationery WHERE slug = $1)`
var exists bool
err := DB.QueryRow(ctx, query, slug).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check slug existence: %w", err)
}
return exists, nil
}

View File

@@ -0,0 +1,84 @@
package services
import (
"context"
"testing"
"jd-book-uploader/models"
)
func TestCreateStationery(t *testing.T) {
// This test requires a running database
// Skip if not available
t.Skip("Skipping CreateStationery test - requires running database")
ctx := context.Background()
// This test would require:
// 1. Database connection initialized
// 2. Valid test data
// 3. Cleanup after test
req := &models.StationeryCreateRequest{
StationeryName: "Test Pen",
Cost: 2.50,
Price: 5.99,
Discount: 0,
Quantity: 200,
Color: "Blue",
Material: "Plastic",
Dimensions: "15cm",
Category: "Writing",
Description: "A test pen",
ImageURL: "https://example.com/image.png",
}
stationery, err := CreateStationery(ctx, req)
if err != nil {
t.Fatalf("CreateStationery() error = %v", err)
}
if stationery == nil {
t.Fatal("CreateStationery() returned nil stationery")
}
if stationery.StationeryCode == "" {
t.Error("CreateStationery() did not generate stationery_code")
}
if stationery.Slug == "" {
t.Error("CreateStationery() did not generate slug")
}
if stationery.Slug != "test-pen" {
t.Errorf("CreateStationery() slug = %q, want %q", stationery.Slug, "test-pen")
}
}
func TestGetStationeryByCode(t *testing.T) {
// This test requires a running database
// Skip if not available
t.Skip("Skipping GetStationeryByCode test - requires running database")
ctx := context.Background()
// This test would require:
// 1. Database connection initialized
// 2. A stationery item already in the database
// 3. Valid stationery_code
stationeryCode := "test-stationery-code"
stationery, err := GetStationeryByCode(ctx, stationeryCode)
if err != nil {
t.Logf("GetStationeryByCode() error = %v (expected if stationery not found)", err)
return
}
if stationery == nil {
t.Error("GetStationeryByCode() returned nil stationery")
}
if stationery.StationeryCode != stationeryCode {
t.Errorf("GetStationeryByCode() stationery_code = %q, want %q", stationery.StationeryCode, stationeryCode)
}
}

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)
}
})
}
}