From aadfc2b266210f890723f558a36dfe52cffd6185 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Sat, 4 Oct 2025 16:54:20 -0600 Subject: [PATCH 1/9] Create AI agent for PRs (#14) * Create ai agent for PRs * Update ai-pr-summary.yml --- .github/workflows/ai-pr-summary.yml | 124 ++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/ai-pr-summary.yml diff --git a/.github/workflows/ai-pr-summary.yml b/.github/workflows/ai-pr-summary.yml new file mode 100644 index 0000000..3172969 --- /dev/null +++ b/.github/workflows/ai-pr-summary.yml @@ -0,0 +1,124 @@ +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 fetures or changes. Consider markdown as the default output format." + "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) + + 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 + From e94ad2d199a8a6135a407eb45af9e35e2b330fb5 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Sat, 4 Oct 2025 17:19:29 -0600 Subject: [PATCH 2/9] Deleted empty classes Following YAGNI principles, deleted unused InventorySummary.java; deleted unused hello.html and reformatted other files. --- .github/workflows/ai-pr-summary.yml | 5 +---- .../inventory_manager/service/InventorySummary.java | 4 ---- src/main/resources/templates/hello.html | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 src/main/java/com/encorazone/inventory_manager/service/InventorySummary.java delete mode 100644 src/main/resources/templates/hello.html diff --git a/.github/workflows/ai-pr-summary.yml b/.github/workflows/ai-pr-summary.yml index 3172969..2ebbdc7 100644 --- a/.github/workflows/ai-pr-summary.yml +++ b/.github/workflows/ai-pr-summary.yml @@ -25,7 +25,6 @@ jobs: git diff $BASE $HEAD > pr.diff echo "path=pr.diff" >> $GITHUB_OUTPUT - - name: Set up Python uses: actions/setup-python@v5 with: @@ -114,11 +113,9 @@ jobs: 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 - + path: summary.txt \ No newline at end of file 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/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 From 040d4ba046226f0f5a8908850618a2fa9b1dc062 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Sat, 4 Oct 2025 17:49:22 -0600 Subject: [PATCH 3/9] API project runs on port 9090 Added server port property Added port property to server within application.yml file and set the port to 9090 to make the server run on this port. PR Summary WHAT Changed Updated the application.yml configuration file to change the server port from the default to 9090. --- src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4a0cb95..ad1d987 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 From 7ab4af7a41ae39be4f520fea65b7cbd0b044c42e Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Sun, 5 Oct 2025 00:46:48 -0600 Subject: [PATCH 4/9] Project running with no external DBs Modified README Modified readme to show the current storage technology -> runtime local database. --- README.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) 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 From 7d4fca71639ec05849febc581eb44f3ec3f93218 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Sun, 5 Oct 2025 01:24:46 -0600 Subject: [PATCH 5/9] Fine-tune agent behavior Modified the agent prompt and strengthen the fallback function --- .github/workflows/ai-pr-summary.yml | 40 ++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ai-pr-summary.yml b/.github/workflows/ai-pr-summary.yml index 2ebbdc7..915bfe1 100644 --- a/.github/workflows/ai-pr-summary.yml +++ b/.github/workflows/ai-pr-summary.yml @@ -53,7 +53,12 @@ jobs: 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 fetures or changes. Consider markdown as the default output format." + "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 ) @@ -77,6 +82,39 @@ jobs: 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: From 948c96575eb78eb843a7c46ac8c96ee4ee65b7a3 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Mon, 13 Oct 2025 14:12:41 -0600 Subject: [PATCH 6/9] Update CORS to allow client on localhost:8080 Allows cross-origin requests from the new frontend port. --- .../controller/InventoryManagerController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 22ccd6395b0d408a82579e7807df8d33636fe10a Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Thu, 16 Oct 2025 11:48:11 -0600 Subject: [PATCH 7/9] Update repository query * Update repository query logic - Fixed logic of the query to maths the requirements. * Updated show-sql setting - Updated the show-sql as theres no longer need of the logs --------- Signed-off-by: Leonardo Trevizo --- .../inventory_manager/repository/ProductRepository.java | 4 ++-- src/main/resources/application.yml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) 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/resources/application.yml b/src/main/resources/application.yml index ad1d987..f6a63d8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,4 +18,3 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true From eca633d0b9d3f29be44fa36cc341eab98e96e93a Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Thu, 16 Oct 2025 22:00:30 -0600 Subject: [PATCH 8/9] Upgraded test compliance - Updated the test to be more compliant --- .../InventoryManagerControllerTests.java | 253 +++++++++++++++++- .../mapper/ProductMapperTests.java | 100 +++++++ .../service/InventoryProductsFilterTests.java | 81 +++++- 3 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/encorazone/inventory_manager/mapper/ProductMapperTests.java 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)); + } } From 48e0cf1c31e0187ca7c55ad0e37e8f81ff553587 Mon Sep 17 00:00:00 2001 From: Leonardo Trevizo Date: Thu, 16 Oct 2025 22:02:49 -0600 Subject: [PATCH 9/9] Upgraded pom to avoid vulnerabilities (#26) - Updated the pom to comply with security, avoiding current vulnerabilities Signed-off-by: Leonardo Trevizo --- pom.xml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 + +