From faa3f57317dedbfee42f5d09c5d7bcde41d824ef Mon Sep 17 00:00:00 2001 From: mama-samba-braima Date: Wed, 29 Oct 2025 23:06:31 +0000 Subject: [PATCH] feat: add S3 integration and update AWS configuration for production deployment --- AWS_DEPLOYMENT_CHECKLIST.md | 92 +++++ AWS_DEPLOYMENT_GUIDE.md | 341 ++++++++++++++++ README.md | 327 +++++++++++++++ docker-compose-db-only.yml | 22 - docker-compose-services.yml | 60 +++ pom.xml | 5 + setup-local.sh | 117 ++++++ .../com/amigoscode/config/AwsS3Config.java | 65 +++ .../amigoscode/product/ProductController.java | 41 +- .../product/ProductImageService.java | 50 +++ .../amigoscode/product/ProductService.java | 65 ++- .../amigoscode/storage/S3StorageService.java | 73 ++++ .../resources/application-prod.properties | 22 + src/main/resources/application.properties | 8 + src/main/resources/static/index.html | 383 ++++++++++++++++++ .../product/ProductServiceTest.java | 352 +++++++++++++--- 16 files changed, 1941 insertions(+), 82 deletions(-) create mode 100644 AWS_DEPLOYMENT_CHECKLIST.md create mode 100644 AWS_DEPLOYMENT_GUIDE.md create mode 100644 README.md delete mode 100644 docker-compose-db-only.yml create mode 100644 docker-compose-services.yml create mode 100755 setup-local.sh create mode 100644 src/main/java/com/amigoscode/config/AwsS3Config.java create mode 100644 src/main/java/com/amigoscode/product/ProductImageService.java create mode 100644 src/main/java/com/amigoscode/storage/S3StorageService.java create mode 100644 src/main/resources/application-prod.properties create mode 100644 src/main/resources/static/index.html diff --git a/AWS_DEPLOYMENT_CHECKLIST.md b/AWS_DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..cf092f2 --- /dev/null +++ b/AWS_DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,92 @@ +# Quick AWS Deployment Checklist + +## ✅ What You Need to Do in AWS Console + +### 1. Create S3 Bucket +- Go to **S3** → **Create bucket** +- Name: `yourcompany-product-images-prod` (unique name) +- Region: Same as your EC2 (e.g., `us-east-1`) +- Encryption: Enable (SSE-S3) +- **Save bucket name** + +### 2. Create IAM Policy +- Go to **IAM** → **Policies** → **Create policy** +- Use JSON tab, paste: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:HeadObject"], + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*" + }, + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME" + } + ] +} +``` +- Replace `YOUR-BUCKET-NAME` with your bucket name +- Name: `ProductServiceS3Policy` + +### 3. Create IAM Role (Recommended) +- Go to **IAM** → **Roles** → **Create role** +- Select **EC2** → **Next** +- Attach `ProductServiceS3Policy` → **Next** +- Name: `EC2-S3-Access-Role` → **Create role** + +### 4. Attach Role to EC2 +- Go to **EC2** → **Instances** +- Select your instance → **Actions** → **Security** → **Modify IAM role** +- Select `EC2-S3-Access-Role` → **Update** + +### 5. Update Application Configuration + +Create `application-prod.properties` or use environment variables: + +```properties +aws.region=us-east-1 +aws.s3.bucket=your-bucket-name-here +aws.s3.endpoint-override= +aws.s3.path-style-enabled=false +aws.access-key-id= +aws.secret-access-key= +``` + +**Note**: Leave access keys empty if using IAM role (recommended) + +### 6. Deploy Application +```bash +# Build +mvn clean package + +# Copy to EC2 +scp -i key.pem target/product-service.jar ec2-user@your-ec2-ip:~/app/ + +# SSH into EC2 +ssh -i key.pem ec2-user@your-ec2-ip + +# Run with production config +cd ~/app +java -jar product-service.jar --spring.config.location=application-prod.properties +``` + +## 🔐 Alternative: Using Access Keys (Less Secure) + +If not using IAM role, create IAM user: +- **IAM** → **Users** → **Create user** +- Attach `ProductServiceS3Policy` +- Create access key → **Save keys securely** +- Set environment variables on EC2: +```bash +export AWS_ACCESS_KEY_ID=your-key-id +export AWS_SECRET_ACCESS_KEY=your-secret-key +``` + +--- + +**See `AWS_DEPLOYMENT_GUIDE.md` for detailed step-by-step instructions.** + diff --git a/AWS_DEPLOYMENT_GUIDE.md b/AWS_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..237279b --- /dev/null +++ b/AWS_DEPLOYMENT_GUIDE.md @@ -0,0 +1,341 @@ +# AWS Deployment Guide for S3 Integration + +This guide walks you through setting up AWS S3 for your Spring Boot application deployment on EC2. + +## Prerequisites +- ✅ EC2 instance already running +- AWS Console access with appropriate permissions + +--- + +## Step 1: Create S3 Bucket + +1. **Log into AWS Console** + - Go to https://console.aws.amazon.com + - Navigate to **S3** service (search "S3" in the top search bar) + +2. **Create Bucket** + - Click **"Create bucket"** button + - **Bucket name**: Enter a unique name (e.g., `yourcompany-product-images-prod`) + - ⚠️ Bucket names must be globally unique across all AWS accounts + - Use lowercase letters, numbers, hyphens only + - Example: `amigoscode-product-images-2024` + +3. **Configure Bucket Settings** + - **AWS Region**: Select your region (e.g., `us-east-1` - same as your EC2 region) + - **Object Ownership**: Select **"ACLs disabled (recommended)"** + - **Block Public Access**: + - ✅ **Keep all settings enabled** (unless you need public access to images) + - If you want public image access, uncheck "Block all public access" and acknowledge + - **Versioning**: Disable (unless you need versioning) + - **Encryption**: Choose **"Enable"** → **"Amazon S3 managed keys (SSE-S3)"** (recommended) + - **Tags**: Optional - add tags for organization + +4. **Create Bucket** + - Click **"Create bucket"** button at the bottom + - **Note down your bucket name** - you'll need it for configuration + +--- + +## Step 2: Create IAM User for Application Access + +### Option A: IAM User with Access Keys (Recommended for EC2) + +1. **Navigate to IAM** + - In AWS Console, search for **"IAM"** and open the service + - Click **"Users"** in the left sidebar + - Click **"Create user"** button + +2. **Set User Details** + - **User name**: `product-service-s3-user` (or your preferred name) + - **Select credential type**: ✅ Check **"Provide user access to the AWS Management Console"** (optional, for testing) + - Or just check **"Access key - Programmatic access"** (for API access only) + - Click **"Next"** + +3. **Set Permissions** + - Select **"Attach policies directly"** + - Click **"Create policy"** button (opens in new tab) + +4. **Create Custom Policy** + - In the new tab, click **"JSON"** tab + - Replace the content with: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:HeadObject" + ], + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*" + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME" + } + ] + } + ``` + - **Replace `YOUR-BUCKET-NAME`** with your actual bucket name (e.g., `amigoscode-product-images-2024`) + - Click **"Next"** + - **Policy name**: `ProductServiceS3Policy` + - **Description**: `Allows S3 access for product image service` + - Click **"Create policy"** + - **Go back to the user creation tab** + +5. **Attach Policy to User** + - Refresh the policy list (click refresh icon) + - Search for `ProductServiceS3Policy` + - ✅ Check the box next to your policy + - Click **"Next"** + +6. **Review and Create** + - Review the settings + - Click **"Create user"** + +7. **Save Access Keys** ⚠️ **CRITICAL - DO THIS NOW** + - After creating user, you'll see **"Access key"** section + - Click **"Create access key"** + - **Use case**: Select **"Application running outside AWS"** + - Click **"Next"** + - Click **"Create access key"** + - **⚠️ IMPORTANT**: + - **Copy the Access Key ID** (e.g., `AKIAIOSFODNN7EXAMPLE`) + - **Copy the Secret Access Key** (e.g., `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) + - **Download CSV file** as backup + - **You cannot view the secret key again** after closing this page + - Click **"Done"** + +--- + +## Step 3: Configure EC2 Instance with Credentials + +### Method 1: Using IAM Role (Recommended - More Secure) + +1. **Create IAM Role** + - In IAM Console, click **"Roles"** in left sidebar + - Click **"Create role"** + - **Select trusted entity**: **"AWS service"** + - **Use case**: Select **"EC2"** + - Click **"Next"** + +2. **Attach Permissions** + - Search for `ProductServiceS3Policy` (the policy you created earlier) + - ✅ Check the box + - Click **"Next"** + +3. **Name Role** + - **Role name**: `EC2-S3-Access-Role` + - **Description**: `Allows EC2 instance to access S3 bucket` + - Click **"Create role"** + +4. **Attach Role to EC2 Instance** + - Go to **EC2 Console** → **Instances** + - Select your EC2 instance + - Click **"Actions"** → **"Security"** → **"Modify IAM role"** + - Select `EC2-S3-Access-Role` + - Click **"Update IAM role"** + +### Method 2: Using Environment Variables (Alternative) + +If you prefer using access keys directly: + +1. **SSH into your EC2 instance** + ```bash + ssh -i your-key.pem ec2-user@your-ec2-ip + ``` + +2. **Set environment variables** (add to `/etc/environment` or your deployment script) + ```bash + sudo nano /etc/environment + ``` + + Add these lines: + ``` + AWS_ACCESS_KEY_ID=your-access-key-id + AWS_SECRET_ACCESS_KEY=your-secret-access-key + AWS_DEFAULT_REGION=us-east-1 + ``` + +3. **Or create a `.env` file** in your application directory: + ```bash + nano ~/app/.env + ``` + ``` + AWS_ACCESS_KEY_ID=your-access-key-id + AWS_SECRET_ACCESS_KEY=your-secret-access-key + AWS_DEFAULT_REGION=us-east-1 + ``` + +--- + +## Step 4: Update Application Configuration + +### Option A: Using Environment Variables (Recommended) + +Create or update your `application.properties` for production: + +```properties +# AWS S3 Configuration (Production) +aws.region=us-east-1 +aws.s3.bucket=your-bucket-name-here +aws.s3.endpoint-override= +aws.s3.path-style-enabled=false +aws.access-key-id=${AWS_ACCESS_KEY_ID} +aws.secret-access-key=${AWS_SECRET_ACCESS_KEY} +``` + +### Option B: Direct Configuration (Less Secure) + +If not using environment variables: + +```properties +# AWS S3 Configuration (Production) +aws.region=us-east-1 +aws.s3.bucket=your-bucket-name-here +aws.s3.endpoint-override= +aws.s3.path-style-enabled=false +aws.access-key-id=AKIAIOSFODNN7EXAMPLE +aws.secret-access-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +``` + +⚠️ **Security Note**: Never commit access keys to version control! + +--- + +## Step 5: Update Application Code (if needed) + +If using IAM Role (Method 1), you need to update `AwsS3Config.java` to support IAM roles: + +The current code uses `StaticCredentialsProvider`. If using IAM roles, AWS SDK will automatically use instance credentials. Update the config: + +```java +@Bean +public S3Client s3Client() { + S3ClientBuilder builder = S3Client.builder() + .region(Region.of(region)) + .serviceConfiguration( + S3Configuration + .builder() + .pathStyleAccessEnabled(pathStyleEnabled) + .build() + ); + + // Only use static credentials if access key is provided + if (StringUtils.isNotBlank(accessKeyId) && !accessKeyId.equals("minioadmin")) { + builder = builder.credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey)) + ); + } + // Otherwise, AWS SDK will use default credential chain (IAM role, env vars, etc.) + + if (StringUtils.isNotBlank(endpointOverride)) { + builder = builder.endpointOverride(URI.create(endpointOverride)); + } + return builder.build(); +} +``` + +--- + +## Step 6: Deploy Application to EC2 + +1. **Build your application** + ```bash + mvn clean package + ``` + +2. **Transfer JAR to EC2** + ```bash + scp -i your-key.pem target/product-service.jar ec2-user@your-ec2-ip:~/app/ + ``` + +3. **SSH into EC2** + ```bash + ssh -i your-key.pem ec2-user@your-ec2-ip + ``` + +4. **Create application.properties for production** + ```bash + cd ~/app + nano application.properties + ``` + + Add production configuration (see Step 4) + +5. **Run application** + ```bash + java -jar product-service.jar --spring.config.location=application.properties + ``` + + Or with environment variables: + ```bash + export AWS_ACCESS_KEY_ID=your-key-id + export AWS_SECRET_ACCESS_KEY=your-secret-key + export AWS_DEFAULT_REGION=us-east-1 + java -jar product-service.jar + ``` + +--- + +## Step 7: Verify S3 Integration + +1. **Test Image Upload** + - Use your application's API to upload a product image + - Go to S3 Console → Your bucket + - Verify the image appears in `products/` folder + +2. **Check Permissions** + - If upload fails, verify: + - IAM role/policy is attached correctly + - Bucket name matches configuration + - Region matches your EC2 instance region + +--- + +## Security Best Practices + +1. ✅ **Use IAM Roles** instead of access keys when possible (Method 1) +2. ✅ **Never commit credentials** to version control +3. ✅ **Use environment variables** or AWS Secrets Manager for sensitive data +4. ✅ **Restrict S3 bucket access** to specific IPs if needed (via bucket policy) +5. ✅ **Enable S3 bucket versioning** for production (if needed) +6. ✅ **Enable CloudTrail** to audit S3 access (optional but recommended) + +--- + +## Troubleshooting + +### Issue: "Access Denied" when uploading +- **Solution**: Verify IAM policy permissions and bucket name +- Check CloudTrail logs for detailed error messages + +### Issue: "Bucket not found" +- **Solution**: Verify bucket name matches exactly (case-sensitive) +- Ensure bucket is in the same region as configured + +### Issue: "Invalid endpoint" +- **Solution**: Remove `aws.s3.endpoint-override` property for production +- Set `aws.s3.path-style-enabled=false` for AWS S3 + +### Testing Credentials +```bash +aws s3 ls s3://your-bucket-name/ --region us-east-1 +``` + +--- + +## Next Steps + +- Set up CloudFront CDN for image delivery (optional) +- Configure S3 lifecycle policies for old images +- Set up monitoring with CloudWatch +- Configure backup strategies + diff --git a/README.md b/README.md new file mode 100644 index 0000000..347faaa --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# Product Image Management with AWS S3 + +This Spring Boot application provides a complete product management system with image upload and download capabilities using Amazon S3 for storage. + +## Features + +- **Product Management**: Create, read, update, and delete products +- **Image Upload**: Upload product images to Amazon S3 +- **Image Download**: Download and display product images from S3 +- **Web Interface**: Simple HTML interface for testing functionality +- **REST API**: Complete REST API for product and image management + +## Prerequisites + +- Java 21+ +- Maven 3.6+ +- PostgreSQL database +- AWS Account with S3 access +- AWS CLI configured (optional, for local development) + +## Setup Instructions + +### 1. Quick Local Setup (Recommended) + +For local development and testing, we use MinIO (S3-compatible storage) and PostgreSQL via Docker: + +```bash +# Run the setup script +./setup-local.sh + +# Or manually start services +docker compose -f docker-compose-services.yml up -d +``` + +This will start: +- **PostgreSQL** on port 5333 +- **MinIO** (S3-compatible) on port 9000 (API) and 9001 (Console) +- **MinIO bucket initialization** (creates `product-images` bucket) + +### 2. Manual Database Setup + +**Option A: Use Docker (Recommended)** +```bash +docker compose -f docker-compose-services.yml up -d +``` + +**Option B: Local PostgreSQL** +- Install PostgreSQL +- Create a database named `jfs` +- Update `application.properties` with your database credentials + +### 3. Local Development Configuration + +The application is pre-configured for local development with MinIO: + +```properties +# Database Configuration +spring.datasource.url=jdbc:postgresql://localhost:5333/jfs +spring.datasource.username=amigoscode +spring.datasource.password=password + +# AWS S3 Configuration (MinIO for local development) +aws.region=us-east-1 +aws.s3.bucket=product-images +aws.s3.endpoint-override=http://localhost:9000 +aws.s3.path-style-enabled=true +aws.access-key-id=minioadmin +aws.secret-access-key=minioadmin123 +``` + +### 4. Production AWS S3 Configuration + +For production deployment with real AWS S3: + +#### Create S3 Bucket +1. Log into AWS Console +2. Go to S3 service +3. Create a new bucket (e.g., `your-product-images-bucket`) +4. Note the bucket name for configuration + +#### Configure AWS Credentials + +**Option A: AWS CLI (Recommended for local development)** +```bash +aws configure +``` + +**Option B: Environment Variables** +```bash +export AWS_ACCESS_KEY_ID=your_access_key +export AWS_SECRET_ACCESS_KEY=your_secret_key +export AWS_DEFAULT_REGION=us-east-1 +``` + +**Option C: IAM Roles (for EC2/ECS deployment)** +- Attach appropriate IAM role with S3 permissions + +#### Update Application Properties for Production + +```properties +# AWS S3 Configuration (Production) +aws.region=us-east-1 +aws.s3.bucket=your-product-images-bucket +aws.s3.endpoint-override= +aws.s3.path-style-enabled=false +aws.access-key-id=${AWS_ACCESS_KEY_ID} +aws.secret-access-key=${AWS_SECRET_ACCESS_KEY} +``` + +### 5. Required S3 Permissions + +Your AWS credentials need the following S3 permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:HeadObject" + ], + "Resource": "arn:aws:s3:::your-product-images-bucket/*" + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": "arn:aws:s3:::your-product-images-bucket" + } + ] +} +``` + +## Running the Application + +### 1. Start Local Services +```bash +# Quick setup (recommended) +./setup-local.sh + +# Or manually +docker compose -f docker-compose-services.yml up -d +``` + +### 2. Build the Application +```bash +mvn clean package +``` + +### 3. Run the Application +```bash +mvn spring-boot:run +``` + +Or run the JAR file: +```bash +java -jar target/product-service.jar +``` + +### 4. Access the Application +- **Web Interface**: http://localhost:8080 +- **API Base URL**: http://localhost:8080/api/v1/products +- **MinIO Console**: http://localhost:9001 (minioadmin/minioadmin123) + +## API Endpoints + +### Product Management +- `GET /api/v1/products` - Get all products +- `GET /api/v1/products/{id}` - Get product by ID +- `POST /api/v1/products` - Create new product (JSON) +- `POST /api/v1/products` - Create new product with image (multipart/form-data) +- `PUT /api/v1/products/{id}` - Update product +- `DELETE /api/v1/products/{id}` - Delete product + +### Image Management +- `POST /api/v1/products/{id}/image` - Upload product image +- `GET /api/v1/products/{id}/image` - Download product image + +## Usage Examples + +### Create a Product with Image + +**Using the Web Interface:** +1. Open http://localhost:8080 +2. Fill in the product form +3. Select an image file +4. Click "Create Product" + +**Using cURL (Single Request - Recommended):** +```bash +# Create product with image in single request +curl -X POST http://localhost:8080/api/v1/products \ + -F "name=Sample Product" \ + -F "description=A sample product description" \ + -F "price=29.99" \ + -F "stockLevel=100" \ + -F "image=@/path/to/image.jpg" +``` + +**Using cURL (Separate Requests):** +```bash +# Create product first +curl -X POST http://localhost:8080/api/v1/products \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Sample Product", + "description": "A sample product description", + "price": 29.99, + "stockLevel": 100 + }' + +# Upload image (replace {product-id} with actual ID) +curl -X POST http://localhost:8080/api/v1/products/{product-id}/image \ + -F "file=@/path/to/image.jpg" +``` + +### Download Product Image +```bash +curl -O http://localhost:8080/api/v1/products/{product-id}/image +``` + +## Project Structure + +``` +src/ +├── main/ +│ ├── java/com/amigoscode/ +│ │ ├── config/ +│ │ │ └── AwsS3Config.java # AWS S3 configuration +│ │ ├── product/ +│ │ │ ├── Product.java # Product entity +│ │ │ ├── ProductController.java # REST controller +│ │ │ ├── ProductService.java # Business logic +│ │ │ ├── ProductImageService.java # Image handling service +│ │ │ └── ProductRepository.java # Data access +│ │ └── storage/ +│ │ └── S3StorageService.java # S3 operations +│ └── resources/ +│ ├── static/ +│ │ └── index.html # Web interface +│ └── application.properties # Configuration +``` + +## Docker Deployment + +### Build Docker Image +```bash +mvn jib:build +``` + +### Run with Docker Compose +```bash +docker compose up -d +``` + +## Testing + +### Unit Tests +```bash +mvn test +``` + +### Integration Tests +```bash +mvn verify +``` + +## Troubleshooting + +### Common Issues + +1. **MinIO Connection Issues (Local Development)** + - Ensure MinIO is running: `docker ps | grep minio` + - Check MinIO logs: `docker logs jfs-minio-local` + - Verify MinIO is accessible: `curl http://localhost:9000/minio/health/live` + - Access MinIO console at http://localhost:9001 + +2. **AWS Credentials Not Found (Production)** + - Ensure AWS credentials are properly configured + - Check environment variables or AWS CLI configuration + +3. **S3 Bucket Access Denied** + - Verify bucket name in configuration + - Check IAM permissions for S3 access + - For MinIO: ensure bucket exists and is public + +4. **Database Connection Issues** + - Ensure PostgreSQL is running: `docker ps | grep postgres` + - Check database logs: `docker logs jfs-postgres-local` + - Verify database credentials in application.properties + +5. **Image Upload Fails** + - Check file size limits + - Verify image file format is supported + - Ensure S3/MinIO bucket exists and is accessible + - Check MinIO bucket policy: should be public for downloads + +### Logs +Check application logs for detailed error messages: +```bash +tail -f logs/application.log +``` + +## Security Considerations + +- Use IAM roles instead of access keys when possible +- Implement proper CORS configuration for production +- Add authentication and authorization as needed +- Consider using S3 pre-signed URLs for direct uploads +- Implement file type validation and size limits + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +## License + +This project is licensed under the MIT License. diff --git a/docker-compose-db-only.yml b/docker-compose-db-only.yml deleted file mode 100644 index abbc1fe..0000000 --- a/docker-compose-db-only.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - db-local-postgres: - container_name: jfs-postgres-local - image: postgres - environment: - POSTGRES_USER: amigoscode - POSTGRES_PASSWORD: password - POSTGRES_DB: jfs - ports: - - "5333:5432" - restart: unless-stopped - volumes: - - db-local:/data/postgres - networks: - - amigos - -networks: - amigos: - driver: bridge - -volumes: - db-local: \ No newline at end of file diff --git a/docker-compose-services.yml b/docker-compose-services.yml new file mode 100644 index 0000000..fd70ccc --- /dev/null +++ b/docker-compose-services.yml @@ -0,0 +1,60 @@ +services: + db-local-postgres: + container_name: jfs-postgres-local + image: postgres + environment: + POSTGRES_USER: amigoscode + POSTGRES_PASSWORD: password + POSTGRES_DB: jfs + ports: + - "5333:5432" + restart: unless-stopped + volumes: + - db-local:/data/postgres + networks: + - amigos + + minio: + container_name: jfs-minio-local + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + ports: + - "9000:9000" # API port + - "9001:9001" # Console port + restart: unless-stopped + volumes: + - minio-data:/data + networks: + - amigos + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + minio-init: + container_name: jfs-minio-init + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin123; + /usr/bin/mc mb myminio/product-images --ignore-existing; + /usr/bin/mc policy set public myminio/product-images; + exit 0; + " + networks: + - amigos + +networks: + amigos: + driver: bridge + +volumes: + db-local: + minio-data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3b304bb..1091649 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,11 @@ spring-boot-starter-webflux test + + software.amazon.awssdk + s3 + 2.21.29 + diff --git a/setup-local.sh b/setup-local.sh new file mode 100755 index 0000000..35c1e14 --- /dev/null +++ b/setup-local.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# Product Image Management - Local Testing Script +# This script helps you test the application locally with MinIO + +echo "🚀 Starting Product Image Management Local Testing Setup" +echo "==================================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + print_error "Docker is not running. Please start Docker and try again." + exit 1 +fi + +print_status "Docker is running ✓" + +# Start the services +print_status "Starting PostgreSQL and MinIO services..." +docker compose -f docker-compose-services.yml up -d + +# Wait for services to be ready +print_status "Waiting for services to be ready..." +sleep 10 + +# Check if services are running +if docker ps | grep -q "jfs-postgres-local"; then + print_success "PostgreSQL is running ✓" +else + print_error "PostgreSQL failed to start" + exit 1 +fi + +if docker ps | grep -q "jfs-minio-local"; then + print_success "MinIO is running ✓" +else + print_error "MinIO failed to start" + exit 1 +fi + +# Check if MinIO bucket was created +print_status "Checking MinIO bucket creation..." +sleep 5 + +if docker logs jfs-minio-init 2>&1 | grep -q "product-images"; then + print_success "MinIO bucket 'product-images' created ✓" +else + print_warning "MinIO bucket creation may have failed. You can create it manually in the MinIO console." +fi + +echo "" +echo "🎉 Setup Complete!" +echo "==================" +echo "" +echo "📊 Service URLs:" +echo " • PostgreSQL: localhost:5333" +echo " • MinIO API: http://localhost:9000" +echo " • MinIO Console: http://localhost:9001" +echo "" +echo "🔑 MinIO Credentials:" +echo " • Username: minioadmin" +echo " • Password: minioadmin123" +echo "" +echo "🚀 Next Steps:" +echo " 1. Start the Spring Boot application:" +echo " mvn spring-boot:run" +echo "" +echo " 2. Open the web interface:" +echo " http://localhost:8080" +echo "" +echo " 3. Access MinIO console to manage buckets:" +echo " http://localhost:9001" +echo "" +echo "🧪 Test the application:" +echo " • Create a product with an image" +echo " • Upload images to existing products" +echo " • View images in the product list" +echo "" +echo "📝 Useful Commands:" +echo " • Stop services: docker compose -f docker-compose-db-only.yml down" +echo " • View logs: docker compose -f docker-compose-db-only.yml logs -f" +echo " • Restart services: docker compose -f docker-compose-db-only.yml restart" +echo "" + +# Test MinIO connectivity +print_status "Testing MinIO connectivity..." +if curl -s http://localhost:9000/minio/health/live > /dev/null; then + print_success "MinIO is accessible ✓" +else + print_warning "MinIO may not be fully ready yet. Wait a few more seconds and try again." +fi + +echo "" +print_success "Setup completed successfully! 🎉" diff --git a/src/main/java/com/amigoscode/config/AwsS3Config.java b/src/main/java/com/amigoscode/config/AwsS3Config.java new file mode 100644 index 0000000..3bc9e1c --- /dev/null +++ b/src/main/java/com/amigoscode/config/AwsS3Config.java @@ -0,0 +1,65 @@ +package com.amigoscode.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.utils.StringUtils; + +import java.net.URI; + +@Configuration +public class AwsS3Config { + + @Value("${aws.region:us-east-1}") + private String region; + + @Value("${aws.s3.endpoint-override:}") + private String endpointOverride; + + @Value("${aws.s3.path-style-enabled:false}") + private boolean pathStyleEnabled; + + @Value("${aws.access-key-id:}") + private String accessKeyId; + + @Value("${aws.secret-access-key:}") + private String secretAccessKey; + + @Bean + public S3Client s3Client() { + S3ClientBuilder builder = S3Client.builder() + .region(Region.of(region)) + .serviceConfiguration( + S3Configuration + .builder() + .pathStyleAccessEnabled(pathStyleEnabled) + .build() + ); + + // Use static credentials if provided (for local/MinIO or explicit credentials) + // Otherwise use default credential chain (IAM roles, environment variables, etc.) + if (StringUtils.isNotBlank(accessKeyId) && StringUtils.isNotBlank(secretAccessKey)) { + builder = builder.credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey)) + ); + } else { + // Use default credential chain - will automatically use: + // 1. IAM role (if running on EC2/ECS/Lambda) + // 2. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + // 3. AWS credentials file (~/.aws/credentials) + builder = builder.credentialsProvider(DefaultCredentialsProvider.create()); + } + + if (StringUtils.isNotBlank(endpointOverride)) { + builder = builder.endpointOverride(URI.create(endpointOverride)); + } + return builder.build(); + } +} diff --git a/src/main/java/com/amigoscode/product/ProductController.java b/src/main/java/com/amigoscode/product/ProductController.java index ef95609..90e54e7 100644 --- a/src/main/java/com/amigoscode/product/ProductController.java +++ b/src/main/java/com/amigoscode/product/ProductController.java @@ -1,8 +1,14 @@ package com.amigoscode.product; +import com.amigoscode.storage.S3StorageService; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.UUID; @@ -12,9 +18,11 @@ public class ProductController { private final ProductService productService; + private final ProductImageService productImageService; - public ProductController(ProductService productService) { + public ProductController(ProductService productService, ProductImageService productImageService) { this.productService = productService; + this.productImageService = productImageService; } @GetMapping @@ -38,9 +46,40 @@ public UUID saveProduct(@RequestBody @Valid NewProductRequest product) { return productService.saveNewProduct(product); } + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public UUID saveProductWithImage(@RequestParam("name") @NotBlank String name, + @RequestParam("description") @NotBlank String description, + @RequestParam("price") @NotBlank String price, + @RequestParam("stockLevel") @NotBlank String stockLevel, + @RequestParam(value = "image", required = false) MultipartFile image) { + return productService.saveNewProductWithImage(name, description, price, stockLevel, image); + } + @PutMapping("{id}") public void updateProduct(@PathVariable UUID id, @RequestBody @Valid UpdateProductRequest request) { productService.updateProduct(id, request); } + + @PostMapping("{id}/image") + @ResponseStatus(HttpStatus.OK) + public void uploadProductImage(@PathVariable UUID id, + @RequestParam("file") MultipartFile file) { + productImageService.uploadProductImage(id, file); + } + + @GetMapping("{id}/image") + public ResponseEntity downloadProductImage(@PathVariable UUID id) { + S3StorageService.StoredObject storedObject = productImageService.downloadProductImage(id); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(storedObject.contentType())); + headers.setContentLength(storedObject.bytes().length); + headers.set(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"product-image\""); + + return ResponseEntity.ok() + .headers(headers) + .body(storedObject.bytes()); + } } diff --git a/src/main/java/com/amigoscode/product/ProductImageService.java b/src/main/java/com/amigoscode/product/ProductImageService.java new file mode 100644 index 0000000..668ae1b --- /dev/null +++ b/src/main/java/com/amigoscode/product/ProductImageService.java @@ -0,0 +1,50 @@ +package com.amigoscode.product; + +import com.amigoscode.exception.ResourceNotFound; +import com.amigoscode.storage.S3StorageService; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Objects; +import java.util.UUID; + +@Service +public class ProductImageService { + + private final ProductRepository productRepository; + private final S3StorageService s3; + + public ProductImageService(ProductRepository productRepository, S3StorageService s3) { + this.productRepository = productRepository; + this.s3 = s3; + } + + public void uploadProductImage(UUID productId, MultipartFile file) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFound("product with id [" + productId + "] not found")); + + String filename = Objects.requireNonNullElse(file.getOriginalFilename(), "image"); + String contentType = Objects.requireNonNullElse(file.getContentType(), MediaType.APPLICATION_OCTET_STREAM_VALUE); + String key = s3.computeProductImageKey(productId, filename); + try { + s3.upload(file.getBytes(), contentType, key); + } catch (IOException e) { + throw new RuntimeException("Failed to read uploaded file", e); + } + product.setImageUrl(key); + productRepository.save(product); + } + + public S3StorageService.StoredObject downloadProductImage(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFound("product with id [" + productId + "] not found")); + String key = product.getImageUrl(); + if (key == null || key.isBlank()) { + throw new ResourceNotFound("product with id [" + productId + "] does not have an image"); + } + return s3.download(key) + .orElseThrow(() -> new ResourceNotFound("image for product with id [" + productId + "] not found")); + } +} diff --git a/src/main/java/com/amigoscode/product/ProductService.java b/src/main/java/com/amigoscode/product/ProductService.java index 8bcfb9d..3fa042f 100644 --- a/src/main/java/com/amigoscode/product/ProductService.java +++ b/src/main/java/com/amigoscode/product/ProductService.java @@ -2,7 +2,9 @@ import com.amigoscode.exception.ResourceNotFound; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.math.BigDecimal; import java.util.List; import java.util.UUID; import java.util.function.Function; @@ -11,9 +13,12 @@ @Service public class ProductService { private final ProductRepository productRepository; + private final ProductImageService productImageService; - public ProductService(ProductRepository productRepository) { + public ProductService(ProductRepository productRepository, + ProductImageService productImageService) { this.productRepository = productRepository; + this.productImageService = productImageService; } public List getAllProducts() { @@ -54,6 +59,64 @@ public UUID saveNewProduct(NewProductRequest product) { return id; } + public UUID saveNewProductWithImage(String name, String description, String price, String stockLevel, MultipartFile image) { + UUID id = UUID.randomUUID(); + + // Validate input + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Product name cannot be empty"); + } + if (description == null || description.trim().isEmpty()) { + throw new IllegalArgumentException("Product description cannot be empty"); + } + + BigDecimal priceValue; + try { + priceValue = new BigDecimal(price); + if (priceValue.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Price must be greater than 0"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid price format: " + price); + } + + Integer stockLevelValue; + try { + stockLevelValue = Integer.parseInt(stockLevel); + if (stockLevelValue < 0) { + throw new IllegalArgumentException("Stock level cannot be negative"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid stock level format: " + stockLevel); + } + + // Create the product first + Product newProduct = new Product( + id, + name.trim(), + description.trim(), + priceValue, + null, // imageUrl will be set after upload + stockLevelValue + ); + productRepository.save(newProduct); + + // Upload image if provided + if (image != null && !image.isEmpty()) { + try { + productImageService.uploadProductImage(id, image); + } catch (Exception e) { + // Log the error but don't fail the product creation + System.err.println("Failed to upload image for product " + id + ": " + e.getMessage()); + // Optionally, you could delete the product if image upload is critical + // productRepository.deleteById(id); + // throw new IllegalStateException("Product created but image upload failed", e); + } + } + + return id; + } + Function mapToResponse() { return p -> new ProductResponse( p.getId(), diff --git a/src/main/java/com/amigoscode/storage/S3StorageService.java b/src/main/java/com/amigoscode/storage/S3StorageService.java new file mode 100644 index 0000000..4dc7069 --- /dev/null +++ b/src/main/java/com/amigoscode/storage/S3StorageService.java @@ -0,0 +1,73 @@ +package com.amigoscode.storage; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Service +public class S3StorageService { + private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); + + private final S3Client s3Client; + + @Value("${aws.s3.bucket:}") + private String bucket; + + public S3StorageService(S3Client s3Client) { + this.s3Client = s3Client; + } + + public String computeProductImageKey(UUID productId, String filename) { + String safe = filename == null ? "image" : filename.replaceAll("[^a-zA-Z0-9\\.\\-]", "_"); + return "products/" + productId + "/" + Instant.now().toEpochMilli() + "-" + safe; + } + + public String upload(byte[] bytes, String contentType, String key) { + PutObjectRequest put = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build(); + s3Client.putObject(put, RequestBody.fromBytes(bytes)); + return key; + } + + public Optional download(String key) { + try { + GetObjectRequest req = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + try (InputStream is = s3Client.getObject(req); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int r; + while ((r = is.read(buf)) != -1) { + baos.write(buf, 0, r); + } + HeadObjectResponse meta = s3Client.headObject(b -> b.bucket(bucket).key(key)); + String contentType = meta.contentType(); + return Optional.of(new StoredObject(baos.toByteArray(), contentType)); + } + } catch (NoSuchKeyException e) { + log.warn("S3 key not found: {}", key); + return Optional.empty(); + } catch (Exception e) { + throw new RuntimeException("Failed to download S3 object: " + key, e); + } + } + + public record StoredObject(byte[] bytes, String contentType) {} +} diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..a450982 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,22 @@ +spring.application.name=jfs +spring.datasource.url=jdbc:postgresql://localhost:5333/jfs +spring.datasource.username=amigoscode +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +server.error.include-message=always + +cors.allowed.origins=* +cors.allowed.methods=* + +# AWS S3 Configuration +aws.region=eu-west-1 +aws.s3.bucket=amigoscode-product-images-prod +aws.s3.endpoint-override= +aws.s3.path-style-enabled=false +aws.access-key-id= +aws.secret-access-key= \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ee25d64..0894933 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,3 +12,11 @@ server.error.include-message=always cors.allowed.origins=* cors.allowed.methods=* + +# AWS S3 Configuration (MinIO for local development) +aws.region=us-east-1 +aws.s3.bucket=product-images +aws.s3.endpoint-override=http://localhost:9000 +aws.s3.path-style-enabled=true +aws.access-key-id=minioadmin +aws.secret-access-key=minioadmin123 \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..49471c8 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,383 @@ + + + + + + Product Image Management + + + +
+

