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 { +}