diff --git a/.github/workflows/ai-pr-summary.yml b/.github/workflows/ai-pr-summary.yml
new file mode 100644
index 0000000..915bfe1
--- /dev/null
+++ b/.github/workflows/ai-pr-summary.yml
@@ -0,0 +1,159 @@
+name: PR AI Summary
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ summarize:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get PR diff
+ id: diff
+ run: |
+ BASE="${{ github.event.pull_request.base.sha }}"
+ HEAD="${{ github.event.pull_request.head.sha }}"
+ # Trae exactamente esos commits (evita problemas de merge-base y shallow clones)
+ git fetch --no-tags --prune --depth=1 origin $BASE $HEAD
+ git diff $BASE $HEAD > pr.diff
+ echo "path=pr.diff" >> $GITHUB_OUTPUT
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install deps
+ run: |
+ python -m pip install --upgrade pip
+ pip install openai==1.* # SDK oficial
+
+ - name: Generate AI summary (OpenAI)
+ id: ai
+ continue-on-error: true
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ MODEL: gpt-4o-mini
+ run: |
+ python - << 'PY'
+ import os
+ from openai import OpenAI
+ client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
+
+ with open("pr.diff","r",encoding="utf-8") as f:
+ diff = f.read()[:200000] # tope por costos/ruido
+
+ prompt = (
+ "You are a code reviewer. Summarize this PR in 2-20 bullets. "
+ "Include WHAT changed, WHY it matters, RISKS, TESTS to add, and any BREAKING CHANGES. "
+ "Highlight key features or changes. Consider markdown as the default output format."
+ "Keep in mind the following points:"
+ "1) If DIFF shows only documentation files (e.g., .md/.mdx/.txt/README), state 'Docs-only change', "
+ " make clear that the change is included only in documentation files, if that is the case, "
+ " otherwise explain normally, considering the DIFF changes like normal. "
+ "2) Include a short list of changed file paths as extracted from DIFF. "
+ "Keep it concise and actionable.\n\nDIFF:\n" + diff
+ )
+
+ resp = client.chat.completions.create(
+ model=os.getenv("MODEL","gpt-4o-mini"),
+ temperature=0.2,
+ messages=[{"role":"user","content":prompt}],
+ )
+ text = resp.choices[0].message.content.strip()
+ with open("summary.txt","w",encoding="utf-8") as f:
+ f.write(text)
+ PY
+
+ - name: Heuristic fallback if AI failed
+ if: ${{ steps.ai.outcome == 'failure' }}
+ run: |
+ python - << 'PY'
+ import re, pathlib
+ diff = pathlib.Path("pr.diff").read_text(encoding="utf-8")
+
+ added = len(re.findall(r"^\\+[^+].*$", diff, flags=re.M))
+ removed = len(re.findall(r"^\\-[^-].*$", diff, flags=re.M))
+ files = re.findall(r"^\\+\\+\\+ b/(.+)$", diff, flags=re.M)
+
+ lower_paths = [f.lower() for f in files]
+ DOC_EXT = (".md", ".mdx", ".txt", ".rst", ".adoc")
+ is_doc = lambda p: p.endswith(DOC_EXT) or "/docs/" in p or "/doc/" in p
+ docs_only = len(files) > 0 and all(is_doc(p) for p in lower_paths)
+
+ # ---------- Doc-only summary ----------
+ if docs_only:
+ bullets_changed = []
+ for f in files[:20]: # evita listas enormes
+ bullets_changed.append(f"- `{f}`")
+ doc_summary = [
+ "## PR Summary",
+ "",
+ "### WHAT Changed",
+ "- **Docs-only change** detected from DIFF.",
+ f"- Files changed ({len(files)}):",
+ *bullets_changed,
+ "",
+ "### WHY It Matters",
+ "- Improves documentation/README clarity and onboarding experience.",
+ "",
+ "### RISKS",
+ "- None to runtime behavior (documentation only).",
+ "",
+ "### TESTS to Add",
+ "- N/A (no code changes).",
+ "",
+ "### BREAKING CHANGES",
+ "- None.",
+ ]
+ pathlib.Path("summary.txt").write_text("\n".join(doc_summary), encoding="utf-8")
+ raise SystemExit(0)
+
+ scopes = set()
+ for f in files:
+ fl = f.lower()
+ if "/controller" in fl: scopes.add("controller")
+ elif "/service" in fl: scopes.add("service")
+ elif "/repository" in fl or "jparepository" in diff.lower(): scopes.add("repository")
+ elif "/entity" in fl or "/model" in fl: scopes.add("entity")
+ elif "application" in fl and (fl.endswith(".yml") or fl.endswith(".yaml") or fl.endswith(".properties")):
+ scopes.add("config")
+ elif fl.endswith("test.java"): scopes.add("test")
+
+ scope = ",".join(sorted(scopes)) if scopes else "core"
+ kind = "refactor"
+ if added and not removed: kind = "feat"
+ if removed and not added: kind = "chore"
+ if re.search(r"@Test", diff): kind = "test"
+ if re.search(r"fix|bug|exception|stacktrace", diff, re.I): kind = "fix"
+
+ subject = f"[Fallback] {kind}({scope}): {len(files)} file(s), +{added}/-{removed}"
+
+ bullets = []
+ bullets.append(f"- Files changed: {len(files)}")
+ bullets.append(f"- Lines: +{added} / -{removed}")
+ if scopes:
+ bullets.append(f"- Layers: {', '.join(sorted(scopes))}")
+ if re.search(r"@Transactional", diff): bullets.append("- Touches transactional boundaries")
+ if re.search(r"@RestController|@Controller", diff): bullets.append("- Controller changes present")
+ if re.search(r"@Service", diff): bullets.append("- Service-layer changes present")
+ if re.search(r"@Repository|JpaRepository", diff): bullets.append("- Repository-layer changes present")
+ if re.search(r"todo|fixme", diff, re.I): bullets.append("- Contains TODO/FIXME markers")
+
+ text = subject + "\\n\\n" + "\\n".join(bullets)
+ pathlib.Path("summary.txt").write_text(text, encoding="utf-8")
+ PY
+
+ - name: Comment on PR
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: ai-pr-summary
+ recreate: true
+ path: summary.txt
\ No newline at end of file
diff --git a/README.md b/README.md
index 81690a4..ac0068d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# InventoryManagerBC
-Breakable Toy 1 - Inventory Manager
+Breakable Toy 1 (Gen AI Augmented) - Inventory Manager
# Inventory Management Application
@@ -7,7 +7,7 @@ This is a Spring Boot-based inventory management application designed to help ma
---
-## 🚀 Features
+## Features
- **Product Management**
- Create new products with name, category, quantity, unit price, and optional expiration date.
@@ -30,10 +30,10 @@ This is a Spring Boot-based inventory management application designed to help ma
---
-## 🧱 Technical Overview
+## Technical Overview
### Model: `Product`
-- `id` (Unique Identifier)
+- `id` (Unique Identifier. Auto-generated)
- `name` (Required, max 120 characters)
- `category` (Required)
- `unitPrice` (Required)
@@ -44,30 +44,31 @@ This is a Spring Boot-based inventory management application designed to help ma
### API Endpoints
-| Method | Endpoint | Description |
-|--------|----------------------------------|---------------------------------------------------|
-| GET | `/products` | Retrieve products with filtering, sorting, and pagination |
-| POST | `/products` | Create a new product |
-| PUT | `/products/{id}` | Update an existing product |
-| POST | `/products/{id}/outofstock` | Mark a product as out of stock |
-| PUT | `/products/{id}/instock` | Restore a product's stock |
+| Method | Endpoint | Description |
+|--------|-----------------------------|------------------------------------------------------------------|
+| GET | `/products` | Retrieve products with pagination support |
+| POST | `/products` | Create a new product |
+| DELETE | `/products` | Delete a products |
+| PUT | `/products/{id}` | Update an existing product |
+| PATCH | `/products/{id}/outofstock` | Mark a product as out of stock |
+| PATCH | `/products/{id}/instock` | Restore a product's stock
|
+| GET | `/products/summary` | Retrieves inventory metrics |
+| GET | `/products/filters` | Retrieves products based on filters, sort methods and pagination |
+| GET | `/products/categories` | Retrieves all the categories available |
-### Storage
-
-Currently, product data is stored in-memory using Java Collections. The application is designed to allow easy migration to a persistent storage layer in the future.
---
-## 🛠️ Tech Stack
+## Tech Stack
- **Language:** Java
- **Framework:** Spring Boot
- **Build Tool:** Maven
-- **Data Storage:** In-Memory (Java Collections)
+- **Data Storage:** H2 local Runtime via JDBC
---
-## ⚙️ Getting Started
+## Getting Started
### Prerequisites
@@ -77,4 +78,5 @@ Currently, product data is stored in-memory using Java Collections. The applicat
### Running the Application
```bash
-mvn spring-boot:run
\ No newline at end of file
+ mvn spring-boot:run
+```
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 38b1c16..addac6b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,14 +5,14 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.0
-
+ 3.5.6
+
com.encorazone
inventory-manager
- 0.0.1-SNAPSHOT
+ 1.0.0
inventory-manager-spark
- Demo project for Spring Boot
+ Inventory management application
@@ -27,7 +27,14 @@
+ 1.5.19
17
+ 21.3.0.0
+ 2.8.13
+ 1.18.38
+ 1.5.5.Final
+ 3.14.0
+ 3.18.0
@@ -46,7 +53,11 @@
org.springframework.boot
spring-boot-starter-web
-
+
+ com.oracle.database.jdbc
+ ojdbc8
+ ${ojdbc.version}
+
org.springframework.boot
spring-boot-devtools
@@ -66,17 +77,64 @@
org.springdoc
springdoc-openapi-starter-webmvc-ui
- 2.3.0
+ ${springdoc.version}
+
+
+ org.hibernate.validator
+ hibernate-validator
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+ provided
+
+
+ com.h2database
+ h2
+ runtime
-
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven-compiler.version}
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
org.springframework.boot
spring-boot-maven-plugin
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -XX:+EnableDynamicAgentLoading -Xshare:off
+
+
diff --git a/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java b/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java
index 2c6311c..6bd321a 100644
--- a/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java
+++ b/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java
@@ -1,51 +1,150 @@
package com.encorazone.inventory_manager.controller;
+import com.encorazone.inventory_manager.domain.InventorySummaryResponse;
import com.encorazone.inventory_manager.domain.Product;
-import com.encorazone.inventory_manager.service.ProductService;
+import com.encorazone.inventory_manager.domain.ProductListResponse;
+import com.encorazone.inventory_manager.domain.ProductShortResponse;
+import com.encorazone.inventory_manager.service.InventoryService;
+
+import org.springdoc.core.annotations.ParameterObject;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+
import java.util.List;
+import java.util.UUID;
-//@Controller
+@CrossOrigin(origins = "http://localhost:8080")
@RestController
@RequestMapping("/products")
final class InventoryManagerController {
@Autowired
- private ProductService productService;
+ private InventoryService inventoryService;
+ /**
+ * Edpoin to get all the elemnts from database, no sorting nor filtering
+ * Just pagination for the client usability
+ *
+ * @param page represents the page for the table. Example 0.
+ * @param size represents the number of elements per page, default is 10. Example 20.
+ * @return response status amd a list containing the pagexsize elements
+ */
@GetMapping
- public ResponseEntity> getAll(
- @RequestParam(required = false) String filter,
- @RequestParam(required = false) String sort,
- @RequestParam(defaultValue = "0") int page,
- @RequestParam(defaultValue = "10") int size) {
- return ResponseEntity.ok(productService.getAll(filter, sort, page, size));
+ public ResponseEntity getAll(
+ @RequestParam(required = false, defaultValue = "0") int page,
+ @RequestParam(required = false, defaultValue = "10") int size) {
+ return ResponseEntity.ok(inventoryService.getAll(page, size));
+ }
+
+ /**
+ * Endpoint for filtered data retrieving, including name, category and availability
+ * filtering from DB objects.
+ *
+ * @param name represent the name of the product. Example, Watermelon.
+ * @param category represents the category the element is part of; like food.
+ * @param stockQuantity represents the amount of elements in the inventory. Example 10.
+ * @param pageable Builtin object for sorting and pagination, the API asks for the json by itself
+ * @return responsse status and a list containing the pagexsixe elements
+ * complying with the sort and filter parameters
+ */
+ @GetMapping("/filters")
+ public ResponseEntity findByFilter(
+ @ModelAttribute @RequestParam(required = false) String name,
+ @ModelAttribute @RequestParam(required = false) String category,
+ @ModelAttribute @RequestParam(required = false, defaultValue = "0") Integer stockQuantity,
+ @ParameterObject Pageable pageable) {
+ return ResponseEntity.ok(inventoryService.findByNameAndCategoryAndStockQuantity(
+ name, category, stockQuantity, pageable));
}
+ /**
+ * Endpoint to create a new product
+ *
+ * @param product object representing the product to be added to the inventory
+ * @return status. Example 200(OK)
+ */
@PostMapping
- public ResponseEntity create(@RequestBody Product product) {
- return ResponseEntity.ok(productService.create(product));
+ public ResponseEntity create(@RequestBody Product product) {
+ return ResponseEntity.ok(inventoryService.create(product));
}
+ /**
+ * endpoint to update a product
+ *
+ * @param id represents the DB/BS internal id fro managing elements.
+ * Example 785c0229-b7e5-4ea4-853b-fa5ad4eb84f4
+ * @param product Object with the changes fto be made to the product
+ * @return status. Example 500 (Internal server error)
+ */
@PutMapping("/{id}")
- public ResponseEntity update(@PathVariable Long id, @RequestBody Product product) {
- return productService.update(id, product)
+ public ResponseEntity update(@PathVariable UUID id, @RequestBody Product product) {
+ return inventoryService.update(id, product)
+ .map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ /**
+ * endpoint to automatically set stock to 0
+ *
+ * @param id Represents the id of the element we want the stock to be 0
+ * @return status. Example 200
+ */
+ @PatchMapping("/{id}/outofstock")
+ public ResponseEntity markOutOfStock(@PathVariable UUID id) {
+ return inventoryService.markOutOfStock(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
- @PostMapping("/{id}/outofstock")
- public ResponseEntity markOutOfStock(@PathVariable Long id) {
- return productService.markOutOfStock(id)
+ /**
+ * endpoint to automatically set stock to a given number, By default 10
+ *
+ * @param id Represents the id of the element we want the stock to be 0
+ * @param stockQuantity Represents the amount to put into stock. Example 10
+ * @return status. Example 200(OK)
+ */
+ @PatchMapping("/{id}/instock")
+ public ResponseEntity restoreStock(@PathVariable UUID id,
+ @RequestParam(defaultValue = "10") Integer stockQuantity) {
+ return inventoryService.updateStock(id, stockQuantity)
+ .map(ResponseEntity::ok)
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ /**
+ * Endpoint to delete products
+ *
+ * @param id Represemts tje id of the element to be deleted
+ * @return status code
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteProduct(@PathVariable UUID id) {
+ inventoryService.delete(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ /**
+ * Endpoint to retrieve categories
+ *
+ * @return list with the categories
+ */
+ @GetMapping("/categories")
+ public ResponseEntity> fetchCategories() {
+ return inventoryService.fetchCategories()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
- @PutMapping("/{id}/instock")
- public ResponseEntity restoreStock(@PathVariable Long id) {
- return productService.restoreStock(id)
+ /**
+ * Endpoint to retrieve inventory summary
+ *
+ * @return list with the summary
+ */
+ @GetMapping("/summary")
+ public ResponseEntity> fetchSummary() {
+ return inventoryService.fetchInventorySummary()
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryInterface.java b/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryInterface.java
new file mode 100644
index 0000000..a9c22a4
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryInterface.java
@@ -0,0 +1,10 @@
+package com.encorazone.inventory_manager.domain;
+
+import java.math.BigDecimal;
+
+public interface InventorySummaryInterface {
+ String getCategory();
+ Long getProductsInStock();
+ BigDecimal getValueInStock();
+ BigDecimal getAverageValue();
+}
\ No newline at end of file
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryResponse.java b/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryResponse.java
new file mode 100644
index 0000000..91741b5
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/domain/InventorySummaryResponse.java
@@ -0,0 +1,24 @@
+package com.encorazone.inventory_manager.domain;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class InventorySummaryResponse{
+ private String category;
+
+ private Integer productsInStock;
+
+ private BigDecimal valueInStock;
+
+ private BigDecimal averageValue;
+
+ public InventorySummaryResponse(String category, long productsInStock, BigDecimal valueInStock,
+ BigDecimal averageValue) {
+ this.category = category;
+ this.productsInStock = (int) productsInStock;
+ this.valueInStock = valueInStock;
+ this.averageValue = averageValue;
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/Product.java b/src/main/java/com/encorazone/inventory_manager/domain/Product.java
index e92d424..c056eb2 100644
--- a/src/main/java/com/encorazone/inventory_manager/domain/Product.java
+++ b/src/main/java/com/encorazone/inventory_manager/domain/Product.java
@@ -1,35 +1,42 @@
package com.encorazone.inventory_manager.domain;
import jakarta.persistence.*;
+import lombok.Data;
+
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.UUID;
+@Data
@Entity
-@Table(name = "products")
+@Table(name = "PRODUCTS")
public class Product {
@Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
+ @GeneratedValue(generator = "uuid2")
+ @Column(name = "ID", columnDefinition = "RAW(16)")
+ private UUID id;
- @Column(nullable = false, length = 120)
+ @Column(nullable = false, length = 120, name = "NAME")
private String name;
- @Column(nullable = false)
+ @Column(nullable = false, name = "CATEGORY")
private String category;
- @Column(nullable = false)
+ @Column(nullable = false, name = "UNIT_PRICE")
private BigDecimal unitPrice;
+ @Column(name = "EXPIRATION_DATE")
private LocalDate expirationDate;
- @Column(nullable = false)
- private Integer quantityInStock;
+ @Column(nullable = false, name = "STOCK_QUANTITY")
+ private Integer stockQuantity;
- @Column(updatable = false)
+ @Column(updatable = false, name = "CREATION_DATE")
private LocalDateTime creationDate;
+ @Column(name = "UPDATE_DATE")
private LocalDateTime updateDate;
@PrePersist
@@ -43,67 +50,5 @@ public void onUpdate() {
updateDate = LocalDateTime.now();
}
- public Long getId() {
- return id;
- }
-
- public void setId(Long id) {
- this.id = id;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getCategory() {
- return category;
- }
-
- public void setCategory(String category) {
- this.category = category;
- }
-
- public BigDecimal getUnitPrice() {
- return unitPrice;
- }
-
- public void setUnitPrice(BigDecimal unitPrice) {
- this.unitPrice = unitPrice;
- }
- public LocalDate getExpirationDate() {
- return expirationDate;
- }
-
- public void setExpirationDate(LocalDate expirationDate) {
- this.expirationDate = expirationDate;
- }
-
- public Integer getQuantityInStock() {
- return quantityInStock;
- }
-
- public void setQuantityInStock(Integer quantityInStock) {
- this.quantityInStock = quantityInStock;
- }
-
- public LocalDateTime getCreationDate() {
- return creationDate;
- }
-
- public void setCreationDate(LocalDateTime creationDate) {
- this.creationDate = creationDate;
- }
-
- public LocalDateTime getUpdateDate() {
- return updateDate;
- }
-
- public void setUpdateDate(LocalDateTime updateDate) {
- this.updateDate = updateDate;
- }
}
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/ProductListResponse.java b/src/main/java/com/encorazone/inventory_manager/domain/ProductListResponse.java
new file mode 100644
index 0000000..fb4959b
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/domain/ProductListResponse.java
@@ -0,0 +1,17 @@
+package com.encorazone.inventory_manager.domain;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class ProductListResponse {
+ private List products;
+
+ private Integer totalPages;
+
+ public ProductListResponse(List products, Integer totalPages) {
+ this.products = products;
+ this.totalPages = totalPages;
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/ProductResponse.java b/src/main/java/com/encorazone/inventory_manager/domain/ProductResponse.java
new file mode 100644
index 0000000..4f28f7a
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/domain/ProductResponse.java
@@ -0,0 +1,46 @@
+package com.encorazone.inventory_manager.domain;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Data
+public class ProductResponse {
+ private UUID id;
+
+ private String name;
+
+ private String category;
+
+ private BigDecimal unitPrice;
+
+ private LocalDate expirationDate;
+
+ private Integer stockQuantity;
+
+ private LocalDateTime creationDate;
+
+ private LocalDateTime updateDate;
+
+ public ProductResponse(
+ UUID id,
+ String name,
+ String category,
+ BigDecimal unitPrice,
+ LocalDate expirationDate,
+ Integer stockQuantity,
+ LocalDateTime creationDate,
+ LocalDateTime updateDate) {
+ this.id = id;
+ this.name = name;
+ this.category = category;
+ this.unitPrice = unitPrice;
+ this.expirationDate = expirationDate;
+ this.stockQuantity = stockQuantity;
+ this.creationDate = creationDate;
+ this.updateDate = updateDate;
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/domain/ProductShortResponse.java b/src/main/java/com/encorazone/inventory_manager/domain/ProductShortResponse.java
new file mode 100644
index 0000000..2c168a7
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/domain/ProductShortResponse.java
@@ -0,0 +1,28 @@
+package com.encorazone.inventory_manager.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Data
+public class ProductShortResponse {
+ private UUID id;
+
+ private String name;
+
+ private LocalDateTime creationDate;
+
+ private LocalDateTime updateDate;
+
+ public ProductShortResponse(
+ UUID id,
+ String name,
+ LocalDateTime creationDate,
+ LocalDateTime updateDate) {
+ this.id = id;
+ this.name = name;
+ this.creationDate = creationDate;
+ this.updateDate = updateDate;
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/mapper/ProductMapper.java b/src/main/java/com/encorazone/inventory_manager/mapper/ProductMapper.java
new file mode 100644
index 0000000..a8cde9f
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/mapper/ProductMapper.java
@@ -0,0 +1,54 @@
+package com.encorazone.inventory_manager.mapper;
+
+import com.encorazone.inventory_manager.domain.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class ProductMapper {
+ public static ProductShortResponse toProductShortResponse(Product product) {
+ return new ProductShortResponse(
+ product.getId(),
+ product.getName(),
+ product.getCreationDate(),
+ product.getUpdateDate()
+ );
+ }
+
+ public static ProductResponse toProductResponse(Product product) {
+ return new ProductResponse(
+ product.getId(),
+ product.getName(),
+ product.getCategory(),
+ product.getUnitPrice(),
+ product.getExpirationDate(),
+ product.getStockQuantity(),
+ product.getCreationDate(),
+ product.getUpdateDate()
+ );
+ }
+
+ public static ProductListResponse toProductListResponse(List products, Integer totalPages) {
+ return new ProductListResponse(
+ products.stream()
+ .map(ProductMapper::toProductResponse)
+ .collect(Collectors.toList()),
+ totalPages);
+ }
+
+ public static InventorySummaryResponse toInventorySummaryResponse(InventorySummaryInterface product) {
+ return new InventorySummaryResponse(
+ product.getCategory(),
+ product.getProductsInStock(),
+ product.getValueInStock(),
+ product.getAverageValue()
+ );
+ }
+
+ public static List toInventorySummaryResponseList(List lista){
+ return lista
+ .stream()
+ .map(ProductMapper::toInventorySummaryResponse)
+ .toList();
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java b/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java
index c5ef7b9..da7a811 100644
--- a/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java
+++ b/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java
@@ -1,7 +1,22 @@
package com.encorazone.inventory_manager.repository;
+import com.encorazone.inventory_manager.domain.InventorySummaryInterface;
import com.encorazone.inventory_manager.domain.Product;
+
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor {
+ @Query("SELECT DISTINCT p.category FROM Product p")
+ Optional> findDistinctCategories();
-public interface ProductRepository extends JpaRepository {
+ @Query("SELECT p.category AS category, SUM(p.stockQuantity) AS productsInStock, " +
+ "SUM(p.unitPrice * p.stockQuantity) AS valueInStock, AVG(p.unitPrice) AS averageValue " +
+ "FROM Product p GROUP BY p.category")
+ List findCategoriesSummary();
}
diff --git a/src/main/java/com/encorazone/inventory_manager/service/InventoryProductsFilter.java b/src/main/java/com/encorazone/inventory_manager/service/InventoryProductsFilter.java
new file mode 100644
index 0000000..ce476d2
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/service/InventoryProductsFilter.java
@@ -0,0 +1,50 @@
+package com.encorazone.inventory_manager.service;
+
+import com.encorazone.inventory_manager.domain.Product;
+import org.springframework.data.jpa.domain.Specification;
+
+public class InventoryProductsFilter {
+
+ /**
+ * Creates a specification for filtering products whose names contain the given substring,
+ *
+ * @param name the name substring to search for; if null or blank, no filter is applied
+ * @return a Specification for matching product names, or null if the input is invalid
+ */
+ public static Specification nameContains(String name) {
+ return (root, query, cb) ->
+ name == null || name.isBlank() ? null :
+ cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
+ }
+
+ /**
+ * Creates a specification for filtering products where the category contain the given substring,
+ *
+ * @param category the name substring to search for; if null or blank, no filter is applied
+ * @return a Specification for matching product categories, or null if the input is invalid
+ */
+ public static Specification categoryContains(String category) {
+ return (root, query, cb) ->
+ category == null || category.isBlank() ? null :
+ cb.like(cb.lower(root.get("category")), "%" + category.toLowerCase() + "%");
+ }
+
+ /**
+ * Creates a specification for filtering the availability of the products.
+ *
+ * @param stock the stock quantity parameter:
+ * 0 -> don't filter, 1 -> only in stock, 2 -> Not in stock, 3 -> to reset status
+ * @return a spec for matching stock quantities, or a null if the input is null
+ */
+ public static Specification quantityEquals(Integer stock) {
+ return switch (stock) {
+ case 0, 3 -> (root, query, cb) ->
+ cb.greaterThanOrEqualTo(root.get("stockQuantity"), 0);
+ case 1 -> (root, query, cb) ->
+ cb.greaterThan(root.get("stockQuantity"), 0);
+ case 2 -> (root, query, cb) ->
+ cb.equal(root.get("stockQuantity"), 0);
+ default -> null;
+ };
+ }
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/service/InventoryService.java b/src/main/java/com/encorazone/inventory_manager/service/InventoryService.java
new file mode 100644
index 0000000..fb56b53
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/service/InventoryService.java
@@ -0,0 +1,93 @@
+package com.encorazone.inventory_manager.service;
+
+import com.encorazone.inventory_manager.domain.Product;
+
+import com.encorazone.inventory_manager.domain.ProductListResponse;
+import com.encorazone.inventory_manager.domain.ProductShortResponse;
+import com.encorazone.inventory_manager.domain.InventorySummaryResponse;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface InventoryService {
+
+ /**
+ * method to get all the elemnts from database, no sorting nor filtering
+ * Just pagination for the client usability
+ *
+ * @param page represents the page for the table. Example 0.
+ * @param size represents the number of elements per page, default is 10. Example 20.
+ * @return a list containing the pagexsize elements
+ */
+ ProductListResponse getAll(int page, int size);
+
+ /**
+ * Method to create a new product and save it
+ *
+ * @param product object representing the product to be added to the inventory
+ * @return the product creeated
+ */
+ ProductShortResponse create(Product product);
+
+ /**
+ * Updates an existing product identified by the given ID.
+ *
+ * @param id the UUID of the product to update
+ * @param product the updated product data
+ * @return an Optional containing the updated product if found, or empty if not found
+ */
+ Optional update(UUID id, Product product);
+
+ /**
+ * Method to delet product
+ *
+ * @param id Reoresents the id of the element to be deleted
+ */
+ void delete(UUID id);
+
+ /**
+ * Method to automatically set stock to 0
+ *
+ * @param id Represents the id of the element we want the stock to be 0
+ * @return an optional containing the updated product if the operation succeeded, or empty if not found
+ */
+ Optional markOutOfStock(UUID id);
+
+ /**
+ * method to automatically set stock to the given number
+ *
+ * @param id Represents the id of the element we want the stock to be 0
+ * @param stock Represents the amount to put into stock. Example 10
+ * @return n optional containing the updated product if the operation succeeded, or empty if not found
+ */
+ Optional updateStock(UUID id, Integer stock);
+
+ /**
+ * Endpoint for filtered data retrieving, including name, category and availability
+ * filtering from DB objects.
+ *
+ * @param name represent the name of the product (or part of it). Example, Watermelon.
+ * @param category represents the category the element is part of; like food.
+ * @param stockQuantity represents the amount of elements in the inventory. Example 10.
+ * @param pageable Builtin object for sorting and pagination, the API asks for the json by itself
+ * @return a list of products matching the criteria
+ */
+ ProductListResponse findByNameAndCategoryAndStockQuantity(String name, String category,
+ Integer stockQuantity, Pageable pageable);
+
+ /**
+ * Method to retrieve categories
+ *
+ * @return a list with the categories
+ */
+ Optional> fetchCategories();
+
+ /**
+ * Method to retrieve the inventory summary
+ *
+ * @return a list with the summary
+ */
+ Optional> fetchInventorySummary();
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/service/InventoryServiceImpl.java b/src/main/java/com/encorazone/inventory_manager/service/InventoryServiceImpl.java
new file mode 100644
index 0000000..5119e9e
--- /dev/null
+++ b/src/main/java/com/encorazone/inventory_manager/service/InventoryServiceImpl.java
@@ -0,0 +1,101 @@
+package com.encorazone.inventory_manager.service;
+
+import com.encorazone.inventory_manager.domain.InventorySummaryResponse;
+import com.encorazone.inventory_manager.domain.Product;
+import com.encorazone.inventory_manager.domain.ProductListResponse;
+import com.encorazone.inventory_manager.domain.ProductShortResponse;
+import com.encorazone.inventory_manager.repository.ProductRepository;
+import com.encorazone.inventory_manager.mapper.ProductMapper;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.UUID;
+
+@Service
+public class InventoryServiceImpl implements InventoryService {
+
+ @Autowired
+ private ProductRepository productRepository;
+
+ @Override
+ public ProductListResponse getAll(int page, int size) {
+ Page products = productRepository.findAll(PageRequest.of(page, size));
+ return ProductMapper.toProductListResponse(products.getContent(),products.getTotalPages());
+ }
+
+ @Override
+ public ProductShortResponse create(Product product) {
+ return ProductMapper.toProductShortResponse(productRepository.save(product));
+ }
+
+ @Override
+ public Optional update(UUID id, Product newProduct) {
+ return productRepository.findById(id).map(existing -> {
+ existing.setName(newProduct.getName());
+ existing.setCategory(newProduct.getCategory());
+ existing.setUnitPrice(newProduct.getUnitPrice());
+ existing.setExpirationDate(newProduct.getExpirationDate());
+ existing.setStockQuantity(newProduct.getStockQuantity());
+ return productRepository.save(existing);
+ }).map(ProductMapper::toProductShortResponse);
+ }
+
+ @Override
+ public void delete(UUID id) {
+ if (productRepository.existsById(id)) {
+ productRepository.deleteById(id);
+ } else {
+ throw new NoSuchElementException("Id " + id + "not found in db");
+ }
+ }
+
+ @Override
+ public Optional markOutOfStock(UUID id) {
+ return productRepository.findById(id).map(product -> {
+ if (product.getStockQuantity() > 0) {
+ product.setStockQuantity(0);
+ return productRepository.save(product);
+ } else {
+ return product;
+ }
+ }).map(ProductMapper::toProductShortResponse);
+ }
+
+ @Override
+ public Optional updateStock(UUID id, Integer stock) {
+ return productRepository.findById(id).map(product -> {
+ product.setStockQuantity(stock);
+ return productRepository.save(product);
+ }).map(ProductMapper::toProductShortResponse);
+ }
+
+ @Override
+ public ProductListResponse findByNameAndCategoryAndStockQuantity(String name, String category,
+ Integer stockQuantity, Pageable pageable) {
+ Specification spec = InventoryProductsFilter.nameContains(name)
+ .and(InventoryProductsFilter.categoryContains(category))
+ .and(InventoryProductsFilter.quantityEquals(stockQuantity));
+
+ Page page = productRepository.findAll(spec, pageable);
+ return ProductMapper.toProductListResponse(page.getContent(), page.getTotalPages());
+ }
+
+ @Override
+ public Optional> fetchCategories(){
+ return productRepository.findDistinctCategories();
+ }
+
+ @Override
+ public Optional> fetchInventorySummary(){
+ return Optional.ofNullable(ProductMapper.toInventorySummaryResponseList(productRepository.findCategoriesSummary()));
+ }
+
+}
diff --git a/src/main/java/com/encorazone/inventory_manager/service/ProductService.java b/src/main/java/com/encorazone/inventory_manager/service/ProductService.java
deleted file mode 100644
index dbb58e2..0000000
--- a/src/main/java/com/encorazone/inventory_manager/service/ProductService.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.encorazone.inventory_manager.service;
-
-import com.encorazone.inventory_manager.domain.Product;
-
-import java.util.List;
-import java.util.Optional;
-
-public interface ProductService {
- List getAll(String filter, String sort, int page, int size);
- Product create(Product product);
- Optional update(Long id, Product product);
- Optional markOutOfStock(Long id);
- Optional restoreStock(Long id);
-}
diff --git a/src/main/java/com/encorazone/inventory_manager/service/ProductServiceImpl.java b/src/main/java/com/encorazone/inventory_manager/service/ProductServiceImpl.java
deleted file mode 100644
index fa70a97..0000000
--- a/src/main/java/com/encorazone/inventory_manager/service/ProductServiceImpl.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.encorazone.inventory_manager.service;
-
-import com.encorazone.inventory_manager.domain.Product;
-import com.encorazone.inventory_manager.repository.ProductRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.stereotype.Service;
-import java.util.List;
-import java.util.Optional;
-
-@Service
-public class ProductServiceImpl implements ProductService {
-
- @Autowired
- private ProductRepository productRepository;
-
- @Override
- public List getAll(String filter, String sort, int page, int size) {
- // Simplified: no filter/sort logic, just pagination
- return productRepository.findAll(PageRequest.of(page, size)).getContent();
- }
-
- @Override
- public Product create(Product product) {
- return productRepository.save(product);
- }
-
- @Override
- public Optional update(Long id, Product newProduct) {
- return productRepository.findById(id).map(existing -> {
- existing.setName(newProduct.getName());
- existing.setCategory(newProduct.getCategory());
- existing.setUnitPrice(newProduct.getUnitPrice());
- existing.setExpirationDate(newProduct.getExpirationDate());
- existing.setQuantityInStock(newProduct.getQuantityInStock());
- return productRepository.save(existing);
- });
- }
-
- @Override
- public Optional markOutOfStock(Long id) {
- return productRepository.findById(id).map(product -> {
- product.setQuantityInStock(0);
- return productRepository.save(product);
- });
- }
-
- @Override
- public Optional restoreStock(Long id) {
- return productRepository.findById(id).map(product -> {
- product.setQuantityInStock(10); // Default restore value
- return productRepository.save(product);
- });
- }
-}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 9c813cc..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=inventory-manager-spark
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..f6a63d8
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,20 @@
+server:
+ port: 9090
+
+spring:
+ application:
+ name: inventory-manager-spark
+
+ datasource:
+ url: jdbc:h2:mem:testdb
+ username: 'sa'
+ password:
+ driver-class-name: org.h2.Driver
+
+ h2:
+ console:
+ enabled: true
+
+ jpa:
+ hibernate:
+ ddl-auto: update
diff --git a/src/main/resources/templates/hello.html b/src/main/resources/templates/hello.html
deleted file mode 100644
index 9615ada..0000000
--- a/src/main/resources/templates/hello.html
+++ /dev/null
@@ -1 +0,0 @@
-"Hello from Thymeleaf"
\ No newline at end of file
diff --git a/src/test/java/com/encorazone/inventory_manager/controller/InventoryManagerControllerTests.java b/src/test/java/com/encorazone/inventory_manager/controller/InventoryManagerControllerTests.java
new file mode 100644
index 0000000..b073c0c
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/controller/InventoryManagerControllerTests.java
@@ -0,0 +1,270 @@
+package com.encorazone.inventory_manager.controller;
+
+import com.encorazone.inventory_manager.domain.*;
+import com.encorazone.inventory_manager.service.InventoryService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.hasItems;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+public class InventoryManagerControllerTests {
+
+ @Autowired
+ private MockMvc mvc;
+
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @MockitoBean
+ private InventoryService inventoryService;
+
+ @Test
+ @DisplayName("returns selected page with products when getting all products")
+ void getAll_returnsPage() throws Exception {
+ ProductListResponse payload = new ProductListResponse(List.of(sampleProductRes()), 1);
+ given(inventoryService.getAll(0, 10)).willReturn(payload);
+
+ mvc.perform(get("/products"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.products", hasSize(1)))
+ .andExpect(jsonPath("$.totalPages", is(1)));
+ }
+
+ @Test
+ @DisplayName("returns selected page according to de filter/sort orders")
+ void findByFilter_passesPageable() throws Exception {
+ ProductListResponse payload = new ProductListResponse(List.of(sampleProductRes()), 1);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class);
+ Pageable expected = PageRequest.of(1, 10, Sort.by(Sort.Order.desc("price")));
+ given(inventoryService.findByNameAndCategoryAndStockQuantity(
+ "Watermelon", "food", 0, expected))
+ .willReturn(payload);
+
+ mvc.perform(get("/products/filters")
+ .param("name", "Watermelon")
+ .param("category", "food")
+ .param("stockQuantity", "0")
+ .param("page", "1")
+ .param("size", "10")
+ .param("sort", "price,desc"))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.products", hasSize(1)))
+ .andExpect(jsonPath("$.totalPages", is(1)));
+ verify(inventoryService)
+ .findByNameAndCategoryAndStockQuantity(
+ eq("Watermelon"),
+ eq("food"),
+ eq(0),
+ captor.capture());
+
+ Pageable p = captor.getValue();
+ assert p.getPageNumber() == 1;
+ assert p.getPageSize() == 10;
+ Sort.Order o = p.getSort().getOrderFor("price");
+ assert o != null && o.getDirection() == Sort.Direction.DESC;
+ }
+
+ @Test
+ @DisplayName("returns small product descriptions corresponding to the created product")
+ void create_returnsShortResp() throws Exception {
+ ProductShortResponse created = sampleProductShort();
+ given(inventoryService.create(nullable(Product.class))).willReturn(created);
+
+ Product toCreate = sampleProduct();
+ mvc.perform(post("/products")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(toCreate)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", notNullValue()))
+ .andExpect(jsonPath("$.name", is("Watermelon")));
+ }
+
+ @Test
+ @DisplayName("returns small product descriptions corresponding to the updated product")
+ void update_returnsShortResp() throws Exception {
+ ProductShortResponse updated = sampleProductShort();
+ UUID id = updated.getId();
+ given(inventoryService.update(eq(id), nullable(Product.class))).willReturn(Optional.of(updated));
+
+ mvc.perform(put("/products/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(sampleProduct())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(id.toString())))
+ .andExpect(jsonPath("$.name", is("Watermelon")));
+ }
+
+ @Test
+ @DisplayName("returns 404-Not Found when no product id corresponds to the sent one - update")
+ void update_returnsNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ given(inventoryService.update(eq(id), nullable(Product.class))).willReturn(Optional.empty());
+
+ mvc.perform(put("/products/{id}", id)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(sampleProduct())))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("returns small product descriptions corresponding to the marked out of stock product")
+ void markOutOfStock_returnsShortResp() throws Exception {
+ ProductShortResponse resp = sampleProductShort();
+ UUID id = resp.getId();
+ given(inventoryService.markOutOfStock(id)).willReturn(Optional.of(resp));
+
+ mvc.perform(patch("/products/{id}/outofstock", id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(id.toString())))
+ .andExpect(jsonPath("$.name", is("Watermelon")));
+ }
+
+ @Test
+ @DisplayName("returns 404-Not Found when no product id corresponds to the sent one - markOutOfStock")
+ void markOutOfStock_returnsNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ given(inventoryService.markOutOfStock(id)).willReturn(Optional.empty());
+
+ mvc.perform(patch("/products/{id}/outofstock", id))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("returns small product descriptions corresponding to the restored stock product")
+ void restoreStock_returnsShortResp() throws Exception {
+ ProductShortResponse resp = sampleProductShort();
+ UUID id = resp.getId();
+ given(inventoryService.updateStock(id, 10)).willReturn(Optional.of(resp));
+
+ mvc.perform(patch("/products/{id}/instock", id).param("stockQuantity", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", is(id.toString())))
+ .andExpect(jsonPath("$.name", is("Watermelon")));
+ }
+
+ @Test
+ @DisplayName("returns 404-Not Found when no product id corresponds to the sent one - restoreStock")
+ void restoreStock_returnsNotFound() throws Exception {
+ UUID id = UUID.randomUUID();
+ given(inventoryService.updateStock(id, 10)).willReturn(Optional.empty());
+
+ mvc.perform(patch("/products/{id}/instock", id).param("stockQuantity", "10"))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("returns 204-No Content when deleting a product")
+ void delete_returnsNoContent() throws Exception {
+ UUID id = UUID.randomUUID();
+ doNothing().when(inventoryService).delete(id);
+
+ mvc.perform(delete("/products/{id}", id))
+ .andExpect(status().isNoContent());
+ }
+
+ @Test
+ @DisplayName("returns a list with all the categories with products")
+ void fetchCategories_returnsCategories() throws Exception {
+ given(inventoryService.fetchCategories()).willReturn(Optional.of(List.of("food", "drinks")));
+
+ mvc.perform(get("/products/categories"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasItems("food", "drinks")));
+ }
+
+ @Test
+ @DisplayName("returns 404-Not Found when no category is available")
+ void fetchCategories_returnsNotFound() throws Exception {
+ given(inventoryService.fetchCategories()).willReturn(Optional.empty());
+
+ mvc.perform(get("/products/categories"))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ @DisplayName("returns Inventory Summary when calling for metrics")
+ void fetchSummary_returnsInventorySummary() throws Exception {
+ InventorySummaryResponse row = new InventorySummaryResponse("food", 100L, BigDecimal.valueOf(2500), BigDecimal.valueOf(25));
+ given(inventoryService.fetchInventorySummary()).willReturn(Optional.of(List.of(row)));
+
+ mvc.perform(get("/products/summary"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].category", is("food")));
+ }
+
+ @Test
+ @DisplayName("returns 404-Not Found when no product in stock")
+ void fetchSummary_returnsNotFound() throws Exception {
+ given(inventoryService.fetchInventorySummary()).willReturn(Optional.empty());
+
+ mvc.perform(get("/products/summary"))
+ .andExpect(status().isNotFound());
+ }
+
+ private Product sampleProduct() {
+ Product p = new Product();
+ p.setName("Melon");
+ p.setCategory("food");
+ p.setUnitPrice(BigDecimal.valueOf(25));
+ p.setStockQuantity(5);
+ return p;
+ }
+
+ private ProductResponse sampleProductRes() {
+ return new ProductResponse(
+ UUID.randomUUID(),
+ "Watermelon",
+ "food",
+ BigDecimal.valueOf(20),
+ null,
+ 10,
+ LocalDateTime.now(),
+ LocalDateTime.now());
+ }
+
+ private ProductShortResponse sampleProductShort() {
+ return new ProductShortResponse(
+ UUID.randomUUID(),
+ "Watermelon",
+ LocalDateTime.now(),
+ LocalDateTime.now());
+ }
+}
diff --git a/src/test/java/com/encorazone/inventory_manager/mapper/ProductMapperTests.java b/src/test/java/com/encorazone/inventory_manager/mapper/ProductMapperTests.java
new file mode 100644
index 0000000..52eef7a
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/mapper/ProductMapperTests.java
@@ -0,0 +1,100 @@
+package com.encorazone.inventory_manager.mapper;
+
+import com.encorazone.inventory_manager.domain.Product;
+import com.encorazone.inventory_manager.domain.InventorySummaryInterface;
+import com.encorazone.inventory_manager.domain.ProductListResponse;
+import com.encorazone.inventory_manager.domain.ProductResponse;
+import com.encorazone.inventory_manager.domain.ProductShortResponse;
+import com.encorazone.inventory_manager.domain.InventorySummaryResponse;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProductMapperTests {
+
+ @Test
+ void toProductShortResponse_mapsFields() {
+ Product p = sampleProduct();
+ ProductShortResponse r = ProductMapper.toProductShortResponse(p);
+ assertEquals(p.getId(), r.getId());
+ assertEquals(p.getName(), r.getName());
+ assertEquals(p.getCreationDate(), r.getCreationDate());
+ assertEquals(p.getUpdateDate(), r.getUpdateDate());
+ }
+
+ @Test
+ void toProductResponse_mapsFields() {
+ Product p = sampleProduct();
+ ProductResponse r = ProductMapper.toProductResponse(p);
+ assertEquals(p.getId(), r.getId());
+ assertEquals(p.getName(), r.getName());
+ assertEquals(p.getCategory(), r.getCategory());
+ assertEquals(p.getUnitPrice(), r.getUnitPrice());
+ assertEquals(p.getExpirationDate(), r.getExpirationDate());
+ assertEquals(p.getStockQuantity(), r.getStockQuantity());
+ assertEquals(p.getCreationDate(), r.getCreationDate());
+ assertEquals(p.getUpdateDate(), r.getUpdateDate());
+ }
+
+ @Test
+ void toProductListResponse_mapsListAndTotalPages() {
+ Product p1 = sampleProduct();
+ Product p2 = sampleProduct();
+ p2.setId(UUID.randomUUID());
+ p2.setName("Another");
+ ProductListResponse r = ProductMapper.toProductListResponse(List.of(p1, p2), 7);
+ assertEquals(2, r.getProducts().size());
+ assertEquals(7, r.getTotalPages());
+ assertEquals(p1.getId(), r.getProducts().get(0).getId());
+ assertEquals(p2.getId(), r.getProducts().get(1).getId());
+ }
+
+ @Test
+ void toInventorySummaryResponse_mapsFields() {
+ InventorySummaryInterface row = sampleSummaryRow("food", 12L, new BigDecimal("345.67"), new BigDecimal("28.81"));
+ InventorySummaryResponse r = ProductMapper.toInventorySummaryResponse(row);
+ assertEquals("food", r.getCategory());
+ assertEquals(12L, Long.valueOf(r.getProductsInStock()));
+ assertEquals(new BigDecimal("345.67"), r.getValueInStock());
+ assertEquals(new BigDecimal("28.81"), r.getAverageValue());
+ }
+
+ @Test
+ void toInventorySummaryResponseList_mapsList() {
+ List list = ProductMapper.toInventorySummaryResponseList(List.of(
+ sampleSummaryRow("food", 10L, new BigDecimal("100.00"), new BigDecimal("10.00")),
+ sampleSummaryRow("drinks", 5L, new BigDecimal("55.50"), new BigDecimal("11.10"))
+ ));
+ assertEquals(2, list.size());
+ assertEquals("food", list.get(0).getCategory());
+ assertEquals("drinks", list.get(1).getCategory());
+ }
+
+ private Product sampleProduct() {
+ Product p = new Product();
+ p.setId(UUID.randomUUID());
+ p.setName("Watermelon");
+ p.setCategory("food");
+ p.setUnitPrice(new BigDecimal("19.99"));
+ p.setExpirationDate(LocalDate.now().plusDays(30));
+ p.setStockQuantity(7);
+ p.setCreationDate(LocalDateTime.now().minusDays(1));
+ p.setUpdateDate(LocalDateTime.now());
+ return p;
+ }
+
+ private InventorySummaryInterface sampleSummaryRow(String category, Long inStock, BigDecimal value, BigDecimal avg) {
+ return new InventorySummaryInterface() {
+ @Override public String getCategory() { return category; }
+ @Override public Long getProductsInStock() { return inStock; }
+ @Override public BigDecimal getValueInStock() { return value; }
+ @Override public BigDecimal getAverageValue() { return avg; }
+ };
+ }
+}
diff --git a/src/test/java/com/encorazone/inventory_manager/resources/application-test.yml b/src/test/java/com/encorazone/inventory_manager/resources/application-test.yml
new file mode 100644
index 0000000..ce99546
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/resources/application-test.yml
@@ -0,0 +1,12 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb
+ username: sa
+ password:
+ driver-class-name: org.h2.Driver
+ jpa:
+ hibernate.ddl-auto: none
+ show-sql: true
+ sql:
+ init:
+ mode: always
\ No newline at end of file
diff --git a/src/test/java/com/encorazone/inventory_manager/resources/data.sql b/src/test/java/com/encorazone/inventory_manager/resources/data.sql
new file mode 100644
index 0000000..402fde8
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/resources/data.sql
@@ -0,0 +1,21 @@
+INSERT INTO PRODUCTS (ID, NAME, CATEGORY, UNIT_PRICE, EXPIRATION_DATE, STOCK_QUANTITY, CREATION_DATE, UPDATE_DATE) VALUES (
+ '00212243-4455-6677-8899-aabbccdd8eff',,
+ 'Fresh Milk',
+ 'Dairy',
+ 25.50,
+ '2025-12-31',
+ 100,
+ CURRENT_TIMESTAMP,
+ NULL
+);
+
+INSERT INTO PRODUCTS (ID, NAME, CATEGORY, UNIT_PRICE, EXPIRATION_DATE, STOCK_QUANTITY, CREATION_DATE, UPDATE_DATE) VALUES (
+ '00112233-4455-6677-8899-aabbccddeeff',,
+ 'Whole Wheat Bread',
+ 'Bakery',
+ 15.25,
+ '2025-06-30',
+ 50,
+ CURRENT_TIMESTAMP,
+ NULL
+);
diff --git a/src/test/java/com/encorazone/inventory_manager/resources/schema.sql b/src/test/java/com/encorazone/inventory_manager/resources/schema.sql
new file mode 100644
index 0000000..89d4224
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/resources/schema.sql
@@ -0,0 +1,11 @@
+CREATE TABLE PRODUCTS (
+ ID RAW(16) NOT NULL,
+ NAME VARCHAR(120) NOT NULL,
+ CATEGORY VARCHAR(255) NOT NULL,
+ UNIT_PRICE DECIMAL(38,2) NOT NULL,
+ EXPIRATION_DATE DATE,
+ STOCK_QUANTITY INT NOT NULL,
+ CREATION_DATE TIMESTAMP,
+ UPDATE_DATE TIMESTAMP,
+ PRIMARY KEY (ID)
+);
\ No newline at end of file
diff --git a/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java b/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java
new file mode 100644
index 0000000..bff1b5f
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java
@@ -0,0 +1,83 @@
+package com.encorazone.inventory_manager.service;
+
+import com.encorazone.inventory_manager.domain.Product;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.jpa.domain.Specification;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.Path;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+class InventoryProductsFilterTests {
+
+ @Test
+ void nameContains_builds_like_lowercased() {
+ @SuppressWarnings("unchecked")
+ Root root = mock(Root.class);
+ CriteriaQuery> query = mock(CriteriaQuery.class);
+ CriteriaBuilder cb = mock(CriteriaBuilder.class);
+ @SuppressWarnings("unchecked")
+ Path path = (Path) mock(Path.class);
+ @SuppressWarnings("unchecked") Expression lower = (Expression) mock(Expression.class);
+ Predicate pred = mock(Predicate.class);
+ given(root.get("name")).willReturn((path));
+ given(cb.lower(path)).willReturn(lower);
+ given(cb.like(lower, "%apple%")).willReturn(pred);
+
+ Specification spec = InventoryProductsFilter.nameContains("Apple");
+ assertSame(pred, spec.toPredicate(root, query, cb));
+ }
+
+ @Test
+ void categoryContains_returns_null_when_blank() {
+ Specification spec = InventoryProductsFilter.categoryContains(" ");
+ @SuppressWarnings("unchecked")
+ Root root = mock(Root.class);
+ CriteriaQuery> query = mock(CriteriaQuery.class);
+ CriteriaBuilder cb = mock(CriteriaBuilder.class);
+ assertNull(spec.toPredicate(root, query, cb));
+ }
+
+ @Test
+ void quantityEquals_inStock_builds_gt_zero() {
+ @SuppressWarnings("unchecked")
+ Root root = mock(Root.class);
+ CriteriaQuery> query = mock(CriteriaQuery.class);
+ CriteriaBuilder cb = mock(CriteriaBuilder.class);
+ @SuppressWarnings("unchecked")
+ Path path = (Path) mock(Path.class);
+ Predicate pred = mock(Predicate.class);
+
+ given(root.get("stockQuantity")).willReturn(path);
+ given(cb.greaterThan(path, 0)).willReturn(pred);
+
+ Specification spec = InventoryProductsFilter.quantityEquals(1);
+ assertNotNull(spec);
+ assertSame(pred, spec.toPredicate(root, query, cb));
+ }
+
+ @Test
+ void quantityEquals_outOfStock_builds_eq_zero() {
+ @SuppressWarnings("unchecked")
+ Root root = mock(Root.class);
+ CriteriaQuery> query = mock(CriteriaQuery.class);
+ CriteriaBuilder cb = mock(CriteriaBuilder.class);
+ @SuppressWarnings("unchecked")
+ Path path = (Path) mock(Path.class);
+ Predicate pred = mock(Predicate.class);
+
+ given(root.get("stockQuantity")).willReturn(path);
+ given(cb.equal(path, 0)).willReturn(pred);
+
+ Specification spec = InventoryProductsFilter.quantityEquals(2);
+ assertNotNull(spec);
+ assertSame(pred, spec.toPredicate(root, query, cb));
+ }
+}
diff --git a/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceITests.java b/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceITests.java
new file mode 100644
index 0000000..59ce884
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceITests.java
@@ -0,0 +1,53 @@
+package com.encorazone.inventory_manager.service;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+public class InventoryServiceITests {
+ @Test
+ void getAll() {
+
+ }
+
+ @Test
+ void create() {
+
+ }
+
+ @Test
+ void update() {
+
+ }
+
+ @Test
+ void delete() {
+
+ }
+
+ @Test
+ void markOutOfStock() {
+
+ }
+
+ @Test
+ void updateStock() {
+
+ }
+
+ @Test
+ void findByNameAndCategoryAndStockQuantity() {
+
+ }
+
+ @Test
+ void fetchCategories() {
+
+ }
+
+ @Test
+ void fetchInventorySummary() {
+
+ }
+}
diff --git a/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceImplTests.java b/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceImplTests.java
new file mode 100644
index 0000000..3e0c6ab
--- /dev/null
+++ b/src/test/java/com/encorazone/inventory_manager/service/InventoryServiceImplTests.java
@@ -0,0 +1,4 @@
+package com.encorazone.inventory_manager.service;
+
+public class InventoryServiceImplTests {
+}