diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md new file mode 100644 index 0000000..4315fca --- /dev/null +++ b/PROJECT_DESCRIPTION.md @@ -0,0 +1,334 @@ +# Shipping Line API - Project Description + +## ๐Ÿšข Project Overview + +**Freight Operations API** is a Spring Boot proof-of-concept (POC) backend system designed for managing shipping line freight operations. The application enables internal operations teams to schedule vessel voyages between ports and book containers (freight orders) onto those voyages. + +**Project Name:** freight-ops +**Version:** 0.1.0-SNAPSHOT +**Java Version:** 21 +**Spring Boot Version:** 3.4.5 +**Build Tool:** Maven + +--- + +## ๐Ÿ—๏ธ Architecture & Domain Model + +The system models shipping operations around five core entities: + +``` +Port โ†โ”€โ”€ Voyage โ”€โ”€โ†’ Port + โ”‚ + โ”‚ Vessel + โ”‚ + FreightOrder + โ”‚ + Container (20ft / 40ft, DRY / REEFER / โ€ฆ) +``` + +### Core Entities + +#### **Port** +- Represents a seaport location +- Identified by UN/LOCODE (e.g., `AEJEA` for Jebel Ali, Dubai) +- Serves as departure and arrival points for voyages + +#### **Vessel** +- Represents a cargo ship +- Identified by a 7-digit IMO (International Maritime Organization) number +- Assigned to voyages to carry freight + +#### **Container** +- Represents cargo containers used in shipping +- Identified by ISO 6346 code +- Attributes: + - **Size:** 20-foot or 40-foot containers + - **Type:** DRY, REEFER, OPEN_TOP, FLAT_RACK, TANK + +#### **Voyage** +- Represents a scheduled vessel trip +- Links a departure port to an arrival port +- Includes: + - Vessel assignment + - Departure and arrival times + - Status tracking (PLANNED, IN_TRANSIT, COMPLETED, CANCELLED) +- Can accommodate multiple freight orders via containers + +#### **FreightOrder** +- Represents the booking of a container onto a voyage +- Links a container to a specific voyage +- Tracks: + - Order creator (`orderedBy`) + - Additional notes + - Order status +- **Constraint:** Cannot book freight on cancelled voyages + +--- + +## ๐Ÿ“ Project Structure + +``` +shipping-line-api/ +โ”œโ”€โ”€ docker/ +โ”‚ โ””โ”€โ”€ docker-compose.yml # PostgreSQL containerization +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ main/ +โ”‚ โ”‚ โ”œโ”€โ”€ java/com/shipping/freightops/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ FreightOpsApplication.java # Spring Boot entry point +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ GlobalExceptionHandler.java # Centralized exception handling +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ controller/ +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ FreightOrderController.java # REST API endpoints +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ dto/ +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ CreateFreightOrderRequest.java +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ FreightOrderResponse.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ entity/ +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ BaseEntity.java # Base JPA entity (timestamps) +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Container.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ FreightOrder.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Port.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Vessel.java +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ Voyage.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ enums/ +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ContainerSize.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ContainerType.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ OrderStatus.java +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ VoyageStatus.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ repository/ +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ContainerRepository.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ FreightOrderRepository.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ PortRepository.java +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ VesselRepository.java +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ VoyageRepository.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ service/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ FreightOrderService.java # Business logic layer +โ”‚ โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ”‚ โ”œโ”€โ”€ application.properties # Configuration (DB, port, etc.) +โ”‚ โ”‚ โ””โ”€โ”€ data.sql # Initial seed data +โ”‚ โ””โ”€โ”€ test/ +โ”‚ โ”œโ”€โ”€ java/com/shipping/freightops/ +โ”‚ โ”‚ โ””โ”€โ”€ controller/ +โ”‚ โ”‚ โ””โ”€โ”€ FreightOrderControllerTest.java +โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ””โ”€โ”€ application.properties # H2 in-memory config for tests +โ”œโ”€โ”€ pom.xml # Maven configuration +โ”œโ”€โ”€ mvnw / mvnw.cmd # Maven wrapper scripts +โ””โ”€โ”€ README.md # Quick start guide +``` + +--- + +## ๐Ÿ”ง Technology Stack + +### Backend Framework +- **Spring Boot 3.4.5** โ€“ Rapid application development +- **Spring Data JPA** โ€“ Object-relational mapping (ORM) +- **Spring MVC** โ€“ REST API layer +- **Spring Validation** โ€“ Input validation (Jakarta Bean Validation) + +### Database +- **PostgreSQL 16** โ€“ Production database (runtime) +- **H2** โ€“ In-memory testing database + +### Build & Deployment +- **Maven 3.8+** โ€“ Build automation +- **Docker & Docker Compose** โ€“ Containerization + +### Development +- **Java 21** โ€“ Latest LTS release +- **Hibernate** โ€“ JPA implementation +- **Jackson** โ€“ JSON serialization/deserialization + +--- + +## ๐Ÿš€ API Endpoints + +### Freight Order Management +The application currently provides full REST CRUD operations for freight orders: + +#### **Create Freight Order** (POST) +``` +POST /api/v1/freight-orders +Content-Type: application/json + +{ + "voyageId": 1, + "containerId": 1, + "orderedBy": "ops-team", + "notes": "Fragile cargo" +} +``` +- **Response:** 201 Created with `Location` header + +#### **Get Freight Order by ID** (GET) +``` +GET /api/v1/freight-orders/{id} +``` +- **Response:** 200 OK with FreightOrderResponse + +#### **List All Freight Orders** (GET) +``` +GET /api/v1/freight-orders +GET /api/v1/freight-orders?voyageId=1 # Filter by voyage +``` +- **Response:** 200 OK with list of orders + +--- + +## ๐Ÿ—„๏ธ Database Configuration + +### Connection Details (Docker) +- **Host:** localhost +- **Port:** 5432 +- **Database:** freightops +- **Username:** freight +- **Password:** freight +- **Driver:** PostgreSQL JDBC + +### Initialization +- **DDL Mode:** `update` (Hibernate auto-creates/modifies schema) +- **Seed Data:** `data.sql` loaded on startup +- **Deferred Initialization:** Enabled to allow Hibernate schema creation before data loading + +--- + +## ๐Ÿ“Š Application Configuration + +Located in `src/main/resources/application.properties`: + +```properties +# Server +server.port=8080 + +# Postxxxxxxxxxxxxction +spring.datasource.url=jdbc:postgresql://localhost:5432/freightops +spring.datasource.username=freight +spring.datasource.password=freight + +# JPA/Hibernate +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.show-sql=true + +# SQL Seed Data +spring.sql.init.mode=always +spring.jpa.defer-datasource-initialization=true + +# JSON Formatting +spring.jackson.serialization.write-dates-as-timestamps=false +``` + +--- + +## ๐Ÿงช Testing + +### Test Setup +- **Framework:** JUnit 5 (via Spring Boot) +- **Database:** H2 in-memory (no PostgreSQL required) +- **Location:** `src/test/java/com/shipping/freightops/` + +### Running Tests +```bash +./mvnw test +``` + +### Test Coverage +- **FreightOrderControllerTest** โ€“ API endpoint testing +- Tests use isolated H2 database with separate configuration + +--- + +## ๐Ÿš€ Quick Start Guide + +### Prerequisites +| Tool | Version | Purpose | +|----------------|----------|----------------------------------| +| Java (JDK) | 21+ | Language runtime | +| Maven | 3.8+ | Build and dependency management | +| Docker | 20+ | Container runtime | +| Docker Compose | 2+ | Multi-container orchestration | + +### Step 1: Start PostgreSQL +```bash +cd docker +docker compose up -d +``` +Starts PostgreSQL 16 at `localhost:5432` + +### Step 2: Build & Run Application +```bash +./mvnw clean install +./mvnw spring-boot:run +``` +- On first startup, Hibernate creates tables +- `data.sql` seeds sample data + +### Step 3: Verify Server +```bash +curl http://localhost:8080/api/v1/freight-orders +``` + +--- + +## ๐Ÿ“ Notes for Development + +### Current Implementation Status +โœ… **Complete:** +- FreightOrder entity, repository, service, and controller +- Exception handling +- Data persistence layer +- REST API for freight orders + +โณ **TODO (POC Phase):** +- Voyage management controller (reference: FreightOrderController) +- Container management controller +- Port management controller +- Vessel management controller +- Authentication/authorization +- API documentation (Swagger/OpenAPI) +- Additional business logic (capacity checking, voyage status transitions, etc.) + +### Design Patterns Used +- **Repository Pattern** โ€“ Data access abstraction +- **Service Layer Pattern** โ€“ Business logic separation +- **DTO Pattern** โ€“ Request/response transformation +- **Exception Handling** โ€“ Global exception handling via `GlobalExceptionHandler` +- **Transactional Management** โ€“ Spring `@Transactional` annotations + +### Key Features +- โœ… RESTful API design +- โœ… Input validation (Jakarta Bean Validation) +- โœ… Transaction management +- โœ… Domain-driven design +- โœ… Clean separation of concerns +- โœ… Seed data initialization +- โœ… Docker containerization + +--- + +## ๐Ÿ”— Key Files Reference + +| File | Purpose | +|------|---------| +| [FreightOpsApplication.java](src/main/java/com/shipping/freightops/FreightOpsApplication.java) | Spring Boot entry point | +| [FreightOrderController.java](src/main/java/com/shipping/freightops/controller/FreightOrderController.java) | REST API example (use as reference) | +| [FreightOrderService.java](src/main/java/com/shipping/freightops/service/FreightOrderService.java) | Business logic example | +| [FreightOrder.java](src/main/java/com/shipping/freightops/entity/FreightOrder.java) | Core entity definition | +| [pom.xml](pom.xml) | Maven dependencies & build configuration | +| [application.properties](src/main/resources/application.properties) | Runtime configuration | +| [docker-compose.yml](docker/docker-compose.yml) | PostgreSQL setup | + +--- + +## ๐Ÿ“š Related Documentation +- Spring Boot: https://spring.io/projects/spring-boot +- Spring Data JPA: https://spring.io/projects/spring-data-jpa +- PostgreSQL: https://www.postgresql.org/ +- Docker: https://docs.docker.com/ + +--- + +**Last Updated:** February 16, 2026 +**Project Status:** Proof of Concept (POC) +**Owner:** hajk1 (GitHub) diff --git a/src/main/java/com/shipping/freightops/controller/AgentController.java b/src/main/java/com/shipping/freightops/controller/AgentController.java index 619c0f2..b39fcb3 100644 --- a/src/main/java/com/shipping/freightops/controller/AgentController.java +++ b/src/main/java/com/shipping/freightops/controller/AgentController.java @@ -15,6 +15,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,7 +33,12 @@ public AgentController(AgentService agentService) { this.agentService = agentService; } - /** Create a new agent. */ + @Operation(summary = "Create a new agent") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Agent successfully created"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "409", description = "Agent with this email already exists") + }) @PostMapping public ResponseEntity create(@Valid @RequestBody AgentCreateRequest request) { Agent agent = agentService.createAgent(request); @@ -39,7 +47,10 @@ public ResponseEntity create(@Valid @RequestBody AgentCreateReque return ResponseEntity.created(location).body(body); } - /** List all agents, with optional type and active filters. */ + @Operation(summary = "List all agents with optional type and active filters") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of agents retrieved successfully") + }) @GetMapping public ResponseEntity> list( @RequestParam(required = false) AgentType type, @@ -49,14 +60,23 @@ public ResponseEntity> list( return ResponseEntity.ok(body); } - /** Get a single agent by ID. */ + @Operation(summary = "Get agent by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Agent found"), + @ApiResponse(responseCode = "404", description = "Agent not found") + }) @GetMapping("/{id}") public ResponseEntity getById(@PathVariable Long id) { Agent agent = agentService.getAgent(id); return ResponseEntity.ok(toResponse(agent)); } - /** Partial update: commissionPercent and/or active. */ + @Operation(summary = "Update agent commission percent or active status") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Agent updated successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "404", description = "Agent not found") + }) @PatchMapping("/{id}") public ResponseEntity update( @PathVariable Long id, @Valid @RequestBody AgentUpdateRequest request) { diff --git a/src/main/java/com/shipping/freightops/controller/CustomerController.java b/src/main/java/com/shipping/freightops/controller/CustomerController.java index 4bf57db..0b04cc4 100644 --- a/src/main/java/com/shipping/freightops/controller/CustomerController.java +++ b/src/main/java/com/shipping/freightops/controller/CustomerController.java @@ -7,6 +7,9 @@ import jakarta.validation.Valid; import java.net.URI; import java.util.List; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,6 +23,11 @@ public CustomerController(CustomerService service) { this.service = service; } + @Operation(summary = "Create a new customer") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Customer successfully created"), + @ApiResponse(responseCode = "400", description = "Invalid input data") + }) @PostMapping public ResponseEntity create( @Valid @RequestBody CreateCustomerRequest request) { @@ -29,12 +37,21 @@ public ResponseEntity create( return ResponseEntity.created(location).body(body); } + @Operation(summary = "Get customer by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Customer found"), + @ApiResponse(responseCode = "404", description = "Customer not found") + }) @GetMapping("/{id}") public ResponseEntity getById(@PathVariable Long id) { Customer customer = service.getCustomer(id); return ResponseEntity.ok(CustomerResponse.fromEntity(customer)); } + @Operation(summary = "List all customers") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of customers retrieved successfully") + }) @GetMapping public ResponseEntity> list() { List customers = service.getAllCustomers(); diff --git a/src/main/java/com/shipping/freightops/controller/PortController.java b/src/main/java/com/shipping/freightops/controller/PortController.java index 106b356..aaf59b8 100644 --- a/src/main/java/com/shipping/freightops/controller/PortController.java +++ b/src/main/java/com/shipping/freightops/controller/PortController.java @@ -7,6 +7,9 @@ import jakarta.validation.Valid; import java.net.URI; import java.util.List; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,7 +29,12 @@ public PortController(PortService service) { this.service = service; } - /** Create a new port. */ + @Operation(summary = "Create a new port") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Port successfully created"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "409", description = "Port with this UN/LOCODE already exists") + }) @PostMapping public ResponseEntity create(@Valid @RequestBody CreatePortRequest request) { Port port = service.createPort(request); @@ -35,14 +43,21 @@ public ResponseEntity create(@Valid @RequestBody CreatePortRequest return ResponseEntity.created(location).body(body); } - /** Get a single port by ID. */ + @Operation(summary = "Get port by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Port found"), + @ApiResponse(responseCode = "404", description = "Port not found") + }) @GetMapping("/{id}") public ResponseEntity getById(@PathVariable Long id) { Port port = service.getPort(id); return ResponseEntity.ok(PortResponse.fromEntity(port)); } - /** List all ports. */ + @Operation(summary = "List all ports") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of ports retrieved successfully") + }) @GetMapping public ResponseEntity> list() { List ports = service.getAllPorts(); diff --git a/src/main/java/com/shipping/freightops/controller/VesselController.java b/src/main/java/com/shipping/freightops/controller/VesselController.java index 7f03d87..97305a6 100644 --- a/src/main/java/com/shipping/freightops/controller/VesselController.java +++ b/src/main/java/com/shipping/freightops/controller/VesselController.java @@ -7,6 +7,9 @@ import jakarta.validation.Valid; import java.net.URI; import java.util.List; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,7 +23,12 @@ public VesselController(VesselService service) { this.service = service; } - /** Create a new vessel. */ + @Operation(summary = "Create a new vessel") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Vessel successfully created"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "409", description = "Vessel with this IMO number already exists") + }) @PostMapping public ResponseEntity create(@Valid @RequestBody CreateVesselRequest request) { Vessel vessel = service.createVessel(request); @@ -29,7 +37,10 @@ public ResponseEntity create(@Valid @RequestBody CreateVesselReq return ResponseEntity.created(location).body(body); } - /** List all vessels. */ + @Operation(summary = "List all vessels") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of vessels retrieved successfully") + }) @GetMapping public ResponseEntity> list() { List vessels = service.getAllVessels(); @@ -38,7 +49,11 @@ public ResponseEntity> list() { return ResponseEntity.ok(body); } - /** Get a vessel by its ID. */ + @Operation(summary = "Get vessel by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Vessel found"), + @ApiResponse(responseCode = "404", description = "Vessel not found") + }) @GetMapping("/{id}") public ResponseEntity getById(@PathVariable Long id) { Vessel vessel = service.getVessel(id); diff --git a/src/main/java/com/shipping/freightops/controller/VoyageController.java b/src/main/java/com/shipping/freightops/controller/VoyageController.java index 039190e..7bf646f 100644 --- a/src/main/java/com/shipping/freightops/controller/VoyageController.java +++ b/src/main/java/com/shipping/freightops/controller/VoyageController.java @@ -56,7 +56,7 @@ public ResponseEntity getById(@PathVariable Long voyageId) { @ApiResponse(responseCode = "404", description = "Vessel or Port not found") }) @PostMapping - public ResponseEntity addVoyage(@RequestBody CreateVoyageRequest voyageRequest) { + public ResponseEntity addVoyage(@Valid @RequestBody CreateVoyageRequest voyageRequest) { Voyage voyage = voyageService.addVoyage(voyageRequest); VoyageResponse response = new VoyageResponse(voyage); return ResponseEntity.created(URI.create("/api/v1/voyages")).body(response); @@ -110,4 +110,16 @@ public ResponseEntity> getVoyagePrices( Page mapped = voyagePrices.map(VoyagePriceResponse::fromEntity); return ResponseEntity.ok(PageResponse.from(mapped)); } + + @Operation(summary = "Get commission report for a completed voyage") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Commission report retrieved"), + @ApiResponse(responseCode = "404", description = "Voyage not found"), + @ApiResponse(responseCode = "409", description = "Voyage is not in COMPLETED status") + }) + @GetMapping("/{voyageId}/commissions") + public ResponseEntity getCommissions( + @PathVariable Long voyageId) { + return ResponseEntity.ok(voyageService.calculateCommissions(voyageId)); +} } diff --git a/src/main/java/com/shipping/freightops/dto/AgentCommissionResponse.java b/src/main/java/com/shipping/freightops/dto/AgentCommissionResponse.java new file mode 100644 index 0000000..dc14eeb --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/AgentCommissionResponse.java @@ -0,0 +1,62 @@ +package com.shipping.freightops.dto; + +import com.shipping.freightops.enums.AgentType; +import java.math.BigDecimal; + +public class AgentCommissionResponse { + + private String agentName; + private AgentType type; + private BigDecimal commissionPercent; + private int orderCount; + private BigDecimal totalOrderValueUsd; + private BigDecimal commissionEarnedUsd; + + public String getAgentName() { + return agentName; + } + + public void setAgentName(String agentName) { + this.agentName = agentName; + } + + public AgentType getType() { + return type; + } + + public void setType(AgentType type) { + this.type = type; + } + + public BigDecimal getCommissionPercent() { + return commissionPercent; + } + + public void setCommissionPercent(BigDecimal commissionPercent) { + this.commissionPercent = commissionPercent; + } + + public int getOrderCount() { + return orderCount; + } + + public void setOrderCount(int orderCount) { + this.orderCount = orderCount; + } + + public BigDecimal getTotalOrderValueUsd() { + return totalOrderValueUsd; + } + + public void setTotalOrderValueUsd(BigDecimal totalOrderValueUsd) { + this.totalOrderValueUsd = totalOrderValueUsd; + } + + public BigDecimal getCommissionEarnedUsd() { + return commissionEarnedUsd; + } + + public void setCommissionEarnedUsd(BigDecimal commissionEarnedUsd) { + this.commissionEarnedUsd = commissionEarnedUsd; + } +} diff --git a/src/main/java/com/shipping/freightops/dto/FreightOrderResponse.java b/src/main/java/com/shipping/freightops/dto/FreightOrderResponse.java index 39fcbd8..8c64f97 100644 --- a/src/main/java/com/shipping/freightops/dto/FreightOrderResponse.java +++ b/src/main/java/com/shipping/freightops/dto/FreightOrderResponse.java @@ -64,6 +64,7 @@ public Long getAgentId() { public String getAgentName() { return agentName; } + public String getCustomerName() { return customerName; } @@ -104,4 +105,3 @@ public String getDiscountReason() { return discountReason; } } - diff --git a/src/main/java/com/shipping/freightops/dto/VoyageCommissionReportResponse.java b/src/main/java/com/shipping/freightops/dto/VoyageCommissionReportResponse.java new file mode 100644 index 0000000..8cd646d --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/VoyageCommissionReportResponse.java @@ -0,0 +1,35 @@ +package com.shipping.freightops.dto; + +import java.math.BigDecimal; +import java.util.List; + +public class VoyageCommissionReportResponse { + + private String voyageNumber; + private List agents; + private BigDecimal totalCommissionsUsd; + + public String getVoyageNumber() { + return voyageNumber; + } + + public void setVoyageNumber(String voyageNumber) { + this.voyageNumber = voyageNumber; + } + + public List getAgents() { + return agents; + } + + public void setAgents(List agents) { + this.agents = agents; + } + + public BigDecimal getTotalCommissionsUsd() { + return totalCommissionsUsd; + } + + public void setTotalCommissionsUsd(BigDecimal totalCommissionsUsd) { + this.totalCommissionsUsd = totalCommissionsUsd; + } +} diff --git a/src/main/java/com/shipping/freightops/repository/AgentRepository.java b/src/main/java/com/shipping/freightops/repository/AgentRepository.java index c9cfdf6..b8e6c85 100644 --- a/src/main/java/com/shipping/freightops/repository/AgentRepository.java +++ b/src/main/java/com/shipping/freightops/repository/AgentRepository.java @@ -11,4 +11,6 @@ public interface AgentRepository extends JpaRepository { List findByType(AgentType type); List findByActive(boolean active); + + boolean existsByEmail(String email); } diff --git a/src/main/java/com/shipping/freightops/repository/FreightOrderRepository.java b/src/main/java/com/shipping/freightops/repository/FreightOrderRepository.java index dc1641b..b54f9a4 100644 --- a/src/main/java/com/shipping/freightops/repository/FreightOrderRepository.java +++ b/src/main/java/com/shipping/freightops/repository/FreightOrderRepository.java @@ -2,9 +2,7 @@ import com.shipping.freightops.entity.FreightOrder; import com.shipping.freightops.enums.OrderStatus; - import java.util.List; - import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -16,5 +14,8 @@ public interface FreightOrderRepository extends JpaRepository findByStatus(OrderStatus status, Pageable pageable); List findByAgentId(Long agentId); + Page findByOrderedBy(String orderedBy, Pageable pageable); + + List findByVoyageIdAndStatus(Long voyageId, OrderStatus status); } diff --git a/src/main/java/com/shipping/freightops/service/AgentService.java b/src/main/java/com/shipping/freightops/service/AgentService.java index 0a3f98f..e4e3241 100644 --- a/src/main/java/com/shipping/freightops/service/AgentService.java +++ b/src/main/java/com/shipping/freightops/service/AgentService.java @@ -21,6 +21,9 @@ public AgentService(AgentRepository agentRepository) { @Transactional public Agent createAgent(AgentCreateRequest request) { + if (agentRepository.existsByEmail(request.getEmail())) { + throw new IllegalStateException("Agent with this email already exists: " + request.getEmail()); + } Agent agent = new Agent(); agent.setName(request.getName()); agent.setEmail(request.getEmail()); diff --git a/src/main/java/com/shipping/freightops/service/FreightOrderService.java b/src/main/java/com/shipping/freightops/service/FreightOrderService.java index 6254a10..0aba8a2 100644 --- a/src/main/java/com/shipping/freightops/service/FreightOrderService.java +++ b/src/main/java/com/shipping/freightops/service/FreightOrderService.java @@ -1,23 +1,21 @@ package com.shipping.freightops.service; import com.shipping.freightops.dto.CreateFreightOrderRequest; +import com.shipping.freightops.dto.UpdateDiscountRequest; +import com.shipping.freightops.entity.*; import com.shipping.freightops.entity.Agent; import com.shipping.freightops.entity.Container; import com.shipping.freightops.entity.FreightOrder; import com.shipping.freightops.entity.Voyage; -import com.shipping.freightops.enums.VoyageStatus; -import com.shipping.freightops.repository.AgentRepository; -import com.shipping.freightops.repository.ContainerRepository; -import com.shipping.freightops.repository.FreightOrderRepository; -import com.shipping.freightops.repository.VoyageRepository; -import java.util.List; -import com.shipping.freightops.dto.UpdateDiscountRequest; -import com.shipping.freightops.entity.*; import com.shipping.freightops.enums.ContainerSize; import com.shipping.freightops.enums.OrderStatus; import com.shipping.freightops.enums.VoyageStatus; import com.shipping.freightops.exception.BadRequestException; import com.shipping.freightops.repository.*; +import com.shipping.freightops.repository.AgentRepository; +import com.shipping.freightops.repository.ContainerRepository; +import com.shipping.freightops.repository.FreightOrderRepository; +import com.shipping.freightops.repository.VoyageRepository; import java.math.BigDecimal; import java.math.RoundingMode; import org.springframework.data.domain.Page; @@ -41,8 +39,8 @@ public FreightOrderService( VoyageRepository voyageRepository, ContainerRepository containerRepository, AgentRepository agentRepository, - CustomerRepository customerRepository, - VoyagePriceRepository voyagePriceRepository) { + CustomerRepository customerRepository, + VoyagePriceRepository voyagePriceRepository) { this.orderRepository = orderRepository; this.voyageRepository = voyageRepository; this.containerRepository = containerRepository; diff --git a/src/main/java/com/shipping/freightops/service/VoyageService.java b/src/main/java/com/shipping/freightops/service/VoyageService.java index 5a6e0dd..54bb11f 100644 --- a/src/main/java/com/shipping/freightops/service/VoyageService.java +++ b/src/main/java/com/shipping/freightops/service/VoyageService.java @@ -1,19 +1,29 @@ package com.shipping.freightops.service; +import com.shipping.freightops.dto.AgentCommissionResponse; import com.shipping.freightops.dto.CreateVoyageRequest; +import com.shipping.freightops.dto.VoyageCommissionReportResponse; import com.shipping.freightops.dto.VoyagePriceRequest; +import com.shipping.freightops.entity.FreightOrder; import com.shipping.freightops.entity.Port; import com.shipping.freightops.entity.Vessel; import com.shipping.freightops.entity.Voyage; import com.shipping.freightops.entity.VoyagePrice; +import com.shipping.freightops.enums.OrderStatus; import com.shipping.freightops.enums.VoyageStatus; +import com.shipping.freightops.repository.FreightOrderRepository; import com.shipping.freightops.repository.PortRepository; import com.shipping.freightops.repository.VesselRepository; import com.shipping.freightops.repository.VoyagePriceRepository; import com.shipping.freightops.repository.VoyageRepository; import jakarta.validation.Valid; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import org.springframework.data.domain.Page; @@ -27,6 +37,7 @@ public class VoyageService { private final VesselRepository vesselRepository; private final PortRepository portRepository; private final VoyagePriceRepository voyagePriceRepository; + private final FreightOrderRepository freightOrderRepository; private Voyage mapCreateVoyageRequestToVoyage(CreateVoyageRequest voyageRequest) { Voyage voyage = new Voyage(); @@ -64,11 +75,13 @@ public VoyageService( VoyageRepository voyageRepository, VesselRepository vesselRepository, PortRepository portRepository, - VoyagePriceRepository voyagePriceRepository) { + VoyagePriceRepository voyagePriceRepository, + FreightOrderRepository freightOrderRepository) { this.voyageRepository = voyageRepository; this.vesselRepository = vesselRepository; this.portRepository = portRepository; this.voyagePriceRepository = voyagePriceRepository; + this.freightOrderRepository = freightOrderRepository; } public List getAll() { @@ -135,4 +148,60 @@ public Page getAllPricesByVoyageId(Long voyageId, Pageable pageable } return voyagePriceRepository.findByVoyageId(voyageId, pageable); } + + @Transactional(readOnly = true) + public VoyageCommissionReportResponse calculateCommissions(Long voyageId) { + Voyage voyage = + voyageRepository + .findById(voyageId) + .orElseThrow(() -> new IllegalArgumentException("Voyage not found")); + + if (voyage.getStatus() != VoyageStatus.COMPLETED) { + throw new IllegalStateException("Commission report is only available for COMPLETED voyages"); + } + + List deliveredOrders = + freightOrderRepository.findByVoyageIdAndStatus(voyageId, OrderStatus.DELIVERED); + + // Group orders by agent id, preserving insertion order + Map> byAgent = new LinkedHashMap<>(); + for (FreightOrder order : deliveredOrders) { + Long agentId = order.getAgent().getId(); + byAgent.computeIfAbsent(agentId, k -> new ArrayList<>()).add(order); + } + + List agentResponses = new ArrayList<>(); + BigDecimal totalCommissions = BigDecimal.ZERO; + + for (List orders : byAgent.values()) { + var agent = orders.get(0).getAgent(); + + BigDecimal totalValue = + orders.stream().map(FreightOrder::getFinalPrice).reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal commissionEarned = + agent + .getCommissionPercent() + .divide(BigDecimal.valueOf(100)) + .multiply(totalValue) + .setScale(2, RoundingMode.HALF_UP); + + AgentCommissionResponse agentDto = new AgentCommissionResponse(); + agentDto.setAgentName(agent.getName()); + agentDto.setType(agent.getType()); + agentDto.setCommissionPercent(agent.getCommissionPercent()); + agentDto.setOrderCount(orders.size()); + agentDto.setTotalOrderValueUsd(totalValue.setScale(2, RoundingMode.HALF_UP)); + agentDto.setCommissionEarnedUsd(commissionEarned); + + agentResponses.add(agentDto); + totalCommissions = totalCommissions.add(commissionEarned); + } + + VoyageCommissionReportResponse response = new VoyageCommissionReportResponse(); + response.setVoyageNumber(voyage.getVoyageNumber()); + response.setAgents(agentResponses); + response.setTotalCommissionsUsd(totalCommissions.setScale(2, RoundingMode.HALF_UP)); + return response; + } } diff --git a/src/test/java/com/shipping/freightops/controller/FreightOrderControllerTest.java b/src/test/java/com/shipping/freightops/controller/FreightOrderControllerTest.java index f5bd7fb..1c0da3e 100644 --- a/src/test/java/com/shipping/freightops/controller/FreightOrderControllerTest.java +++ b/src/test/java/com/shipping/freightops/controller/FreightOrderControllerTest.java @@ -8,6 +8,7 @@ import com.shipping.freightops.dto.CreateFreightOrderRequest; import com.shipping.freightops.dto.UpdateDiscountRequest; import com.shipping.freightops.entity.*; +import com.shipping.freightops.enums.AgentType; import com.shipping.freightops.enums.ContainerSize; import com.shipping.freightops.enums.ContainerType; import com.shipping.freightops.enums.OrderStatus; @@ -46,15 +47,18 @@ class FreightOrderControllerTest { @Autowired private FreightOrderRepository freightOrderRepository; @Autowired private VoyagePriceRepository voyagePriceRepository; @Autowired private FreightOrderService freightOrderService; + @Autowired private AgentRepository agentRepository; private Voyage savedVoyage; private Container savedContainer; private Customer savedCustomer; + private Agent savedAgent; @BeforeEach void setUp() { // Clear state between tests โ€” children first to respect FK constraints freightOrderRepository.deleteAll(); + agentRepository.deleteAll(); voyagePriceRepository.deleteAll(); voyageRepository.deleteAll(); containerRepository.deleteAll(); @@ -84,6 +88,13 @@ void setUp() { customer.setEmail("John@testCust.com"); savedCustomer = customerRepository.save(customer); + Agent agent = new Agent(); + agent.setName("Test Agent"); + agent.setEmail("agent@test.com"); + agent.setCommissionPercent(BigDecimal.valueOf(5)); + agent.setType(AgentType.EXTERNAL); + savedAgent = agentRepository.save(agent); + VoyagePrice price = new VoyagePrice(); price.setVoyage(savedVoyage); price.setContainerSize(ContainerSize.TWENTY_FOOT); @@ -98,6 +109,7 @@ void createOrder_returnsCreated() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("ops-team"); request.setNotes("Urgent delivery"); @@ -140,6 +152,7 @@ void listOrders_returnsOk() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("user-" + i); request.setNotes("order-" + i); @@ -169,6 +182,7 @@ void listOrders_withoutPageSize_returnsOk() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("user-" + i); request.setNotes("order-" + i); @@ -195,6 +209,7 @@ void listOrders_withoutPage_returnsOk() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("user-" + i); request.setNotes("order-" + i); @@ -222,6 +237,7 @@ void listOrders_pageSize101_returnsOk() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("user-" + i); request.setNotes("order-" + i); @@ -245,6 +261,7 @@ void updateDiscount_returnsUpdatedOrder() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("ops-team"); request.setNotes("Urgent delivery"); FreightOrder order = freightOrderService.createOrder(request); @@ -286,6 +303,7 @@ void updateDiscount_invalidRequest() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("ops-team"); request.setNotes("Urgent delivery"); FreightOrder order = freightOrderService.createOrder(request); @@ -309,6 +327,7 @@ void updateDiscount_invalidState() throws Exception { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("ops-team"); request.setNotes("Urgent delivery"); FreightOrder order = freightOrderService.createOrder(request); diff --git a/src/test/java/com/shipping/freightops/controller/VoyageCommissionTest.java b/src/test/java/com/shipping/freightops/controller/VoyageCommissionTest.java new file mode 100644 index 0000000..1283455 --- /dev/null +++ b/src/test/java/com/shipping/freightops/controller/VoyageCommissionTest.java @@ -0,0 +1,268 @@ +package com.shipping.freightops.controller; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.shipping.freightops.entity.*; +import com.shipping.freightops.enums.AgentType; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; +import com.shipping.freightops.enums.OrderStatus; +import com.shipping.freightops.enums.VoyageStatus; +import com.shipping.freightops.repository.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class VoyageCommissionTest { + + @Autowired private MockMvc mockMvc; + @Autowired private FreightOrderRepository freightOrderRepository; + @Autowired private VoyageRepository voyageRepository; + @Autowired private AgentRepository agentRepository; + @Autowired private ContainerRepository containerRepository; + @Autowired private CustomerRepository customerRepository; + @Autowired private VesselRepository vesselRepository; + @Autowired private PortRepository portRepository; + @Autowired private VoyagePriceRepository voyagePriceRepository; + + private Voyage completedVoyage; + private Agent internalAgent; + private Agent externalAgent; + private Customer customer; + + @BeforeEach + void setUp() { + freightOrderRepository.deleteAll(); + agentRepository.deleteAll(); + voyagePriceRepository.deleteAll(); + voyageRepository.deleteAll(); + containerRepository.deleteAll(); + customerRepository.deleteAll(); + vesselRepository.deleteAll(); + portRepository.deleteAll(); + + Port departure = portRepository.save(new Port("AEJEA", "Jebel Ali", "UAE")); + Port arrival = portRepository.save(new Port("CNSHA", "Shanghai", "China")); + Vessel vessel = vesselRepository.save(new Vessel("MV Commission", "1234567", 5000)); + + Voyage voyage = new Voyage(); + voyage.setVoyageNumber("VOY-COMM-001"); + voyage.setVessel(vessel); + voyage.setDeparturePort(departure); + voyage.setArrivalPort(arrival); + voyage.setDepartureTime(LocalDateTime.now().minusDays(20)); + voyage.setArrivalTime(LocalDateTime.now().minusDays(5)); + voyage.setStatus(VoyageStatus.COMPLETED); + completedVoyage = voyageRepository.save(voyage); + + Agent agentA = new Agent(); + agentA.setName("Ali Hassan"); + agentA.setEmail("ali@internal.com"); + agentA.setCommissionPercent(BigDecimal.valueOf(5)); + agentA.setType(AgentType.INTERNAL); + internalAgent = agentRepository.save(agentA); + + Agent agentB = new Agent(); + agentB.setName("FastFreight FZE"); + agentB.setEmail("ops@fastfreight.com"); + agentB.setCommissionPercent(BigDecimal.valueOf(8)); + agentB.setType(AgentType.EXTERNAL); + externalAgent = agentRepository.save(agentB); + + Customer cust = new Customer(); + cust.setCompanyName("Test Co."); + cust.setContactName("Jane Smith"); + cust.setEmail("jane@testco.com"); + customer = customerRepository.save(cust); + } + + // โ”€โ”€ Helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private FreightOrder deliveredOrder(Voyage voyage, Agent agent, BigDecimal finalPrice) { + Container container = + containerRepository.save( + new Container( + "TCKU" + (int) (Math.random() * 9_000_000 + 1_000_000), + ContainerSize.TWENTY_FOOT, + ContainerType.DRY)); + FreightOrder order = new FreightOrder(); + order.setVoyage(voyage); + order.setAgent(agent); + order.setCustomer(customer); + order.setContainer(container); + order.setOrderedBy("ops-team"); + order.setBasePriceUsd(finalPrice); + order.setDiscountPercent(BigDecimal.ZERO); + order.setFinalPrice(finalPrice); + order.setStatus(OrderStatus.DELIVERED); + return freightOrderRepository.save(order); + } + + // โ”€โ”€ Happy Path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 200: correct commission per agent") + void commissions_calculatedCorrectly() throws Exception { + // Agent A (INTERNAL, 5%): orders 1000 + 2000 = 3000 โ†’ commission 150 + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(1000)); + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(2000)); + // Agent B (EXTERNAL, 8%): order 5000 โ†’ commission 400 + deliveredOrder(completedVoyage, externalAgent, BigDecimal.valueOf(5000)); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/api/v1/voyages/" + completedVoyage.getId() + "/commissions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voyageNumber").value("VOY-COMM-001")) + .andExpect(jsonPath("$.agents.length()").value(2)) + .andExpect(jsonPath("$.totalCommissionsUsd").value(550.00)); + } + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 200: agent order count and value are correct") + void commissions_agentStatsAreCorrect() throws Exception { + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(1000)); + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(2000)); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/api/v1/voyages/" + completedVoyage.getId() + "/commissions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.agents[0].agentName").value("Ali Hassan")) + .andExpect(jsonPath("$.agents[0].type").value("INTERNAL")) + .andExpect(jsonPath("$.agents[0].commissionPercent").value(5.0)) + .andExpect(jsonPath("$.agents[0].orderCount").value(2)) + .andExpect(jsonPath("$.agents[0].totalOrderValueUsd").value(3000.00)) + .andExpect(jsonPath("$.agents[0].commissionEarnedUsd").value(150.00)); + } + + // โ”€โ”€ Edge Cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 200: no delivered orders returns empty agents list") + void commissions_noDeliveredOrders_returnsEmptyList() throws Exception { + // Create a PENDING order โ€” should be excluded + Container container = + containerRepository.save( + new Container("TCKU1234567", ContainerSize.TWENTY_FOOT, ContainerType.DRY)); + FreightOrder pendingOrder = new FreightOrder(); + pendingOrder.setVoyage(completedVoyage); + pendingOrder.setAgent(internalAgent); + pendingOrder.setCustomer(customer); + pendingOrder.setContainer(container); + pendingOrder.setOrderedBy("ops-team"); + pendingOrder.setBasePriceUsd(BigDecimal.valueOf(1000)); + pendingOrder.setDiscountPercent(BigDecimal.ZERO); + pendingOrder.setFinalPrice(BigDecimal.valueOf(1000)); + pendingOrder.setStatus(OrderStatus.PENDING); + freightOrderRepository.save(pendingOrder); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/api/v1/voyages/" + completedVoyage.getId() + "/commissions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voyageNumber").value("VOY-COMM-001")) + .andExpect(jsonPath("$.agents").isEmpty()) + .andExpect(jsonPath("$.totalCommissionsUsd").value(0.00)); + } + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 200: only DELIVERED orders count, others excluded") + void commissions_excludesNonDeliveredOrders() throws Exception { + // 1 DELIVERED order + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(1000)); + + // PENDING order (should be excluded) + Container container2 = + containerRepository.save( + new Container("TCKU7654321", ContainerSize.TWENTY_FOOT, ContainerType.DRY)); + FreightOrder pendingOrder = new FreightOrder(); + pendingOrder.setVoyage(completedVoyage); + pendingOrder.setAgent(internalAgent); + pendingOrder.setCustomer(customer); + pendingOrder.setContainer(container2); + pendingOrder.setOrderedBy("ops-team"); + pendingOrder.setBasePriceUsd(BigDecimal.valueOf(500)); + pendingOrder.setDiscountPercent(BigDecimal.ZERO); + pendingOrder.setFinalPrice(BigDecimal.valueOf(500)); + pendingOrder.setStatus(OrderStatus.CANCELLED); + freightOrderRepository.save(pendingOrder); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/api/v1/voyages/" + completedVoyage.getId() + "/commissions")) + .andExpect(status().isOk()) + // Only the 1 delivered order counts + .andExpect(jsonPath("$.agents[0].orderCount").value(1)) + .andExpect(jsonPath("$.agents[0].totalOrderValueUsd").value(1000.00)) + .andExpect(jsonPath("$.agents[0].commissionEarnedUsd").value(50.00)) + .andExpect(jsonPath("$.totalCommissionsUsd").value(50.00)); + } + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 409: voyage not COMPLETED") + void commissions_voyageNotCompleted_returns409() throws Exception { + Port dep2 = portRepository.save(new Port("USNYC", "New York", "USA")); + Port arr2 = portRepository.save(new Port("GBLON", "London", "UK")); + Vessel vessel2 = vesselRepository.save(new Vessel("MV Planned", "7654321", 3000)); + + Voyage plannedVoyage = new Voyage(); + plannedVoyage.setVoyageNumber("VOY-PLAN-001"); + plannedVoyage.setVessel(vessel2); + plannedVoyage.setDeparturePort(dep2); + plannedVoyage.setArrivalPort(arr2); + plannedVoyage.setDepartureTime(LocalDateTime.now().plusDays(5)); + plannedVoyage.setArrivalTime(LocalDateTime.now().plusDays(15)); + plannedVoyage.setStatus(VoyageStatus.PLANNED); + Voyage saved = voyageRepository.save(plannedVoyage); + + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/voyages/" + saved.getId() + "/commissions")) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 404: voyage not found") + void commissions_voyageNotFound_returns404() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/voyages/99999/commissions")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("GET /{voyageId}/commissions โ†’ 200: INTERNAL and EXTERNAL agents handled correctly") + void commissions_mixedAgentTypes() throws Exception { + deliveredOrder(completedVoyage, internalAgent, BigDecimal.valueOf(1000)); + deliveredOrder(completedVoyage, externalAgent, BigDecimal.valueOf(1000)); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/api/v1/voyages/" + completedVoyage.getId() + "/commissions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.agents.length()").value(2)) + // Internal agent: 5% of 1000 = 50 + .andExpect(jsonPath("$.agents[0].type").value("INTERNAL")) + .andExpect(jsonPath("$.agents[0].commissionEarnedUsd").value(50.00)) + // External agent: 8% of 1000 = 80 + .andExpect(jsonPath("$.agents[1].type").value("EXTERNAL")) + .andExpect(jsonPath("$.agents[1].commissionEarnedUsd").value(80.00)) + .andExpect(jsonPath("$.totalCommissionsUsd").value(130.00)); + } +} diff --git a/src/test/java/com/shipping/freightops/dto/AgentCreateRequestTest.java b/src/test/java/com/shipping/freightops/dto/AgentCreateRequestTest.java new file mode 100644 index 0000000..3e674f6 --- /dev/null +++ b/src/test/java/com/shipping/freightops/dto/AgentCreateRequestTest.java @@ -0,0 +1,366 @@ +package com.shipping.freightops.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.enums.AgentType; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.math.BigDecimal; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AgentCreateRequest} DTO. */ +class AgentCreateRequestTest { + + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Nested + @DisplayName("Getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("setName/getName works correctly") + void nameGetterSetter() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + + assertThat(request.getName()).isEqualTo("Test Agent"); + } + + @Test + @DisplayName("setEmail/getEmail works correctly") + void emailGetterSetter() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setEmail("test@example.com"); + + assertThat(request.getEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("setCommissionPercent/getCommissionPercent works correctly") + void commissionPercentGetterSetter() { + AgentCreateRequest request = new AgentCreateRequest(); + BigDecimal commission = new BigDecimal("15.50"); + request.setCommissionPercent(commission); + + assertThat(request.getCommissionPercent()).isEqualByComparingTo("15.50"); + } + + @Test + @DisplayName("setType/getType works correctly") + void typeGetterSetter() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setType(AgentType.EXTERNAL); + + assertThat(request.getType()).isEqualTo(AgentType.EXTERNAL); + } + } + + @Nested + @DisplayName("Validation - valid cases") + class ValidationValid { + + @Test + @DisplayName("valid request with all fields passes validation") + void validRequestPassesValidation() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Valid Agent"); + request.setEmail("valid@example.com"); + request.setCommissionPercent(new BigDecimal("10.00")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("valid request with minimum commission (0.0)") + void validWithMinimumCommission() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Zero Commission"); + request.setEmail("zero@example.com"); + request.setCommissionPercent(new BigDecimal("0.0")); + request.setType(AgentType.EXTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("valid request with maximum commission (100.0)") + void validWithMaximumCommission() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Max Commission"); + request.setEmail("max@example.com"); + request.setCommissionPercent(new BigDecimal("100.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("valid request with both agent types") + void validWithBothAgentTypes() { + AgentCreateRequest internal = new AgentCreateRequest(); + internal.setName("Internal Agent"); + internal.setEmail("internal@example.com"); + internal.setCommissionPercent(new BigDecimal("5.0")); + internal.setType(AgentType.INTERNAL); + + AgentCreateRequest external = new AgentCreateRequest(); + external.setName("External Agent"); + external.setEmail("external@example.com"); + external.setCommissionPercent(new BigDecimal("5.0")); + external.setType(AgentType.EXTERNAL); + + assertThat(validator.validate(internal)).isEmpty(); + assertThat(validator.validate(external)).isEmpty(); + } + } + + @Nested + @DisplayName("Validation - name field") + class ValidationName { + + @Test + @DisplayName("null name fails validation") + void nullNameFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName(null); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("name")); + } + + @Test + @DisplayName("empty name fails validation") + void emptyNameFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName(""); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("name")); + } + + @Test + @DisplayName("blank name (whitespace only) fails validation") + void blankNameFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName(" "); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("name")); + } + } + + @Nested + @DisplayName("Validation - email field") + class ValidationEmail { + + @Test + @DisplayName("null email fails validation") + void nullEmailFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail(null); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("email")); + } + + @Test + @DisplayName("empty email fails validation") + void emptyEmailFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail(""); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("email")); + } + + @Test + @DisplayName("invalid email format fails validation") + void invalidEmailFormatFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("not-an-email"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("email")); + } + + @Test + @DisplayName("email without @ fails validation") + void emailWithoutAtFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("bademail.com"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("email")); + } + } + + @Nested + @DisplayName("Validation - commissionPercent field") + class ValidationCommissionPercent { + + @Test + @DisplayName("null commissionPercent fails validation") + void nullCommissionFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("test@example.com"); + request.setCommissionPercent(null); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("negative commissionPercent fails validation") + void negativeCommissionFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("-1.0")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("commissionPercent > 100 fails validation") + void commissionOver100Fails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("100.01")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("commissionPercent of 50.5 passes validation") + void midRangeCommissionPasses() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("50.5")); + request.setType(AgentType.INTERNAL); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + } + + @Nested + @DisplayName("Validation - type field") + class ValidationType { + + @Test + @DisplayName("null type fails validation") + void nullTypeFails() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName("Test Agent"); + request.setEmail("test@example.com"); + request.setCommissionPercent(new BigDecimal("5.0")); + request.setType(null); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("type")); + } + } + + @Nested + @DisplayName("Multiple validation errors") + class MultipleValidationErrors { + + @Test + @DisplayName("request with all null fields has multipations") + void allNullFieldsHaveMultipleViolations() { + AgentCreateRequest request = new AgentCreateRequest(); + + Set> violations = validator.validate(request); + + assertThat(violations).hasSizeGreaterThanOrEqualTo(4); + } + + @Test + @DisplayName("request with multiple invalid fields reports all violations") + void multipleInvalidFieldsReportedTogether() { + AgentCreateRequest request = new AgentCreateRequest(); + request.setName(""); // invalid + request.setEmail("bad-email"); // invalid + request.setCommissionPercent(new BigDecimal("-5.0")); // invalid + request.setType(null); // invalid + + Set> violations = validator.validate(request); + + assertThat(violations).hasSizeGreaterThanOrEqualTo(4); + } + } +} diff --git a/src/test/java/com/shipping/freightops/dto/AgentResponseTest.java b/src/test/java/com/shipping/freightops/dto/AgentResponseTest.java new file mode 100644 index 0000000..34fabdb --- /dev/null +++ b/src/test/java/com/shipping/freightops/dto/AgentResponseTest.java @@ -0,0 +1,297 @@ +package com.shipping.freightops.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.enums.AgentType; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AgentResponse} DTO. */ +class AgentResponseTest { + + @Nested + @DisplayName("Getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("setId/getId works correctly") + void idGetterSetter() { + AgentResponse response = new AgentResponse(); + response.setId(42L); + + assertThat(response.getId()).isEqualTo(42L); + } + + @Test + @DisplayName("setName/getName works correctly") + void nameGetterSetter() { + AgentResponse response = new AgentResponse(); + response.setName("John Doe"); + + assertThat(response.getName()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("setEmail/getEmail works correctly") + void emailGetterSetter() { + AgentResponse response = new AgentResponse(); + response.setEmail("john@example.com"); + + assertThat(response.getEmail()).isEqualTo("john@example.com"); + } + + @Test + @DisplayName("setCommissionPercent/getCommissionPercent works correctly") + void commissionPercentGetterSetter() { + AgentResponse response = new AgentResponse(); + BigDecimal commission = new BigDecimal("15.75"); + response.setCommissionPercent(commission); + + assertThat(response.getCommissionPercent()).isEqualByComparingTo("15.75"); + } + + @Test + @DisplayName("setType/getType works correctly") + void typeGetterSetter() { + AgentResponse response = new AgentResponse(); + response.setType(AgentType.EXTERNAL); + + assertThat(response.getType()).isEqualTo(AgentType.EXTERNAL); + } + + @Test + @DisplayName("setActive/isActive works correctly") + void activeGetterSetter() { + AgentResponse response = new AgentResponse(); + response.setActive(true); + + assertThat(response.isActive()).isTrue(); + + response.setActive(false); + + assertThat(response.isActive()).isFalse(); + } + + @Test + @DisplayName("setCreatedAt/getCreatedAt works correctly") + void createdAtGetterSetter() { + AgentResponse response = new AgentResponse(); + LocalDateTime now = LocalDateTime.now(); + response.setCreatedAt(now); + + assertThat(response.getCreatedAt()).isEqualTo(now); + } + + @Test + @DisplayName("setUpdatedAt/getUpdatedAt works correctly") + void updatedAtGetterSetter() { + AgentResponse response = new AgentResponse(); + LocalDateTime now = LocalDateTime.now(); + response.setUpdatedAt(now); + + assertThat(response.getUpdatedAt()).isEqualTo(now); + } + } + + @Nested + @DisplayName("Default values") + class DefaultValues { + + @Test + @DisplayName("new AgentResponse() has all null fields") + void allFieldsNullByDefault() { + AgentResponse response = new AgentResponse(); + + assertThat(response.getId()).isNull(); + assertThat(response.getName()).isNull(); + assertThat(response.getEmail()).isNull(); + assertThat(response.getCommissionPercent()).isNull(); + assertThat(response.getType()).isNull(); + assertThat(response.isActive()).isFalse(); // boolean default + assertThat(response.getCreatedAt()).isNull(); + assertThat(response.getUpdatedAt()).isNull(); + } + } + + @Nested + @DisplayName("Complete response object") + class CompleteResponseObject { + + @Test + @DisplayName("can create fully populated response") + void fullyPopulatedResponse() { + AgentResponse response = new AgentResponse(); + LocalDateTime created = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime updated = LocalDateTime.of(2026, 3, 1, 15, 30); + + response.setId(123L); + response.setName("Alice Johnson"); + response.setEmail("alice@company.com"); + response.setCommissionPercent(new BigDecimal("8.50")); + response.setType(AgentType.INTERNAL); + response.setActive(true); + response.setCreatedAt(created); + response.setUpdatedAt(updated); + + assertThat(response.getId()).isEqualTo(123L); + assertThat(response.getName()).isEqualTo("Alice Johnson"); + assertThat(response.getEmail()).isEqualTo("alice@company.com"); + assertThat(response.getCommissionPercent()).isEqualByComparingTo("8.50"); + assertThat(response.getType()).isEqualTo(AgentType.INTERNAL); + assertThat(response.isActive()).isTrue(); + assertThat(response.getCreatedAt()).isEqualTo(created); + assertThat(response.getUpdatedAt()).isEqualTo(updated); + } + + @Test + @DisplayName("can create inactive external agent response") + void inactiveExternalAgent() { + AgentResponse response = new AgentResponse(); + LocalDateTime timestamp = LocalDateTime.now(); + + response.setId(999L); + response.setName("Inactive Agent"); + response.setEmail("inactive@external.com"); + response.setCommissionPercent(new BigDecimal("20.00")); + response.setType(AgentType.EXTERNAL); + response.setActive(false); + response.setCreatedAt(timestamp); + response.setUpdatedAt(timestamp); + + assertThat(response.getId()).isEqualTo(999L); + assertThat(response.getName()).isEqualTo("Inactive Agent"); + assertThat(response.getEmail()).isEqualTo("inactive@external.com"); + assertThat(response.getCommissionPercent()).isEqualByComparingTo("20.00"); + assertThat(response.getType()).isEqualTo(AgentType.EXTERNAL); + assertThat(response.isActive()).isFalse(); + assertThat(response.getCreatedAt()).isEqualTo(timestamp); + assertThat(response.getUpdatedAt()).isEqualTo(timestamp); + } + } + + @Nested + @DisplayName("Edge cases and special values") + class EdgeCases { + + @Test + @DisplayName("can handle zero commission") + void zeroCommission() { + AgentResponse response = new AgentResponse(); + response.setCommissionPercent(new BigDecimal("0.00")); + + assertThat(response.getCommissionPercent()).isEqualByComparingTo("0.00"); + } + + @Test + @DisplayName("can handle maximum commission") + void maximumCommission() { + AgentResponse response = new AgentResponse(); + response.setCommissionPercent(new BigDecimal("100.00")); + + assertThat(response.getCommissionPercent()).isEqualByComparingTo("100.00"); + } + + @Test + @DisplayName("commission preserves precision") + void commissionPreservesPrecision() { + AgentResponse response = new AgentResponse(); + response.setCommissionPercent(new BigDecimal("12.3456789")); + + assertThat(response.getCommissionPercent()).isEqualByComparingTo("12.3456789"); + } + + @Test + @DisplayName("can update fields after initial setup") + void canUpdateFields() { + AgentResponse response = new AgentResponse(); + response.setId(1L); + response.setName("Original"); + response.setActive(true); + + // Update fields + response.setName("Updated"); + response.setActive(false); + + assertThat(response.getId()).isEqualTo(1L); // unchanged + assertThat(response.getName()).isEqualTo("Updated"); + assertThat(response.isActive()).isFalse(); + } + + @Test + @DisplayName("createdAt and updatedAt can be different") + void createdAndUpdatedCanDiffer() { + AgentResponse response = new AgentResponse(); + LocalDateTime created = LocalDateTime.of(2025, 1, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 3, 2, 12, 30); + + response.setCreatedAt(created); + response.setUpdatedAt(updated); + + assertThat(response.getCreatedAt()).isEqualTo(created); + assertThat(response.getUpdatedAt()).isEqualTo(updated); + assertThat(response.getUpdatedAt()).isAfter(response.getCreatedAt()); + } + + @Test + @DisplayName("can handle both AgentType values") + void bothAgentTypes() { + AgentResponse internal = new AgentResponse(); + internal.setType(AgentType.INTERNAL); + + AgentResponse external = new AgentResponse(); + external.setType(AgentType.EXTERNAL); + + assertThat(internal.getType()).isEqualTo(AgentType.INTERNAL); + assertThat(external.getType()).isEqualTo(AgentType.EXTERNAL); + } + } + + @Nested + @DisplayName("Typical use cases") + class TypicalUseCases { + + @Test + @DisplayName("response for newly created agent") + void newlyCreatedAgent() { + LocalDateTime now = LocalDateTime.now(); + AgentResponse response = new AgentResponse(); + + response.setId(1L); + response.setName("New Agent"); + response.setEmail("new@test.com"); + response.setCommissionPercent(new BigDecimal("5.00")); + response.setType(AgentType.INTERNAL); + response.setActive(true); + response.setCreatedAt(now); + response.setUpdatedAt(now); + + assertThat(response.getId()).isNotNull(); + assertThat(response.isActive()).isTrue(); + assertThat(response.getCreatedAt()).isEqualTo(response.getUpdatedAt()); + } + + @Test + @DisplayName("response for updated agent") + void updatedAgent() { + LocalDateTime created = LocalDateTime.of(2025, 1, 1, 0, 0); + LocalDateTime updated = LocalDateTime.now(); + + AgentResponse response = new AgentResponse(); + response.setId(1L); + response.setName("Updated Agent"); + response.setEmail("updated@test.com"); + response.setCommissionPercent(new BigDecimal("12.00")); + response.setType(AgentType.EXTERNAL); + response.setActive(false); + response.setCreatedAt(created); + response.setUpdatedAt(updated); + + assertThat(response.getUpdatedAt()).isAfter(response.getCreatedAt()); + assertThat(response.getCommissionPercent()).isEqualByComparingTo("12.00"); + assertThat(response.isActive()).isFalse(); + } + } +} diff --git a/src/test/java/com/shipping/freightops/dto/AgentUpdateRequestTest.java b/src/test/java/com/shipping/freightops/dto/AgentUpdateRequestTest.java new file mode 100644 index 0000000..3f2edf9 --- /dev/null +++ b/src/test/java/com/shipping/freightops/dto/AgentUpdateRequestTest.java @@ -0,0 +1,317 @@ +package com.shipping.freightops.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.math.BigDecimal; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AgentUpdateRequest} DTO. */ +class AgentUpdateRequestTest { + + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Nested + @DisplayName("Getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("setCommissionPercent/getCommissionPercent works correctly") + void commissionPercentGetterSetter() { + AgentUpdateRequest request = new AgentUpdateRequest(); + BigDecimal commission = new BigDecimal("12.75"); + request.setCommissionPercent(commission); + + assertThat(request.getCommissionPercent()).isEqualByComparingTo("12.75"); + } + + @Test + @DisplayName("setActive/getActive works correctly") + void activeGetterSetter() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setActive(true); + + assertThat(request.getActive()).isTrue(); + + request.setActive(false); + + assertThat(request.getActive()).isFalse(); + } + + @Test + @DisplayName("default values are null") + void defaultValuesAreNull() { + AgentUpdateRequest request = new AgentUpdateRequest(); + + assertThat(request.getCommissionPercent()).isNull(); + assertThat(request.getActive()).isNull(); + } + } + + @Nested + @DisplayName("Validation - valid cases") + class ValidationValid { + + @Test + @DisplayName("empty request (all null) passes validation") + void emptyRequestPassesValidation() { + AgentUpdateRequest request = new AgentUpdateRequest(); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("request with only commissionPercent passes validation") + void onlyCommissionPercentPasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("15.00")); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("request with only active passes validation") + void onlyActivePasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setActive(false); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("request with both fields passes validation") + void bothFieldsPass() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("20.50")); + request.setActive(true); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("minimum commission (0.0) passes validation") + void minimumCommissionPasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("0.0")); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("maximum commission (100.0) passes validation") + void maximumCommissionPasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("100.0")); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + } + + @Nested + @DisplayName("Validation - commissionPercent field") + class ValidationCommissionPercent { + + @Test + @DisplayName("negative commissionPercent fails validation") + void negativeCommissionFails() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("-0.01")); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("commissionPercent > 100 fails validation") + void commissionOver100Fails() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("100.01")); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("very large commissionPercent fails validation") + void veryLargeCommissionFails() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("1000.00")); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("very small negative commissionPercent fails validation") + void verySmallNegativeCommissionFails() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("-50.00")); + + Set> violations = validator.validate(request); + + assertThat(violations).isNotEmpty(); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("commissionPercent")); + } + + @Test + @DisplayName("commission with high precision passes if within range") + void highPrecisionCommissionPasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("12.345678")); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + } + + @Nested + @DisplayName("Validation - active field") + class ValidationActive { + + @Test + @DisplayName("active=true passes validation") + void activeTruePasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setActive(true); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("active=false passes validation") + void activeFalsePasses() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setActive(false); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + } + + @Nested + @DisplayName("Partial update scenarios") + class PartialUpdateScenarios { + + @Test + @DisplayName("can update only commission, leaving active null") + void updateOnlyCommission() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("25.00")); + + assertThat(request.getCommissionPercent()).isEqualByComparingTo("25.00"); + assertThat(request.getActive()).isNull(); + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("can update only active, leaving commission null") + void updateOnlyActive() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setActive(false); + + assertThat(request.getActive()).isFalse(); + assertThat(request.getCommissionPercent()).isNull(); + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("can deactivate with zero commission") + void deactivateWithZeroCommission() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("0.00")); + request.setActive(false); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("can activate with maximum commission") + void activateWithMaxCommission() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("100.00")); + request.setActive(true); + + assertThat(validator.validate(request)).isEmpty(); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("commission at boundary 0.0 is valid") + void commissionAtZeroBoundary() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("0.0")); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("commission at boundary 100.0 is valid") + void commissionAt100Boundary() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("100.0")); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("commission just below minimum fails") + void commissionJustBelowMinimum() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("-0.000001")); + + assertThat(validator.validate(request)).isNotEmpty(); + } + + @Test + @DisplayName("commission just above maximum fails") + void commissionJustAboveMaximum() { + AgentUpdateRequest request = new AgentUpdateRequest(); + request.setCommissionPercent(new BigDecimal("100.000001")); + + assertThat(validator.validate(request)).isNotEmpty(); + } + } +} diff --git a/src/test/java/com/shipping/freightops/entity/AgentTest.java b/src/test/java/com/shipping/freightops/entity/AgentTest.java new file mode 100644 index 0000000..bab3adc --- /dev/null +++ b/src/test/java/com/shipping/freightops/entity/AgentTest.java @@ -0,0 +1,250 @@ +package com.shipping.freightops.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.enums.AgentType; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link Agent} entity. */ +class AgentTest { + + @Nested + @DisplayName("Constructor and defaults") + class ConstructorAndDefaults { + + @Test + @DisplayName("new Agent() sets active=true by default") + void defaultActiveIsTrue() { + Agent agent = new Agent(); + + assertThat(agent.isActive()).isTrue(); + } + + @Test + @DisplayName("new Agent() has null fields except active") + void defaultFieldsAreNull() { + Agent agent = new Agent(); + + assertThat(agent.getName()).isNull(); + assertThat(agent.getEmail()).isNull(); + assertThat(agent.getCommissionPercent()).isNull(); + assertThat(agent.getType()).isNull(); + assertThat(agent.getId()).isNull(); + assertThat(agent.getCreatedAt()).isNull(); + assertThat(agent.getUpdatedAt()).isNull(); + } + } + + @Nested + @DisplayName("Getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("setName/getName works correctly") + void nameGetterSetter() { + Agent agent = new Agent(); + agent.setName("John Doe"); + + assertThat(agent.getName()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("setEmail/getEmail works correctly") + void emailGetterSetter() { + Agent agent = new Agent(); + agent.setEmail("john@example.com"); + + assertThat(agent.getEmail()).isEqualTo("john@example.com"); + } + + @Test + @DisplayName("setCommissionPercent/getCommissionPercent works correctly") + void commissionPercentGetterSetter() { + Agent agent = new Agent(); + BigDecimal commission = new BigDecimal("12.50"); + agent.setCommissionPercent(commission); + + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("12.50"); + } + + @Test + @DisplayName("setType/getType works correctly") + void typeGetterSetter() { + Agent agent = new Agent(); + agent.setType(AgentType.EXTERNAL); + + assertThat(agent.getType()).isEqualTo(AgentType.EXTERNAL); + } + + @Test + @DisplayName("setActive/isActive works correctly") + void activeGetterSetter() { + Agent agent = new Agent(); + agent.setActive(false); + + assertThat(agent.isActive()).isFalse(); + + agent.setActive(true); + + assertThat(agent.isActive()).isTrue(); + } + } + + @Nested + @DisplayName("BaseEntity inherited methods") + class BaseEntityInheritance { + + @Test + @DisplayName("setId/getId works correctly") + void idGetterSetter() { + Agent agent = new Agent(); + agent.setId(123L); + + assertThat(agent.getId()).isEqualTo(123L); + } + } + + @Nested + @DisplayName("Field validation constraints") + class ValidationConstraints { + + @Test + @DisplayName("can set valid email format") + void validEmailFormat() { + Agent agent = new Agent(); + agent.setEmail("valid.email@domain.com"); + + assertThat(agent.getEmail()).isEqualTo("valid.email@domain.com"); + } + + @Test + @DisplayName("can set commission within valid range (0-100)") + void validCommissionRange() { + Agent agent = new Agent(); + + agent.setCommissionPercent(new BigDecimal("0.00")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("0.00"); + + agent.setCommissionPercent(new BigDecimal("50.00")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("50.00"); + + agent.setCommissionPercent(new BigDecimal("100.00")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("100.00"); + } + + @Test + @DisplayName("can set all valid AgentType values") + void validAgentTypes() { + Agent agent = new Agent(); + + agent.setType(AgentType.INTERNAL); + assertThat(agent.getType()).isEqualTo(AgentType.INTERNAL); + + agent.setType(AgentType.EXTERNAL); + assertThat(agent.getType()).isEqualTo(AgentType.EXTERNAL); + } + } + + @Nested + @DisplayName("Complete entity setup") + class CompleteEntitySetup { + + @Test + @DisplayName("can create fully populated agent") + void fullyPopulatedAgent() { + Agent agent = new Agent(); + agent.setId(1L); + agent.setName("Jane Smith"); + agent.setEmail("jane.smith@company.com"); + agent.setCommissionPercent(new BigDecimal("7.25")); + agent.setType(AgentType.INTERNAL); + agent.setActive(true); + + assertThat(agent.getId()).isEqualTo(1L); + assertThat(agent.getName()).isEqualTo("Jane Smith"); + assertThat(agent.getEmail()).isEqualTo("jane.smith@company.com"); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("7.25"); + assertThat(agent.getType()).isEqualTo(AgentType.INTERNAL); + assertThat(agent.isActive()).isTrue(); + } + + @Test + @DisplayName("can create inactive external agent") + void inactiveExternalAgent() { + Agent agent = new Agent(); + agent.setName("Inactive Agent"); + agent.setEmail("inactive@external.com"); + agent.setCommissionPercent(new BigDecimal("15.00")); + agent.setType(AgentType.EXTERNAL); + agent.setActive(false); + + assertThat(agent.getName()).isEqualTo("Inactive Agent"); + assertThat(agent.getEmail()).isEqualTo("inactive@external.com"); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("15.00"); + assertThat(agent.getType()).isEqualTo(AgentType.EXTERNAL); + assertThat(agent.isActive()).isFalse(); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("can handle null values for nullable fields") + void handlesNullValues() { + Agent agent = new Agent(); + agent.setName("Name"); + agent.setEmail("email@test.com"); + agent.setCommissionPercent(new BigDecimal("5.00")); + agent.setType(AgentType.INTERNAL); + + // Try setting null where allowed by entity (though validation might prevent it in real use) + agent.setId(null); + + assertThat(agent.getId()).isNull(); + } + + @Test + @DisplayName("can update existing agent fields") + void canUpdateFields() { + Agent agent = new Agent(); + agent.setName("Original Name"); + agent.setEmail("original@test.com"); + agent.setCommissionPercent(new BigDecimal("5.00")); + agent.setType(AgentType.INTERNAL); + agent.setActive(true); + + // Update fields + agent.setName("Updated Name"); + agent.setEmail("updated@test.com"); + agent.setCommissionPercent(new BigDecimal("10.00")); + agent.setType(AgentType.EXTERNAL); + agent.setActive(false); + + assertThat(agent.getName()).isEqualTo("Updated Name"); + assertThat(agent.getEmail()).isEqualTo("updated@test.com"); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("10.00"); + assertThat(agent.getType()).isEqualTo(AgentType.EXTERNAL); + assertThat(agent.isActive()).isFalse(); + } + + @Test + @DisplayName("commission percent maintains precision") + void commissionPrecision() { + Agent agent = new Agent(); + + agent.setCommissionPercent(new BigDecimal("12.345")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("12.345"); + + agent.setCommissionPercent(new BigDecimal("0.01")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("0.01"); + + agent.setCommissionPercent(new BigDecimal("99.999")); + assertThat(agent.getCommissionPercent()).isEqualByComparingTo("99.999"); + } + } +} diff --git a/src/test/java/com/shipping/freightops/enums/AgentTypeTest.java b/src/test/java/com/shipping/freightops/enums/AgentTypeTest.java new file mode 100644 index 0000000..1f0d94f --- /dev/null +++ b/src/test/java/com/shipping/freightops/enums/AgentTypeTest.java @@ -0,0 +1,234 @@ +package com.shipping.freightops.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link AgentType} enum. */ +class AgentTypeTest { + + @Nested + @DisplayName("Enum values") + class EnumValues { + + @Test + @DisplayName("has INTERNAL value") + void hasInternalValue() { + AgentType type = AgentType.INTERNAL; + + assertThat(type).isNotNull(); + assertThat(type.name()).isEqualTo("INTERNAL"); + } + + @Test + @DisplayName("has EXTERNAL value") + void hasExternalValue() { + AgentType type = AgentType.EXTERNAL; + + assertThat(type).isNotNull(); + assertThat(type.name()).isEqualTo("EXTERNAL"); + } + + @Test + @DisplayName("has exactly 2 values") + void hasExactlyTwoValues() { + AgentType[] values = AgentType.values(); + + assertThat(values).hasSize(2); + assertThat(values).containsExactlyInAnyOrder(AgentType.INTERNAL, AgentType.EXTERNAL); + } + } + + @Nested + @DisplayName("Enum comparison") + class EnumComparison { + + @Test + @DisplayName("INTERNAL equals itself") + void internalEqualsItself() { + AgentType type1 = AgentType.INTERNAL; + AgentType type2 = AgentType.INTERNAL; + + assertThat(type1).isEqualTo(type2); + assertThat(type1 == type2).isTrue(); + } + + @Test + @DisplayName("EXTERNAL equals itself") + void externalEqualsItself() { + AgentType type1 = AgentType.EXTERNAL; + AgentType type2 = AgentType.EXTERNAL; + + assertThat(type1).isEqualTo(type2); + assertThat(type1 == type2).isTrue(); + } + + @Test + @DisplayName("INTERNAL not equals EXTERNAL") + void internalNotEqualsExternal() { + AgentType internal = AgentType.INTERNAL; + AgentType external = AgentType.EXTERNAL; + + assertThat(internal).isNotEqualTo(external); + assertThat(internal == external).isFalse(); + } + } + + @Nested + @DisplayName("valueOf method") + class ValueOfMethod { + + @Test + @DisplayName("valueOf(\"INTERNAL\") returns INTERNAL") + void valueOfInternal() { + AgentType type = AgentType.valueOf("INTERNAL"); + + assertThat(type).isEqualTo(AgentType.INTERNAL); + } + + @Test + @DisplayName("valueOf(\"EXTERNAL\") returns EXTERNAL") + void valueOfExternal() { + AgentType type = AgentType.valueOf("EXTERNAL"); + + assertThat(type).isEqualTo(AgentType.EXTERNAL); + } + + @Test + @DisplayName("valueOf with invalid name throws IllegalArgumentException") + void valueOfInvalidThrows() { + try { + AgentType.valueOf("INVALID"); + throw new AssertionError("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + @DisplayName("valueOf is case-sensitive") + void valueOfCaseSensitive() { + try { + AgentType.valueOf("internal"); + throw new AssertionError("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + } + } + + @Nested + @DisplayName("name method") + class NameMethod { + + @Test + @DisplayName("INTERNAL.name() returns \"INTERNAL\"") + void internalName() { + assertThat(AgentType.INTERNAL.name()).isEqualTo("INTERNAL"); + } + + @Test + @DisplayName("EXTERNAL.name() returns \"EXTERNAL\"") + void externalName() { + assertThat(AgentType.EXTERNAL.name()).isEqualTo("EXTERNAL"); + } + } + + @Nested + @DisplayName("ordinal method") + class OrdinalMethod { + + @Test + @DisplayName("enum values have sequential ordinals") + void sequentialOrdinals() { + AgentType[] values = AgentType.values(); + + assertThat(values[0].ordinal()).isEqualTo(0); + assertThat(values[1].ordinal()).isEqualTo(1); + } + + @Test + @DisplayName("INTERNAL and EXTERNAL have different ordinals") + void differentOrdinals() { + assertThat(AgentType.INTERNAL.ordinal()).isNotEqualTo(AgentType.EXTERNAL.ordinal()); + } + } + + @Nested + @DisplayName("Switch statement compatibility") + class SwitchCompatibility { + + @Test + @DisplayName("can be used in switch statement") + void canBeUsedInSwitch() { + String result = getTypeDescription(AgentType.INTERNAL); + assertThat(result).isEqualTo("Internal Agent"); + + result = getTypeDescription(AgentType.EXTERNAL); + assertThat(result).isEqualTo("External Agent"); + } + + private String getTypeDescription(AgentType type) { + switch (type) { + case INTERNAL: + return "Internal Agent"; + case EXTERNAL: + return "External Agent"; + default: + return "Unknown"; + } + } + } + + @Nested + @DisplayName("toString method") + class ToStringMethod { + + @Test + @DisplayName("toString returns enum name") + void toStringReturnsName() { + assertThat(AgentType.INTERNAL.toString()).isEqualTo("INTERNAL"); + assertThat(AgentType.EXTERNAL.toString()).isEqualTo("EXTERNAL"); + } + } + + @Nested + @DisplayName("Usage scenarios") + class UsageScenarios { + + @Test + @DisplayName("can be used as map key") + void canBeUsedAsMapKey() { + java.util.Map map = new java.util.HashMap<>(); + map.put(AgentType.INTERNAL, "Internal agents"); + map.put(AgentType.EXTERNAL, "External agents"); + + assertThat(map.get(AgentType.INTERNAL)).isEqualTo("Internal agents"); + assertThat(map.get(AgentType.EXTERNAL)).isEqualTo("External agents"); + } + + @Test + @DisplayName("can be used in collections") + void canBeUsedInCollections() { + java.util.List types = + java.util.Arrays.asList(AgentType.INTERNAL, AgentType.EXTERNAL, AgentType.INTERNAL); + + assertThat(types).hasSize(3); + assertThat(types).containsExactly(AgentType.INTERNAL, AgentType.EXTERNAL, AgentType.INTERNAL); + } + + @Test + @DisplayName("can be compared with == operator") + void canBeComparedWithEquals() { + AgentType type = AgentType.INTERNAL; + + boolean isInternal = (type == AgentType.INTERNAL); + boolean isExternal = (type == AgentType.EXTERNAL); + + assertThat(isInternal).isTrue(); + assertThat(isExternal).isFalse(); + } + } +} diff --git a/src/test/java/com/shipping/freightops/repository/AgentRepositoryTest.java b/src/test/java/com/shipping/freightops/repository/AgentRepositoryTest.java new file mode 100644 index 0000000..db5e992 --- /dev/null +++ b/src/test/java/com/shipping/freightops/repository/AgentRepositoryTest.java @@ -0,0 +1,316 @@ +package com.shipping.freightops.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.entity.Agent; +import com.shipping.freightops.enums.AgentType; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +/** Integration tests for {@link AgentRepository}. */ +@DataJpaTest +class AgentRepositoryTest { + + @Autowired private TestEntityManager entityManager; + @Autowired private AgentRepository agentRepository; + + @BeforeEach + void setUp() { + agentRepository.deleteAll(); + } + + @Nested + @DisplayName("findByType") + class FindByType { + + @Test + @DisplayName("returns only agents with matching type") + void returnsMatchingType() { + Agent internal1 = createAgent("Internal One", "i1@test.com", AgentType.INTERNAL, true); + Agent internal2 = createAgent("Internal Two", "i2@test.com", AgentType.INTERNAL, false); + Agent external = createAgent("External", "e@test.com", AgentType.EXTERNAL, true); + + entityManager.persist(internal1); + entityManager.persist(internal2); + entityManager.persist(external); + entityManager.flush(); + + List result = agentRepository.findByType(AgentType.INTERNAL); + + assertThat(result) + .hasSize(2) + .extracting(Agent::getName) + .containsExactlyInAnyOrder("Internal One", "Internal Two"); + } + + @Test + @DisplayName("returns empty list when no agents match type") + void returnsEmptyWhenNoMatch() { + Agent internal = createAgent("Internal", "i@test.com", AgentType.INTERNAL, true); + entityManager.persist(internal); + entityManager.flush(); + + List result = agentRepository.findByType(AgentType.EXTERNAL); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returns empty list when repository is empty") + void returnsEmptyWhenRepositoryEmpty() { + List result = agentRepository.findByType(AgentType.INTERNAL); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findByActive") + class FindByActive { + + @Test + @DisplayName("returns only active agents when active=true") + void returnsActiveAgents() { + Agent active1 = createAgent("Active One", "a1@test.com", AgentType.INTERNAL, true); + Agent active2 = createAgent("Active Two", "a2@test.com", AgentType.EXTERNAL, true); + Agent inactive = createAgent("Inactive", "i@test.com", AgentType.INTERNAL, false); + + entityManager.persist(active1); + entityManager.persist(active2); + entityManager.persist(inactive); + entityManager.flush(); + + List result = agentRepository.findByActive(true); + + assertThat(result) + .hasSize(2) + .extracting(Agent::getName) + .containsExactlyInAnyOrder("Active One", "Active Two"); + } + + @Test + @DisplayName("returns only inactive agents when active=false") + void returnsInactiveAgents() { + Agent active = createAgent("Active", "a@test.com", AgentType.INTERNAL, true); + Agent inactive1 = createAgent("Inactive One", "i1@test.com", AgentType.EXTERNAL, false); + Agent inactive2 = createAgent("Inactive Two", "i2@test.com", AgentType.INTERNAL, false); + + entityManager.persist(active); + entityManager.persist(inactive1); + entityManager.persist(inactive2); + entityManager.flush(); + + List result = agentRepository.findByActive(false); + + assertThat(result) + .hasSize(2) + .extracting(Agent::getName) + .containsExactlyInAnyOrder("Inactive One", "Inactive Two"); + } + + @Test + @DisplayName("returns empty list when no agents match active status") + void returnsEmptyWhenNoMatch() { + Agent active = createAgent("Active", "a@test.com", AgentType.INTERNAL, true); + entityManager.persist(active); + entityManager.flush(); + + List result = agentRepository.findByActive(false); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findByTypeAndActive") + class FindByTypeAndActive { + + @Test + @DisplayName("returns agents matching both type and active status") + void returnsMatchingBothCriteria() { + Agent target1 = createAgent("Target One", "t1@test.com", AgentType.EXTERNAL, true); + Agent target2 = createAgent("Target Two", "t2@test.com", AgentType.EXTERNAL, true); + Agent wrongType = createAgent("Wrong Type", "wt@test.com", AgentType.INTERNAL, true); + Agent wrongActive = createAgent("Wrong Active", "wa@test.com", AgentType.EXTERNAL, false); + + entityManager.persist(target1); + entityManager.persist(target2); + entityManager.persist(wrongType); + entityManager.persist(wrongActive); + entityManager.flush(); + + List result = agentRepository.findByTypeAndActive(AgentType.EXTERNAL, true); + + assertThat(result) + .hasSize(2) + .extracting(Agent::getName) + .containsExactlyInAnyOrder("Target One", "Target Two"); + } + + @Test + @DisplayName("returns empty list when no agents match both criteria") + void returnsEmptyWhenNoMatch() { + Agent agent1 = createAgent("Agent One", "a1@test.com", AgentType.INTERNAL, true); + Agent agent2 = createAgent("Agent Two", "a2@test.com", AgentType.EXTERNAL, false); + + entityManager.persist(agent1); + entityManager.persist(agent2); + entityManager.flush(); + + List result = agentRepository.findByTypeAndActive(AgentType.EXTERNAL, true); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("handles all combinations of type and active status") + void handlesAllCombinations() { + Agent intActive = createAgent("Internal Active", "ia@test.com", AgentType.INTERNAL, true); + Agent intInactive = + createAgent("Internal Inactive", "ii@test.com", AgentType.INTERNAL, false); + Agent extActive = createAgent("External Active", "ea@test.com", AgentType.EXTERNAL, true); + Agent extInactive = + createAgent("External Inactive", "ei@test.com", AgentType.EXTERNAL, false); + + entityManager.persist(intActive); + entityManager.persist(intInactive); + entityManager.persist(extActive); + entityManager.persist(extInactive); + entityManager.flush(); + + // Test all 4 combinations + List intActiveResult = agentRepository.findByTypeAndActive(AgentType.INTERNAL, true); + assertThat(intActiveResult) + .hasSize(1) + .extracting(Agent::getName) + .containsExactly("Internal Active"); + + List intInactiveResult = + agentRepository.findByTypeAndActive(AgentType.INTERNAL, false); + assertThat(intInactiveResult) + .hasSize(1) + .extracting(Agent::getName) + .containsExactly("Internal Inactive"); + + List extActiveResult = agentRepository.findByTypeAndActive(AgentType.EXTERNAL, true); + assertThat(extActiveResult) + .hasSize(1) + .extracting(Agent::getName) + .containsExactly("External Active"); + + List extInactiveResult = + agentRepository.findByTypeAndActive(AgentType.EXTERNAL, false); + assertThat(extInactiveResult) + .hasSize(1) + .extracting(Agent::getName) + .containsExactly("External Inactive"); + } + } + + @Nested + @DisplayName("Basic JPA operations") + class BasicOperations { + + @Test + @DisplayName("save persists agent with all fields") + void savePersistsAllFields() { + Agent agent = createAgent("Test Agent", "test@test.com", AgentType.INTERNAL, true); + agent.setCommissionPercent(new BigDecimal("15.75")); + + Agent saved = agentRepository.save(agent); + entityManager.flush(); + entityManager.clear(); + + Agent found = agentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(found.getName()).isEqualTo("Test Agent"); + assertThat(found.getEmail()).isEqualTo("test@test.com"); + assertThat(found.getCommissionPercent()).isEqualByComparingTo("15.75"); + assertThat(found.getType()).isEqualTo(AgentType.INTERNAL); + assertThat(found.isActive()).isTrue(); + assertThat(found.getCreatedAt()).isNotNull(); + assertThat(found.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("findById returns empty when agent doesn't exist") + void findByIdReturnsEmpty() { + assertThat(agentRepository.findById(999L)).isEmpty(); + } + + @Test + @DisplayName("findAll returns all agents") + void findAllReturnsAll() { + Agent agent1 = createAgent("Agent 1", "a1@test.com", AgentType.INTERNAL, true); + Agent agent2 = createAgent("Agent 2", "a2@test.com", AgentType.EXTERNAL, false); + Agent agent3 = createAgent("Agent 3", "a3@test.com", AgentType.INTERNAL, true); + + entityManager.persist(agent1); + entityManager.persist(agent2); + entityManager.persist(agent3); + entityManager.flush(); + + List all = agentRepository.findAll(); + + assertThat(all).hasSize(3); + } + + @Test + @DisplayName("delete removes agent from repository") + void deleteRemovesAgent() { + Agent agent = createAgent("To Delete", "delete@test.com", AgentType.INTERNAL, true); + Agent saved = entityManager.persist(agent); + entityManager.flush(); + + Long id = saved.getId(); + agentRepository.delete(saved); + entityManager.flush(); + + assertThat(agentRepository.findById(id)).isEmpty(); + } + + @Test + @DisplayName("update modifies existing agent") + void updateModifiesAgent() { + Agent agent = createAgent("Original", "original@test.com", AgentType.INTERNAL, true); + Agent saved = entityManager.persist(agent); + entityManager.flush(); + entityManager.clear(); + + Agent found = agentRepository.findById(saved.getId()).orElseThrow(); + found.setName("Updated Name"); + found.setActive(false); + found.setCommissionPercent(new BigDecimal("25.00")); + + agentRepository.save(found); + entityManager.flush(); + entityManager.clear(); + + Agent updated = agentRepository.findById(saved.getId()).orElseThrow(); + + assertThat(updated.getName()).isEqualTo("Updated Name"); + assertThat(updated.isActive()).isFalse(); + assertThat(updated.getCommissionPercent()).isEqualByComparingTo("25.00"); + assertThat(updated.getEmail()).isEqualTo("original@test.com"); // unchanged + } + } + + // โ”€โ”€ HELPER โ”€โ”€ + + private Agent createAgent(String name, String email, AgentType type, boolean active) { + Agent agent = new Agent(); + agent.setName(name); + agent.setEmail(email); + agent.setCommissionPercent(new BigDecimal("5.00")); + agent.setType(type); + agent.setActive(active); + return agent; + } +} diff --git a/src/test/java/com/shipping/freightops/service/FreightOrderServiceTest.java b/src/test/java/com/shipping/freightops/service/FreightOrderServiceTest.java index 699e9a7..3737f10 100644 --- a/src/test/java/com/shipping/freightops/service/FreightOrderServiceTest.java +++ b/src/test/java/com/shipping/freightops/service/FreightOrderServiceTest.java @@ -6,6 +6,7 @@ import com.shipping.freightops.dto.CreateFreightOrderRequest; import com.shipping.freightops.dto.UpdateDiscountRequest; import com.shipping.freightops.entity.*; +import com.shipping.freightops.enums.AgentType; import com.shipping.freightops.enums.ContainerSize; import com.shipping.freightops.enums.ContainerType; import com.shipping.freightops.enums.OrderStatus; @@ -32,14 +33,17 @@ public class FreightOrderServiceTest { @Autowired private VesselRepository vesselRepository; @Autowired private VoyagePriceRepository voyagePriceRepository; @Autowired private FreightOrderRepository freightOrderRepository; + @Autowired private AgentRepository agentRepository; private Voyage savedVoyage; private Container savedContainer; private Customer savedCustomer; + private Agent savedAgent; @BeforeEach void setUp() { freightOrderRepository.deleteAll(); + agentRepository.deleteAll(); voyagePriceRepository.deleteAll(); voyageRepository.deleteAll(); containerRepository.deleteAll(); @@ -71,6 +75,13 @@ void setUp() { customer.setEmail("john@test.com"); savedCustomer = customerRepository.save(customer); + Agent agent = new Agent(); + agent.setName("Test Agent"); + agent.setEmail("agent@test.com"); + agent.setCommissionPercent(BigDecimal.valueOf(5)); + agent.setType(AgentType.EXTERNAL); + savedAgent = agentRepository.save(agent); + VoyagePrice price = new VoyagePrice(); price.setVoyage(savedVoyage); price.setContainerSize(ContainerSize.TWENTY_FOOT); @@ -85,6 +96,7 @@ void createOrder_withDiscount_appliesCorrectPrice() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); request.setDiscountPercent(BigDecimal.valueOf(10)); // 10% @@ -102,6 +114,7 @@ void createOrder_withoutDiscount_setsFullPrice() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); FreightOrder order = freightOrderService.createOrder(request); @@ -119,6 +132,7 @@ void createOrder_withoutVoyagePrice_throwsException() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); assertThatThrownBy(() -> freightOrderService.createOrder(request)) .isInstanceOf(BadRequestException.class); @@ -158,6 +172,7 @@ void updateDiscount_appliesCorrectly() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); FreightOrder order = freightOrderService.createOrder(request); @@ -180,6 +195,7 @@ void updateDiscount_whenCancelled_throws() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); FreightOrder order = freightOrderService.createOrder(request); @@ -213,6 +229,7 @@ void updateDiscount_recalculatesFinalPrice() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); FreightOrder order = freightOrderService.createOrder(request); @@ -243,6 +260,7 @@ void updateDiscount_fullDiscount() { request.setVoyageId(savedVoyage.getId()); request.setContainerId(savedContainer.getId()); request.setCustomerId(savedCustomer.getId()); + request.setAgentId(savedAgent.getId()); request.setOrderedBy("tester"); FreightOrder order = freightOrderService.createOrder(request);