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 1f7057d..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
@@ -57,10 +57,6 @@ This is a Spring Boot-based inventory management application designed to help ma
| GET | `/products/categories` | Retrieves all the categories available |
-### Storage
-
-Currently, product data is stored in a local database using docker.
-
---
## Tech Stack
@@ -68,7 +64,7 @@ Currently, product data is stored in a local database using docker.
- **Language:** Java
- **Framework:** Spring Boot
- **Build Tool:** Maven
-- **Data Storage:** Oracle DB
+- **Data Storage:** H2 local Runtime via JDBC
---
@@ -82,13 +78,5 @@ Currently, product data is stored in a local database using docker.
### Running the Application
```bash
-docker run -d \
---name oracle-xe \
--e ORACLE_PASSWORD=admin \
--p 1521:1521 \
--p 5500:5500 \
-oracle-xe-inventory-manager:1.0
-```
-```bash
-mvn spring-boot:run
+ mvn spring-boot:run
```
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 114eb60..addac6b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.0
+ 3.5.6
com.encorazone
@@ -27,12 +27,14 @@
+ 1.5.19
17
21.3.0.0
- 2.3.0
+ 2.8.13
1.18.38
1.5.5.Final
3.14.0
+ 3.18.0
@@ -86,11 +88,6 @@
lombok
${lombok.version}
-
- com.h2database
- h2
- test
-
org.mapstruct
mapstruct
@@ -127,11 +124,17 @@
-
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 f99438c..6bd321a 100644
--- a/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java
+++ b/src/main/java/com/encorazone/inventory_manager/controller/InventoryManagerController.java
@@ -15,7 +15,7 @@
import java.util.List;
import java.util.UUID;
-@CrossOrigin(origins = "http://localhost:3000")
+@CrossOrigin(origins = "http://localhost:8080")
@RestController
@RequestMapping("/products")
final class InventoryManagerController {
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 551cfff..da7a811 100644
--- a/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java
+++ b/src/main/java/com/encorazone/inventory_manager/repository/ProductRepository.java
@@ -15,8 +15,8 @@ public interface ProductRepository extends JpaRepository, JpaSpec
@Query("SELECT DISTINCT p.category FROM Product p")
Optional> findDistinctCategories();
- @Query("SELECT p.category AS category, COUNT(p) AS productsInStock, " +
- "SUM(p.unitPrice) AS valueInStock, AVG(p.unitPrice) AS averageValue " +
+ @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/InventorySummary.java b/src/main/java/com/encorazone/inventory_manager/service/InventorySummary.java
deleted file mode 100644
index b6e4465..0000000
--- a/src/main/java/com/encorazone/inventory_manager/service/InventorySummary.java
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.encorazone.inventory_manager.service;
-
-public class InventorySummary {
-}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 4a0cb95..f6a63d8 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,3 +1,6 @@
+server:
+ port: 9090
+
spring:
application:
name: inventory-manager-spark
@@ -15,4 +18,3 @@ spring:
jpa:
hibernate:
ddl-auto: update
- show-sql: true
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
index 27c10a4..b073c0c 100644
--- a/src/test/java/com/encorazone/inventory_manager/controller/InventoryManagerControllerTests.java
+++ b/src/test/java/com/encorazone/inventory_manager/controller/InventoryManagerControllerTests.java
@@ -1,19 +1,44 @@
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
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
@@ -21,15 +46,225 @@
public class InventoryManagerControllerTests {
@Autowired
- private MockMvc mockMvc;
+ private MockMvc mvc;
+
+ @Autowired
+ ObjectMapper objectMapper;
@MockitoBean
private InventoryService inventoryService;
@Test
- void getAllProducts_shouldReturnDataFromDatabase() throws Exception {
- mockMvc.perform(get("/products"))
- .andExpect(status().isOk());
+ @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/service/InventoryProductsFilterTests.java b/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java
index edf236a..bff1b5f 100644
--- a/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java
+++ b/src/test/java/com/encorazone/inventory_manager/service/InventoryProductsFilterTests.java
@@ -1,4 +1,83 @@
package com.encorazone.inventory_manager.service;
-public class InventoryProductsFilterTests {
+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));
+ }
}