diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..b2e599a
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,18 @@
+name: Build workflow
+on:
+ pull_request:
+ branches:
+ - main
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ - name: Maven Clean Verify
+ run: mvn -B -ntp clean verify
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3867cfa
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+# Build argument for Java version
+ARG JAVA_VERSION=21
+
+# Stage 1: Build the application
+FROM maven:3.9.4 AS builder
+WORKDIR /app
+COPY pom.xml .
+COPY src ./src
+RUN mvn clean package
+
+# Stage 2: Run the application
+FROM openjdk:${JAVA_VERSION}-jdk-slim
+WORKDIR /app
+COPY --from=builder /app/target/product-service.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/docker-compose-db-only.yml b/docker-compose-db-only.yml
new file mode 100644
index 0000000..abbc1fe
--- /dev/null
+++ b/docker-compose-db-only.yml
@@ -0,0 +1,22 @@
+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.yml b/docker-compose.yml
new file mode 100644
index 0000000..e8ac947
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+services:
+ product-service:
+ container_name: product
+# image: amigoscode/product-service:jibMaven
+ build:
+ context: .
+ dockerfile: Dockerfile
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/jfs
+ SPRING_DATASOURCE_USERNAME: amigoscode
+ SPRING_DATASOURCE_PASSWORD: password
+ ports:
+ - "8090:8080"
+ networks:
+ - amigos
+ db:
+ container_name: jfs-postgres
+ image: postgres
+ environment:
+ POSTGRES_USER: amigoscode
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: jfs
+ ports:
+ - "5333:5432"
+ restart: unless-stopped
+ volumes:
+ - db:/data/postgres
+ networks:
+ - amigos
+
+networks:
+ amigos:
+ driver: bridge
+
+volumes:
+ db:
diff --git a/pom.xml b/pom.xml
index a494e2a..3b304bb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,7 @@
com.amigoscode
jfs
0.0.1-SNAPSHOT
+ jar
java-springboot-full-stack
java-springboot-full-stack
@@ -28,6 +29,9 @@
21
+ amigoscode
+ product-service
+
@@ -40,14 +44,103 @@
spring-boot-starter-test
test
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+ org.testcontainers
+ junit-jupiter
+
+
+ org.testcontainers
+ postgresql
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ test
+
+ product-service
org.springframework.boot
spring-boot-maven-plugin
+
+ com.google.cloud.tools
+ jib-maven-plugin
+ 3.3.2
+
+
+ eclipse-temurin:21-jre
+
+
+ amd64
+ linux
+
+
+ arm64
+ linux
+
+
+
+
+ docker.io/${docker.username}/${docker.image.name}:${docker.image.tag}
+
+ latest
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IntegrationTest.java
+ **/*IT.java
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ **/*IntegrationTest.java
+ **/*IT.java
+
+
+
diff --git a/src/main/java/com/amigoscode/Main.java b/src/main/java/com/amigoscode/Main.java
index cc8e2f5..2278728 100644
--- a/src/main/java/com/amigoscode/Main.java
+++ b/src/main/java/com/amigoscode/Main.java
@@ -1,7 +1,14 @@
package com.amigoscode;
+import com.amigoscode.product.Product;
+import com.amigoscode.product.ProductRepository;
+import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+
+import java.math.BigDecimal;
+import java.util.UUID;
@SpringBootApplication
public class Main {
@@ -10,4 +17,31 @@ public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
+ // @Bean
+ public CommandLineRunner commandLineRunner(
+ ProductRepository productRepository) {
+ return args -> {
+ Product product1 = new Product();
+ product1.setName("Macbook Pro");
+ product1.setDescription("Macbook Pro M4");
+ product1.setPrice(new BigDecimal(3000));
+ product1.setId(UUID.fromString(
+ "d95062e6-9f0b-4224-bc9d-d0723949848f")
+ );
+ product1.setStockLevel(100);
+ productRepository.save(product1);
+
+ Product product2 = new Product();
+ product2.setId(UUID.fromString(
+ "94d2cc8a-ad09-4902-a321-a6bf658e2463"
+ ));
+ product2.setName("Mouse");
+ product2.setDescription("LG Mouse");
+ product2.setPrice(new BigDecimal(78));
+ product2.setStockLevel(1000);
+
+ productRepository.save(product2);
+ };
+ }
+
}
diff --git a/src/main/java/com/amigoscode/exception/ErrorResponse.java b/src/main/java/com/amigoscode/exception/ErrorResponse.java
new file mode 100644
index 0000000..41aafb4
--- /dev/null
+++ b/src/main/java/com/amigoscode/exception/ErrorResponse.java
@@ -0,0 +1,13 @@
+package com.amigoscode.exception;
+
+import java.time.Instant;
+import java.util.Map;
+
+public record ErrorResponse(
+ String message,
+ String error,
+ int statusCode,
+ String path,
+ Instant timestamp,
+ Map fieldErrors) {
+}
diff --git a/src/main/java/com/amigoscode/exception/GlobalExceptionHandler.java b/src/main/java/com/amigoscode/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..da69694
--- /dev/null
+++ b/src/main/java/com/amigoscode/exception/GlobalExceptionHandler.java
@@ -0,0 +1,88 @@
+package com.amigoscode.exception;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@ControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(ResourceNotFound.class)
+ public ResponseEntity handleResourceNotFound(
+ ResourceNotFound ex,
+ HttpServletRequest request
+ ) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ ex.getMessage(),
+ HttpStatus.NOT_FOUND.getReasonPhrase(),
+ HttpStatus.NOT_FOUND.value(),
+ request.getRequestURI(),
+ Instant.now(),
+ null
+ );
+ return new ResponseEntity<>(
+ errorResponse,
+ HttpStatus.NOT_FOUND
+ );
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationException(
+ MethodArgumentNotValidException ex,
+ HttpServletRequest request
+ ) {
+
+ Map errors = ex.getBindingResult()
+ .getFieldErrors()
+ .stream()
+ .collect(Collectors.toMap(
+ FieldError::getField,
+ fieldError ->
+ Objects.requireNonNullElse(
+ fieldError.getDefaultMessage(),
+ "no error available"
+ )
+ ));
+ ErrorResponse errorResponse = new ErrorResponse(
+ ex.getMessage(),
+ HttpStatus.BAD_REQUEST.getReasonPhrase(),
+ HttpStatus.BAD_REQUEST.value(),
+ request.getRequestURI(),
+ Instant.now(),
+ errors
+ );
+ return new ResponseEntity<>(
+ errorResponse,
+ HttpStatus.BAD_REQUEST
+ );
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(
+ Exception ex,
+ HttpServletRequest request
+ ) {
+ ErrorResponse errorResponse = new ErrorResponse(
+ ex.getMessage(),
+ HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
+ HttpStatus.INTERNAL_SERVER_ERROR.value(),
+ request.getRequestURI(),
+ Instant.now(),
+ null
+ );
+ return new ResponseEntity<>(
+ errorResponse,
+ HttpStatus.INTERNAL_SERVER_ERROR
+ );
+ }
+
+}
diff --git a/src/main/java/com/amigoscode/exception/ResourceNotFound.java b/src/main/java/com/amigoscode/exception/ResourceNotFound.java
new file mode 100644
index 0000000..0b74f6e
--- /dev/null
+++ b/src/main/java/com/amigoscode/exception/ResourceNotFound.java
@@ -0,0 +1,7 @@
+package com.amigoscode.exception;
+
+public class ResourceNotFound extends RuntimeException {
+ public ResourceNotFound(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/amigoscode/product/NewProductRequest.java b/src/main/java/com/amigoscode/product/NewProductRequest.java
new file mode 100644
index 0000000..5336784
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/NewProductRequest.java
@@ -0,0 +1,30 @@
+package com.amigoscode.product;
+
+import jakarta.validation.constraints.*;
+
+import java.math.BigDecimal;
+
+public record NewProductRequest(
+ @NotBlank
+ @Size(
+ min = 2,
+ max = 50,
+ message = "Name must be between 2 and 50 characters"
+ )
+ String name,
+ @Size(
+ min = 5,
+ max = 500,
+ message = "Description must be between 5 and 500 characters"
+ )
+ String description,
+ @NotNull(message = "Price is required")
+ @DecimalMin(value = "0.1", message = "Price must be greater than 0.1")
+ BigDecimal price,
+
+ @NotNull(message = "Price is required")
+ @Min(value = 1, message = "Min Stock Level is 1")
+ Integer stockLevel,
+ String imageUrl
+) {
+}
diff --git a/src/main/java/com/amigoscode/product/Product.java b/src/main/java/com/amigoscode/product/Product.java
new file mode 100644
index 0000000..a7a620d
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/Product.java
@@ -0,0 +1,165 @@
+package com.amigoscode.product;
+
+import jakarta.persistence.*;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+
+@Entity
+@Table(
+ name = "product"
+)
+public class Product {
+
+ @Id
+ private UUID id;
+
+ @Column(nullable = false, length = 50)
+ private String name;
+
+ @Column(length = 500)
+ private String description;
+
+ @Column(nullable = false, precision = 10, scale = 2)
+ private BigDecimal price;
+
+ @Column(length = 200)
+ private String imageUrl;
+
+ @Column(nullable = false)
+ private Integer stockLevel;
+
+ @Column(nullable = false, updatable = false)
+ private Instant createdAt;
+
+ private Instant updatedAt;
+
+ private Instant deletedAt;
+
+ private Boolean isPublished = true;
+
+ public Product() {
+ }
+
+ public Product(UUID id,
+ String name,
+ String description,
+ BigDecimal price,
+ String imageUrl,
+ Integer stockLevel) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ this.price = price;
+ this.imageUrl = imageUrl;
+ this.stockLevel = stockLevel;
+ }
+
+ @PrePersist
+ public void prePersist() {
+ if (this.id == null) {
+ this.id = UUID.randomUUID();
+ }
+ this.createdAt = Instant.now();
+ this.updatedAt = Instant.now();
+ }
+
+ @PreUpdate
+ public void preUpdate() {
+ this.updatedAt = Instant.now();
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public BigDecimal getPrice() {
+ return price;
+ }
+
+ public void setPrice(BigDecimal price) {
+ this.price = price;
+ }
+
+ public String getImageUrl() {
+ return imageUrl;
+ }
+
+ public void setImageUrl(String imageUrl) {
+ this.imageUrl = imageUrl;
+ }
+
+ public int getStockLevel() {
+ return stockLevel;
+ }
+
+ public void setStockLevel(int stockLevel) {
+ this.stockLevel = stockLevel;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public Instant getDeletedAt() {
+ return deletedAt;
+ }
+
+ public void setDeletedAt(Instant deletedAt) {
+ this.deletedAt = deletedAt;
+ }
+
+ public Boolean getPublished() {
+ return isPublished;
+ }
+
+ public void setPublished(Boolean published) {
+ isPublished = published;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+ Product product = (Product) o;
+ return Objects.equals(stockLevel, product.stockLevel) && Objects.equals(id, product.id) && Objects.equals(name, product.name) && Objects.equals(description, product.description) && Objects.equals(price, product.price) && Objects.equals(imageUrl, product.imageUrl) && Objects.equals(createdAt, product.createdAt) && Objects.equals(updatedAt, product.updatedAt) && Objects.equals(deletedAt, product.deletedAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, description, price, imageUrl, stockLevel, createdAt, updatedAt, deletedAt);
+ }
+}
diff --git a/src/main/java/com/amigoscode/product/ProductController.java b/src/main/java/com/amigoscode/product/ProductController.java
new file mode 100644
index 0000000..ef95609
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/ProductController.java
@@ -0,0 +1,46 @@
+package com.amigoscode.product;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/v1/products")
+public class ProductController {
+
+ private final ProductService productService;
+
+ public ProductController(ProductService productService) {
+ this.productService = productService;
+ }
+
+ @GetMapping
+ public List getAllProducts() {
+ return productService.getAllProducts();
+ }
+
+ @GetMapping("{id}")
+ public ProductResponse getProductById(@PathVariable("id") UUID id) {
+ return productService.getProductById(id);
+ }
+
+ @DeleteMapping("{id}")
+ public void deleteProductById(@PathVariable("id") UUID id) {
+ productService.deleteProductById(id);
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public UUID saveProduct(@RequestBody @Valid NewProductRequest product) {
+ return productService.saveNewProduct(product);
+ }
+
+ @PutMapping("{id}")
+ public void updateProduct(@PathVariable UUID id,
+ @RequestBody @Valid UpdateProductRequest request) {
+ productService.updateProduct(id, request);
+ }
+}
diff --git a/src/main/java/com/amigoscode/product/ProductRepository.java b/src/main/java/com/amigoscode/product/ProductRepository.java
new file mode 100644
index 0000000..fc88d02
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/ProductRepository.java
@@ -0,0 +1,14 @@
+package com.amigoscode.product;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+import java.util.UUID;
+
+public interface ProductRepository
+ extends JpaRepository {
+
+ @Query("SELECT p FROM Product p WHERE p.isPublished AND p.stockLevel > 0 ORDER BY p.price ASC")
+ List findAvailablePublishedProducts();
+}
diff --git a/src/main/java/com/amigoscode/product/ProductResponse.java b/src/main/java/com/amigoscode/product/ProductResponse.java
new file mode 100644
index 0000000..8ff5d16
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/ProductResponse.java
@@ -0,0 +1,18 @@
+package com.amigoscode.product;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.UUID;
+
+public record ProductResponse(
+ UUID id,
+ String name,
+ String description,
+ BigDecimal price,
+ String imageUrl,
+ Integer stockLevel,
+ boolean isPublished, Instant createdAt,
+ Instant updatedAt,
+ Instant deletedAt
+) {
+}
diff --git a/src/main/java/com/amigoscode/product/ProductService.java b/src/main/java/com/amigoscode/product/ProductService.java
new file mode 100644
index 0000000..8bcfb9d
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/ProductService.java
@@ -0,0 +1,102 @@
+package com.amigoscode.product;
+
+import com.amigoscode.exception.ResourceNotFound;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+public class ProductService {
+ private final ProductRepository productRepository;
+
+ public ProductService(ProductRepository productRepository) {
+ this.productRepository = productRepository;
+ }
+
+ public List getAllProducts() {
+ return productRepository.findAll().stream()
+ .map(mapToResponse())
+ .collect(Collectors.toList());
+ }
+
+ public ProductResponse getProductById(UUID id) {
+ return productRepository.findById(id)
+ .map(mapToResponse())
+ .orElseThrow(() -> new ResourceNotFound(
+ "product with id [" + id + "] not found"
+ ));
+ }
+
+ public void deleteProductById(UUID id) {
+ boolean exists = productRepository.existsById(id);
+ if (!exists) {
+ throw new ResourceNotFound(
+ "product with id [" + id + "] not found"
+ );
+ }
+ productRepository.deleteById(id);
+ }
+
+ public UUID saveNewProduct(NewProductRequest product) {
+ UUID id = UUID.randomUUID();
+ Product newProduct = new Product(
+ id,
+ product.name(),
+ product.description(),
+ product.price(),
+ product.imageUrl(),
+ product.stockLevel()
+ );
+ productRepository.save(newProduct);
+ return id;
+ }
+
+ Function mapToResponse() {
+ return p -> new ProductResponse(
+ p.getId(),
+ p.getName(),
+ p.getDescription(),
+ p.getPrice(),
+ p.getImageUrl(),
+ p.getStockLevel(),
+ p.getPublished(),
+ p.getCreatedAt(),
+ p.getUpdatedAt(),
+ p.getDeletedAt()
+ );
+ }
+
+
+ public void updateProduct(UUID id,
+ UpdateProductRequest updateRequest) {
+ Product product = productRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFound(
+ "product with id [" + id + "] not found"
+ ));
+
+ if (updateRequest.name() != null && !updateRequest.name().equals(product.getName())) {
+ product.setName(updateRequest.name());
+ }
+ if (updateRequest.description() != null && !updateRequest.description().equals(product.getDescription())) {
+ product.setDescription(updateRequest.description());
+ }
+ if (updateRequest.price() != null && !updateRequest.price().equals(product.getPrice())) {
+ product.setPrice(updateRequest.price());
+ }
+ if (updateRequest.imageUrl() != null && !updateRequest.imageUrl().equals(product.getImageUrl())) {
+ product.setImageUrl(updateRequest.imageUrl());
+ }
+ if (updateRequest.stockLevel() != null && !updateRequest.stockLevel().equals(product.getStockLevel())) {
+ product.setStockLevel(updateRequest.stockLevel());
+ }
+
+ if (updateRequest.isPublished() != null && !updateRequest.isPublished().equals(product.getPublished())) {
+ product.setPublished(updateRequest.isPublished());
+ }
+
+ productRepository.save(product);
+ }
+}
diff --git a/src/main/java/com/amigoscode/product/UpdateProductRequest.java b/src/main/java/com/amigoscode/product/UpdateProductRequest.java
new file mode 100644
index 0000000..5c8191f
--- /dev/null
+++ b/src/main/java/com/amigoscode/product/UpdateProductRequest.java
@@ -0,0 +1,35 @@
+package com.amigoscode.product;
+
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.math.BigDecimal;
+
+public record UpdateProductRequest(
+ @Size(
+ min = 2,
+ max = 50,
+ message = "Name must be between 2 and 50 characters"
+ )
+ String name,
+
+ @Size(
+ min = 5,
+ max = 500,
+ message = "Description must be between 5 and 500 characters"
+ )
+ String description,
+
+ String imageUrl,
+
+ @DecimalMin(value = "0.1", message = "Price must be greater than 0.1")
+ BigDecimal price,
+
+ @Min(value = 1, message = "Min Stock Level is 1")
+ Integer stockLevel,
+
+ Boolean isPublished
+) {
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index e653aee..0d37076 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1 +1,11 @@
-spring.application.name=java-springboot-full-stack
+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
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V1__Product_Table.sql b/src/main/resources/db/migration/V1__Product_Table.sql
new file mode 100644
index 0000000..08d38cd
--- /dev/null
+++ b/src/main/resources/db/migration/V1__Product_Table.sql
@@ -0,0 +1,13 @@
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+CREATE TABLE IF NOT EXISTS product (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name VARCHAR(50) NOT NULL CHECK (char_length(name) > 1),
+ description VARCHAR(500) CHECK (char_length(description) >= 5),
+ price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
+ image_url VARCHAR(200) CHECK (char_length(image_url) > 0),
+ stock_level INT NOT NULL DEFAULT 1 CHECK (stock_level >= 0),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+);
diff --git a/src/main/resources/db/migration/V2__Add_Is_Published_To_Product.sql b/src/main/resources/db/migration/V2__Add_Is_Published_To_Product.sql
new file mode 100644
index 0000000..4df7de6
--- /dev/null
+++ b/src/main/resources/db/migration/V2__Add_Is_Published_To_Product.sql
@@ -0,0 +1,2 @@
+ALTER TABLE product
+ADD COLUMN is_published BOOLEAN DEFAULT TRUE;
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V3__Set_Product_Is_Published_To_Not_Null.sql b/src/main/resources/db/migration/V3__Set_Product_Is_Published_To_Not_Null.sql
new file mode 100644
index 0000000..015cfdf
--- /dev/null
+++ b/src/main/resources/db/migration/V3__Set_Product_Is_Published_To_Not_Null.sql
@@ -0,0 +1,3 @@
+ALTER TABLE product
+ALTER COLUMN is_published
+SET NOT NULL;
\ No newline at end of file
diff --git a/src/test/java/com/amigoscode/AbstractTestConfig.java b/src/test/java/com/amigoscode/AbstractTestConfig.java
new file mode 100644
index 0000000..a9ff141
--- /dev/null
+++ b/src/test/java/com/amigoscode/AbstractTestConfig.java
@@ -0,0 +1,20 @@
+package com.amigoscode;
+
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.test.annotation.DirtiesContext;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureWebTestClient
+@Testcontainers
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+public abstract class AbstractTestConfig {
+
+ @Container
+ @ServiceConnection
+ private static final SharedPostgresContainer POSTGRES =
+ SharedPostgresContainer.getInstance();
+}
diff --git a/src/test/java/com/amigoscode/SharedPostgresContainer.java b/src/test/java/com/amigoscode/SharedPostgresContainer.java
new file mode 100644
index 0000000..83ce976
--- /dev/null
+++ b/src/test/java/com/amigoscode/SharedPostgresContainer.java
@@ -0,0 +1,42 @@
+package com.amigoscode;
+
+import org.flywaydb.core.Flyway;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+public class SharedPostgresContainer extends PostgreSQLContainer {
+ private static final DockerImageName IMAGE_NAME
+ = DockerImageName.parse("postgres:17-alpine");
+
+ private static volatile SharedPostgresContainer sharedPostgresContainer;
+
+ public SharedPostgresContainer(DockerImageName dockerImageName) {
+ super(dockerImageName);
+ this.withReuse(true)
+ .withUsername("amigoscode")
+ .withDatabaseName("amigos")
+ .withLabel("name", "amigscode")
+ .withPassword("password");
+ }
+
+ public static SharedPostgresContainer getInstance() {
+ if(sharedPostgresContainer == null) {
+ synchronized (SharedPostgresContainer.class) {
+ sharedPostgresContainer = new SharedPostgresContainer(
+ IMAGE_NAME
+ );
+ sharedPostgresContainer.start();
+ Flyway flyway = Flyway.configure()
+ .dataSource(
+ sharedPostgresContainer.getJdbcUrl(),
+ sharedPostgresContainer.getUsername(),
+ sharedPostgresContainer.getPassword()
+ )
+ .load();
+ flyway.migrate();
+ System.out.println("flyway applied migrations");
+ }
+ }
+ return sharedPostgresContainer;
+ }
+}
diff --git a/src/test/java/com/amigoscode/journey/ProductIT.java b/src/test/java/com/amigoscode/journey/ProductIT.java
new file mode 100644
index 0000000..dae920e
--- /dev/null
+++ b/src/test/java/com/amigoscode/journey/ProductIT.java
@@ -0,0 +1,252 @@
+package com.amigoscode.journey;
+
+import com.amigoscode.AbstractTestConfig;
+import com.amigoscode.product.NewProductRequest;
+import com.amigoscode.product.ProductResponse;
+import com.amigoscode.product.UpdateProductRequest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ProductIT extends AbstractTestConfig {
+
+ public static final String PRODUCT_BASE_URL = "api/v1/products";
+ @Autowired
+ private WebTestClient webTestClient;
+
+ @Test
+ void canCreateProduct() {
+ createProduct(new NewProductRequest(
+ "Laptop",
+ "1gb ram etc",
+ BigDecimal.TEN,
+ 100,
+ "https://amigoscode.com/laptop.png"
+ ));
+ }
+
+ private UUID createProduct(NewProductRequest request) {
+ // when
+ return webTestClient
+ .post()
+ .uri(PRODUCT_BASE_URL)
+ .bodyValue(request)
+ .exchange()
+ .expectStatus()
+ .isCreated()
+ .expectBody(UUID.class)
+ .value(uuid -> assertThat(uuid).isNotNull())
+ .returnResult()
+ .getResponseBody();
+ }
+
+ @Test
+ void canGetAllProducts() {
+ // given 1st product
+ NewProductRequest laptop = new NewProductRequest(
+ "Laptop",
+ "1gb ram etc",
+ new BigDecimal("10.00"),
+ 100,
+ "https://amigoscode.com/laptop.png"
+ );
+ var laptopId = createProduct(laptop);
+
+ // given 2nd product
+ NewProductRequest tv = new NewProductRequest(
+ "Tv",
+ "LED with crystal clear nano...",
+ new BigDecimal("1.00"),
+ 50,
+ "https://amigoscode.com/tv.png"
+ );
+ var tvId = createProduct(tv);
+
+ // when
+ List products = webTestClient.get()
+ .uri(PRODUCT_BASE_URL)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBodyList(new ParameterizedTypeReference() {
+ })
+ .returnResult()
+ .getResponseBody();
+ // then
+ var productsFromDb = products.stream()
+ .filter(
+ p -> p.id().equals(tvId) || p.id().equals(laptopId)
+ )
+ .toList();
+
+ assertThat(productsFromDb)
+ .hasSize(2)
+ .usingRecursiveFieldByFieldElementComparatorIgnoringFields(
+ "createdAt", "updatedAt", "deletedAt"
+ )
+ .containsExactlyInAnyOrder(
+ new ProductResponse(
+ tvId,
+ tv.name(),
+ tv.description(),
+ tv.price(),
+ tv.imageUrl(),
+ tv.stockLevel(),
+ true, null, null, null
+ ),
+ new ProductResponse(
+ laptopId,
+ laptop.name(),
+ laptop.description(),
+ laptop.price(),
+ laptop.imageUrl(),
+ laptop.stockLevel(),
+ true, null, null, null
+ )
+ );
+ }
+
+ @Test
+ void canGetProductById() {
+ // given
+ NewProductRequest laptop = new NewProductRequest(
+ "Laptop",
+ "1gb ram etc",
+ new BigDecimal("10.00"),
+ 100,
+ "https://amigoscode.com/laptop.png"
+ );
+ var laptopId = createProduct(laptop);
+
+ // when
+ ProductResponse responseBody = webTestClient.get()
+ .uri(PRODUCT_BASE_URL + "/" + laptopId)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(new ParameterizedTypeReference() {
+ })
+ .returnResult()
+ .getResponseBody();
+
+ // then
+ assertThat(responseBody)
+ .usingRecursiveComparison()
+ .ignoringFields("createdAt", "updatedAt", "deletedAt")
+ .isEqualTo(
+ new ProductResponse(
+ laptopId,
+ laptop.name(),
+ laptop.description(),
+ laptop.price(),
+ laptop.imageUrl(),
+ laptop.stockLevel(),
+ true, null, null, null
+ )
+ );
+ }
+
+ @Test
+ void canGetDeleteProductById() {
+ // given
+ NewProductRequest laptop = new NewProductRequest(
+ "Laptop",
+ "1gb ram etc",
+ new BigDecimal("10.00"),
+ 100,
+ "https://amigoscode.com/laptop.png"
+ );
+ var laptopId = createProduct(laptop);
+
+ // when
+ webTestClient.delete()
+ .uri(PRODUCT_BASE_URL + "/" + laptopId)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody()
+ .isEmpty();
+
+ // then
+ webTestClient.get()
+ .uri(PRODUCT_BASE_URL + "/" + laptopId)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus()
+ .isNotFound()
+ .expectBody()
+ .jsonPath("$.message").isEqualTo("product with id [" + laptopId +"] not found")
+ .jsonPath("$.error").isEqualTo("Not Found")
+ .jsonPath("$.statusCode").isEqualTo("404")
+ .jsonPath("$.path").isEqualTo("/api/v1/products/" + laptopId)
+ .jsonPath("$.fieldError").doesNotExist();
+
+// {"message":"product with id [02682eec-6d8e-43ac-a5c8-60dd7bb6fa14] not found","error":"Not Found","statusCode":404,"path":"/api/v1/products/02682eec-6d8e-43ac-a5c8-60dd7bb6fa14","timestamp":"2025-06-26T16:00:01.854366Z","fieldErrors":null}
+
+
+ }
+
+ @Test
+ void canUpdateProduct() {
+ // given
+ NewProductRequest laptop = new NewProductRequest(
+ "Laptop",
+ "1gb ram etc",
+ new BigDecimal("10.00"),
+ 100,
+ "https://amigoscode.com/laptop.png"
+ );
+ var laptopId = createProduct(laptop);
+
+ // when
+ webTestClient.put()
+ .uri(PRODUCT_BASE_URL + "/" + laptopId)
+ .accept(MediaType.APPLICATION_JSON)
+ .bodyValue(new UpdateProductRequest(
+ null, null, null, new BigDecimal("200.00"), 500, false
+ ))
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody()
+ .isEmpty();
+ // then
+ ProductResponse responseBody = webTestClient.get()
+ .uri(PRODUCT_BASE_URL + "/" + laptopId)
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectBody(new ParameterizedTypeReference() {
+ })
+ .returnResult()
+ .getResponseBody();
+
+ // then
+ assertThat(responseBody)
+ .usingRecursiveComparison()
+ .ignoringFields("createdAt", "updatedAt", "deletedAt")
+ .isEqualTo(
+ new ProductResponse(
+ laptopId,
+ laptop.name(),
+ laptop.description(),
+ new BigDecimal("200.00"),
+ laptop.imageUrl(),
+ 500,
+ false, null, null, null
+ )
+ );
+ }
+}
diff --git a/src/test/java/com/amigoscode/product/ProductRepositoryTest.java b/src/test/java/com/amigoscode/product/ProductRepositoryTest.java
new file mode 100644
index 0000000..96ee6ad
--- /dev/null
+++ b/src/test/java/com/amigoscode/product/ProductRepositoryTest.java
@@ -0,0 +1,95 @@
+package com.amigoscode.product;
+
+import com.amigoscode.SharedPostgresContainer;
+import org.junit.jupiter.api.BeforeEach;
+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.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(
+ replace = AutoConfigureTestDatabase.Replace.NONE
+)
+@Testcontainers
+class ProductRepositoryTest {
+
+ @Container
+ @ServiceConnection
+ private static final SharedPostgresContainer POSTGRES =
+ SharedPostgresContainer.getInstance();
+
+
+ @Autowired
+ private ProductRepository underTest;
+
+ @BeforeEach
+ void setUp() {
+ underTest.deleteAll();
+ }
+
+ @Test
+ void canFindAvailablePublishedProducts() {
+ // given
+ Product product1 = new Product(
+ UUID.randomUUID(),
+ "iphone",
+ "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank",
+ new BigDecimal("1000"),
+ "https://amigoscode.com/logo.png",
+ 10
+ );
+
+ Product product2 = new Product(
+ UUID.randomUUID(),
+ "samsung",
+ "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank",
+ new BigDecimal("1200"),
+ "https://amigoscode.com/logo.png",
+ 5
+ );
+ product2.setPublished(false);
+
+ Product product3 = new Product(
+ UUID.randomUUID(),
+ "watch",
+ "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank",
+ new BigDecimal("5000"),
+ "https://amigoscode.com/logo.png",
+ 0
+ );
+
+ Product product4 = new Product(
+ UUID.randomUUID(),
+ "PS5",
+ "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank",
+ new BigDecimal("300"),
+ "https://amigoscode.com/logo.png",
+ 90
+ );
+
+ underTest.saveAll(
+ List.of(product1, product2, product3, product4)
+ );
+ // when
+ List availablePublishedProducts =
+ underTest.findAvailablePublishedProducts();
+ // then
+ assertThat(availablePublishedProducts)
+ .usingRecursiveFieldByFieldElementComparatorIgnoringFields(
+ "updatedAt", "createdAt"
+ )
+ .containsExactly(
+ product4, product1
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/amigoscode/product/ProductServiceTest.java b/src/test/java/com/amigoscode/product/ProductServiceTest.java
new file mode 100644
index 0000000..c5b3f31
--- /dev/null
+++ b/src/test/java/com/amigoscode/product/ProductServiceTest.java
@@ -0,0 +1,117 @@
+package com.amigoscode.product;
+
+import com.amigoscode.SharedPostgresContainer;
+import org.junit.jupiter.api.BeforeAll;
+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 java.math.BigDecimal;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(
+ replace = AutoConfigureTestDatabase.Replace.NONE
+)
+@Import({
+ ProductService.class
+})
+@Testcontainers
+class ProductServiceTest {
+
+ @Container
+ @ServiceConnection
+ private static final SharedPostgresContainer POSTGRES =
+ SharedPostgresContainer.getInstance();
+
+ @Autowired
+ private ProductRepository productRepository;
+
+ @Autowired
+ 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();
+ }
+
+ @Test
+ @Disabled
+ void canGetAllProducts() {
+ // given
+ Product product = new Product(
+ UUID.randomUUID(),
+ "foo",
+ "bardnjknjkndsjknkjnajkndjksandsajkndkjasnkdjank",
+ BigDecimal.TEN,
+ "https://amigoscode.com/logo.png",
+ 10
+ );
+
+ productRepository.save(product);
+
+ // when
+ List allProducts =
+ underTest.getAllProducts();
+ // then
+ ProductResponse expected = underTest.mapToResponse().apply(product);
+
+ assertThat(allProducts)
+ .usingRecursiveFieldByFieldElementComparatorIgnoringFields(
+ "updatedAt", "createdAt"
+ )
+ .containsOnly(expected);
+
+ }
+
+ @Test
+ @Disabled
+ void getProductById() {
+ // given
+ // when
+ // then
+ }
+
+ @Test
+ @Disabled
+ void deleteProductById() {
+ // given
+ // when
+ // then
+ }
+
+ @Test
+ @Disabled
+ void saveNewProduct() {
+ // given
+ // when
+ // then
+ }
+
+ @Test
+ @Disabled
+ void updateProduct() {
+ // given
+ // when
+ // then
+ }
+}
\ No newline at end of file