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