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:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
490
README.md
Normal 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
107
config/config.go
Normal 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
205
config/config_test.go
Normal 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
79
go.mod
Normal 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
266
go.sum
Normal 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
237
handlers/book.go
Normal 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
72
handlers/book_test.go
Normal 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
52
handlers/health.go
Normal 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
23
handlers/health_test.go
Normal 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
234
handlers/stationery.go
Normal 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
|
||||||
|
}
|
||||||
72
handlers/stationery_test.go
Normal file
72
handlers/stationery_test.go
Normal 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
87
main.go
Normal 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
20
middleware/cors.go
Normal 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
42
middleware/cors_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
middleware/error_handler.go
Normal file
66
middleware/error_handler.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
63
middleware/error_handler_test.go
Normal file
63
middleware/error_handler_test.go
Normal 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
44
middleware/logger.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
migrations/001_create_tables.sql
Normal file
46
migrations/001_create_tables.sql
Normal 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);
|
||||||
|
|
||||||
25
migrations/002_fix_books_table.sql
Normal file
25
migrations/002_fix_books_table.sql
Normal 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);
|
||||||
|
|
||||||
43
migrations/003_add_unique_slug_constraints.sql
Normal file
43
migrations/003_add_unique_slug_constraints.sql
Normal 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
41
migrations/README.md
Normal 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
33
models/book.go
Normal 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
37
models/stationery.go
Normal 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
9
project.toml
Normal 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
32
scripts/run_migration.sh
Executable 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
62
scripts/verify_schema.sh
Executable 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
151
services/book_service.go
Normal 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
|
||||||
|
}
|
||||||
82
services/book_service_test.go
Normal file
82
services/book_service_test.go
Normal 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
85
services/database.go
Normal 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
57
services/database_test.go
Normal 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
141
services/firebase.go
Normal 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
108
services/firebase_test.go
Normal 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
|
||||||
|
}
|
||||||
157
services/stationery_service.go
Normal file
157
services/stationery_service.go
Normal 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
|
||||||
|
}
|
||||||
84
services/stationery_service_test.go
Normal file
84
services/stationery_service_test.go
Normal 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
87
utils/slug.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateSlug converts a string to a URL-friendly slug
|
||||||
|
// Converts to lowercase, replaces spaces with hyphens, removes special characters
|
||||||
|
func GenerateSlug(text string) string {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to lowercase
|
||||||
|
slug := strings.ToLower(text)
|
||||||
|
|
||||||
|
// Replace spaces and underscores with hyphens
|
||||||
|
slug = strings.ReplaceAll(slug, " ", "-")
|
||||||
|
slug = strings.ReplaceAll(slug, "_", "-")
|
||||||
|
|
||||||
|
// Remove all non-alphanumeric characters except hyphens
|
||||||
|
reg := regexp.MustCompile(`[^a-z0-9-]`)
|
||||||
|
slug = reg.ReplaceAllString(slug, "")
|
||||||
|
|
||||||
|
// Remove multiple consecutive hyphens
|
||||||
|
reg = regexp.MustCompile(`-+`)
|
||||||
|
slug = reg.ReplaceAllString(slug, "-")
|
||||||
|
|
||||||
|
// Remove leading and trailing hyphens
|
||||||
|
slug = strings.Trim(slug, "-")
|
||||||
|
|
||||||
|
// If empty after processing, return a default
|
||||||
|
if slug == "" {
|
||||||
|
slug = "item"
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidSlug checks if a string is a valid slug format
|
||||||
|
func IsValidSlug(slug string) bool {
|
||||||
|
if slug == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it contains only lowercase letters, numbers, and hyphens
|
||||||
|
reg := regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
if !reg.MatchString(slug) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check it doesn't start or end with hyphen
|
||||||
|
if strings.HasPrefix(slug, "-") || strings.HasSuffix(slug, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check it doesn't have consecutive hyphens
|
||||||
|
if strings.Contains(slug, "--") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanString removes leading/trailing whitespace and normalizes spaces
|
||||||
|
func CleanString(s string) string {
|
||||||
|
// Trim whitespace
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
// Replace multiple spaces with single space
|
||||||
|
reg := regexp.MustCompile(`\s+`)
|
||||||
|
s = reg.ReplaceAllString(s, " ")
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsOnlyLetters checks if a string contains only letters (and spaces)
|
||||||
|
func ContainsOnlyLetters(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsSpace(r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
177
utils/slug_test.go
Normal file
177
utils/slug_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGenerateSlug(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple text",
|
||||||
|
input: "Hello World",
|
||||||
|
expected: "hello-world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with special characters",
|
||||||
|
input: "Book Name!@#$%",
|
||||||
|
expected: "book-name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with numbers",
|
||||||
|
input: "Book 123",
|
||||||
|
expected: "book-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with multiple spaces",
|
||||||
|
input: "Book Name Here",
|
||||||
|
expected: "book-name-here",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with underscores",
|
||||||
|
input: "Book_Name_Here",
|
||||||
|
expected: "book-name-here",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with mixed case",
|
||||||
|
input: "My Awesome Book",
|
||||||
|
expected: "my-awesome-book",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only special characters",
|
||||||
|
input: "!@#$%",
|
||||||
|
expected: "item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading and trailing spaces",
|
||||||
|
input: " Book Name ",
|
||||||
|
expected: "book-name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with hyphens",
|
||||||
|
input: "Book-Name-Here",
|
||||||
|
expected: "book-name-here",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with consecutive special chars",
|
||||||
|
input: "Book!!!Name",
|
||||||
|
expected: "bookname",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := GenerateSlug(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("GenerateSlug(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSlug(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
slug string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid slug",
|
||||||
|
slug: "hello-world",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid slug with numbers",
|
||||||
|
slug: "book-123",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - uppercase",
|
||||||
|
slug: "Hello-World",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - special characters",
|
||||||
|
slug: "hello-world!",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - leading hyphen",
|
||||||
|
slug: "-hello-world",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - trailing hyphen",
|
||||||
|
slug: "hello-world-",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - consecutive hyphens",
|
||||||
|
slug: "hello--world",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - empty",
|
||||||
|
slug: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - single word",
|
||||||
|
slug: "book",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := IsValidSlug(tt.slug)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsValidSlug(%q) = %v, want %v", tt.slug, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal string",
|
||||||
|
input: "Hello World",
|
||||||
|
expected: "Hello World",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with leading/trailing spaces",
|
||||||
|
input: " Hello World ",
|
||||||
|
expected: "Hello World",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with multiple spaces",
|
||||||
|
input: "Hello World",
|
||||||
|
expected: "Hello World",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CleanString(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CleanString(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
182
utils/validation.go
Normal file
182
utils/validation.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError represents a validation error with field name
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBookFields validates all book form fields
|
||||||
|
func ValidateBookFields(bookName, cost, price, discount, quantity, publisherAuthor, category, description string) []*ValidationError {
|
||||||
|
var errors []*ValidationError
|
||||||
|
|
||||||
|
// Book Name
|
||||||
|
bookName = strings.TrimSpace(bookName)
|
||||||
|
if bookName == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "book_name", Message: "is required"})
|
||||||
|
} else if len(bookName) > 200 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "book_name", Message: "exceeds 200 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost
|
||||||
|
if cost == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "cost", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
costVal, err := strconv.ParseFloat(cost, 64)
|
||||||
|
if err != nil || costVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price
|
||||||
|
if price == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "price", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
priceVal, err := strconv.ParseFloat(price, 64)
|
||||||
|
if err != nil || priceVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount (optional)
|
||||||
|
if discount != "" {
|
||||||
|
discountVal, err := strconv.ParseFloat(discount, 64)
|
||||||
|
if err != nil || discountVal < 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity
|
||||||
|
if quantity == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
quantityVal, err := strconv.Atoi(quantity)
|
||||||
|
if err != nil || quantityVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publisher/Author
|
||||||
|
publisherAuthor = strings.TrimSpace(publisherAuthor)
|
||||||
|
if publisherAuthor == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "publisher_author", Message: "is required"})
|
||||||
|
} else if len(publisherAuthor) > 200 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "publisher_author", Message: "exceeds 200 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category
|
||||||
|
category = strings.TrimSpace(category)
|
||||||
|
if category == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "category", Message: "is required"})
|
||||||
|
} else if len(category) > 100 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (optional)
|
||||||
|
if description != "" && len(description) > 1000 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStationeryFields validates all stationery form fields
|
||||||
|
func ValidateStationeryFields(stationeryName, cost, price, discount, quantity, category, description string) []*ValidationError {
|
||||||
|
var errors []*ValidationError
|
||||||
|
|
||||||
|
// Stationery Name
|
||||||
|
stationeryName = strings.TrimSpace(stationeryName)
|
||||||
|
if stationeryName == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "stationery_name", Message: "is required"})
|
||||||
|
} else if len(stationeryName) > 200 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "stationery_name", Message: "exceeds 200 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost
|
||||||
|
if cost == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "cost", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
costVal, err := strconv.ParseFloat(cost, 64)
|
||||||
|
if err != nil || costVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price
|
||||||
|
if price == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "price", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
priceVal, err := strconv.ParseFloat(price, 64)
|
||||||
|
if err != nil || priceVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount (optional)
|
||||||
|
if discount != "" {
|
||||||
|
discountVal, err := strconv.ParseFloat(discount, 64)
|
||||||
|
if err != nil || discountVal < 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity
|
||||||
|
if quantity == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"})
|
||||||
|
} else {
|
||||||
|
quantityVal, err := strconv.Atoi(quantity)
|
||||||
|
if err != nil || quantityVal <= 0 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category
|
||||||
|
category = strings.TrimSpace(category)
|
||||||
|
if category == "" {
|
||||||
|
errors = append(errors, &ValidationError{Field: "category", Message: "is required"})
|
||||||
|
} else if len(category) > 100 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (optional)
|
||||||
|
if description != "" && len(description) > 1000 {
|
||||||
|
errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFileType checks if file type is allowed
|
||||||
|
func ValidateFileType(contentType string) error {
|
||||||
|
allowedTypes := map[string]bool{
|
||||||
|
"image/png": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/jpg": true,
|
||||||
|
"image/webp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedTypes[contentType] {
|
||||||
|
return fmt.Errorf("invalid file type. Only PNG, JPEG, and WEBP are allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFileSize checks if file size is within limit
|
||||||
|
func ValidateFileSize(size, maxSize int64) error {
|
||||||
|
if size > maxSize {
|
||||||
|
return fmt.Errorf("file size exceeds %dMB limit", maxSize/(1024*1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
195
utils/validation_test.go
Normal file
195
utils/validation_test.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateBookFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bookName string
|
||||||
|
cost string
|
||||||
|
price string
|
||||||
|
discount string
|
||||||
|
quantity string
|
||||||
|
publisherAuthor string
|
||||||
|
category string
|
||||||
|
description string
|
||||||
|
wantErrors int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid fields",
|
||||||
|
bookName: "Test Book",
|
||||||
|
cost: "10.50",
|
||||||
|
price: "15.99",
|
||||||
|
discount: "0",
|
||||||
|
quantity: "100",
|
||||||
|
publisherAuthor: "Test Publisher",
|
||||||
|
category: "Fiction",
|
||||||
|
description: "A test book",
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing book_name",
|
||||||
|
bookName: "",
|
||||||
|
cost: "10.50",
|
||||||
|
price: "15.99",
|
||||||
|
quantity: "100",
|
||||||
|
publisherAuthor: "Test Publisher",
|
||||||
|
category: "Fiction",
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid cost",
|
||||||
|
bookName: "Test Book",
|
||||||
|
cost: "-10",
|
||||||
|
price: "15.99",
|
||||||
|
quantity: "100",
|
||||||
|
publisherAuthor: "Test Publisher",
|
||||||
|
category: "Fiction",
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing category",
|
||||||
|
bookName: "Test Book",
|
||||||
|
cost: "10.50",
|
||||||
|
price: "15.99",
|
||||||
|
quantity: "100",
|
||||||
|
publisherAuthor: "Test Publisher",
|
||||||
|
category: "",
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description too long",
|
||||||
|
bookName: "Test Book",
|
||||||
|
cost: "10.50",
|
||||||
|
price: "15.99",
|
||||||
|
quantity: "100",
|
||||||
|
publisherAuthor: "Test Publisher",
|
||||||
|
category: "Fiction",
|
||||||
|
description: string(make([]byte, 1001)), // 1001 characters
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
errors := ValidateBookFields(
|
||||||
|
tt.bookName,
|
||||||
|
tt.cost,
|
||||||
|
tt.price,
|
||||||
|
tt.discount,
|
||||||
|
tt.quantity,
|
||||||
|
tt.publisherAuthor,
|
||||||
|
tt.category,
|
||||||
|
tt.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(errors) != tt.wantErrors {
|
||||||
|
t.Errorf("ValidateBookFields() returned %d errors, want %d", len(errors), tt.wantErrors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStationeryFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stationeryName string
|
||||||
|
cost string
|
||||||
|
price string
|
||||||
|
quantity string
|
||||||
|
category string
|
||||||
|
wantErrors int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid fields",
|
||||||
|
stationeryName: "Test Pen",
|
||||||
|
cost: "2.50",
|
||||||
|
price: "5.99",
|
||||||
|
quantity: "200",
|
||||||
|
category: "Writing",
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing stationery_name",
|
||||||
|
stationeryName: "",
|
||||||
|
cost: "2.50",
|
||||||
|
price: "5.99",
|
||||||
|
quantity: "200",
|
||||||
|
category: "Writing",
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid quantity",
|
||||||
|
stationeryName: "Test Pen",
|
||||||
|
cost: "2.50",
|
||||||
|
price: "5.99",
|
||||||
|
quantity: "-10",
|
||||||
|
category: "Writing",
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
errors := ValidateStationeryFields(
|
||||||
|
tt.stationeryName,
|
||||||
|
tt.cost,
|
||||||
|
tt.price,
|
||||||
|
"",
|
||||||
|
tt.quantity,
|
||||||
|
tt.category,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(errors) != tt.wantErrors {
|
||||||
|
t.Errorf("ValidateStationeryFields() returned %d errors, want %d", len(errors), tt.wantErrors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFileType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid PNG", "image/png", false},
|
||||||
|
{"valid JPEG", "image/jpeg", false},
|
||||||
|
{"valid JPG", "image/jpg", false},
|
||||||
|
{"valid WEBP", "image/webp", false},
|
||||||
|
{"invalid type", "image/gif", true},
|
||||||
|
{"invalid type", "text/plain", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateFileType(tt.contentType)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateFileType() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFileSize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
maxSize int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid size", 5 * 1024 * 1024, 10 * 1024 * 1024, false},
|
||||||
|
{"exact max size", 10 * 1024 * 1024, 10 * 1024 * 1024, false},
|
||||||
|
{"too large", 11 * 1024 * 1024, 10 * 1024 * 1024, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateFileSize(tt.size, tt.maxSize)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateFileSize() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user