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)); + } }