Product Image Management System

+ +
+

Add New Product

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Click to select image file +
+
+ +
+
+ +
+
+ +
+

Products

+
+
Loading products...
+
+
+ + + + diff --git a/src/test/java/com/amigoscode/product/ProductServiceTest.java b/src/test/java/com/amigoscode/product/ProductServiceTest.java index c5b3f31..ecca838 100644 --- a/src/test/java/com/amigoscode/product/ProductServiceTest.java +++ b/src/test/java/com/amigoscode/product/ProductServiceTest.java @@ -1,117 +1,353 @@ package com.amigoscode.product; -import com.amigoscode.SharedPostgresContainer; -import org.junit.jupiter.api.BeforeAll; +import com.amigoscode.exception.ResourceNotFound; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Import; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; +import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; -@DataJpaTest -@AutoConfigureTestDatabase( - replace = AutoConfigureTestDatabase.Replace.NONE -) -@Import({ - ProductService.class -}) -@Testcontainers +@ExtendWith(MockitoExtension.class) class ProductServiceTest { - @Container - @ServiceConnection - private static final SharedPostgresContainer POSTGRES = - SharedPostgresContainer.getInstance(); - - @Autowired + @Mock private ProductRepository productRepository; - - @Autowired + @Mock + private ProductImageService productImageService; private ProductService underTest; - @BeforeAll - static void beforeAll() { - System.out.println(POSTGRES.getDatabaseName()); - System.out.println(POSTGRES.getJdbcUrl()); - System.out.println(POSTGRES.getPassword()); - System.out.println(POSTGRES.getDriverClassName()); - System.out.println(POSTGRES.getTestQueryString()); - } - @BeforeEach void setUp() { - productRepository.deleteAll(); + underTest = new ProductService(productRepository, productImageService); } @Test - @Disabled void canGetAllProducts() { // given + UUID productId = UUID.randomUUID(); Product product = new Product( - UUID.randomUUID(), - "foo", - "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank", + productId, + "Test Product", + "A test product description", BigDecimal.TEN, - "https://amigoscode.com/logo.png", + "https://example.com/image.png", 10 ); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + product.setPublished(true); - productRepository.save(product); + when(productRepository.findAll()).thenReturn(List.of(product)); // when - List allProducts = - underTest.getAllProducts(); + List allProducts = underTest.getAllProducts(); + // then - ProductResponse expected = underTest.mapToResponse().apply(product); + assertThat(allProducts).hasSize(1); + ProductResponse response = allProducts.get(0); + assertThat(response.id()).isEqualTo(productId); + assertThat(response.name()).isEqualTo("Test Product"); + assertThat(response.description()).isEqualTo("A test product description"); + assertThat(response.price()).isEqualTo(BigDecimal.TEN); + assertThat(response.imageUrl()).isEqualTo("https://example.com/image.png"); + assertThat(response.stockLevel()).isEqualTo(10); + assertThat(response.isPublished()).isTrue(); - assertThat(allProducts) - .usingRecursiveFieldByFieldElementComparatorIgnoringFields( - "updatedAt", "createdAt" - ) - .containsOnly(expected); + verify(productRepository).findAll(); + } + @Test + void canGetProductById() { + // given + UUID productId = UUID.randomUUID(); + Product product = new Product( + productId, + "Test Product", + "A test product description", + BigDecimal.TEN, + "https://example.com/image.png", + 10 + ); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + product.setPublished(true); + + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // when + ProductResponse response = underTest.getProductById(productId); + + // then + assertThat(response.id()).isEqualTo(productId); + assertThat(response.name()).isEqualTo("Test Product"); + assertThat(response.description()).isEqualTo("A test product description"); + assertThat(response.price()).isEqualTo(BigDecimal.TEN); + assertThat(response.imageUrl()).isEqualTo("https://example.com/image.png"); + assertThat(response.stockLevel()).isEqualTo(10); + assertThat(response.isPublished()).isTrue(); + + verify(productRepository).findById(productId); } @Test - @Disabled - void getProductById() { + void getProductByIdThrowsWhenProductNotFound() { // given + UUID productId = UUID.randomUUID(); + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> underTest.getProductById(productId)) + .isInstanceOf(ResourceNotFound.class) + .hasMessageContaining("product with id [" + productId + "] not found"); + + verify(productRepository).findById(productId); + } + + @Test + void canDeleteProductById() { + // given + UUID productId = UUID.randomUUID(); + when(productRepository.existsById(productId)).thenReturn(true); + // when + underTest.deleteProductById(productId); + // then + verify(productRepository).existsById(productId); + verify(productRepository).deleteById(productId); + } + + @Test + void deleteProductByIdThrowsWhenProductNotFound() { + // given + UUID productId = UUID.randomUUID(); + when(productRepository.existsById(productId)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> underTest.deleteProductById(productId)) + .isInstanceOf(ResourceNotFound.class) + .hasMessageContaining("product with id [" + productId + "] not found"); + + verify(productRepository).existsById(productId); + verify(productRepository, never()).deleteById(any()); } @Test - @Disabled - void deleteProductById() { + void canSaveNewProduct() { // given + NewProductRequest request = new NewProductRequest( + "New Product", + "A new product description", + BigDecimal.valueOf(25.99), + 50, + "https://example.com/image.png" + ); + + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + return product; + }); + // when + UUID productId = underTest.saveNewProduct(request); + // then + assertThat(productId).isNotNull(); + verify(productRepository).save(any(Product.class)); } @Test - @Disabled - void saveNewProduct() { + void canSaveNewProductWithImage() { // given + String name = "Product with Image"; + String description = "A product with image description"; + String price = "29.99"; + String stockLevel = "25"; + MultipartFile mockImage = mock(MultipartFile.class); + + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + return product; + }); + // when + UUID productId = underTest.saveNewProductWithImage(name, description, price, stockLevel, mockImage); + // then + assertThat(productId).isNotNull(); + verify(productRepository).save(any(Product.class)); + verify(productImageService).uploadProductImage(productId, mockImage); } @Test - @Disabled - void updateProduct() { + void canSaveNewProductWithoutImage() { // given + String name = "Product without Image"; + String description = "A product without image description"; + String price = "19.99"; + String stockLevel = "15"; + + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + return product; + }); + // when + UUID productId = underTest.saveNewProductWithImage(name, description, price, stockLevel, null); + + // then + assertThat(productId).isNotNull(); + verify(productRepository).save(any(Product.class)); + verify(productImageService, never()).uploadProductImage(any(), any()); + } + + @Test + void saveNewProductWithImageHandlesImageUploadFailure() { + // given + String name = "Product with Failed Image"; + String description = "A product with failed image upload"; + String price = "39.99"; + String stockLevel = "30"; + MultipartFile mockImage = mock(MultipartFile.class); + + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setCreatedAt(Instant.now()); + product.setUpdatedAt(Instant.now()); + return product; + }); + + doThrow(new RuntimeException("Image upload failed")) + .when(productImageService).uploadProductImage(any(), any()); + + // when + UUID productId = underTest.saveNewProductWithImage(name, description, price, stockLevel, mockImage); + + // then + assertThat(productId).isNotNull(); + verify(productRepository).save(any(Product.class)); + verify(productImageService).uploadProductImage(productId, mockImage); + } + + @Test + void saveNewProductWithImageValidatesInput() { + // Test empty name + assertThatThrownBy(() -> underTest.saveNewProductWithImage("", "description", "10.00", "5", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Product name cannot be empty"); + + // Test empty description + assertThatThrownBy(() -> underTest.saveNewProductWithImage("name", "", "10.00", "5", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Product description cannot be empty"); + + // Test invalid price + assertThatThrownBy(() -> underTest.saveNewProductWithImage("name", "description", "invalid", "5", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid price format"); + + // Test negative price + assertThatThrownBy(() -> underTest.saveNewProductWithImage("name", "description", "0", "5", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Price must be greater than 0"); + + // Test invalid stock level + assertThatThrownBy(() -> underTest.saveNewProductWithImage("name", "description", "10.00", "invalid", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid stock level format"); + + // Test negative stock level + assertThatThrownBy(() -> underTest.saveNewProductWithImage("name", "description", "10.00", "-1", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stock level cannot be negative"); + } + + @Test + void canUpdateProduct() { + // given + UUID productId = UUID.randomUUID(); + Product existingProduct = new Product( + productId, + "Original Name", + "Original Description", + BigDecimal.valueOf(10.00), + "original-image.png", + 5 + ); + existingProduct.setCreatedAt(Instant.now()); + existingProduct.setUpdatedAt(Instant.now()); + existingProduct.setPublished(true); + + UpdateProductRequest updateRequest = new UpdateProductRequest( + "Updated Name", + "Updated Description", + "updated-image.png", + BigDecimal.valueOf(15.00), + 10, + false + ); + + when(productRepository.findById(productId)).thenReturn(Optional.of(existingProduct)); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + product.setUpdatedAt(Instant.now()); + return product; + }); + + // when + underTest.updateProduct(productId, updateRequest); + // then + verify(productRepository).findById(productId); + verify(productRepository).save(existingProduct); + + assertThat(existingProduct.getName()).isEqualTo("Updated Name"); + assertThat(existingProduct.getDescription()).isEqualTo("Updated Description"); + assertThat(existingProduct.getImageUrl()).isEqualTo("updated-image.png"); + assertThat(existingProduct.getPrice()).isEqualTo(BigDecimal.valueOf(15.00)); + assertThat(existingProduct.getStockLevel()).isEqualTo(10); + assertThat(existingProduct.getPublished()).isFalse(); + } + + @Test + void updateProductThrowsWhenProductNotFound() { + // given + UUID productId = UUID.randomUUID(); + UpdateProductRequest updateRequest = new UpdateProductRequest( + "Updated Name", + "Updated Description", + "updated-image.png", + BigDecimal.valueOf(15.00), + 10, + false + ); + + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> underTest.updateProduct(productId, updateRequest)) + .isInstanceOf(ResourceNotFound.class) + .hasMessageContaining("product with id [" + productId + "] not found"); + + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any()); } } \ No newline at end of file